# AGENTS.md — developing dsh-plugin-radicale Read this before writing code in this repository. The README is for humans installing and configuring the plugin; **this file is for agents (and humans) developing it.** ## What this is A userland DeepSeek Harness (DSH) profile bundle that mounts one `radicale` tool for a DSH profile: list collections / list events / read / create / update / search / create an empty calendar / cancel and uncancel events (a move into an archive, never a delete) against a local Radicale (CalDAV) server. **No DSH source modifications, ever** — the whole surface is one entry file plus a small tree of dependency-light ESM TypeScript modules in `src/`, resolved from the DSH checkout through the `node_modules` symlink in this directory. ## The design decisions the code embodies 1. **No deletes, ever — enforced in the transport, not in the docs.** The server holds the user's real calendar data, so the guarantee must not rely on the model or on the tool description: `src/client.ts` carries no `DELETE` transport at all. The mount's HTTP surface is `PROPFIND`/`GET`/`PUT` plus the user-approved `MKCOL`/`MOVE` pair (added 2026-08-31 for `createCollection` and the `cancel`/`uncancel` pair): `MKCOL` only ever creates a new, empty collection, and `MOVE` only ever relocates an existing item into another collection under the same file name and UID — neither can remove an entry, and no further method may be added without the same review. `create` sends `If-None-Match: *` so even a name collision is a failure (HTTP 412, retried under a fresh name) rather than a silent replacement. The stub suite asserts zero DELETE requests across the whole run. Do not "conveniently" add a delete or an overwrite path. 2. **One simple package, no preemptive splitting.** The *behavioral* split (transport / ICS / operations / tool) is file-level within one plugin; do not extract new packages or services. 3. **Thin client.** `src/client.ts` is a thin wrapper over the DAV surface: one bounded call, one clean bounded error (`RadicaleError` with the HTTP status). All WebDAV knowledge lives in the client and the mount operations; the tool never touches HTTP. 4. **Bounded fan-out.** A collection listing/search fetches each item individually; `maxItems` caps that fan-out and capped listings are flagged `truncated`. Keep every new operation bounded. 5. **Best-effort, failure-isolated.** A stopped or failing Radicale server must never break a session: every transport failure degrades to a clean bounded error (`radicale: …`), unreadable items in a listing are listed by name rather than failing the listing, and an unreachable server makes the tool call fail cleanly. 6. **The tool description IS the management policy.** WHAT the model does (list before acting, read before updating because a PUT replaces the whole ICS, no deletion) is decided by the description text in `src/tool.ts`. Changing that text is a behavior change — say so in the commit message. 7. **Minimal ICS by construction.** `src/ics.ts` builds exactly one VEVENT with the fields the tool exposes (UTC basic dates, `VALUE=DATE` for all-day, a default end of one hour / next day). Anything richer (recurrence, attendees, alarms) flows through the `ical` parameter as complete, verbatim ICS — the builder must not grow a mini-ICS framework. 8. **Locators are forgiving, failures are precise.** `read`/`update` locate an event by exact file name (± `.ics`), unique file-name prefix, or unique exact SUMMARY; ambiguity and misses are clean errors that list the candidates. 9. **Test bar: mock only the external service.** `test/stub-server.mjs` is a dependency-free in-process emulation of the DAV surface (multistatus XML in Radicale's shape, 412 on guarded collisions, MKCOL/MOVE semantics as the real server implements them, request recording). `test/live.mjs` is a **read-only** round-trip against the real server — it must never send a PUT. New behavior gets a check in `test/test.mjs`. 10. **Identity is preserved on update.** A structured update re-sends the stored event's UID (the mount reads it first; a UID swap is a 409 `C:no-uid-conflict` on Radicale and reads as a delete + new to calendar clients, so the stub enforces the same 409). `read` surfaces the ETag and `update` takes an optional `etag`, sent as `If-Match` — optimistic concurrency, mirroring Radicale's put handling. Keep the stub's UID-collision and If-Match behavior in lockstep with the server. ## Layout | file | role | | --- | --- | | `radicale.ts` | the entry: the loader's whole contract (`name`/`inject`/`Config`/`apply`); `apply` builds one mount and registers the tool | | `src/config.ts` | `ResolvedConfig` + the hand-rolled Standard-Schema v1 `Config` validator | | `src/client.ts` | the DAV transport (`PROPFIND`/`GET`/`PUT` plus the user-approved `MKCOL`/`MOVE` pair - no `DELETE`, ever), one bounded call, one clean bounded error shape | | `src/ics.ts` | VCALENDAR building (structured fields or verbatim), display-field parsing (summary/start/end/location), and the cancel-provenance helpers (`X-DSH-CANCELLED` stamping/stripping) | | `src/mount.ts` | `createMount`: the per-mount factory and the operations — **the extension point for new Radicale operations** | | `src/tool.ts` | the model-facing `radicale` tool (the description IS the management policy) | | `package.json` | the package manifest; `dsh.bundle: { patch: "./cordis.patch.yml" }` makes this a profile bundle | | `cordis.patch.yml` | the bundle's patch layer — the host-plane mounting row (`id: radicale`, **shipped `disabled: true`**) | | `test/stub-server.mjs`, `test/test.mjs` | dependency-free smoke suite (stub DAV server, 23 checks - the last asserts the no-DELETE invariant across the whole run) | | `.github/workflows/ci.yml` | CI: rebuilds the `node_modules` symlink from a checked-out DSH install, then type-checks and runs the stub suite | | `test/live.mjs` | live **read-only** round-trip against the real server | | `test/register.mjs`, `test/hooks.mjs` | tsx loader bootstrap so `node` can import the `.ts` plugin in tests | ## The development loop All commands run from the repo root (the plugin directory) unless noted: ```bash # type-check (strict; tsc is resolved through the machine-local # node_modules symlink into the DSH checkout — no absolute path here) TSC="$(cd "$(realpath node_modules)/../../.." && pwd)/node_modules/.bin/tsc" "$TSC" --noEmit --strict \ --noUnusedLocals --noUnusedParameters --noFallthroughCasesInSwitch \ --module nodenext --target es2023 --allowImportingTsExtensions \ --skipLibCheck radicale.ts # the stub suite (23 checks) cd test && node --import ./register.mjs test.mjs # live round-trip (real server; READ-ONLY by design) RADICALE_URL=http://127.0.0.1:5232 node --import ./register.mjs live.mjs ``` - `node_modules` here is a **machine-local symlink** into the DSH checkout's `apps/cli/node_modules` — it is gitignored, not part of the repo. A fresh checkout recreates it once (`ln -s /apps/cli/node_modules node_modules`) and then do not `npm install` in this repo and do not add runtime dependencies without first deleting an equivalent amount of code. `src/` must stay free of `node:*` imports (no `@types/node` is reachable through the symlink) — keep it plain ESM TypeScript. On a machine without the symlink (or in CI) the test bootstrap accepts the checkout path: `DSH_ROOT=/path/to/deepseek-harness node --import ./register.mjs test.mjs` (the loader anchor in `test/hooks.mjs` moves there). - Edits to the entry or `src/` take effect after a `dsh web` restart (the node half loads at session start); config edits to `cordis.patch.yml` likewise. - Commit messages are written by the maintainer as quoted-heredoc paste blocks (`git commit -m "$(cat <<'EOF' …)"`) — match the sibling plugins' log style (`feat(radicale): …`, `refactor: …`, `docs: …`). ## Extending the plugin (scalability paths) - **New Radicale operation** (e.g. calendar-home discovery via `REPORT`): add the transport method in `src/client.ts` (the current surface is `PROPFIND`/`GET`/`PUT` plus the user-approved `MKCOL`/`MOVE` pair - anything else, above all a `DELETE`, requires the review of invariant 1), the operation in `src/mount.ts` (the designated extension point), and - only if model-facing - an action in `src/tool.ts` plus description text. Keep the operation bounded and failure-isolated. (Collection creation and the cancel/uncancel moves already shipped as the `MKCOL`/`MOVE` pair; the stub suite models both.) - **Server authentication**: the local server runs with `[auth] type = none`, so the plugin carries no credential surface. If the server gains auth, add an optional `user`/`passwordRef` to the config (the credential REFERENCE, never the value — the hindsight plugin's `apiKeyRef` is the pattern) and one `Authorization` header in `src/client.ts`. Do not add it preemptively. - **New config key**: `src/config.ts` (the hand-rolled Standard-Schema v1 validator), the entry file's doc comment, `cordis.patch.yml` (the reference block), and the README's configuration table — all four. ## Invariants (do not break) - The entry file is the loader contract; `inject` stays `['tools']` — the plugin provides no service of its own and its rows need no isolate realm. - **No deletion path, ever**: no `DELETE` transport, no delete action, and `create` must keep sending `If-None-Match: *`. The `MKCOL`/`MOVE` surface is the user-approved exception to the method set (invariant 1) - do not extend it. The stub suite's final check (zero DELETE requests) is the tripwire - keep it. - `live.mjs` stays read-only: it runs against the user's real calendar data and must never send a PUT. - The shipped bundle row stays `disabled: true` — activation is always the user's declarative act. - `src/` stays dependency-free (no `node:*` imports, no npm dependencies).