diff --git a/.cards/backlog.jsonl b/.cards/backlog.jsonl index 318ec61..39390f1 100644 --- a/.cards/backlog.jsonl +++ b/.cards/backlog.jsonl @@ -15,6 +15,7 @@ {"data":{"id":"card_069ec1d16a804b3286dfccb64d10d0e4","workspace_id":"demo","type_id":"api-task","schema_version":1,"title":"CLI: patch/claim/release without a preceding GET (--if-match latest, service-side)","status":"todo","fields":{"acceptance":"1) `cards patch --status done --if-match latest` succeeds with no prior GET — the server re-reads the current version under the write lock (not a client-side GET). 2) A concurrent-writer test pins who wins when two `--if-match latest` writes race: the second returns 409 version_conflict, exit code 4. No last-write-wins. 3) Explicit `--version N` guard unchanged: a stale version still returns 409, exit code 4. 4) `cards claim` and `cards release` gain the same `--if-match latest` affordance (release keeps `--force` meaning transition bypass — the two flags are orthogonal and a test covers them together). 5) Default behavior (no flag, no version) unchanged for patch/claim/release — still the existing usage error, so the safe default is preserved. 6) docs/spec/api-surface.md documents the opt-in on all three endpoints, and docs/reference/cli.md documents the flag. 7) The race-loser path is race-clean: `go test -race` covers the concurrent case.","api_change":"additive","branch":"feat/if-match-latest","description":"Problem: `cards patch`, `cards claim` and `cards release` all REQUIRE `--version` (commands.go:169, :240, :263), so every scripted mutation must do a GET first to fetch the current version. That is friction for agents and one-shot shell scripts.\n\nAdd an opt-in convenience that stays safe by default: `--if-match latest`, where the SERVER re-reads the card's current version under the write lock and applies the mutation in one round-trip. This is preferred over a client-side fetch-then-retry-on-409 loop because it closes the race server-side (the read and the conditional write share one lock). Document the race semantics plainly: two concurrent `--if-match latest` writes still conflict — the second sees the version bumped by the first and returns 409 (exit code 4), exactly like an explicit stale `--version`.\n\nThe explicit `--version N` guard remains the canonical optimistic-concurrency path and is unchanged. `--if-match latest` is purely a convenience for the no-prior-GET case.\n\nLOCKED DECISIONS (2026-07-27 sprint planning — settle these before work starts, they are permanent surface)\n\n1. Implementation site: service/core, under the write lock. NOT a CLI-side GET-then-write. A CLI-only auto-GET would give the CLI its own concurrency story that HTTP does not share — a side door around the contract. The re-read belongs where the lock is.\n\n2. Flag spelling: `--if-match latest`. Rejected alternatives:\n - bare `--force` — COLLIDES. `cards release --force` already exists (commands.go:258, sends body[\"force\"]=true) and means transition bypass, an unrelated thing.\n - `--version latest` — overloads the CAS argument with a non-CAS sentinel; reads as \"version number\" everywhere else.\n\n3. Semantics: NO last-write-wins, ever. Concurrent latest-latest → the loser gets structured version_conflict / exit 4. Missing `--version` with no opt-in flag stays a hard usage error; the opt-in never becomes the default.\n\n4. Verbs covered this sprint: `patch`, `claim`, AND `release`. `release` is included because it has the same hard `--version` requirement and is the verb agents hit at the end of every work item — leaving it out would mean the round-trip is only half removed.\n\n5. API surface: because the re-read is service-side, this sprint DOES touch the machine-facing contract. docs/spec/api-surface.md records the opt-in on all three endpoints. Shape: HTTP body `version` keeps today's meaning for explicit CAS; the opt-in is an additive optional field alongside it. api_change stays `additive` — nothing existing moves.\n\nOUT OF SCOPE: changing the default (no-flag) behavior, last-write-wins, multi-write transactions, `append` (it may adopt the same flag later — see card 0f7be7f6 — but not here), MCP tool changes.","endpoint":"cards patch / claim / release --if-match latest (service-side re-read under write lock)","verify":"go test ./internal/cli -run 'IfMatchLatest' && go test -race ./internal/core -run IfMatchLatestConcurrent"},"comments":[{"id":"cm_9633c88593804abb","author":"foz","body":"**Review (batch C, keep backlog).**\n\n**Validated:** `--version` is required for both `patch` (commands.go:168) and `claim` (commands.go:239); 409 maps to exit code 4 (cli_test.go:47, client.go:199 `ExitCode`). No `--if-match`/auto-retry path exists today. Claims accurate.\n\n**Issues found:** Original acceptance bundled \"concurrent-writer test pins who wins\" without stating the expected loser behavior (exit 4). Did not explicitly state the default (no-flag) path is preserved — important for the \"safe by default\" claim.\n\n**Changes made:** Rewrote description as problem → solution → race semantics → out-of-scope. Added 5 numbered acceptance checks (incl. default-path preserved and `claim` parity). Replaced verify with named test fns for both patch and claim plus the concurrent-loser test. api_change confirmed additive.\n\n**Scope verdict:** Tight and correctly bounded — one opt-in flag, no contract change. Keep backlog.\n\n**Arch verdict:** Aligned. Server-side re-read under the write lock is the right call (closes the race rather than papering over it with client retry), and preserving the explicit `--version` guard keeps the optimistic-concurrency contract intact.","created_at":"2026-07-19T17:33:52.497179Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_5f7a43e941804f70","author":"claude","body":"**Shape locked 2026-07-27 (sprint planning, agent-cli-ergonomics).** This is the high-leverage card of the theme — the theme sentence (\"drive the board without a GET before every write\") points here, not at the markdown view.\n\nFive decisions now recorded in the description so implementation does not have to re-open them:\n\n1. **Service-side, under the write lock** — not a CLI GET-then-write. A CLI-only auto-GET would give the CLI a concurrency story HTTP does not share.\n2. **Flag spelling `--if-match latest`.** Bare `--force` is rejected on collision: `cards release --force` already exists (commands.go:258, sends `body[\"force\"]=true`) and means transition bypass. `--version latest` is rejected for overloading the CAS argument with a sentinel.\n3. **No last-write-wins.** Concurrent latest-latest → loser gets version_conflict / exit 4. Missing `--version` with no flag stays a hard usage error.\n4. **`release` is in scope**, alongside patch and claim — it carries the same hard `--version` requirement (commands.go:263) and is the verb agents hit at the end of every work item. Its `--force` and the new `--if-match` are orthogonal; a test covers them together.\n5. **api-surface.md changes this sprint.** Because the re-read is service-side, this touches the machine-facing contract. Additive optional field beside `version`; `api_change` stays additive.\n\nPrecedent for the sentinel: DeleteCard already treats `Version != 0` as the guard (core/service.go:919-920).\n\nSprint position: item 2 of 3 by ship order. If capacity runs short, card c0102825 (--md) slips before this one.","created_at":"2026-07-27T09:49:42.171826Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_1bafe8c4b4ff4e49","author":"claude","body":"**Line-citation correction (2026-08-07).** An independent verification pass re-read this against a built binary and caught that my earlier note cited the wrong line for release.\n\n`cards release` rejects a missing version at **commands.go:265-266** (`if *version == 0 { return fmt.Errorf(\"--version is required\") }`). Line **:263** is the preceding arg-count usage error (`usage: cards release --version N [--status S] [--force]`) — a different failure. Decision 4 in the description (release is in scope, same hard --version requirement) is unaffected; only the pointer was off.\n\nAlso confirmed unchanged: patch at commands.go:169, claim at :240, DeleteCard's Version!=0 sentinel precedent at core/service.go:919-920. PatchCard itself is at service.go:741. Zero grep hits for if_match / IfMatch / if-match anywhere in internal/, so none of this is quietly already built.","created_at":"2026-08-07T06:10:43.823582Z","edited_at":"0001-01-01T00:00:00Z"}],"version":11,"created_at":"2026-07-06T16:32:19.33596Z","updated_at":"2026-08-09T13:25:08.397091Z","created_by":"local-dev","status_since":"2026-07-27T09:49:42.213633Z"},"type":"card"} {"data":{"id":"card_06a1c3c62b46496d9e91048ae446fa97","workspace_id":"demo","type_id":"frontend-task","schema_version":1,"title":"Themes step 2: workspace-loaded theme files + sharing (loader shipped; extract deferred)","status":"done","fields":{"acceptance":"Loader: definitions/themes/*.{css,json} loads; broken theme → warn+skip; board.presentation.theme + resolveTheme precedence work. Extraction of embedded journal/labels is card_3d8ed6d9 (out of scope here).","branch":"feat/theme-loader","description":"THEMES.md step 2 + sharing story. STATUS 2026-07-11 reconciliation with what shipped in Sprint P4 (card_3845a834 / themes loader lineage, e.g. fcb9844):\n\nSHIPPED under this workstream (loader path — do not redo):\n1) LOADER — internal/config reads definitions/themes/.css + optional .json manifest; uiStylesheet serves embedded base + workspace themes; resolveTheme + contract version warn; broken theme file → warning + skip (keep serving), never boot-hard-error.\n2) PER-BOARD ASSIGNMENT — board.presentation.theme; resolveTheme precedence ?theme/cookie > board presentation > settings.theme.\n3) DOCS / examples — two-file publish/install story; demo has ocean (+ others).\n\nEXPLICITLY DEFERRED (own card — not a silent gap):\n- EXTRACT embedded journal/labels blocks out of style.css into definitions/themes/ → card_3d8ed6d9 (mechanical CSS move, own regression risk). Bare `cards init` still needs embedded defaults until extraction lands cleanly.\n\nACCEPT (revised — honest vs extract):\n- External theme installs by file drop + cards reload; board.presentation.theme styles one board; broken theme → warning + default, never an error; loader present in tree with tests.\n- Extraction is NOT required to close this card; it is accepted by flipping card_3d8ed6d9 later.\n\nVERIFY before done flip: themes.go + tests present; demo definitions/themes/* load; broken file path warned; ACC above matches code.","design_ref":"docs/design/THEMES.md (contract v1 + Sharing sections)","surface":"theme"},"links":[{"type_id":"depends-on","target":"card_e71a64fab0ba4e02b8b7468a2c12518d","note":"step 1 (CSS-only themes) is the prerequisite; done as 0eb5dee","created_by":"local-dev","created_at":"2026-07-07T17:59:02.303644Z"},{"type_id":"related","target":"card_3d8ed6d9f2c3474f848d8c0f7d1814b9","note":"extract journal/labels is THIS deferred scope (not loader ACC)","created_by":"claude","created_at":"2026-07-11T20:41:39.401403Z"}],"comments":[{"id":"cm_ce2571ff53554df5","author":"jeremy","body":"Tracked in sprint as P4 (card_3845a8346bee433690ac06ddca376062). This card keeps the detailed loader/extraction/per-board-assignment breakdown.","created_at":"2026-07-07T20:52:15.611982Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_e0e6f060209e4c01","author":"jeremy","body":"Substantially delivered by Sprint P4 (card_3845a8346, commit fcb9844): loader, per-board presentation.theme, resolveTheme precedence, nav picker, two-file sharing (css+json manifest), install-by-reload. The ONE remaining deliverable — extracting the embedded journal/labels blocks into definitions/themes/ files — is tracked in card_3d8ed6d9f2c3474f848d8c0f7d1814b9. Recommend closing this card in favor of P4 + card_3d8ed6d9f2c3474f848d8c0f7d1814b9.","created_at":"2026-07-08T09:34:03.239609Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_1e789c9ac0b94f6d","author":"jeremy","body":"STATUS CHECK 2026-07-10 on frontend-rebuild:\n\nLoader path appears landed (internal/config/themes.go + tests; demo has definitions/themes/ocean + jeeruh). Nav theme picker lists loaded themes.\n\nACCEPTANCE gap to close before done:\n- journal/labels still embedded in style.css (tracked by 3d8ed6d9 extract card — OK as dep, don't block \"loader\" if examples ocean/jeeruh satisfy install-by-drop)\n- Confirm broken theme → warning + fallback (test exists for unscoped reject)\n- Confirm board.presentation.theme resolves one board (theming chain in render.go)\n\nIf extract is explicitly deferred, update acceptance here so this can move to done without waiting on 3d8ed6d9. Otherwise keep in review under extract.","created_at":"2026-07-09T23:25:58.678452Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_1b4525f0a8d2429d","author":"claude","body":"ACC hygiene 2026-07-11: extraction obligation removed from this card's acceptance (now card_3d8ed6d9). Loader/sharing path was substantially delivered by Sprint P4. After human confirms themes.go + demo themes load + broken-theme warn path, READY-TO-FLIP-DONE against the loader commit lineage (e.g. fcb9844 / card_3845a834). Do not rubber-stamp without that check.","created_at":"2026-07-11T20:41:39.384695Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_23ca82c6ddc14651","author":"claude","body":"P0a disposition 2026-07-11: LEFT IN REVIEW (do-not-flip list). Loader lineage largely shipped; extraction deferred to card_3d8ed6d9. Needs human loader verify before flip.","created_at":"2026-07-11T21:30:44.38117Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_df54acf3456a4e7c","author":"claude","body":"Human-accepted 2026-07-12: loader path verified (internal/config/themes.go + demo ocean/jeeruh themes load; broken-theme warn path). Embedded-theme extraction remains card_3d8ed6d9 (already linked).","created_at":"2026-07-12T01:54:15.0537Z","edited_at":"0001-01-01T00:00:00Z"}],"version":13,"created_at":"2026-07-07T17:58:41.22433Z","updated_at":"2026-07-12T01:54:15.090689Z","created_by":"local-dev","status_since":"2026-07-12T01:54:15.090689Z"},"type":"card"} {"data":{"id":"card_0707a00842774e5895cce3c2a40909e1","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"UI: board settings editor MVP (columns, types, theme, WIP limits)","status":"backlog","fields":{"branch":"feat/board-settings","description":"Edit an existing board's definition from the UI (not hand-edited JSON).\n\n**Current state (verified):** a create-board flow and `board_create.html` template exist (server.go:218), sharing `field_control.html`. This editor reuses those form primitives for the EDIT case rather than inventing a second form.\n\n**Depends / sequencing:**\n- Create-board flow (f6d2f5ea) already exists — settings SHOULD share form primitives (columns, types, WIP) with create, not invent a second form.\n- Schema-authoring depth answer lives on 8b5a4937: this editor edits BOARD subset + presentation only, NOT workspace columns/card-types generators.\n\n**MVP scope:**\n1. Columns: enable/order from workspace.columns (no invent-new-column in UI).\n2. `card_type_ids` multiselect.\n3. Theme name dropdown of loaded themes.\n4. WIP limit per column (simple int field).\n5. Persist via the existing reload/write-definition path; show structured errors inline.\n\n**Entry point:** gear icon on the board header (control layer), not buried in nav.\n\n**LATER (not this card):** saved-filters DSL UI, `lane_group_by`, `detail_sections`, token-swatch playground, transitions editor.\n\n**Done when:** an admin can change a board's columns (enable/reorder), card types, theme, and per-column WIP limits from the UI; changes persist via the definition-reload path and surface on the live board; invalid input shows a structured error (which field, what was allowed) instead of a generic 500. No JSON editing required."},"tags":["feature"],"comments":[{"id":"cm_a7730be3854f43dc","author":"jeremy","body":"2026-07-10 re-scope: cut from \"edit everything\" to board-subset MVP so it can ship after create-board polish. Visual schema editing of workspace types stays a design question on 8b5a4937, not this implement ticket.","created_at":"2026-07-09T23:25:58.669805Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_4046d205230c4e84","author":"foz","body":"**Review (batch C, keep backlog).**\n\n**Validated:** `board_create.html` template + create flow exist (server.go:218), sharing `field_control.html`. The MVP-vs-LATER split jeremy added on 2026-07-10 is sound and was already reflected in the description. The depends-on (f6d2f5ea create flow, 8b5a4937 schema-authoring depth) is consistent.\n\n**Issues found:** Title listed \"filters, presentation\" but those are explicitly LATER/out-of-scope per the re-scope — title was misleading. No done-when checks; persistence path and error UX were implied but not stated as acceptance.\n\n**Changes made:** Retitled to \"board settings editor MVP (columns, types, theme, WIP limits)\" to match the actual scope. Added a \"Current state (verified)\" line citing server.go:218. Added an explicit \"Done when\" block covering persistence via the definition-reload path, live-board surfacing, and structured-error behavior on invalid input. Kept #feature tag and the depends-on sequencing.\n\n**Scope verdict:** Correctly MVP-bounded after jeremy's re-scope; the title now matches. The LATER list (filters DSL, lane_group_by, detail_sections, swatch playground, transitions editor) cleanly seeds follow-up cards. Keep backlog.\n\n**Arch verdict:** Aligned. Reusing create-board form primitives (principle: small core, big composition; no second form) and persisting via the existing definition-reload path keeps this a thin UI over the schema — no new persistence or engine. Structured errors match principle 9 (fail loudly, guide recovery).","created_at":"2026-07-19T17:35:25.574663Z","edited_at":"0001-01-01T00:00:00Z"}],"version":7,"created_at":"2026-07-02T13:29:42.669676796Z","updated_at":"2026-07-19T17:35:25.574663Z","created_by":"jeremy","status_since":"2026-07-09T23:26:32.365896Z"},"type":"card"} +{"data":{"id":"card_086bb35fe9794d6fa3e183b3f0c34f1c","workspace_id":"demo","type_id":"api-task","schema_version":1,"title":"cards init installs the agent skill beside .cards","status":"review","fields":{"acceptance":"init writes .claude/skills/cards/ beside .cards/ in a fresh directory, and still writes it when the workspace already exists. Re-running reports no-clobber rather than overwriting. --no-skill opts out. --global writes ~/.claude/skills/cards even with CARDS_HOME set elsewhere. init's Next: text names the skill path; get-started.md and reference/cli.md document the flag.","api_change":"additive","description":"Once internal/agentguide embeds the skill, `cards init` becomes the distribution path — no copy-once instruction, no root skills/ mirror. Four things need care in cmd/cards/init.go:\n\n- Skill no-clobber and workspace no-clobber are SEPARATE decisions. Today !created returns at init.go:53 and --quiet returns at init.go:50, so an existing project (the common case) would never get the skill. Install before both.\n- Local target derives from `abs`, not `dir`: /.claude/skills/cards, a sibling of .cards, never inside it.\n- --global must NOT read globalHome() — it honors $CARDS_HOME (workspace.go:92), which would scatter .claude/skills/ wherever the workspace was moved. Use os.UserHomeDir() -> ~/.claude/skills/cards.\n- Always .claude/skills/cards/; --no-skill opts out. No --skills harness enum.\n\nNever overwrite; report \"cards skill already exists at ...; not overwritten.\" No --force in this slice. The isWorkspaceDir(abs) error at init.go:40 keeps returning early — that is pointing at the wrong directory, not an install case.\n\nDepends on the agentguide package landing first (card 524c06f7) or this races the embed.","endpoint":"cards init","verify":"go test ./cmd/cards"},"owner":"claude","links":[{"type_id":"depends-on","target":"card_524c06f7e65447a79231766d1373381f","created_by":"claude","created_at":"2026-08-27T14:22:51.342662Z"}],"comments":[{"id":"cm_c89dd93f5f404ee1","author":"claude","body":"Shipped. `cards init` now installs the embedded skill to /.claude/skills/cards, via agentguide.InstallSkill (mirrors starter.Scaffold's no-clobber contract).\n\nAll four traps handled and each has a test:\n- Skill install runs BEFORE the --quiet return and the !created return, so a project that already has a board finally has an install path. Verified at the CLI: `init --no-skill` then `init` on the same dir installs the skill without touching the workspace.\n- Local target derives from abs, not dir — skill lands beside .cards, never inside it. Asserted by checking no .cards/.claude exists.\n- --global reads os.UserHomeDir(), not globalHome(). Test sets CARDS_HOME and HOME to different temp dirs and asserts the skill follows HOME and does NOT appear under CARDS_HOME.\n- No --skills harness enum. Always .claude/skills/cards/; --no-skill opts out.\n\nNever overwrites: a locally edited SKILL.md survives a re-init, and init reports 'cards skill already exists at ...; not overwritten' rather than silently skipping. No --force in this slice.\n\nDocs: init's Next: block now names the skill path, and get-started.md + reference/cli.md carry --no-skill and the HOME-vs-CARDS_HOME distinction. `cards help` updated for both init and mcp.\n\ngo test ./cmd/cards passes, -race clean.","created_at":"2026-08-27T14:32:22.370781Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_aadd73b4238547ac","author":"claude","body":"P2 addressed — the installer is now atomic.\n\nInstallSkill stages the tree into a sibling temp dir (os.MkdirTemp inside .claude/skills/, so the rename stays on one filesystem) and renames it into place only after every file has landed. A failed or interrupted install now leaves no destination at all, so it can no longer masquerade as a protected user skill on the next run. The staging dir is removed on any failure path.\n\nAlso added the completeness check you asked for: a destination that exists but has no SKILL.md returns ErrIncompleteSkill naming the path and the remedy, rather than the old success-like created=false. Only SKILL.md is required — a user may legitimately prune references, but a directory without SKILL.md is not a skill a harness can load. Nothing is auto-repaired: overwriting could destroy someone's own work, so the fix is named and left to them.\n\nRace handled: if the rename loses to a concurrent install, a now-complete destination is treated as success rather than an error.\n\nOne thing the fix surfaced: returning the skill error immediately meant 'cards init' on a debris directory failed without ever reporting the workspace it had just created. Reordered so both facts print and the error still propagates (exit 1). Tested.\n\nFour new tests: partial destination never survives a failed install, incomplete destination is rejected by name, successful install is complete with no staging residue and is protected on re-run, and the workspace result is not swallowed by a skill failure.\n\nVerified at the CLI: fresh init installs all three files; a hand-made references-only directory produces the incomplete-install error with exit 1; no staging directory survives success.","created_at":"2026-08-27T21:05:11.058133Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_405152dc5a3f4bb7","author":"claude","body":"Follow-up polish: a destination directory with no SKILL.md is now treated as debris and replaced on the next `cards init`, instead of returning ErrIncompleteSkill forever. A regular file occupying the skill path is still refused (not overwritten). Docs now say to delete `.claude/skills/cards` and re-run init to pick up a newer playbook — complete skills remain no-clobber.","created_at":"2026-08-29T21:55:04.196483Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_4060f5fe86ba4bf5","author":"foz","body":"Review correction before PR follow-up: the proposed automatic repair of any `.claude/skills/cards/` directory lacking `SKILL.md` was rejected because it could recursively delete user-authored files and violate no-clobber. The committed behavior remains: installs are atomic, incomplete destinations fail loudly with a manual removal remedy, and existing skill directories are never overwritten. Documentation now explains how to review local edits, delete the directory explicitly, and re-run `cards init` when updating the playbook.","created_at":"2026-08-29T23:49:36.10738Z","edited_at":"0001-01-01T00:00:00Z"}],"version":10,"created_at":"2026-08-27T14:22:43.351925Z","updated_at":"2026-08-29T23:49:36.10738Z","created_by":"claude","status_since":"2026-08-27T21:05:11.089745Z"},"type":"card"} {"data":{"id":"card_096261c37112433b9fcbf08571ca3378","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"Bug: stale-version modal save toasts 'Saved' while dropping the write","status":"done","fields":{"branch":"frontend-rebuild","description":"Found during rebuild Phase 2 verification (pre-existing, NOT a rebuild regression — reproduced identically on the pre-rebuild code path).\r\n\r\nREPRO: POST /ui/cards/{id}/save with a stale version and a changed title → HTTP 200, write correctly rejected by CAS server-side, BUT: uiSaveCard's error branch re-renders the modal WITH the error attached at HTTP 200 (internal/httpapi/ui.go, PatchCard err branch). Client wireDirtySave checks r.ok → sees 200 → toasts 'Saved' and swaps in the error-bearing modal. User sees a green 'Saved' toast while their change was dropped (the embedded error alert renders, but the toast contradicts it).\r\n\r\nFIX (belongs to rebuild Phase 4's shared cardsAPI.send() helper — version-aware fetch + structured-error parse in ONE place): either (a) server returns the real 4xx status with the re-rendered modal body (client swaps on !ok too, shows error toast via apiErrText), or (b) client inspects the swapped HTML for the error alert before toasting. (a) is honest HTTP and matches the /v1 transports' contract (fail loudly, guide recovery).\r\n\r\nAcceptance: stale-version save shows a version-conflict toast + the modal error (no 'Saved'); fresh save still toasts 'Saved'; detail-page save path same contract; regression test on the save handler status code.","work_log":[]},"owner":"jeremy","tags":["bug"],"comments":[{"id":"cm_ea7cf7040923499a","author":"local-dev","body":"Note from rebuild Phase 4 verification: comment add/edit + entry add/edit/remove + artifact upload now run through Alpine components with cardsAPI (409→stale message inline, verified on this card). The Saved-toast-on-conflict bug this card tracks is the remaining dirty-save path — Phase 8 scope.","created_at":"2026-07-09T11:20:34.886831Z","edited_at":"2026-07-09T11:24:48.425506Z"},{"id":"cm_4291d9fda5b042d3","author":"jeremy","body":"FIXED in rebuild Phase 8 (commit 3ff4361, card card_037a6f640f2e4fb48a3b55271ab71a7c). Server-side: renderCardModalErr + renderCardDetail(save-path) now WriteHeader the real HTTPStatus before the alert-embedded fragment — the plan's option (a), honest HTTP matching the /v1 transports. Client-side: editForm on the save-form via cardsAPI; a 409 becomes {stale:true, message: STALE_MSG}. Pinned by TestUISaveReturnsRealStatusOnConflict. Browser-verified: stale save → STALE_MSG toast + embedded version_conflict alert, no false Saved, no server mutation.","created_at":"2026-07-09T15:43:51.065488Z","edited_at":"0001-01-01T00:00:00Z"}],"version":20,"created_at":"2026-07-09T08:38:51.744216Z","updated_at":"2026-07-09T20:00:37.862464Z","created_by":"jeremy","status_since":"2026-07-09T20:00:37.862464Z"},"type":"card"} {"data":{"id":"card_0a642f56db8d42529654ffd27008631b","workspace_id":"demo","type_id":"frontend-task","schema_version":1,"title":"UI: harden $store.live reconnect (single socket, generation guard)","status":"done","fields":{"a11y":"If live status is exposed, keep non-color text/status for reconnecting/down (optional UI).","acceptance":"Never more than one EventSource to /v1/events/stream per tab; reconnect resumes with since=lastId; no silent total death of live updates without user-visible hint after sustained failure.","branch":"ui/live-store-hardening","description":"Sprint 07-10 P4 must-ship #4 (tracker card_3f225267). Follow-on to P9 filter-stall fix and closed card 60f2e6a8. DEPENDS ON: thin harness card_0391870a (the gen-guard invariant needs a Node unit test to live in).\n\nRisks still present:\n- onerror schedules open() with overlapping timers if errors cluster\n- no generation token: stale EventSource handlers can deliver after replace\n- live handler exceptions swallowed (empty catch) — swapBoard failures go silent\n- handlers array not idempotent across remounts if stop() not paired\n\nFIX:\n1) WRITTEN CONTRACT first: code comment above $store.live specifying exactly what open()/stop()/onerror/onmessage do w.r.t. the generation counter — stop() closes the ES, bumps the generation, clears pending delivery (not just a token check in onerror). Do NOT change on/off signatures.\n2) reconnectTimer cleared on stop/open; generation++ per open; ignore mismatched es.\n3) assert single es; close previous before assign.\n4) log/count handler errors; toast after N consecutive swap failures.\n5) Decision logic in a pure shouldDeliver(gen, currentGen)-style helper, unit-tested in the Node harness.\n\nOUT OF SCOPE: extending TestSSEKeepalive — a server-side test cannot pin a client-side invariant; the server test guarantees keepalive only.\n\nVERIFY: DevTools — one EventSource across 20 filter toggles; kill server briefly; one reconnect backoff; no duplicate board thrash. DUAL-CONSUMER check recorded here: board tab + /ui/breaches tab, server kill-and-restart, each event delivers once, no stale ES delivers after stop().\n\nACC: never more than one EventSource to /v1/events/stream per tab; reconnect resumes with since=lastId; Node test pins the generation invariant; no silent death of live updates without a user-visible hint after sustained failure.\n\nFOLDED IN from the PR #18 Copilot review (2026-07-10) — two latent handler-lifecycle bugs, benign today because boardPage lives on the persistent .board-view root (init runs once), but exactly the reconnect/remount hazards this card exists to close:\n- $store.live on() is NOT idempotent: start() has a same-boardId/types guard, but on() always pushes a new closure. A re-init (parent initTree, or a future SPA nav path) would stack duplicate debouncedSwap calls per SSE event. FIX with the generation guard: clear/replace handlers on (re)start, or key delivery by generation so stale closures no-op.\n- boardPage.init() adds a window popstate listener with no teardown. Same persistent-root reason it is harmless now; add removal via Alpine $destroy/destroy() so it cannot stack if navigation ever goes through a fragment path.\nBoth should be covered by the written open/stop/onerror/onmessage + handler-lifecycle contract this card already calls for.","design_ref":"components.js $store.live; sse.go keepalive","platforms":["desktop"],"surface":"board"},"tags":["bug"],"links":[{"type_id":"parent","target":"card_86515fd2a4784e3793e94885b7be2e7f","created_by":"jeremy","created_at":"2026-07-09T23:26:10.913461Z"},{"type_id":"related","target":"card_86515fd2a4784e3793e94885b7be2e7f","created_by":"jeremy","created_at":"2026-07-09T23:26:10.914596Z"},{"type_id":"depends-on","target":"card_0391870affcb460a8d252e928b635024","note":"gen-guard unit test needs the Node harness","created_by":"claude","created_at":"2026-07-10T02:08:01.65465Z"}],"comments":[{"id":"cm_aef28bbf30ea4f4e","author":"claude","body":"Done + committed to main. $store.live hardened with a generation counter (bumped on open/stop; superseded ES self-silence via shouldDeliver), tracked reconnectTimer, idempotent on()/off(), and destroy() cleanup on boardPage+breachesPage (fixes the two #18-review findings: on() dedup + popstate cleanup). Also fixed a pre-existing backoff bug — resetting backoff in open() defeated the ramp; moved to es.onopen so it truly ramps 500ms→8s (verified: ~4 attempts over 6s down, was ~35). Pure decisions (shouldDeliver/nextBackoff/maxEventId) extracted to helpers.js and unit-tested (tests/js/live.test.cjs). Verified in-browser: connect→kill→restart→delivery, handler count stays 1 throughout, card appears exactly once, backoff ramps then resets. The dropped TestSSEKeepalive-extension idea stands (server test can't pin client reconnect). Paired with the JS harness card_0391870a.","created_at":"2026-07-11T12:49:28.177124Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_acae572d33e14c2c","author":"claude","body":"P0a verify 2026-07-11: flipped to done. Verifying commit 8447cb7. shouldDeliver / generation guard + live.test.cjs pin single-socket reconnect behavior.","created_at":"2026-07-11T21:30:34.783482Z","edited_at":"0001-01-01T00:00:00Z"}],"version":11,"created_at":"2026-07-09T23:25:58.7087Z","updated_at":"2026-07-11T21:30:34.823428Z","created_by":"jeremy","status_since":"2026-07-11T21:30:34.823428Z"},"type":"card"} {"data":{"id":"card_0c59f1106fdf4641b9c4eb15be3d682c","workspace_id":"demo","type_id":"programming-task","schema_version":2,"title":"Events: normalize definition reload event envelopes","status":"backlog","fields":{"branch":"fix/reload-event-contract","description":"Definition reload success/failure currently publish raw Event literals from cmd/cards/reload.go with board_id but without the normal board-event scope/version constructor path. Define their live bus-only envelope explicitly, add typed constructors and fixture/stream tests, remove raw literals, and align event docs without making reload notifications durable unless that is a deliberate contract change.","kind":"bug"},"links":[{"type_id":"related","target":"card_ec61b093d1a444dcb5c915518de5c67f","note":"Contract consistency follow-up from event-doc review","created_by":"claude","created_at":"2026-07-23T21:48:41.143928Z"}],"version":2,"created_at":"2026-07-23T21:48:41.114589Z","updated_at":"2026-07-23T21:48:41.143928Z","created_by":"claude","status_since":"2026-07-23T21:48:41.114589Z"},"type":"card"} @@ -78,6 +79,7 @@ {"data":{"id":"card_519e1688f06646ff9817268b2ed0c9a7","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"Sprint P1 — Restore drift audit (doc truth)","status":"done","fields":{"branch":"docs/contract-honesty","description":"Foundation phase. Make source-of-truth docs describe the workspace-reload feature that actually shipped (commit f772951). Zero code risk.\n\nSteps:\n- [x] Rewrite INTEGRATOR-REFERENCE.md §6 reload caveat -> describe POST /v1/workspace/reload (atomic generation swap, store/bus survive, 422 keeps OLD generation, definition_reloaded on SSE). Verify vs cmd/cards/reload.go + reload_test.go.\n- [x] Fix ROADMAP.md §7 'Workspace reload' entry (was proposed/card 4b507da7, now shipped f772951); drop dangling 4b507da7 ref.\n- [x] Update SPEC-API-SURFACE.md 'POST /workspace/reload -> not yet implemented'; add POST /v1/boards if missing.\n- [x] Close (not implement) CSS brace-balance test follow-up — already exists as internal/docaudit/uicontract_test.go::TestStyleCSSBalanced. Add [not built] markers in docs/extensions/MCP.md for tools absent from internal/mcp/mcp.go.\n\nDemo: grep -rn 'no reload handler|reload.*means restart|no file watching' docs/ returns nothing.\nExit: three docs describe reload as shipped w/ 422-keeps-old semantics; brace-test card closed citing TestStyleCSSBalanced; go test ./... green."},"owner":"jeremy","tags":["feature"],"comments":[{"id":"cm_c010ea355d274652","author":"jeremy","body":"DONE. All 4 steps complete; go test ./... green (13 pkgs, exit 0); no live reload-drift phrases remain in docs/ (outside archive + the plan doc itself).\n\nEdits:\n- INTEGRATOR-REFERENCE.md §6 + events section: reload described as [built] (atomic generation swap, store/bus survive, 422 keeps old gen, definition_reloaded board-scoped fan-out) + POST /v1/boards.\n- ROADMAP.md §7: Workspace reload -> built (shipped f772951, card 4b507da7 absorbed).\n- SPEC-API-SURFACE.md: POST /workspace/reload + POST /boards marked implemented with response/status shapes.\n- DEVELOPER-REFERENCE-SCHEMA-AUTHORING.md: reload note corrected (also documents the cards reload CLI verb, which I verified works).\n\nFindings (verify-before-claiming):\n1. No 'brace-balance test' follow-up CARD exists — it was only a TODO in the prior sprint plan + THEMES.md; TestStyleCSSBalanced already exists (internal/docaudit/uicontract_test.go:20). Nothing to close.\n2. MCP docs were NOT over-claiming any tool. Opposite drift: internal/mcp/README.md (the declared 'absolute tool inventory') OMITTED 4 built tools — attach_artifact, get_artifact, events, breaches (+ workspace). Added them so the inventory matches code's 16 tools.","created_at":"2026-07-07T20:57:34.551937Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_b70a224fb6f74dbc","author":"jeremy","body":"Committed as 475b111 on branch docs/contract-honesty.","created_at":"2026-07-07T23:48:49.800781Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_4212188c886a402b","author":"claude","body":"Note for 07-11 Phase 0: this past drift-audit pass fixed INTEGRATOR-REFERENCE for POST /v1/workspace/reload, but the file STILL contains residual \"no reload handler / no file watching\" language (~§6 footnotes / ~L325,448 in current tree) that 07-11 Phase 0 must re-sync. There is also NO dedicated file-watcher board card to \"re-scope\" — Phase 3 must CREATE the --watch card rather than assume one exists. MCP gap list in SPEC-API-SURFACE:186 is also stale relative to mcp.go (breaches/events shipped; six tools still missing).","created_at":"2026-07-11T20:41:39.386173Z","edited_at":"0001-01-01T00:00:00Z"}],"version":8,"created_at":"2026-07-07T20:50:49.734647Z","updated_at":"2026-07-11T20:41:39.386173Z","created_by":"jeremy","status_since":"2026-07-07T23:48:49.760545Z"},"type":"card"} {"data":{"id":"card_51c0facfe8784af3b31194396c748b87","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"Docs: close Alpine/Pinemix ADR gaps (design-system already holds most decisions)","status":"todo","fields":{"branch":"docs/frontend-adr","description":"Most decisions this card wanted are already the lasting contract in `docs/architecture/design-system.md` § \"Interactivity layer (Alpine.js)\" — Alpine 3.15.0 self-hosted+embedded; Go templates own server data + first paint; Alpine owns ephemeral UI state only; `swapHTML` is the sole HTML→DOM seam; Pinemix is a *behavior* reference only (Tailwind styling always replaced); enforcement via `internal/docaudit/frontend_test.go`. Rebuild JS already has `cardsAPI` (`templates/assets/api.js`) as the sole fetch seam.\n\n**Do not treat the prior comment as fact:** `docs/design/ADR-pinemix-reference-only.md` does not exist. `docs/plans/frontend-rebuild-plan.md` was archived to `docs/archive/2026-07-sprints/frontend-rebuild-plan.md`. Sprint note that claimed \"ADR accepted\" was aspirational.\n\n## Remaining work (docs only — no kernel/UI code)\n\nPrefer extending the existing design-system section (it already functions as the ADR) over inventing a parallel `ADR-*.md`, unless you truly need a short standalone pointer file that links there.\n\n1. **Document `cardsAPI` as the sole fetch seam** alongside `swapHTML` in design-system (today only the code + comments say this).\n2. **CSP escape hatch (one sentence):** default Alpine build assumes no CSP / needs eval; if a CSP arrives, switch to `@alpinejs/csp` (authoring impact). Note Google Fonts remote link remains known-out-of-scope if still true.\n3. **One-paragraph \"are we using Pinemix?\" answer**, crisp enough to paste in reviews: optional *behavior* reference (patterns: open/close, roving tabindex, type-ahead); **zero** Pinemix/Tailwind CSS or package dependency; we do not import or vendor their source — so a harvest-license gate is **N/A** unless/until someone copies their source text (// HARD GATE then).\n4. **Point enforcement** at the concrete docaudit guards (`TestSwapSeamIsTheOnlyInnerHTMLWrite`, x-for allowlist, Alpine present, etc.).\n5. **Fix stale links:** any live doc still pointing at `docs/plans/frontend-rebuild-plan.md` should cite the archive path or the design-system section instead.\n\n## Out of scope\nJS/CSS implementation, combobox harvest work, changing Alpine build, new dependencies, status moves on child feature cards.\n\n## Done when (observable)\n- [ ] design-system (or a short ADR that only links to it) states: Alpine self-hosted; division of labor; `swapHTML` sole HTML seam; `cardsAPI` sole fetch seam; Pinemix reference-only + license N/A unless source harvest; CSP → `@alpinejs/csp` escape hatch; docaudit as enforcement.\n- [ ] One paragraph answers \"are we using Pinemix?\" without reading the rebuild archive.\n- [ ] `rg -i pinemix` on non-archive docs shows only that intentional reference (no CSS/import claims).\n- [ ] `rg 'docs/plans/frontend-rebuild-plan'` is clean outside archive (or deliberately historical).\n- [ ] `go test ./internal/docaudit` still green (docs change must not loosen guards).\n\nParent/tracker: UI wave `86515fd2`. Pure docs; no dependency on further JS work.","kind":"design"},"tags":["feature"],"links":[{"type_id":"parent","target":"card_86515fd2a4784e3793e94885b7be2e7f","created_by":"jeremy","created_at":"2026-07-09T23:26:10.921275Z"},{"type_id":"related","target":"card_86515fd2a4784e3793e94885b7be2e7f","created_by":"jeremy","created_at":"2026-07-09T23:26:10.92587Z"},{"type_id":"related","target":"card_61040a3e435b4de8bad3227fc440d788","note":"rides the P2 docs commit train","created_by":"claude","created_at":"2026-07-10T02:08:01.760182Z"}],"comments":[{"id":"cm_90cb502e71ce4f05","author":"claude","body":"Sprint 07-10: rides with the P2 docs commit train (AUTH RFC card_61040a3e) — no dependency on the JS work. Pure docs: docs/design/ADR-pinemix-reference-only.md, status: accepted, zero Pinemix code/CSS imports (grep-verified).","created_at":"2026-07-10T02:07:04.501218Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_b25921652d7a42ac","author":"foz","body":"## Validated\n- Card type/scope is docs-only (programming-task, no kernel claim) — correct for ADR/design prose.\n- Rebuild plan exists only as archive: `docs/archive/2026-07-sprints/frontend-rebuild-plan.md` (not `docs/plans/frontend-rebuild-plan.md`).\n- **Claimed ADR path is false:** `docs/design/ADR-pinemix-reference-only.md` does **not** exist (ls confirmed). Sprint `docs/archive/2026-07-sprints/sprint-2026-07-10.md` line calling this card “ADR … status: accepted” was aspirational bookkeeping, not evidence of a merged file.\n- Substantial “ADR body” **is already ship-quality contract language** in `docs/architecture/design-system.md` § “Interactivity layer (Alpine.js)”: Alpine 3.15.0 self-hosted+embedded; Go templates = server data; Alpine = ephemeral state; no `x-for` over server JSON; `swapHTML` sole HTML seam; docaudit enforcement; Pinemix = behavior reference / Tailwind replaced.\n- Code reality matches that prose: `templates/assets/alpine.min.js`, `ui.js` `swapHTML`, `api.js` `cardsAPI`, `internal/docaudit/frontend_test.go` guards. `rg -i pinemix` outside archive hits only design-system’s intentional reference (no CSS/package import).\n\n## Issues found\n1. **Prior comment inaccurate** — asserts an accepted ADR file that was never written.\n2. **Stale plan path** in the old description (`docs/plans/frontend-rebuild-plan.md`).\n3. **Incomplete vs Phase-0 exit checklist:** design-system does **not** yet document (a) `cardsAPI` as sole fetch seam, (b) `@alpinejs/csp` escape hatch / eval posture, (c) explicit one-paragraph “are we using Pinemix?” + license N/A-unless-source-harvest. Those are the only real leftovers.\n4. Card read as “write greenfield ADR” despite the living contract already being the design-system section — risk of duplicate/conflicting docs.\n\n## Changes made\n- Title reframed to “close … gaps” and acknowledge design-system holds most decisions.\n- Description rewritten: current truth, false ADR claim struck, remaining 5 docs bullets, out-of-scope, observable **Done when** checkboxes.\n- Set `kind: design` (docs/decision residue). Left **status = todo** (not done; leftovers remain). Did not mark done.\n\n## Scope verdict\n**focused leftovers / possibly-complete-on-merge** — not a full rewrite-from-scratch; not a split. One short docs PR can finish it. Prefer patching design-system (optionally a tiny pointer ADR) over a parallel treatise.\n\n## Arch verdict\n**aligned** — pure docs on web-UI division-of-labor; extends existing architecture contract; no kernel/schema/API surface. Enforcement stays in docaudit tests, not new runtime.","created_at":"2026-07-19T15:32:16.225011Z","edited_at":"0001-01-01T00:00:00Z"}],"version":7,"created_at":"2026-07-09T23:25:58.711148Z","updated_at":"2026-07-19T15:32:16.225011Z","created_by":"jeremy","status_since":"2026-07-09T23:25:58.711148Z"},"type":"card"} {"data":{"id":"card_5227ba050b1143bb9d8c7cd4dcfb061d","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"Docs: restructure docs/ directory for clarity","status":"done","fields":{"branch":"plan/triage","description":"Review docs/ and reorganize: split into clear top-level areas (e.g. docs/concepts, docs/reference, docs/guides, docs/design). Rename for consistency (PHILOSOPHY/NOTES/SPEC casing). Add an index/README. Ensure cross-references stay valid. Goal: a reader can navigate from philosophy → getting started → reference without guessing filenames."},"tags":["feature"],"comments":[{"id":"cm_891a8a828c4c47a5","author":"foz","body":"TRIAGE: deferred to backlog (priority P1-later). Not on the critical path for dogfooding fixes. Should move to backlog column but the enforced-transition board does not allow todo→backlog — this is itself dogfooding evidence for the release/force-move card (P0).","created_at":"2026-06-26T12:03:49.409644Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_535751616b8f4cd1","author":"jeremy","body":"Docs restructure shipped in commit ed00609 (docs/ reorganized into architecture/, events/, spec/, reference/, concepts/, examples/). Closing.","created_at":"2026-07-04T22:02:02.555905409Z","edited_at":"0001-01-01T00:00:00Z"}],"version":8,"created_at":"2026-06-26T11:51:21.12383Z","updated_at":"2026-07-04T22:02:27.944594115Z","created_by":"foz","status_since":"2026-07-04T22:02:27.944594115Z"},"type":"card"} +{"data":{"id":"card_524c06f7e65447a79231766d1373381f","workspace_id":"demo","type_id":"api-task","schema_version":1,"title":"Agent guidance: repo owns the skill, MCP serves the handshake","status":"review","fields":{"acceptance":"MCP initialize returns non-empty instructions and a version that is not \"poc\". `cards mcp --print-instructions` stdout equals the embedded bytes. Editing invariants.md fails the splice guard with a message naming the -update command; running it turns the suite green. The handshake stays under the cap. CLI flag facts live in the skill, not in invariants.md, so Sprint A never touches the handshake.","api_change":"additive","description":"The best 'how to work a cards board' document is ~/.claude/skills/cards/SKILL.md — untracked, machine-local, not in chezmoi, and better than what we ship. Meanwhile MCP initialize returns no `instructions` (the protocol has a slot for exactly this) and hardcodes serverInfo.version \"poc\".\n\nScope:\n- New internal/agentguide: invariants.md (IS the MCP instructions body), skill/SKILL.md (authored, one marked region == invariants.md), skill/references/cli-reference.md. Embedded.\n- handleInitialize serves instructions + a real version plumbed via a functional option on mcp.New (match core.ServiceOption; do not lift a package).\n- `cards mcp --print-instructions` prints the same bytes.\n- Docs: agents/instructions.md points at the command instead of hosting a twin; internal/mcp/README.md:8 stale ./.work-cards path fixed.\n- Guards: splice equality with -update; MCPInstructions() under a hard cap (<=40 lines / 2KB); initialize instructions non-empty and version != poc.\n\nResolves the standing contradiction: SKILL.md tells the worker to record on its card, pi-cards/src/work.ts:148 tells it never to. Default is stated once in invariants.md — worker records unless an orchestrator says it owns bookkeeping.\n\nOut of scope: MCP prompts; the adoption/migration playbook; richer tool descriptions; Cursor as an install target.","endpoint":"cards mcp initialize","verify":"go test ./internal/agentguide ./internal/mcp ./cmd/cards"},"owner":"claude","comments":[{"id":"cm_ae0a98d85ebf4d93","author":"claude","body":"Shipped. internal/agentguide now owns the guidance: invariants.md is the single shared trunk, served verbatim behind a two-line preamble as the MCP handshake and spliced into skill/SKILL.md's marked region by `go test ./internal/agentguide -update`. Nothing else is generated — the skill's operational sections stay authored, so the always-on handshake stays short while the on-demand skill grows independently.\n\nVerified:\n- Live handshake against ./.cards returns instructions (1917 bytes / 28 lines, inside the 2048/40 caps) and serverInfo.version 'dev (42eeea6dfd44-dirty)' instead of the 'poc' stub.\n- `cards mcp --print-instructions` output is byte-equal to the served text (asserted in cmd/cards, not just checked by hand).\n- Drift loop exercised end to end: appended a rule to invariants.md, the guard failed naming `go test ./internal/agentguide -update`, ran it, the skill picked the rule up, restored clean.\n- go build ./... && go vet ./... && go test ./... all pass; -race clean on agentguide, mcp, cmd/cards, docaudit.\n- All relative links in the three rewritten docs resolve (mkdocs isn't installed locally; CI builds on push).\n\nTwo decisions worth recording. The handshake carries no CLI flag facts on purpose — Sprint A changes `comment --body` and mandatory --version, and keeping those in the skill means Sprint A never touches the always-on slot. And the ownership contradiction (SKILL.md said the worker records; pi-cards/src/work.ts:148 said it never does) is now settled in one place: the worker records unless an orchestrator states it owns bookkeeping.\n\nDrive-bys: stale ./.work-cards path in internal/mcp/README.md:8, and mkdocs.yml was missing the 07-19 and 08-08 sprint plans from both nav and not_in_nav.","created_at":"2026-08-27T14:32:04.646619Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_ad2d3264f0b44d96","author":"claude","body":"Review findings addressed.\n\nP1 — adoption playbook. Added skill/references/project-practices.md (204 lines) and linked it from SKILL.md §8 and the intro. Covers all nine requested topics: adoption triggers + in-repo layout, the minimal schema ladder, epic-goal/story-acceptance/task-actions semantics, migration recipe with provenance ids, shared-server vs snapshot collaboration, the review packet and second-session review, the UTC storage/display rule, pre-commit + pre-push snapshot practice, and the release card/tag/CHANGELOG/snapshot relationship.\n\nIt is grounded in the two workspaces you named rather than invented. Both record-collection and shapemaker independently converged on the SAME shape — epic(goal), story(outcome+acceptance), task(actions+verify+work_log), a part-of link type, enforce_transitions + strict_fields + tag_policy locked + default_board, one board with wip_limits, and .cards/.gitignore for work-cards.db*. That convergence is the evidence for recommending the ladder as a default, and record-collection's scripts/cards-board.sh export|check|install-hooks is the source for the pre-commit/pre-push section.\n\nTwo things I deliberately did not fake: there is no importer (cards import restores a Cards snapshot only), so the migration section is honest that it is a script calling cards create; and cards release releases card OWNERSHIP, not a project release, so §9 is a convention rather than a command.\n\nAlso widened the skill's frontmatter description to trigger on setup/card-type-design/migration. Without that the playbook would ship but never fire — the description is the only always-visible part. Guarded by TestSkillDescriptionCoversAdoption, plus TestSkillReferenceLinksResolve so a reference can't be linked but unembedded.\n\nP3 — cross-harness overclaim. docs now say .claude/skills/ is what Claude Code and compatible harnesses read, name MCP as the harness-neutral path, and state plainly that another harness's skill location is a follow-up, not a promise. Fixed in get-started.md, reference/cli.md, and agents/instructions.md.\n\nHandshake is unchanged at 1923 bytes / 28 lines — the playbook is entirely on the on-demand side, which was the point.\n\nFull suite, vet, and -race all pass.","created_at":"2026-08-27T21:04:57.857714Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_229d92f975a64121","author":"claude","body":"Review follow-up on P1, still in review.\n\n- Playbook §1 now states the init→ladder seam: `cards init` writes the tutorial workspace (todo/doing/done, one task type, welcome board, no link_types), not the epic/story/task layout. Replace the starter and delete the welcome cards before creating work; `default_board` is set only after that board file exists.\n- SKILL.md §4 hierarchy line now matches the playbook: `part-of` for epic→story→task, `depends-on` for blocking.\n\n`go test ./internal/agentguide` still passes. Handshake untouched.","created_at":"2026-08-27T22:07:41.39382Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_2592405c1cff4e26","author":"claude","body":"Landed on branch feat/agent-guidance, three commits: the mkdocs drive-by split out on its own, the feature, then the smoke tests.\n\nThree review defects fixed before committing:\n- docs/agents/instructions.md said 'paste the handshake text (below)' but there was no below — now points at `cards mcp --print-instructions`.\n- The playbook claimed the two reference workspaces converged 'independently'. They share a template, so that was overstated evidence. Rewritten as 'what two existing Cards projects settled on — a recommended starting point, not a law.'\n- New: added the default_board write-order rule. settings.default_board is cross-checked at load (internal/config/config.go:65) and naming a board whose file doesn't exist yet is a HARD LOAD FAILURE, not a warning — every cards command against that workspace fails until fixed. The playbook told people to set default_board without warning them about the ordering. Now says write boards/.json first, and run `workspace show` after each definitions edit as the cheap check.\n\nThat third one is the sharpest thing the smoke test would catch, and it was found by designing the test rather than by running it.","created_at":"2026-08-28T16:21:12.998079Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_bdbdca8f62f14fb1","author":"claude","body":"Follow-up polish: cli-reference no longer lists depends-on|blocked-by|related as a fixed enum. Link types come from the workspace; part-of is named as the hierarchy convention, with a pointer to project-practices.md.","created_at":"2026-08-29T21:55:04.229299Z","edited_at":"0001-01-01T00:00:00Z"}],"version":10,"created_at":"2026-08-27T14:22:30.497814Z","updated_at":"2026-08-29T21:55:04.229299Z","created_by":"claude","status_since":"2026-08-27T21:05:11.120755Z"},"type":"card"} {"data":{"id":"card_524c5758b3b34e4d814142130cab9eca","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"Sprint 07-11 P3a: fix hook-supervisor stale-generation bug (serve.go:130 captures svc that reloadLocked closes)","status":"done","fields":{"branch":"fix/hook-supervisor-generation","description":"Phase 3 of docs/plans/sprint-2026-07-11.md, first step — a LIVE BUG today, and the --watch poller makes reload frequent, amplifying it from rare to constant. CHERRY-PICK this forward even if the rest of Phase 3 slips.\n\nBUG: the hook supervisor at cmd/cards/serve.go:130 captures the initial *core.Service; reloadLocked() (cmd/cards/reload.go) closes that generation on reload, leaving the captured svc pointing at a Closed service for every GetCard in cardBoardMembership/cardTypeID (internal/hooks/hooks.go).\n\nFIX: rewire the supervisor to a current-generation accessor (a.cur.Load().svc pattern) instead of a captured pointer. Record in the reload-contract note what a reload does to frozen hook declarations — today: nothing; document that as the explicit current rule, with re-declaration handling deferred to the P5 reconcile design.\n\nACCEPTANCE: after any reload, hook condition-evaluation runs against the live generation, never a closed one; the supervisor-generation provenance rule is written down where P5 will read it. Regression test that bites without the fix."},"tags":["bug"],"links":[{"type_id":"parent","target":"card_1b5289099221445090a54893e379106f","note":"sprint 07-11 phase card","created_by":"claude","created_at":"2026-07-11T21:07:50.932355Z"}],"comments":[{"id":"cm_0c646d073b3f440d","author":"claude","body":"P3a done 2026-07-11: hooks.New takes ServiceFunc; serve --run-extensions passes app.currentService so GetCard hits live generation after reload. Reload-contract note: hook declarations stay frozen until P5. TestSupervisorUsesCurrentGenerationAfterSwap + go test -race ./cmd/cards ./internal/hooks green.","created_at":"2026-07-11T21:39:36.475311Z","edited_at":"0001-01-01T00:00:00Z"}],"version":4,"created_at":"2026-07-11T21:07:50.733846Z","updated_at":"2026-07-11T21:39:36.48798Z","created_by":"claude","status_since":"2026-07-11T21:39:36.48798Z"},"type":"card"} {"data":{"id":"card_55405c9354be48c7944a3ec12401c034","workspace_id":"demo","type_id":"programming-task","schema_version":1,"title":"Storage: import/export + git-backed mirror + checkpoint strategy","status":"done","fields":{"branch":"plan/triage","description":"Review the storage layer for portability. (1) Export workspace to a git-friendly format (markdown mirror with frontmatter, or JSON) and import with version-gating (SPEC §3 mirror). (2) Consider backing options: SQLite file is fine, but document a checkpoint/backup strategy (WAL checkpoint, copy-on-write). (3) autoexport setting to keep a mirror in sync on every write for git review. This unlocks human review + disaster recovery.","work_log":[{"author":"pi","commit_hash":"feat/export","entry_id":"ent_15b5014235e142af","notes":"Implemented cards export (JSONL). Local command, reads SQLite directly. Format: {type:card|event|user, data:{...}} per line. Cards include embedded comments+links. Events are the full audit log. Verified: 48 cards, 243 events, 53 comments, 25 links, 5 users exported from the demo workspace. Import remains TODO.","timestamp":"2026-06-26"},{"author":"pi","commit_hash":"feat/cards-import","entry_id":"ent_12706f7ff6a14d10","notes":"Import shipped. cards import --workspace [--in file] restores a JSONL snapshot into a fresh DB, preserving card ids/versions/timestamps/comments/links/events. Restore-not-merge: refuses a non-empty workspace, duplicate card id is a hard error (never silent overwrite). Refactored export/import cores into portable.go (testable); added sqlite.InsertEventRaw. Tests: round-trip fidelity + guards (all green, -race). Docs: SPEC §3 + CLI ref. Verified by re-importing the 48-card engineering board. Committed on feat/cards-import, pushed.","timestamp":"2026-06-27"}]},"owner":"pi","tags":["feature"],"comments":[{"id":"cm_cb91dbe8c6ac40e5","author":"pi","body":"PARTIAL PROGRESS — export shipped (JSONL). cards export --workspace [--out file] dumps all card data (cards+links+comments, events, users) as JSONL, one object per line. Reads directly from SQLite (no server needed). Makes the workspace state git-portable. Import (cards import) is the remaining half — the JSONL format is designed to be loadable back into a fresh DB. Keeping this card open for the import implementation.","created_at":"2026-06-26T18:34:20.773435Z","edited_at":"0001-01-01T00:00:00Z"},{"id":"cm_fdde0acd03d94b72","author":"pi","body":"DONE — import implemented, tested, and merged-ready on feat/cards-import (pushed). The JSONL export/import pair now round-trips the full workspace. NOTE: the version-gated markdown --mirror PATCH import (SPEC §3 / D13) remains a separate future card; opening that as follow-up rather than holding this one open.","created_at":"2026-06-27T14:03:25.987127143Z","edited_at":"0001-01-01T00:00:00Z"}],"version":8,"created_at":"2026-06-26T11:51:21.122611Z","updated_at":"2026-06-27T14:03:51.850602388Z","created_by":"foz","status_since":"2026-06-27T14:03:51.850602388Z"},"type":"card"} {"data":{"id":"card_5628c87bc7f7436daaf8685d9c12c6bb","workspace_id":"demo","type_id":"programming-task","schema_version":2,"title":"Sprint 07-19 P2: JSONL snapshot residual gaps (CLI wrapper, byte-stability, frozen fixture, board.sh smoke)","status":"done","fields":{"branch":"main","description":"## What shipped\n\nBoard snapshots are now locked down by tests. Exporting a board and importing it again is verified byte-for-byte against a frozen fixture, so the `backlog.jsonl` file committed to git will restore the exact same board on any machine — no drift, no surprises. Nothing changed in the product itself; this is pure safety net.\n\n---\n\nPhase 2 of docs/plans/2026-07-19-sprint-plan.md — contract pinning. The headline round-trip is ALREADY pinned by `cmd/cards/portable_test.go` (commit `3236f3d`, 7 tests: full fidelity, state-only, pagination, failure modes). This phase adds only the 4 genuine residual gaps, against a frozen hash-pinned fixture (NOT the live board). No production code changed unless a named non-determinism fix is required.\n\n## DO\n1. **CLI wrapper coverage.** Extend `cmd/cards/portable_test.go` with cases exercising `exportCmd`/`importCmd` flag parsing (`--out`/`--in` file IO) and the fresh-DB pre-flight refusal (`cmd/cards/import.go:52`). Reuse `openWorkspace`/`dbPath` (`cmd/cards/open.go`).\n2. **Re-export byte-stability (named ordering guarantee).** `TestExportStateOnlyByteStable`: export → import → re-export → byte-compare. The guarantee under test is the id-sorted canonical ordering applied by `portable.go`'s INLINE `slices.SortFunc` calls (users `:58` / cards `:93` / events `:118` — there are NO named `sortCards`/`sortUsers`/`sortEvents` functions; the sort is inline). If byte-stability is already guaranteed by those sort calls, the test is a pin (regression guard). If a non-determinism source is found (map iteration in event metadata, field ordering), the fix belongs in `portable.go` — name it in the test comment. Diff sorted line sets, not raw bytes, so a future failure fails diagnostically.\n3. **Frozen, hash-pinned fixture.** Copy `.cards/backlog.jsonl` at sprint start to `cmd/cards/testdata/backlog.frozen.jsonl`. Test loads the frozen copy, asserts a sha256 computed at freeze time and pinned in the test. The live `.cards/backlog.jsonl` is NOT a test input. Silent fixture drift trips a loud single-line failure printing the regeneration command, not a 190-card diff.\n4. **`board.sh` smoke under `go test`.** `TestBoardScriptSmoke` (a Go test shelling out, NOT a standalone bash script) covering `board.sh import --force` and `install-hook` in a temp repo so `install-hook` does not clobber the real `.git/hooks/pre-commit`. Runs under `go test ./...`, not a forgotten CI step.\n\n## ACCEPTANCE (binary)\n- [ ] CLI wrapper flag IO + fresh-DB refusal under test.\n- [ ] Re-export byte-stable (sorted-line diff); ordering guarantee named (id-sorted canonical via `portable.go` inline `slices.SortFunc` at `:58`/`:93`/`:118`, or a named non-determinism fix in `portable.go`).\n- [ ] Test fixture version-pinned: `cmd/cards/testdata/backlog.frozen.jsonl` with sha256 asserted in test; live board is not a test input.\n- [ ] `board.sh import --force` + `install-hook` smoked via `go test` (shelling out), in a temp repo.\n- [ ] No production code changed unless a named non-determinism fix was required; `go test ./cmd/cards` green.\n\n## DEMO\n`go test ./cmd/cards -run 'TestExportStateOnlyByteStable|TestImportFrozenBacklogSnapshot|TestBoardScriptSmoke' -v` PASS; `sha256sum cmd/cards/testdata/backlog.frozen.jsonl` matches the hash pinned in the test.\n\nParent/tracker: card_cf3ba12a. Tests + fixture only.","kind":"infra","work_log":[{"author":"foz","commit_hash":"bb6ffc5eaf334927aeb526809316edda2c9544f7","entry_id":"ent_f2da12d03aae4a19","notes":"All 17 test packages pass, zero failures. The work is complete.\n\n## Summary\n\n**What changed** (2 files, tests + fixture only — zero production code):\n\n**`cmd/cards/testdata/backlog.frozen.jsonl`** (new) — frozen copy of `.cards/backlog.jsonl` at sprint start (190 cards / 34 tombstone events / 7 users). The live board is never a test input.\n\n**`cmd/cards/portable_test.go`** (+412 lines) — four new tests closing the Phase-2 residual gaps:\n\n1. **`TestExportImportCmdFlagIO`** — CLI wrapper coverage: `--workspace` required, `--format` rejection, export's no-DB pre-flight, `--out` file write → `--in` import into a second scaffolded workspace (via `starter.Scaffold`, reusing `openWorkspace`/`dbPath`), and the fresh-DB refusal (`import.go:52`) asserting the \"workspace already contains cards\" error","timestamp":"2026-07-19T21:28:18.182Z"}]},"owner":"foz","links":[{"type_id":"parent","target":"card_cf3ba12afe5b4b84b7b05c7d2669bbba","created_by":"local-dev","created_at":"2026-07-19T20:31:42.522035Z"}],"comments":[{"id":"cm_c98c53d55880483c","author":"foz","body":"completed by foz (subagent run)\n\n.github/workflows/ci.yml | 20 ++\n CLAUDE.md | 7 +\n README.md | 43 ++--\n cmd/cards/main.go | 2 +-\n cmd/cards/portable.go | 2 +-\n cmd/cards/portable_test.go | 412 ++++++++++++++++++++++++++++++++\n cmd/cards/reload.go | 4 +-\n cmd/cards/serve.go | 4 +-\n cmd/cards/supervisor.go | 2 +-\n docs/architecture/index.md | 2 +-\n docs/assets/img/board.png | Bin 315591 -> 230876 bytes\n docs/assets/img/theme-labels.png | Bin 310027 -> 230876 bytes\n docs/assets/img/welcome.png | Bin 285597 -> 76397 bytes\n docs/concepts/index.md | 17 +-\n docs/get-started.md | 12 +-\n docs/index.md | 109 +++++----\n docs/reference/implementation-status.md | 36 ++-\n internal/artifacts/artifacts.go | 5 +-\n internal/cli/client.go | 4 +-\n internal/config/config.go | 4 +-\n internal/config/extensions.go | 4 +-\n internal/core/conditions_test.go | 2 +-\n internal/core/contrast.go | 2 +-\n internal/core/events.go | 2 +-\n internal/core/observer_test.go | 2 +-\n internal/core/service.go | 6 +-\n internal/core/store.go | 3 +-\n internal/core/types.go | 20 +-\n internal/docaudit/docaudit_test.go | 254 +++++++++++++++++++-\n internal/docaudit/uicontract_test.go | 2 +-\n internal/hooks/hooks.go | 6 +-\n internal/hooks/reconcile.go | 2 +-\n internal/httpapi/api.go | 3 +-\n internal/httpapi/filters.go | 49 ----\n internal/httpapi/filters_test.go | 33 ---\n internal/httpapi/httpapi_test.go | 2 +-\n internal/httpapi/render.go | 16 +-\n internal/httpapi/server.go | 4 +-\n internal/httpapi/ui.go | 31 +--\n internal/mcp/mcp.go | 3 +-\n internal/sqlite/sqlite.go | 4 +-\n internal/starter/starter.go | 4 +-\n internal/tui/tui.go | 406 +++++++++++++++++++++++++++++--\n media/board.png | Bin 583099 -> 230876 bytes\n mkdocs.yml | 2 +\n 45 files changed, 1270 insertions(+), 277 deletions(-)\n\nAll 17 test packages pass, zero failures. The work is complete.\n\n## Summary\n\n**What changed** (2 files, tests + fixture only — zero production code):\n\n**`cmd/cards/testdata/backlog.frozen.jsonl`** (new) — frozen copy of `.cards/backlog.jsonl` at sprint start (190 cards / 34 tombstone events / 7 users). The live board is never a test input.\n\n**`cmd/cards/portable_test.go`** (+412 lines) — four new tests closing the Phase-2 residual gaps:\n\n1. **`TestExportImportCmdFlagIO`** — CLI wrapper coverage: `--workspace` required, `--format` rejection, export's no-DB pre-flight, `--out` file write → `--in` import into a second scaffolded workspace (via `starter.Scaffold`, reusing `openWorkspace`/`dbPath`), and the fresh-DB refusal (`import.go:52`) asserting the \"workspace already contains cards\" error","created_at":"2026-07-19T21:28:18.230525Z","edited_at":"0001-01-01T00:00:00Z"}],"version":9,"created_at":"2026-07-19T20:31:31.636926Z","updated_at":"2026-07-20T02:08:00.555655Z","created_by":"foz","status_since":"2026-07-20T01:50:01.232503Z"},"type":"card"} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7cf7477..9b74476 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,25 @@ node --check internal/httpapi/templates/assets/*.js node --test "tests/js/*.test.cjs" ``` +For changes to `cards init`, the installed skill, or the MCP handshake, also run +the deterministic end-to-end smoke — no API key, no network: + +```bash +scripts/smoke-adoption.sh +``` + +The agentic half — whether a real agent can set a board up from the installed +skill alone — needs a model and is not part of the gate: + +```bash +CARDS_AGENT_CMD='claude -p' go test -tags smoke ./internal/smoke/ +CARDS_AGENT_CMD='claude -p' CARDS_SMOKE_RUNS=5 go test -tags smoke -v ./internal/smoke/ +``` + +It asserts over the workspace the agent leaves behind rather than its +transcript, and reports a per-check pass rate across runs. Without +`CARDS_AGENT_CMD` it skips. + For UI/template/CSS work, at minimum run: ```bash diff --git a/cmd/cards/init.go b/cmd/cards/init.go index c2ec7c0..ed0d90b 100644 --- a/cmd/cards/init.go +++ b/cmd/cards/init.go @@ -1,29 +1,44 @@ // Command cards — init subcommand. Scaffolds a fresh workspace (starter // definitions + a welcome board) either locally under ./.cards or globally at -// the personal workspace location. +// the personal workspace location, and installs the agent skill beside it. package main import ( "flag" "fmt" + "os" "path/filepath" + + "github.com/somebox/cards/internal/agentguide" ) func initCmd(args []string) error { fs := flag.NewFlagSet("init", flag.ContinueOnError) global := fs.Bool("global", false, "initialize the personal workspace (~/.cards or $CARDS_HOME)") quiet := fs.Bool("quiet", false, "suppress post-init instructions (like import/export summaries)") + noSkill := fs.Bool("no-skill", false, "do not install the cards agent skill into .claude/skills/") if err := fs.Parse(args); err != nil { return err } - var dir string + // dir is where the workspace goes; harnessRoot is where the agent skill + // goes. They are deliberately different roots — see below. + var dir, harnessRoot string if *global { h, err := globalHome() if err != nil { return err } dir = h + // NOT globalHome(): that honors $CARDS_HOME, which relocates the board. + // It does not relocate the user's harness directory, so deriving the + // skill path from it would scatter .claude/skills/ next to whichever + // workspace happened to be configured. + home, err := os.UserHomeDir() + if err != nil { + return err + } + harnessRoot = home } else { target := "." if fs.NArg() > 0 { @@ -41,20 +56,50 @@ func initCmd(args []string) error { return fmt.Errorf("%s is already a workspace (it has definitions/workspace.json) — nothing to init; use it with: cards --workspace %s", abs, abs) } dir = filepath.Join(abs, ".cards") + // Beside .cards, never inside it: the skill belongs to the project's + // harness, not to the board's data. + harnessRoot = abs } created, err := initWorkspace(dir) if err != nil { return fmt.Errorf("initialize workspace: %w", err) } + + // Installing the skill is independent of whether the workspace was created. + // An established project already has a workspace and no skill — the common + // case, and the one with no other install path. + // A skill failure must not swallow the workspace result: by this point the + // workspace is already scaffolded, and the user needs to be told both facts. + // The error is still returned, so scripts see a non-zero exit. + skillPath, skillCreated := "", false + var skillErr error + if !*noSkill { + skillPath, skillCreated, skillErr = agentguide.InstallSkill(harnessRoot) + } + + wrapSkillErr := func(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("install agent skill: %w", err) + } if *quiet { - return nil + return wrapSkillErr(skillErr) } if !created { fmt.Printf("workspace already initialized at %s\n", dir) + } else { + fmt.Printf("initialized workspace at %s\n", dir) + } + reportSkill(skillPath, skillCreated, *noSkill, skillErr) + if skillErr != nil { + return wrapSkillErr(skillErr) + } + if !created { return nil } - fmt.Printf("initialized workspace at %s\n\n", dir) + fmt.Println() fmt.Println("Next:") if *global { fmt.Println(" cards # serve it (zero-config)") @@ -64,3 +109,17 @@ func initCmd(args []string) error { fmt.Println(" open http://127.0.0.1:8787/ui/boards/welcome") return nil } + +// reportSkill says what happened to the agent skill. An existing skill is +// reported, not silently skipped: the user needs to know why their install did +// not take effect. +func reportSkill(path string, created, skipped bool, err error) { + switch { + case skipped, err != nil: + return // the error itself carries the detail and the remedy + case created: + fmt.Printf("installed the cards agent skill at %s\n", path) + default: + fmt.Printf("cards skill already exists at %s; not overwritten\n", path) + } +} diff --git a/cmd/cards/initskill_test.go b/cmd/cards/initskill_test.go new file mode 100644 index 0000000..a0708e1 --- /dev/null +++ b/cmd/cards/initskill_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/somebox/cards/internal/agentguide" +) + +func skillDir(root string) string { + return filepath.Join(root, filepath.FromSlash(agentguide.SkillDirName)) +} + +func TestInitCmd_InstallsSkillBesideWorkspace(t *testing.T) { + root := t.TempDir() + if err := initCmd([]string{"--quiet", root}); err != nil { + t.Fatalf("init: %v", err) + } + for _, rel := range []string{"SKILL.md", "references/cli-reference.md"} { + if _, err := os.Stat(filepath.Join(skillDir(root), rel)); err != nil { + t.Errorf("skill file %s not installed: %v", rel, err) + } + } + // Beside .cards, never inside it — the skill belongs to the harness, not + // to the board's data directory. + if _, err := os.Stat(filepath.Join(root, ".cards", ".claude")); err == nil { + t.Error("skill was installed inside .cards/") + } +} + +// The case with no other install path: a project that already has a board. +func TestInitCmd_InstallsSkillWhenWorkspaceAlreadyExists(t *testing.T) { + root := t.TempDir() + if err := initCmd([]string{"--quiet", "--no-skill", root}); err != nil { + t.Fatalf("init: %v", err) + } + if _, err := os.Stat(skillDir(root)); err == nil { + t.Fatal("--no-skill still installed the skill") + } + // Workspace now exists, so initWorkspace reports created=false. The skill + // must still land. + if err := initCmd([]string{"--quiet", root}); err != nil { + t.Fatalf("re-init: %v", err) + } + if _, err := os.Stat(filepath.Join(skillDir(root), "SKILL.md")); err != nil { + t.Errorf("skill not installed into an existing workspace: %v", err) + } +} + +func TestInitCmd_SkillIsNeverClobbered(t *testing.T) { + root := t.TempDir() + if err := initCmd([]string{"--quiet", root}); err != nil { + t.Fatalf("init: %v", err) + } + marker := filepath.Join(skillDir(root), "SKILL.md") + if err := os.WriteFile(marker, []byte("locally edited"), 0o644); err != nil { + t.Fatal(err) + } + if err := initCmd([]string{"--quiet", root}); err != nil { + t.Fatalf("re-init: %v", err) + } + got, err := os.ReadFile(marker) + if err != nil { + t.Fatal(err) + } + if string(got) != "locally edited" { + t.Error("re-init overwrote a locally edited skill") + } +} + +// $CARDS_HOME relocates the board, not the user's harness directory. Deriving +// the skill path from globalHome() would scatter .claude/skills/ next to +// whichever workspace happened to be configured. +func TestInitCmd_GlobalSkillFollowsHomeNotCardsHome(t *testing.T) { + cardsHome := t.TempDir() + userHome := t.TempDir() + t.Setenv("CARDS_HOME", cardsHome) + t.Setenv("HOME", userHome) + + if err := initCmd([]string{"--quiet", "--global"}); err != nil { + t.Fatalf("init --global: %v", err) + } + if _, err := os.Stat(filepath.Join(skillDir(userHome), "SKILL.md")); err != nil { + t.Errorf("skill not installed under the user home: %v", err) + } + if _, err := os.Stat(skillDir(cardsHome)); err == nil { + t.Error("skill was installed under CARDS_HOME instead of the user home") + } +} + +func TestMCPPrintInstructionsMatchesTheHandshake(t *testing.T) { + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + runErr := mcpCmd([]string{"--print-instructions"}) + _ = w.Close() + os.Stdout = old + if runErr != nil { + t.Fatalf("mcp --print-instructions: %v", runErr) + } + printed, err := io.ReadAll(r) + _ = r.Close() + if err != nil { + t.Fatalf("read stdout: %v", err) + } + if got, want := string(printed), agentguide.MCPInstructions(); got != want { + t.Errorf("printed instructions differ from the served handshake\ngot %d bytes, want %d", len(got), len(want)) + } +} + +// Debris from an interrupted install must fail loudly, but must not swallow the +// workspace result — by that point the workspace is already scaffolded, and the +// user needs both facts. +func TestInitCmd_ReportsWorkspaceEvenWhenSkillInstallFails(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(skillDir(root), "references"), 0o755); err != nil { + t.Fatal(err) + } + + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + initErr := initCmd([]string{root}) + _ = w.Close() + os.Stdout = old + out, _ := io.ReadAll(r) + _ = r.Close() + + if !errors.Is(initErr, agentguide.ErrIncompleteSkill) { + t.Fatalf("got %v, want ErrIncompleteSkill", initErr) + } + if !strings.Contains(string(out), "initialized workspace at") { + t.Errorf("workspace result was swallowed by the skill failure:\n%s", out) + } + if !isWorkspaceDir(filepath.Join(root, ".cards")) { + t.Error("workspace was not scaffolded") + } +} diff --git a/cmd/cards/main.go b/cmd/cards/main.go index 6bd16ec..3657e24 100644 --- a/cmd/cards/main.go +++ b/cmd/cards/main.go @@ -246,11 +246,11 @@ Commands: workspace show boards show [board_id] - init Scaffold a new workspace (./.cards or, with --global, ~/.cards) + init Scaffold a new workspace + install the agent skill (--no-skill to skip) serve Run the HTTP + web UI server export Dump all card data as JSONL (local; --workspace ) import Load a JSONL export into the workspace DB (local; --workspace ) - mcp Run the stdio MCP server (--workspace ) + mcp Run the stdio MCP server (--workspace ; --print-instructions) run-extensions Run the hook supervisor (--workspace ) do Invoke a run extension (--param k=v) extensions List/show declared extensions diff --git a/cmd/cards/serve.go b/cmd/cards/serve.go index d5e0dfb..f395fda 100644 --- a/cmd/cards/serve.go +++ b/cmd/cards/serve.go @@ -13,6 +13,7 @@ import ( "os" "time" + "github.com/somebox/cards/internal/agentguide" "github.com/somebox/cards/internal/config" "github.com/somebox/cards/internal/httpapi" "github.com/somebox/cards/internal/mcp" @@ -192,9 +193,17 @@ func serveCmd(args []string) error { func mcpCmd(args []string) error { fs := flag.NewFlagSet("mcp", flag.ContinueOnError) workspace := fs.String("workspace", "", "workspace directory (contains definitions/)") + printInstructions := fs.Bool("print-instructions", false, "print the agent instructions served in the MCP handshake, then exit") if err := fs.Parse(args); err != nil { return err } + // Lives on `mcp` rather than as its own verb: this text IS the handshake, and + // it needs no workspace — a harness can be wired from an installed binary + // with no checkout and no server running. + if *printInstructions { + fmt.Print(agentguide.MCPInstructions()) + return nil + } abs, autoInit, err := resolveWorkspaceDir(*workspace) if err != nil { return err @@ -214,6 +223,6 @@ func mcpCmd(args []string) error { if actor == "" { actor = result.Workspace.Settings.DefaultUser } - srv := mcp.New(svc, result.Workspace, result.CardTypes, result.Boards, actor) + srv := mcp.New(svc, result.Workspace, result.CardTypes, result.Boards, actor, mcp.WithVersion(shortVersion())) return srv.Serve() } diff --git a/docs/agents/instructions.md b/docs/agents/instructions.md index b786af6..9f71947 100644 --- a/docs/agents/instructions.md +++ b/docs/agents/instructions.md @@ -1,65 +1,84 @@ # Agent instructions -A ready-to-paste instruction block for agents working a Cards board. Put it -where your harness reads standing instructions — `CLAUDE.md` for Claude Code, -the system prompt or project instructions for other harnesses — alongside the -[MCP server config](mcp.md). - -## Setup - -1. Install the `cards` binary — download it from the - [latest release](https://github.com/somebox/cards/releases/latest) or build - with `go install github.com/somebox/cards/cmd/cards@latest` - ([full install steps](../get-started.md)). -2. Wire the MCP server into your harness: `cards mcp --workspace /abs/path` - ([config snippets](mcp.md)). -3. For exact request shapes against a running server, fetch - `GET /v1/openapi.json` — an OpenAPI 3.1 document generated from the live - workspace, so the field schemas in it are your card types. - -Agents with shell access but no MCP client can use the CLI instead — the same -operations with the same validation ([using Cards](../using-cards.md) -shows CLI, HTTP, and MCP side by side). - -## The instruction block - -The authoritative tool inventory is -[`internal/mcp/README.md`](https://github.com/somebox/cards/blob/main/internal/mcp/README.md). +Cards serves its own agent guidance. There is nothing to hand-paste and keep in +sync. + +Two channels, with different reach: + +- **MCP is harness-neutral.** Any MCP client receives the short coordination + instructions in the `initialize` handshake. This is the path that works + everywhere. +- **The installed skill is Claude Code-shaped.** `cards init` writes + `.claude/skills/cards/`, which Claude Code and compatible harnesses discover. + It carries the fuller playbook. A harness that does not read + `.claude/skills/` should use MCP, or paste the output of + `cards mcp --print-instructions` into wherever it keeps standing instructions. + +## Over MCP — automatic + +The server returns its instructions in the `initialize` handshake, so any MCP +client picks them up on connect. Wire the server in +([config snippets](mcp.md)) and you are done: + +```bash +cards mcp --workspace /abs/path +``` + +The text covers what the tool schemas cannot — the coordination loop, optimistic +concurrency and retry discipline, evidence norms, honest status moves, who owns +card bookkeeping, and session-end persistence. It is deliberately short and +size-capped, because it sits in every session's prompt prefix. + +To read it, or to paste it into a harness that does not surface MCP +instructions: + +```bash +cards mcp --print-instructions +``` + +That needs no workspace and no running server, so it works from a bare install. -````markdown -## Working the Cards board - -You coordinate work through a Cards board, available via MCP tools. - -- Call `workspace` first to learn the columns, card types, boards, and - registered users. Drive every decision from that schema — do not guess - field names or status values. -- Pick up work with `take_next` (atomically claims the next eligible card), - or `list_cards` to survey and `claim` to take a specific card. -- Mutations that change card state require the card's current `version`. - On a `version_conflict` error, the response includes the current card — - re-read it and retry with the new version. Do not blind-retry. -- Record what you do on the card as you work: - - `add_comment` for decisions, questions, and status notes - - `append_entry` for work-log entries (commits, results, measurements) - - `attach_artifact` for files -- Validation errors name the failing field and include `valid_options`. - Correct the value and retry — do not work around the schema. -- Move the card's status as work progresses (`update_`, or `claim` / - `release` for ownership). Boards may enforce allowed transitions; a - rejected move names the allowed next columns. -- To resume interrupted work, call `history` on the card — it returns the - timeline of changes, comments, and entries. -- No MCP connection? The `cards` CLI exposes the same operations with the - same validation (`cards --help`; set `CARDS_URL` to target the running - server so the board updates live). -```` - -Two notes for the human setting this up: +## Over the CLI — an installed skill (Claude Code and compatible) + +Agents with shell access but no MCP client use the same operations with the same +validation. `cards init` installs a skill covering board discovery, the CLI's +flag-order and short-id rules, mapping questions to queries, sprint planning +against a board, and recording work as it lands — plus a `project-practices` +reference for setting a board up, designing card types, migrating a backlog in, +and review/snapshot/release conventions: + +```bash +cards init # installs .claude/skills/cards/ beside .cards/ +cards init --global # installs into ~/.claude/skills/cards/ +cards init --no-skill # workspace only +``` + +Running `init` in a project that already has a board installs the skill without +touching the workspace. An existing skill directory is never overwritten — you +are told when it was left alone or is incomplete. To pick up a newer playbook, +review any local edits, delete `.claude/skills/cards`, and re-run `cards init`. + +The skill states the same invariants as the MCP handshake, so an agent driving +the board over the CLI and one driving it over MCP behave identically. The skill +is the larger document by design: it loads on demand, whereas the handshake sits +in every session's prompt prefix. + +`.claude/skills/` is currently the only install target. Support for another +harness's skill location is a follow-up, not a promise — until then, MCP is the +neutral path. + +## Two notes for the human setting this up - Set `CARDS_USER` in the MCP server's environment to a distinct actor id per - agent (for example `agent-claude`, `agent-pi`) so the event history shows - who did what. + agent (for example `agent-claude`, `agent-pi`) so the event history shows who + did what. - The MCP surface has no idempotency keys yet. If your workflow retries - aggressively, route those writes through the [REST API](../spec/api-surface.md) - instead. + aggressively, route those writes through the + [REST API](../spec/api-surface.md) instead. + +## Exact request shapes + +For a running server, `GET /v1/openapi.json` is an OpenAPI 3.1 document +generated from the live workspace, so the field schemas in it are your card +types. The authoritative MCP tool inventory is +[`internal/mcp/README.md`](https://github.com/somebox/cards/blob/main/internal/mcp/README.md). diff --git a/docs/get-started.md b/docs/get-started.md index 58be3b5..341bfed 100644 --- a/docs/get-started.md +++ b/docs/get-started.md @@ -35,7 +35,7 @@ to end. No account, no cloud, no database server. ## 2. Create a workspace and serve it ```bash -cards init # scaffold ./.cards with a starter "welcome" board +cards init # scaffold ./.cards + install the agent skill cards serve # serve at http://127.0.0.1:8787 open http://127.0.0.1:8787/ui/boards/welcome ``` @@ -46,6 +46,14 @@ interface over it. A bare `cards` on a terminal opens the TUI against the same workspace (no server required). `cards serve` with no `--workspace` walks up for a `.cards/` directory the way git finds `.git/`, falling back to `~/.cards`. +`init` also installs the Cards agent skill at `.claude/skills/cards/`, beside +your `.cards/` folder — the skill format Claude Code and compatible harnesses +discover. Pass `--no-skill` to skip it; an existing skill directory is never +overwritten. To pick up a newer playbook, review any local edits, delete +`.claude/skills/cards`, and re-run `cards init`. Harnesses that don't read +`.claude/skills/` get their guidance over MCP instead, which is harness-neutral — see +[agent instructions](agents/instructions.md). +
![The welcome board right after cards init](assets/img/welcome.png){ .cards-shot }
What you get after cards init — the starter cards walk you through the basics.
diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d8c3a84..0cd063b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -35,7 +35,7 @@ and MCP equivalents — are in [using Cards](../using-cards.md). ```console $ cd my-project -$ cards init # scaffolds ./.cards (definitions + starter welcome board) +$ cards init # scaffolds ./.cards + installs .claude/skills/cards/ $ cards serve # http://127.0.0.1:8787 ``` @@ -44,6 +44,17 @@ $ cards serve # http://127.0.0.1:8787 workspace at `~/.cards`. `cards init --global` creates the personal one; `--workspace ` is always the explicit override. +`init` also installs the agent skill into `.claude/skills/cards/` — the location +Claude Code and compatible harnesses read — next to the +workspace (`--global` installs it under your home directory instead, following +`$HOME` rather than `$CARDS_HOME`). It runs even when the workspace already +exists — that is how an established project picks up the skill — and never +overwrites an existing skill directory. To pick up a newer playbook, review any +local edits, delete `.claude/skills/cards`, and re-run `cards init`. +`--no-skill` opts out. +The matching short-form guidance served to MCP clients is printable with +`cards mcp --print-instructions`. + ## Two backends Client commands (`list`, `create`, `patch`, …) work in either of two modes: diff --git a/internal/agentguide/agentguide_test.go b/internal/agentguide/agentguide_test.go new file mode 100644 index 0000000..47c153e --- /dev/null +++ b/internal/agentguide/agentguide_test.go @@ -0,0 +1,284 @@ +package agentguide + +import ( + "errors" + "flag" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// Regenerate the skill's invariants region after editing invariants.md: +// +// go test ./internal/agentguide -update +var updateSkill = flag.Bool("update", false, "rewrite skill/SKILL.md's invariants region from invariants.md") + +const skillPath = "skill/SKILL.md" + +// Hard cap on the MCP handshake. This string lands in the prompt prefix of every +// MCP session, including sessions that never touch the board, so it is charged +// to every agent. The cap is the enforcement of "the always-on slot stays +// short": the way to fit new guidance is to put it in the skill (loaded on +// demand) or to shorten the trunk — not to grow this. +const ( + maxInstructionBytes = 2048 + maxInstructionLines = 40 +) + +func TestSkillInvariantsRegionMatchesTrunk(t *testing.T) { + body, err := os.ReadFile(skillPath) + if err != nil { + t.Fatalf("read %s: %v", skillPath, err) + } + want, err := spliceInvariants(string(body), Invariants()) + if err != nil { + t.Fatalf("splice %s: %v", skillPath, err) + } + if *updateSkill { + if want == string(body) { + return + } + if err := os.WriteFile(skillPath, []byte(want), 0o644); err != nil { + t.Fatalf("write %s: %v", skillPath, err) + } + t.Logf("updated %s from invariants.md", skillPath) + return + } + if want != string(body) { + t.Errorf("%s's invariants region is out of date with invariants.md.\nRegenerate:\n\n\tgo test ./internal/agentguide -update\n", skillPath) + } +} + +// The splice must fail loudly on a mangled skill rather than appending, or the +// shared rules would silently vanish from the on-demand surface. +func TestSpliceRejectsMissingOrInvertedMarkers(t *testing.T) { + cases := map[string]string{ + "no begin marker": "body\n" + markerEnd + "\n", + "no end marker": markerBegin + "\nbody\n", + "inverted": markerEnd + "\nbody\n" + markerBegin + "\n", + } + for name, body := range cases { + t.Run(name, func(t *testing.T) { + if _, err := spliceInvariants(body, "trunk"); err == nil { + t.Fatal("expected an error, got nil") + } + }) + } +} + +func TestSpliceReplacesRegionRatherThanAppending(t *testing.T) { + body := "head\n" + markerBegin + "\nstale\n" + markerEnd + "\ntail\n" + got, err := spliceInvariants(body, "fresh") + if err != nil { + t.Fatalf("splice: %v", err) + } + if strings.Contains(got, "stale") { + t.Errorf("stale region survived the splice:\n%s", got) + } + for _, want := range []string{"head", "fresh", "tail"} { + if !strings.Contains(got, want) { + t.Errorf("spliced body lost %q:\n%s", want, got) + } + } +} + +func TestMCPInstructionsStayShort(t *testing.T) { + got := MCPInstructions() + if strings.TrimSpace(got) == "" { + t.Fatal("MCPInstructions() is empty") + } + if n := len(got); n > maxInstructionBytes { + t.Errorf("MCP instructions are %d bytes, cap is %d.\nThis text is in every session's prompt prefix. Move guidance into the skill (skill/SKILL.md, loaded on demand) rather than raising the cap.", n, maxInstructionBytes) + } + if n := strings.Count(got, "\n"); n > maxInstructionLines { + t.Errorf("MCP instructions are %d lines, cap is %d (see the byte-cap note above)", n, maxInstructionLines) + } +} + +// The trunk carries the rules that must be true on every surface. Losing one of +// these to an edit is the failure this package exists to prevent. +func TestTrunkStatesTheInvariants(t *testing.T) { + trunk := Invariants() + for _, want := range []string{ + "workspace", // read the schema first + "version_conflict", // optimistic concurrency, retry discipline + "valid_options", // self-correcting validation + "screenshot", // evidence norms + "orchestrator", // who owns card bookkeeping + "--state-only", // session-end persistence + "import", // never over a non-empty DB + } { + if !strings.Contains(trunk, want) { + t.Errorf("invariants.md no longer mentions %q — the shared trunk lost a rule", want) + } + } +} + +func TestSkillFSCarriesTheReference(t *testing.T) { + sub, err := SkillFS() + if err != nil { + t.Fatalf("SkillFS: %v", err) + } + for _, name := range []string{"SKILL.md", "references/cli-reference.md"} { + if _, err := sub.Open(name); err != nil { + t.Errorf("skill tree is missing %s: %v", name, err) + } + } +} + +// A failed or interrupted install must leave no destination at all. The old +// behaviour wrote straight into the final path, so a partial tree survived and +// every later run mistook it for a protected user skill and skipped it — +// turning one transient failure into a permanently broken skill. +func TestInstallSkillLeavesNoPartialDestination(t *testing.T) { + root := t.TempDir() + // Make the staging rename impossible by occupying the destination's parent + // with a regular file, so MkdirAll fails after the root exists. + parent := filepath.Join(root, ".claude", "skills") + if err := os.MkdirAll(filepath.Dir(parent), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parent, []byte("not a directory"), 0o644); err != nil { + t.Fatal(err) + } + dest, created, err := InstallSkill(root) + if err == nil { + t.Fatal("expected an error when the skill parent cannot be created") + } + if created { + t.Error("reported created=true on a failed install") + } + if _, statErr := os.Stat(dest); statErr == nil { + t.Error("a partial destination survived a failed install") + } +} + +// Debris from an interrupted install (or a hand-mangled directory) must not be +// mistaken for a user's protected skill. +func TestInstallSkillRejectsIncompleteDestination(t *testing.T) { + root := t.TempDir() + dest := filepath.Join(root, filepath.FromSlash(SkillDirName)) + if err := os.MkdirAll(filepath.Join(dest, "references"), 0o755); err != nil { + t.Fatal(err) + } + _, created, err := InstallSkill(root) + if !errors.Is(err, ErrIncompleteSkill) { + t.Fatalf("got %v, want ErrIncompleteSkill", err) + } + if created { + t.Error("reported created=true for an incomplete destination") + } + if !strings.Contains(err.Error(), "SKILL.md") { + t.Errorf("error should name the missing file, got: %v", err) + } +} + +func TestInstallSkillIsAtomicAndComplete(t *testing.T) { + root := t.TempDir() + dest, created, err := InstallSkill(root) + if err != nil || !created { + t.Fatalf("install: created=%v err=%v", created, err) + } + for _, rel := range []string{"SKILL.md", "references/cli-reference.md", "references/project-practices.md"} { + if _, err := os.Stat(filepath.Join(dest, filepath.FromSlash(rel))); err != nil { + t.Errorf("installed skill is missing %s: %v", rel, err) + } + } + // No staging directory may survive a successful install. + entries, err := os.ReadDir(filepath.Dir(dest)) + if err != nil { + t.Fatal(err) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".cards-skill-staging-") { + t.Errorf("staging directory %s survived a successful install", e.Name()) + } + } + // A complete skill is protected on re-run. + if _, created, err := InstallSkill(root); err != nil || created { + t.Errorf("re-install: created=%v err=%v, want false/nil", created, err) + } +} + +// Every relative link out of SKILL.md must resolve inside the embedded tree. +// A skill is loaded by a harness, not built by a doc pipeline, so a broken +// reference link is a dead end at runtime with nothing to catch it. +func TestSkillReferenceLinksResolve(t *testing.T) { + sub, err := SkillFS() + if err != nil { + t.Fatalf("SkillFS: %v", err) + } + body, err := fs.ReadFile(sub, "SKILL.md") + if err != nil { + t.Fatalf("read SKILL.md: %v", err) + } + links := regexp.MustCompile(`\]\((?:\./)?(references/[^)#]+)\)`).FindAllStringSubmatch(string(body), -1) + if len(links) == 0 { + t.Fatal("SKILL.md links to no references — the on-demand playbook is unreachable") + } + seen := map[string]bool{} + for _, m := range links { + target := m[1] + if seen[target] { + continue + } + seen[target] = true + if _, err := fs.Stat(sub, target); err != nil { + t.Errorf("SKILL.md links to %s, which is not in the embedded skill: %v", target, err) + } + } + // The adoption playbook is the reason this skill is more than an operator + // manual; losing the link would silently drop that half of the job. + if !seen["references/project-practices.md"] { + t.Error("SKILL.md no longer links to references/project-practices.md") + } +} + +// The description is the only part a harness always has in context, so it is +// what decides whether the skill loads at all. If it stops mentioning setup and +// migration, the adoption playbook ships but never fires. +func TestSkillDescriptionCoversAdoption(t *testing.T) { + sub, err := SkillFS() + if err != nil { + t.Fatalf("SkillFS: %v", err) + } + body, err := fs.ReadFile(sub, "SKILL.md") + if err != nil { + t.Fatalf("read SKILL.md: %v", err) + } + _, rest, found := strings.Cut(string(body), "---\n") + if !found { + t.Fatal("SKILL.md has no frontmatter") + } + front, _, found := strings.Cut(rest, "---\n") + if !found { + t.Fatal("SKILL.md frontmatter is unterminated") + } + for _, want := range []string{"set up", "card types", "migrating"} { + if !strings.Contains(strings.ToLower(front), want) { + t.Errorf("skill description no longer mentions %q — adoption tasks will not trigger it", want) + } + } +} + +// The staging directory is created 0700 by MkdirTemp and Rename preserves the +// mode, so without an explicit chmod the skill root ends up owner-only while +// its own contents are world-readable — unreadable to a CI runner or container +// running as another uid. +func TestInstallSkillDirectoryIsReadable(t *testing.T) { + root := t.TempDir() + dest, _, err := InstallSkill(root) + if err != nil { + t.Fatalf("install: %v", err) + } + info, err := os.Stat(dest) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm&0o055 != 0o055 { + t.Errorf("skill root is %v, want group/other read+execute (0755-style)", perm) + } +} diff --git a/internal/agentguide/embed.go b/internal/agentguide/embed.go new file mode 100644 index 0000000..f382905 --- /dev/null +++ b/internal/agentguide/embed.go @@ -0,0 +1,190 @@ +// Package agentguide owns the project's agent-facing guidance: the short set of +// invariants served in the MCP handshake, and the CLI skill that `cards init` +// installs into a project's harness directory. +// +// invariants.md is the single shared trunk. It is served verbatim as the MCP +// instructions (behind a two-line preamble) and spliced into the marked region +// of skill/SKILL.md by `go test ./internal/agentguide -update`. Nothing else is +// generated. The skill's operational sections are authored, so the always-on +// handshake can stay short while the on-demand skill grows independently — the +// two surfaces have different audiences and different token budgets, and only +// the trunk is common to both. +package agentguide + +import ( + "embed" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// mcpPreamble frames the trunk for an MCP client. It is deliberately two lines: +// the tool schemas already describe the surface, so the handshake only carries +// what they cannot. +const mcpPreamble = `You coordinate work through a Cards board. +The tool list is the manual; call ` + "`workspace`" + ` for the schema rather than guessing.` + +// Splice markers delimiting the generated region inside skill/SKILL.md. +const ( + markerBegin = "" + markerEnd = "" +) + +//go:embed invariants.md +var invariants string + +//go:embed skill +var skillFiles embed.FS + +// Invariants returns the shared trunk: the rules every surface must state. +func Invariants() string { return strings.TrimSpace(invariants) } + +// MCPInstructions returns the text served as the MCP `initialize` instructions. +// It is held under a hard size cap by TestMCPInstructionsStayShort — this string +// sits in every session's prompt prefix, so growth here is charged to every +// agent whether or not it ever touches the board. +func MCPInstructions() string { + return mcpPreamble + "\n\n" + Invariants() + "\n" +} + +// SkillFS returns the installable skill tree (SKILL.md plus references/), +// rooted so that walking it yields "SKILL.md", "references/...". +func SkillFS() (fs.FS, error) { return fs.Sub(skillFiles, "skill") } + +// spliceInvariants replaces the marked region of a SKILL.md body with the +// trunk. It returns an error rather than appending when the markers are absent +// or inverted, so a mangled skill fails loudly instead of silently losing the +// shared rules. +func spliceInvariants(skill, trunk string) (string, error) { + begin := strings.Index(skill, markerBegin) + end := strings.Index(skill, markerEnd) + switch { + case begin < 0: + return "", fmt.Errorf("skill body has no %s marker", markerBegin) + case end < 0: + return "", fmt.Errorf("skill body has no %s marker", markerEnd) + case end < begin: + return "", fmt.Errorf("%s appears before %s", markerEnd, markerBegin) + } + head := skill[:begin+len(markerBegin)] + tail := skill[end:] + return head + "\n" + strings.TrimSpace(trunk) + "\n" + tail, nil +} + +// SkillDirName is the path, relative to a project or home directory, where +// Claude Code and compatible harnesses look for a project-scoped skill. Other +// harnesses should use the MCP handshake, which is harness-neutral. +const SkillDirName = ".claude/skills/cards" + +// requiredSkillFiles must be present for a destination directory to count as a +// real skill. Only SKILL.md is checked: a user may legitimately prune the +// references, but a directory without SKILL.md is not a skill a harness can +// load — it is the debris of an install that did not finish. +var requiredSkillFiles = []string{"SKILL.md"} + +// ErrIncompleteSkill reports a destination that exists but is not a loadable +// skill. It is deliberately not silently repaired: overwriting could destroy a +// user's own work, so the fix is named and left to them. +var ErrIncompleteSkill = errors.New("incomplete skill installation") + +// InstallSkill writes the embedded skill tree into root/.claude/skills/cards +// and reports the path plus whether it created anything. +// +// The write is atomic: the tree is staged in a sibling temporary directory and +// renamed into place only once every file has landed. A failed or interrupted +// install therefore leaves no destination at all, rather than a partial one +// that the next run would mistake for a protected user skill and skip forever. +// +// An existing, complete skill is never overwritten — a user's local edits are +// worth more than a newer embed, and there is no --force yet. Note this is a +// separate decision from whether the *workspace* already exists: an established +// project has a workspace and no skill, and that is exactly the case that needs +// an install path. +func InstallSkill(root string) (path string, created bool, err error) { + parent := filepath.Join(root, filepath.FromSlash(filepath.Dir(SkillDirName))) + dest := filepath.Join(root, filepath.FromSlash(SkillDirName)) + + switch complete, statErr := skillPresent(dest); { + case statErr != nil: + return dest, false, statErr + case complete: + return dest, false, nil + } + + sub, err := SkillFS() + if err != nil { + return dest, false, err + } + if err := os.MkdirAll(parent, 0o755); err != nil { + return dest, false, err + } + // Staged in the destination's own parent so the rename stays on one + // filesystem and is therefore atomic. + staging, err := os.MkdirTemp(parent, ".cards-skill-staging-") + if err != nil { + return dest, false, err + } + defer os.RemoveAll(staging) // no-op once the rename has moved it + + if err := writeSkillTree(sub, staging); err != nil { + return dest, false, err + } + // MkdirTemp creates 0700 and Rename preserves it, which would leave the + // skill root owner-only while everything inside it is world-readable. + // A checkout shared with another uid — a CI runner, a container — could + // then see the directory but not read the skill. + if err := os.Chmod(staging, 0o755); err != nil { + return dest, false, err + } + if err := os.Rename(staging, dest); err != nil { + // Another process may have won the race between our check and our + // rename. A skill that is now present is the outcome we wanted. + if complete, statErr := skillPresent(dest); statErr == nil && complete { + return dest, false, nil + } + return dest, false, err + } + return dest, true, nil +} + +// skillPresent reports whether dest holds a loadable skill. A destination that +// exists but lacks SKILL.md is reported as ErrIncompleteSkill rather than +// silently skipped, so leftover debris cannot masquerade as a user's skill. +func skillPresent(dest string) (bool, error) { + info, err := os.Stat(dest) + if errors.Is(err, fs.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + if !info.IsDir() { + return false, fmt.Errorf("%w: %s exists but is not a directory; remove it and re-run", ErrIncompleteSkill, dest) + } + for _, name := range requiredSkillFiles { + if _, err := os.Stat(filepath.Join(dest, filepath.FromSlash(name))); err != nil { + return false, fmt.Errorf("%w: %s exists but has no %s; remove the directory and re-run to reinstall", ErrIncompleteSkill, dest, name) + } + } + return true, nil +} + +func writeSkillTree(src fs.FS, dest string) error { + return fs.WalkDir(src, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + target := filepath.Join(dest, filepath.FromSlash(p)) + if d.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := fs.ReadFile(src, p) + if err != nil { + return err + } + return os.WriteFile(target, data, 0o644) + }) +} diff --git a/internal/agentguide/invariants.md b/internal/agentguide/invariants.md new file mode 100644 index 0000000..65d8145 --- /dev/null +++ b/internal/agentguide/invariants.md @@ -0,0 +1,25 @@ +- **Read the workspace first.** Call `workspace` at session start; its card types, + required fields, columns, transitions, WIP limits and users are the contract. + Never carry a status or field name over from another project. +- **Writes carry the current `version`.** A `version_conflict` response includes + the current card — re-read from it and retry. Never blind-retry. Every write + returns the updated card, so take the next version from that response instead + of a separate read. +- **Validation errors are actionable.** They name the failing field and carry + `valid_options`. Correct the value; do not work around the schema. +- **Record evidence as work lands.** Comments carry the narrative — what was + done, verified, decided, or surprising. Repeating fields carry structured + records: commits, sources, measurements. Reference commit SHAs and PR URLs, + and attach a screenshot when a reviewer should see the change, not run it. +- **Move status honestly**, only when the work is really there. Where a review + column exists, implementation ends there, and the card must make verification + cheap: acceptance, a verify command, the commits, the evidence. +- **Discoveries become linked cards**, never silent scope creep. File a follow-up + for anything blocking progress or release; leave speculative ideas for triage. +- **Never invent a local-time timestamp**; card dates are RFC3339 UTC. +- **Who owns the card:** by default you record on your own card. An orchestrator + may instead own all card bookkeeping — if so it says that in your instructions, + and you then do the repo work and touch no card. +- **At session end**, make card status reflect reality, then export + `--state-only` to the project's established snapshot path. Never `import` over + a non-empty database. diff --git a/internal/agentguide/skill/SKILL.md b/internal/agentguide/skill/SKILL.md new file mode 100644 index 0000000..58a646e --- /dev/null +++ b/internal/agentguide/skill/SKILL.md @@ -0,0 +1,207 @@ +--- +name: cards +description: >- + Drive the `cards` kanban CLI in any project that has a cards board (a `.cards/` workspace + somewhere in the repo, a `CARDS_WORKSPACE` setting, or a served board on a local port). Use + this whenever the user asks about the board, backlog, or todo state — "summarize the todo + cards", "what's coming up?", "what cards were done recently", "what's blocked" — whenever + sprint planning happens in a project that has a board (including the sprint-plan skill), + whenever finished work should be recorded on a card (comments, screenshots, commit/PR + references, status moves), and at the end of a work session to persist the board snapshot. + Also use it to SET UP Cards in a project that has no board yet — "set up a board", "track + this project's work", designing or simplifying card types, migrating a backlog in from + GitHub Issues, Linear, Jira, or Trello, and establishing review, snapshot-hook, or release + conventions around a board. + If a project has a cards board, the board is the source of truth for work state: consult it + before planning and update it after working, even when the user doesn't mention cards by name. +--- + +# Working a cards board + +`cards` is a local kanban service: typed cards in a SQLite-backed workspace, driven by one +binary (CLI, HTTP, web UI, TUI over the same data). This skill covers finding the board, +answering questions from it, planning sprints against it, recording finished work on it, and +persisting it at session end. Full command surface: +[references/cli-reference.md](references/cli-reference.md). Setting a board up for a project, +designing card types, or migrating a backlog: +[references/project-practices.md](references/project-practices.md). + +## 1. Find the board and learn its shape (once per session) + +Resolve the workspace in this order: + +1. **Project config** — `CARDS_WORKSPACE` in the environment or `.claude/settings.local.json` + (it must point at the `.cards` directory itself, not its parent). +2. **Discovery** — the nearest `.cards/` directory walking up from cwd (like git finds `.git/`), + or a conventional subdirectory (`dev-workspace/.cards`, `board/`). +3. **A served board** — `CARDS_URL` set, or a health check on a known port: + `curl -s http://127.0.0.1:8787/v1/health`. A responding server only wins if it serves *this + project's* workspace — another project's board may happen to hold the port. Confirm (compare + `GET /v1/workspace` against the local definitions) before routing writes through it; + otherwise serverless `--workspace` against the local `.cards` is correct. + +Then learn the vocabulary before assuming anything: + +```bash +cards workspace show # columns, card types, boards, users — every project differs +``` + +Column ids vary per project (`todo` vs `ready`, `in-progress` vs `in_progress`). Map the user's +words onto the actual columns: "todo/upcoming" → the pre-work columns (backlog/ready/todo), +"active" → in-progress/review, "done recently" → done ordered by `updated_at`. + +**Two backends, one rule:** if a server is serving this workspace, target it +(`cards --url http://127.0.0.1:PORT ...`) so its live UI, event stream, and hooks see your +writes — a serverless write bypasses that process's event bus. No server running → plain +serverless commands are correct. + +**Multiple boards:** a project may have more than one workspace — e.g. a dev-tracking board +for the project's own work and a runtime board its application manages. Project CLAUDE.md +rules about which board you may write to always win; when unsure, treat unfamiliar boards as +read-only and ask. + +## 2. The rules that hold on every surface + +These are the same rules the MCP server states in its handshake, so an agent driving the board +over the CLI and one driving it over MCP behave identically. + + +- **Read the workspace first.** Call `workspace` at session start; its card types, + required fields, columns, transitions, WIP limits and users are the contract. + Never carry a status or field name over from another project. +- **Writes carry the current `version`.** A `version_conflict` response includes + the current card — re-read from it and retry. Never blind-retry. Every write + returns the updated card, so take the next version from that response instead + of a separate read. +- **Validation errors are actionable.** They name the failing field and carry + `valid_options`. Correct the value; do not work around the schema. +- **Record evidence as work lands.** Comments carry the narrative — what was + done, verified, decided, or surprising. Repeating fields carry structured + records: commits, sources, measurements. Reference commit SHAs and PR URLs, + and attach a screenshot when a reviewer should see the change, not run it. +- **Move status honestly**, only when the work is really there. Where a review + column exists, implementation ends there, and the card must make verification + cheap: acceptance, a verify command, the commits, the evidence. +- **Discoveries become linked cards**, never silent scope creep. File a follow-up + for anything blocking progress or release; leave speculative ideas for triage. +- **Never invent a local-time timestamp**; card dates are RFC3339 UTC. +- **Who owns the card:** by default you record on your own card. An orchestrator + may instead own all card bookkeeping — if so it says that in your instructions, + and you then do the repo work and touch no card. +- **At session end**, make card status reflect reality, then export + `--state-only` to the project's established snapshot path. Never `import` over + a non-empty database. + + +## 3. CLI-specific ground rules + +- **Global flags go BEFORE the verb:** `cards --url ... --as claude list`, never + `cards list --url ...`. +- **Writes need an actor:** `--as ` or `CARDS_USER`. Use the actor the project has + established for you (check settings/CLAUDE.md); default to `claude`. +- **`patch`/`claim` take `--version N`.** `cards get ` first if you don't already hold a + fresh card — but every write returns the updated card, so prefer the version from the last + response. Comments and links bump the version too. +- **`create` has no `--body` flag** — long context goes in a field (`--field "notes=..."`) or a + follow-up `comment add`. +- **Short 8-char ids work everywhere** (`cards get 4430ab22`) — but **bare hex only**: the + natural truncation `card_4430ab22` (prefix + short hex) is `not_found`. Full id or bare + 8 chars, nothing in between. + +## 4. Answering board questions + +Map intent to query, then *summarize* — the user asked a question, not for raw JSONL. + +| User intent | Query | +|---|---| +| "summarize the todo cards" / "what's on the board?" | `cards list --status ` (add `--include links` to spot dependencies) | +| "what's coming up? / backlog?" | `cards list --status backlog` (+ ready), minus blocked ones | +| "what's in flight?" | `cards list --status in-progress` (and review-type columns), with owners | +| "what was done recently?" | `cards list --status done --limit 20` — list order is `updated_at DESC`, so the top IS most recent. (Don't reach for `cards feed` here: the feed is oldest-first from event 1, paged by `--cursor`/`--since ` — a catch-up log, not a recency view.) | +| "what's blocked?" | `cards list --blocked` (cards whose `depends-on`/`blocked-by` targets aren't done) | +| "what happened to X?" | `cards history ` — the resumption timeline | +| "find the card about X" | `cards list --q "X"` (full-text) | + +Summarize with judgment: group by type or epic, respect the order the board gives you +(`updated_at DESC` — don't re-sort arbitrarily), give counts, name each card with its short id +so the user can act on it, and call out anything blocked or stale. For hierarchy +(epic→story→task via `part-of` links; blocking is `depends-on`), `--include links` +shows the edges. + +## 5. Sprint planning on a cards board + +When planning a sprint in a project with a board — including when the **sprint-plan skill** is +invoked — the board is the survey source and the plan's destination. Fold it in: + +**Survey (before proposing anything):** +- Board topology as constraints: `cards boards show ` — transition rules, WIP limits, and + monitors are planning constraints, not decoration (don't plan 5 concurrent cards into a + WIP-3 lane; an empty promoted lane with a full backlog is itself a finding). +- Active work: in-progress/review columns, with owners — a sprint plan that ignores in-flight + work is wrong on arrival. +- Pending work in board order: backlog/ready columns. The existing order and any priority + fields/tags encode decisions already made — respect them; don't silently reshuffle. +- The blocked set (`cards list --blocked`) and dependency links: **defer blocked work** — a + card whose dependencies aren't done doesn't go in the sprint; its blocker might. +- Recently done cards: context for velocity and for what just unblocked. +- If no live DB is reachable (fresh machine, CI), the committed snapshot (`backlog.jsonl` / + `board-export.jsonl`) is a legitimate read source for the survey — it's the portable truth. + +**Plan like a planner:** +- Where a card is ambiguous, stale, or contradicts the code/docs, **raise it as a + clarification** in the plan (and optionally as a comment on the card) rather than guessing. +- Propose the sprint as a selection from pending cards, in order, plus any genuinely new work + as proposed new cards. + +**Write the plan back (with the user's approval of the plan):** +- New work → `cards create --type ... --title ... --field ...`; wire dependencies with + `cards link add --type depends-on --target `. +- Selected cards → move to the ready/committed column via `patch`; tag with the sprint name if + the project uses tags. +- The board after planning should *be* the sprint plan — someone reading only the board sees + what was decided. + +## 6. Recording work as it lands — the commands + +§2 says *what* to record and when. The verbs: + +- `cards comment add --body "..."` — the narrative. +- `cards patch --version N --field k=v` — a dedicated field, when the card type has one + for commits, branches, or PR links. +- `cards append --version N --entry-json '{...}'` — repeating fields (`work_log`, + `sources`, `change_log`). Repeating fields are **not** patchable via `patch`. +- `cards attach ` — an `artifact` field, for screenshots and evidence files. + If the type has no artifact field, save under the project's evidence convention and + reference the path in the comment. +- `cards patch --version N --status ` — the status move. +- `cards link add --type related --target ` — wire a follow-up card to its origin. + +## 7. Session end: persist the board — the commands + +§2 says to export before the session ends. Which path: + +1. **Use the project's convention if one exists:** a wrapper script (`scripts/board.sh export`), + or an existing snapshot file (`board-export.jsonl`, `backlog.jsonl`) — re-export **to the + same path**: + ```bash + cards --workspace export --state-only --out + ``` +2. **No convention yet:** `cards --workspace export --state-only --out /board-export.jsonl` + and tell the user where it went. +3. If snapshots are git-tracked in this project and you're committing work anyway, include the + refreshed snapshot in the commit (respecting the project's commit rules). + +`--state-only` is the right default: definitions + current cards/links/comments, small and +diff-clean; the event log stays machine-local by design. + +## 8. Adopting Cards in a project + +Introducing Cards to a project, migrating a backlog into it, or designing card types is a +different job from working an existing board. Read +[references/project-practices.md](references/project-practices.md) when the task is any of: + +- setting up a board for a project that doesn't have one, or deciding whether it needs one; +- designing card types — the minimal epic/story/task ladder, and when to add a field; +- migrating from GitHub Issues, Linear, Jira, or Trello, and keeping provenance; +- coordinating several people or agents (shared server vs snapshot sync); +- setting up review, pre-commit/pre-push snapshot hooks, or release conventions. diff --git a/internal/agentguide/skill/references/cli-reference.md b/internal/agentguide/skill/references/cli-reference.md new file mode 100644 index 0000000..cd3c22b --- /dev/null +++ b/internal/agentguide/skill/references/cli-reference.md @@ -0,0 +1,88 @@ +# cards CLI reference (condensed, field-verified) + +Everything runs through the one `cards` binary. `cards --help` for any command's +flags. This file is the deeper surface behind SKILL.md; the upstream docs are at + (source: `docs/` in the cards repo). + +## Environment & backends + +| Variable | Purpose | +|---|---| +| `CARDS_URL` | API base of a running server. **Unset = serverless** (in-process against the workspace). | +| `CARDS_WORKSPACE` | Workspace dir for serverless mode — must be the `.cards` dir itself, not its parent. | +| `CARDS_USER` | Default actor for writes (`--as` overrides per command). | + +- Client verbs (`list`/`get`/`create`/`patch`/`comment`/...) accept `--url` and `--workspace` + as global flags, but combining `--workspace` with `--url` is an error. +- Prefer the server when one is running: serverless writes bypass its event bus (live UI and + hooks won't see them). +- Global flags on every command, placed **before the verb**: `--url`, `--as`, `--workspace`, + `--json`, `--jsonl`, `--quiet`/`-q`. + +## Working with cards + +| Command | Notes | +|---|---| +| `list` | Filters: `--board --owner --status --type --q --blocked --has-link --link-target --limit --cursor`; `--include links,comments`. Order: `updated_at DESC, id DESC`. Default output JSONL. | +| `get ` | One card as JSON. 8-char short ids fine (**bare hex only** — `card_` + short hex is `not_found`); ambiguous short ids fail listing candidates. | +| `create` | `--type T --title T [--status S] [--field k=v]... [--tag t]... [--dry-run]`. **No `--body`.** No owner at create. | +| `patch ` | `--version N [--title] [--status] [--owner] [--field k=v]... [--dry-run]`. Stale version → `version_conflict` with current card on stderr. | +| `claim ` | `--version N [--status S]` — sets owner to the actor. Owner must be a registered user (`users register`). | +| `take-next` | Atomically claim oldest unowned match: `[--type] [--board] [--assign-to] [--status] [--filter-file]`. `{card:null}` = nothing eligible. | +| `comment add --body B` | Appends evidence; **bumps card version**. `comment edit ` to fix. | +| `link add/remove ` | `--type T --target ID [--note N]`. Types come from the workspace (`cards workspace show`), not a fixed enum. Typical boards declare `depends-on` / `blocked-by` / `related`; hierarchy is a `part-of` type (child → parent) — see [project-practices.md](project-practices.md). Stored on the source. Idempotent. | +| `append ` | Repeating-field entry: `--version N --entry-json '{...}'`. Repeating fields are NOT patchable via `patch`. | +| `attach ` | Upload to an `artifact` field (screenshots, evidence files). | +| `delete ` | Leaves a tombstone event. | + +## Reading history and state + +| Command | Notes | +|---|---| +| `history ` | Resumption-ready timeline: creates, moves, comments, entries. | +| `events ` | Raw events with `{before, after}` diffs; `[--types t1,t2] [--limit N]`; `events stream` follows live. | +| `feed` | Workspace-wide event feed, **oldest-first, id-ascending** (`--cursor`/`--since` are event-id floors, not timestamps). Built for catch-up from a known point, not for "latest activity" — use `list` ordering for recency. | +| `breaches` | Current WIP / drained-lane / blocked condition violations. | +| `workspace show` | Columns, card types, boards, users — read this before assuming column names. | +| `boards show [id]` | Board definition (filters, transitions). | + +## Workspace lifecycle + +| Command | Notes | +|---|---| +| `init [dir] [--global]` | Scaffold `.cards/` (or `~/.cards` personal) and install this skill into `.claude/skills/cards/`. `--no-skill` opts out; an existing skill directory is never overwritten. To update, review local edits, delete it, and re-run init. | +| `serve` | `[--workspace] [--port 8787] [--seed] [--run-extensions] [--watch]`. Web UI at `/ui/boards/`. | +| `export` | `[--out F] [--state-only] [--with-artifacts]`. `--state-only` = definitions + current cards/links/comments (+ delete tombstones), small and diff-clean; the event log stays SQLite-owned. | +| `import` | `--in F [--with-artifacts]`. **Refuses a non-empty DB** — never a silent overwrite. | +| `users register` | `--id ID [--kind human\|agent] [--display-name N]` — required before an actor can *own* cards (comments/creates need no registration). | +| `reload` | Reload definitions on a running server. | +| `version` | Version + commit + build info. | + +## Output modes + +- `--json` one object (default for get/create/patch); `--jsonl` newline-delimited (default for + list/events); `-q` ids only, built for `xargs`: + ```bash + cards list --status done -q | xargs -n1 cards get -q + cards list --board engineering --status in_progress | jq -r .title + ``` +- Errors are structured on stderr: `code (field): message [valid: ...]`. + +## The data model in one paragraph + +A card = fixed envelope (`id`, `type_id`, `title`, `status`, `owner`, `tags`, `links`, +`comments`, `version`, timestamps) + schema-validated custom `fields` (types: string, text, +number, date, enum, tags, user, card_link, repeating, artifact). Cards live in ONE workspace; +boards are filtered views, not containers (membership derives from `type_id`). Status values +are workspace column ids. `?blocked=true` means: has a `depends-on`/`blocked-by` link whose +target isn't `done` yet — when all targets reach done, the card drops out of the blocked set. + +## Gotchas learned live + +- Global flags before the verb — `cards list --url ...` does not work. +- `patch` after `comment add` needs a fresh `get` (the comment bumped the version). +- `CARDS_WORKSPACE` pointing at the *parent* of `.cards` silently opens an empty workspace. +- The Mongo-style filter DSL (`$and/$or/...`) exists only in board `default_filter` and + `take-next --filter-file` — not on `list`. +- Timestamps are UTC; convert before making local-time claims. +- A bare `cards` on a TTY opens the TUI; in scripts/pipes it prints usage (harmless). diff --git a/internal/agentguide/skill/references/project-practices.md b/internal/agentguide/skill/references/project-practices.md new file mode 100644 index 0000000..f628063 --- /dev/null +++ b/internal/agentguide/skill/references/project-practices.md @@ -0,0 +1,220 @@ +# Adopting Cards in a project + +Read this when the job is to *introduce* Cards to a project, migrate an existing +backlog into it, or design card types — not when working an existing board +(that's [SKILL.md](../SKILL.md)). + +## 1. When a project wants a board + +Adopt when work outlives a session and more than one actor touches it: a human +plus agents, several agents, or one person across many sessions. Below that, a +TODO file is honestly better — say so rather than installing ceremony. + +Layout, in the repo, committed: + +``` +.cards/ + definitions/ # workspace.json, card-types/, boards/ — git-backed, reviewed + backlog.jsonl # the portable snapshot: COMMIT THIS + .gitignore # work-cards.db* — local working state, never committed + README.md # this board's conventions, for humans +.claude/skills/cards/ # installed by `cards init` (Claude Code and compatible) +``` + +The database is machine-local and disposable; the JSONL snapshot is the truth +that travels. Anything a human authors or reviews belongs in `definitions/`; +anything operational belongs in the DB. + +```bash +cards init # scaffolds .cards/ + installs the skill +cards --workspace .cards # serverless TUI +cards serve --workspace .cards # browser UI at /ui/boards/ +``` + +`cards init` writes a tutorial workspace, not the layout above: columns +`todo`/`doing`/`done`, one `task` type (`notes`, `attachment`), a `welcome` +board, no `link_types`, no `.gitignore`. Fine for a first look. For a real +project board, replace the starter definitions — and delete the welcome cards +— *before* creating work. Changing columns while those cards exist fails +validation; `default_board` must name a board file that already exists or the +workspace will not load. Write the JSON in this document into `definitions/`; +don't layer it onto the tutorial. + +## 2. Start minimal and climb only when the board complains + +This ladder is what two existing Cards projects settled on, and it is the +recommended starting point — not a law. Treat it as a default that has survived +contact with real work, not as proof that it is optimal. + +| Type | Required fields | Says | +|---|---|---| +| `epic` | `goal` (text) | a high-level goal and success picture — **not** an implementation checklist | +| `story` | `outcome`, `acceptance` (text) | an outcome with observable acceptance criteria | +| `task` | `actions` (text); optional `verify` (string), `work_log` (repeating) | an actionable unit with a clear verification path | + +One field on an epic. Two on a story. Three on a task. That is the whole ladder, +and it is a good default for a new project. + +Climb a rung only when a *missing* field has actually cost you something — +a review you couldn't run, a query you couldn't ask. Adding a field changes the +contract everywhere at once (API, CLI, MCP, UI), so each one is a standing tax +on every card of that type. Do not port a mature tracker's taxonomy: you will +import fields nobody fills in, and required fields nobody can answer become +lies. + +Signals you have gone too far: fields that are always empty, always the same +value, or restate the title. Delete them. + +**Hierarchy is a link, not a field.** Declare a `part-of` link type alongside +`depends-on` / `blocked-by` / `related` and wire `task -part-of-> story +-part-of-> epic`. Blocking is `depends-on`; a card whose targets aren't `done` +shows up in `cards list --blocked`. + +**Workspace settings worth turning on from day one** (both reference boards run +all four): + +```json +{ "enforce_transitions": true, "strict_fields": true, + "tag_policy": "locked", "default_board": "engineering" } +``` + +`strict_fields` and `tag_policy: locked` reject typos instead of silently +creating a second vocabulary. `default_board` stops every surface guessing when +there's more than one board — set it after that board file exists; load rejects +an unknown id. The same coupling bites in reverse: deleting a card type while a +board still lists it in `card_type_ids` also fails load, so rewrite the board in +the same pass as the types it names. Run `cards --workspace .cards workspace +show` after each definitions edit — it is the cheapest check that the workspace +still loads, and one bad edit is far easier to unpick than three. One board with +`wip_limits` (e.g. `{"in_progress": 2}`) beats several boards that split +attention. + +## 3. What each level should actually say + +- **Epic — the goal and what success looks like.** If it reads as a list of + tasks, it is a story. Epics rarely move; they are the thing stories point at. +- **Story — the outcome, plus acceptance someone else could check.** Acceptance + is the contract: observable, and phrased so a reviewer who didn't do the work + can verify it. "Works properly" is not acceptance. +- **Task — the concrete actions, plus how it is verified.** `verify` should be + one command a reviewer can run. `work_log` takes the structured record + (commit, files, measurement); the *narrative* goes in comments. + +Titles must survive a done-column scan months later, with no surrounding +conversation: `Finish DB sync testing (P1)`, not `Close P1`. + +## 4. Migrating an existing backlog + +**There is no importer.** `cards import` restores a Cards snapshot only. A +migration is a short script that reads the source and calls `cards create` — +which is a feature, because it forces the triage below. + +1. **Don't import everything.** Most trackers are a graveyard. Take what is + actually planned; leave the rest in the old system, which stays readable. +2. **Map their shape onto the ladder**, don't reproduce it. Typical mapping: + milestone/label-epic → `epic`; issue → `story`; sub-issue or checklist item → + `task`. Their status vocabulary maps onto your columns — decide the mapping + once and write it into `.cards/README.md`. +3. **Keep provenance.** Add one `source` string field (e.g. + `gh#412`, `LIN-88`, `PROJ-1043`, plus the URL) so a card can be traced back + after the old tracker is archived. This is the one field worth adding up + front — reconstructing it later is impossible. +4. **Wire the hierarchy after creating cards**, with `cards link add --type + part-of --target `: you need both ids to exist first. +5. **Record what you did.** Put a short "migration" section in + `.cards/README.md` naming the source, the date, and what was deliberately + left behind. Someone will ask why a card isn't there. + +Per-source notes: **GitHub** issues carry labels and milestones — labels usually +become tags (declare them in `tag_set` first, since `tag_policy: locked` will +reject unknown ones) and milestones usually become epics. **Linear** and +**Jira** both have richer state machines than five columns; collapse aggressively +and keep their id in `source`. **Trello** cards are usually stories with +checklists that become tasks. + +Do a dry run first — `cards create --dry-run` validates without writing — and +migrate into a scratch workspace before the real one. + +## 5. Working with other people and agents + +**One process serves one workspace**, so pick a mode: + +- **Shared server** — one `cards serve`, everyone points `CARDS_URL` at it. + Live UI, event stream, and hooks all see every write. This is the right mode + when people work at the same time. Serverless writes bypass that process's + event bus, so if a server is up, target it. +- **Snapshot sync** — no server; each machine works serverlessly and the + committed `backlog.jsonl` is the exchange format. Merge conflicts land in one + file, which is why `--state-only` exists: it is small and diff-clean. + +Give every actor a distinct id (`CARDS_USER`, or `--as`) — human or agent — so +history says who did what. Owning a card requires a registered user +(`cards users register --id --kind agent`); commenting does not. + +`wip_limits` are a real coordination tool here: they stop two agents claiming +into the same lane. Use `cards take-next` rather than `list` + `claim` when +several actors pull from one queue — it claims atomically, so two agents cannot +take the same card. + +## 6. Review, and closing work honestly + +Work normally exits through `review`, not straight to `done`. A card arriving in +review should be a **review packet** — everything a second party needs, without +asking: + +- the acceptance it claims to meet, +- a `verify` command they can actually run, +- the commits or PR, +- evidence (a screenshot for anything visual, output for anything measured), +- a short note on risk and where to look first. + +Where practical the reviewer is a **different session, agent, or person** than +the implementer — the point of the column is a second pair of eyes, and an +implementer reviewing their own work is just a slower `done`. The reviewer +records the outcome, any suggested decisions, and residual risks on the card. + +Discoveries become linked follow-up cards, not silent scope creep. File one +immediately when it would block progress or a release; leave non-urgent ideas +for triage rather than flooding the board. + +## 7. Time + +Store RFC3339 UTC. Convert only when speaking to a person, and never write a +local-time stamp without an offset. A board read across machines and timezones +is exactly where "3pm" becomes unrecoverable. + +## 8. Keep the snapshot honest: pre-commit and pre-push + +The live DB is gitignored, so the board's committed state is only as fresh as +the last export. Wrap it in a script the project owns: + +```bash +scripts/cards-board.sh export # refresh .cards/backlog.jsonl +scripts/cards-board.sh check # fail if the snapshot is stale +scripts/cards-board.sh install-hooks # opt in to pre-commit + pre-push +``` + +Both hooks earn their place: **pre-commit** export keeps board changes in the +same commit as the code they describe, and **pre-push** check is the backstop +that stops a stale snapshot reaching everyone else. Never hand-edit +`backlog.jsonl` while a server is running against the same workspace, and never +`import` over a non-empty database — it refuses, and that refusal is protecting +someone's live state. + +## 9. Releases + +Cards has no release command (`cards release ` releases *ownership* of a +card). A release is a convention, and a good one is four things landing +together: + +1. a **release card** whose acceptance is the release checklist, moved to `done` + only once the release is actually out; +2. the **tag**, referenced on that card; +3. the **CHANGELOG** entry, written from the cards that closed since the last + release — which is the payoff for honest titles and acceptance; +4. a **refreshed snapshot** committed with the tag, so the board state at that + version is recoverable. + +Cards closed since the last release are the changelog's raw material: `cards +list --status done` is ordered `updated_at DESC`. If that list doesn't read like +release notes, the titles were the problem. diff --git a/internal/mcp/README.md b/internal/mcp/README.md index 19860ab..41cf79f 100644 --- a/internal/mcp/README.md +++ b/internal/mcp/README.md @@ -5,7 +5,7 @@ This package implements the Model Context Protocol (MCP) server for Work Cards, ## Running the MCP Server ```bash -cards mcp --workspace ./.work-cards +cards mcp --workspace ./.cards ``` The MCP server runs over stdio. Mutations delegate to the same service layer as HTTP/CLI, ensuring event emission and validation match the core contract. diff --git a/internal/mcp/instructions_test.go b/internal/mcp/instructions_test.go new file mode 100644 index 0000000..57548fd --- /dev/null +++ b/internal/mcp/instructions_test.go @@ -0,0 +1,69 @@ +package mcp_test + +import ( + "context" + "strings" + "testing" + + "github.com/somebox/cards/internal/agentguide" + "github.com/somebox/cards/internal/config" + "github.com/somebox/cards/internal/core" + "github.com/somebox/cards/internal/mcp" + "github.com/somebox/cards/internal/seed" + "github.com/somebox/cards/internal/sqlite/sqlitetest" +) + +// The initialize handshake is the only place an MCP client is told how to work +// the board — the tool schemas describe the surface, not the loop. An empty +// instructions field means every agent starts by guessing. +func TestInitializeServesInstructions(t *testing.T) { + srv := newMCPServer(t) + res := call(t, srv, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) + + result, ok := res["result"].(map[string]any) + if !ok { + t.Fatalf("initialize returned no result: %v", res) + } + got, _ := result["instructions"].(string) + if strings.TrimSpace(got) == "" { + t.Fatal("initialize returned no instructions") + } + if got != agentguide.MCPInstructions() { + t.Error("initialize instructions differ from agentguide.MCPInstructions()") + } +} + +// serverInfo.version answered "poc" on every build including tagged releases, +// so a client could not tell what it was talking to. +func TestInitializeReportsRealVersion(t *testing.T) { + r, err := config.New("../../examples/demo-workspace").Load() + if err != nil { + t.Fatalf("load: %v", err) + } + st := sqlitetest.Open(t, r.Workspace, 1) + svc := core.NewService(r.Workspace, r.CardTypes, r.Boards, st) + if err := seed.IfEmpty(context.Background(), st, svc, r.Workspace); err != nil { + t.Fatalf("seed: %v", err) + } + srv := mcp.New(svc, r.Workspace, r.CardTypes, r.Boards, "coder-agent", mcp.WithVersion("v9.9.9-test")) + + res := call(t, srv, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) + result := res["result"].(map[string]any) + info, ok := result["serverInfo"].(map[string]any) + if !ok { + t.Fatalf("no serverInfo: %v", result) + } + if got := info["version"]; got != "v9.9.9-test" { + t.Errorf("serverInfo.version = %v, want the injected build version", got) + } +} + +// Without the option the server must still not claim "poc". +func TestInitializeVersionDefaultIsNotPoc(t *testing.T) { + srv := newMCPServer(t) + res := call(t, srv, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) + info := res["result"].(map[string]any)["serverInfo"].(map[string]any) + if got := info["version"]; got == "poc" { + t.Error(`serverInfo.version is still the "poc" stub`) + } +} diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go index 779839a..2fba49a 100644 --- a/internal/mcp/mcp.go +++ b/internal/mcp/mcp.go @@ -23,6 +23,7 @@ import ( "sort" "strings" + "github.com/somebox/cards/internal/agentguide" "github.com/somebox/cards/internal/core" ) @@ -38,9 +39,13 @@ type Server struct { types map[string]*core.CardType boards map[string]*core.Board actor string // session-bound actor (CARDS_USER) - tools []Tool - in io.Reader - out io.Writer + // version is reported as serverInfo.version in the initialize handshake. + // Per-server rather than a package var: a client is entitled to know which + // build it is talking to, and tests construct servers independently. + version string + tools []Tool + in io.Reader + out io.Writer } // Tool is one MCP tool definition. @@ -53,8 +58,25 @@ type Tool struct { } // New constructs the Server and generates tools from the workspace. -func New(svc *core.Service, ws *core.Workspace, types map[string]*core.CardType, boards map[string]*core.Board, actor string) *Server { - s := &Server{svc: svc, ws: ws, types: types, boards: boards, actor: actor, in: os.Stdin, out: os.Stdout} +// Option configures a Server at construction. Variadic, so the existing call +// sites stay source-compatible — matching the core.ServiceOption pattern. +type Option func(*Server) + +// WithVersion reports the running build in the initialize handshake instead of +// the "dev" placeholder. +func WithVersion(v string) Option { + return func(s *Server) { + if v != "" { + s.version = v + } + } +} + +func New(svc *core.Service, ws *core.Workspace, types map[string]*core.CardType, boards map[string]*core.Board, actor string, opts ...Option) *Server { + s := &Server{svc: svc, ws: ws, types: types, boards: boards, actor: actor, version: "dev", in: os.Stdin, out: os.Stdout} + for _, opt := range opts { + opt(s) + } s.tools = s.buildTools() return s } @@ -152,10 +174,15 @@ func (s *Server) handle(req jsonRPCRequest) *jsonRPCResponse { } func (s *Server) handleInitialize(req jsonRPCRequest) *jsonRPCResponse { + // instructions is the protocol's slot for standing guidance. It carries only + // what the tool schemas cannot state — the coordination loop, evidence norms, + // and who owns card bookkeeping — and is size-capped in internal/agentguide, + // because it sits in every session's prompt prefix. result := map[string]any{ "protocolVersion": "2024-11-05", - "serverInfo": map[string]any{"name": "cards", "version": "poc"}, + "serverInfo": map[string]any{"name": "cards", "version": s.version}, "capabilities": map[string]any{"tools": map[string]any{}}, + "instructions": agentguide.MCPInstructions(), } return &jsonRPCResponse{JSONRPC: "2.0", ID: req.ID, Result: result} } diff --git a/internal/smoke/adoption_test.go b/internal/smoke/adoption_test.go new file mode 100644 index 0000000..1fd1b72 --- /dev/null +++ b/internal/smoke/adoption_test.go @@ -0,0 +1,423 @@ +//go:build smoke + +// End-to-end adoption smoke test: drop a real agent into an empty project with +// nothing but `cards init` run, ask it to set up a board, and assert over what +// it leaves behind. +// +// CARDS_AGENT_CMD='claude -p' go test -tags smoke ./internal/smoke/ +// CARDS_AGENT_CMD='pi -p' CARDS_SMOKE_RUNS=5 go test -tags smoke -v ./internal/smoke/ +// +// Skips (does not fail) when no agent command or cards binary is available, so +// `go test ./...` stays the one command and CI needs no secrets. +// +// Two design rules, both load-bearing: +// +// - Assert over ARTIFACTS, never the transcript. The agent's path is +// nondeterministic; the workspace it leaves is not. The transcript is +// diagnostic output for a failed check, nothing more. +// - Assert PROPERTIES, not mimicry. Checking that it produced exactly +// epic/story/task would test whether it copied the playbook. Checking that +// no type carries more than a handful of required fields tests whether it +// understood the advice. +// +// The runner is deliberately swappable via CARDS_AGENT_CMD, because the same +// oracle across several harnesses is the only way to read a failure: every +// runner failing one check means the guidance is wrong; one runner failing it +// means that harness never loaded the skill. +package smoke + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +// The fixture deliberately withholds the vocabulary. It never says +// "epic/story/task", never names the `part-of` link type, and never names the +// `default_board` setting — all of that has to arrive from the installed skill. +// If the prompt taught the answer, the test would only prove the model can +// follow instructions it was just given. +const fixture = `This project needs a work board so we can track what we're building. + +Set it up: +- Replace the starter workspace with card types that suit a small software project. +- Make the board this project's default. +- Add the first few real pieces of work, with the hierarchy between them wired up. + +The project is a command-line tool that converts subtitle files between formats +(SRT, WebVTT, ASS). Nothing is built yet.` + +const agentTimeout = 15 * time.Minute + +func cardsBin(t *testing.T) string { + t.Helper() + if b := os.Getenv("CARDS_BIN"); b != "" { + return b + } + if abs, err := filepath.Abs("../../cards"); err == nil { + if _, statErr := os.Stat(abs); statErr == nil { + return abs + } + } + b, err := exec.LookPath("cards") + if err != nil { + t.Skip("no cards binary: set CARDS_BIN or build ./cmd/cards") + } + return b +} + +// ─── the oracle ────────────────────────────────────────────────────────────── + +// board is the parsed result of `cards workspace show`. +type board struct { + Workspace struct { + LinkTypes []struct { + ID string `json:"id"` + } `json:"link_types"` + Settings map[string]any `json:"settings"` + } `json:"workspace"` + Boards map[string]json.RawMessage `json:"boards"` + CardTypes map[string]struct { + Fields []struct { + ID string `json:"id"` + Required bool `json:"required"` + } `json:"fields"` + } `json:"card_types"` +} + +type card struct { + ID string `json:"id"` + Title string `json:"title"` + TypeID string `json:"type_id"` + Fields map[string]any `json:"fields"` + // type_id, not type — the wire name. Getting this wrong makes the + // hierarchy canary silently unmatchable on every run. + Links []struct { + TypeID string `json:"type_id"` + Target string `json:"target"` + } `json:"links"` +} + +type state struct { + dir string + board board + cards []card +} + +type check struct { + name string + fn func(*state) error +} + +// maxRequiredFields is the "start minimal" advice expressed as a number. The +// reference ladder tops out at one required field on an epic and two on a +// story; four leaves room for a different-but-still-lean design without +// admitting a ported Jira taxonomy. +const maxRequiredFields = 4 + +var checks = []check{ + {"workspace_loads", func(s *state) error { + if len(s.board.CardTypes) == 0 { + return fmt.Errorf("workspace has no card types") + } + return nil + }}, + + {"default_board_set", func(s *state) error { + id, _ := s.board.Workspace.Settings["default_board"].(string) + if id == "" { + return fmt.Errorf("settings.default_board is unset") + } + if _, ok := s.board.Boards[id]; !ok { + return fmt.Errorf("default_board %q names no board (have: %s)", id, keys(s.board.Boards)) + } + return nil + }}, + + {"starter_replaced", func(s *state) error { + if _, ok := s.board.Boards["welcome"]; ok { + return fmt.Errorf("the starter 'welcome' board is still present") + } + for _, c := range s.cards { + if strings.Contains(c.Title, "Welcome to Cards") { + return fmt.Errorf("starter tutorial cards were never cleared (%q)", c.Title) + } + } + return nil + }}, + + {"schema_stayed_minimal", func(s *state) error { + for id, ct := range s.board.CardTypes { + n := 0 + for _, f := range ct.Fields { + if f.Required { + n++ + } + } + if n > maxRequiredFields { + return fmt.Errorf("card type %q has %d required fields (max %d) — the ladder was not kept minimal", id, n, maxRequiredFields) + } + } + return nil + }}, + + {"work_was_created", func(s *state) error { + if len(s.cards) < 3 { + return fmt.Errorf("only %d cards created, want >= 3", len(s.cards)) + } + for _, c := range s.cards { + for _, f := range s.board.CardTypes[c.TypeID].Fields { + if !f.Required { + continue + } + if v, ok := c.Fields[f.ID]; !ok || v == nil || v == "" { + return fmt.Errorf("card %q leaves required field %q empty", c.Title, f.ID) + } + } + } + return nil + }}, + + // The canary. `cards init` ships "link_types": [] — zero — so `part-of` + // cannot have been copied off the scaffold, and it is not what a model + // reaches for unaided (that is a `parent` field or a `subtasks` array). + // Declaring AND using it is the strongest available evidence that the + // installed skill was actually read. + {"hierarchy_uses_links_not_fields", func(s *state) error { + declared := "" + for _, lt := range s.board.Workspace.LinkTypes { + if lt.ID == "part-of" || strings.Contains(lt.ID, "part") { + declared = lt.ID + } + } + if declared == "" { + return fmt.Errorf("no part-of-style link type declared (have: %v) — hierarchy was probably modelled as a field", linkIDs(s.board.Workspace.LinkTypes)) + } + for _, c := range s.cards { + for _, l := range c.Links { + if l.TypeID == declared { + return nil + } + } + } + return fmt.Errorf("link type %q is declared but no card uses it", declared) + }}, + + {"no_parent_pointer_fields", func(s *state) error { + for id, ct := range s.board.CardTypes { + for _, f := range ct.Fields { + switch f.ID { + case "parent", "parent_id", "subtasks", "children", "epic_id", "story_id": + return fmt.Errorf("card type %q models hierarchy as field %q; hierarchy belongs in links", id, f.ID) + } + } + } + return nil + }}, + + {"snapshot_roundtrips", func(s *state) error { + return nil // filled in by run(), which needs the binary + }}, +} + +func keys(m map[string]json.RawMessage) string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return strings.Join(out, ", ") +} + +func linkIDs(lts []struct { + ID string `json:"id"` +}) []string { + out := make([]string, 0, len(lts)) + for _, lt := range lts { + out = append(out, lt.ID) + } + return out +} + +// ─── the runner ────────────────────────────────────────────────────────────── + +func TestAgentCanAdoptCards(t *testing.T) { + agentCmd := os.Getenv("CARDS_AGENT_CMD") + if agentCmd == "" { + t.Skip("set CARDS_AGENT_CMD to the agent to drive, e.g. CARDS_AGENT_CMD='claude -p'") + } + bin := cardsBin(t) + + runs := 1 + if n := os.Getenv("CARDS_SMOKE_RUNS"); n != "" { + if _, err := fmt.Sscanf(n, "%d", &runs); err != nil || runs < 1 { + t.Fatalf("CARDS_SMOKE_RUNS=%q is not a positive integer", n) + } + } + + // Per-check tallies rather than a binary verdict: the system under test is + // stochastic, so "check 4 went 5/5 to 1/5" is the actionable signal and a + // hard gate would just be a flaky red build. + passed := map[string]int{} + for i := 0; i < runs; i++ { + runOnce(t, bin, agentCmd, i, passed) + } + + t.Logf("=== adoption smoke: %d run(s) ===", runs) + for _, c := range checks { + t.Logf(" %-34s %d/%d", c.name, passed[c.name], runs) + } +} + +func runOnce(t *testing.T, bin, agentCmd string, i int, passed map[string]int) { + t.Helper() + dir, err := os.MkdirTemp("", fmt.Sprintf("cards-smoke-%d-", i)) + if err != nil { + t.Fatal(err) + } + keep := false + defer func() { + if keep { + t.Logf("run %d: workspace kept for inspection at %s", i, dir) + return + } + _ = os.RemoveAll(dir) + }() + + // The only setup the agent gets: the same `cards init` a real user runs. + if out, err := exec.Command(bin, "init", "--quiet", dir).CombinedOutput(); err != nil { + t.Fatalf("cards init: %v: %s", err, out) + } + + transcript, agentErr := runAgent(t, dir, agentCmd) + _ = os.WriteFile(filepath.Join(dir, "agent-transcript.txt"), []byte(transcript), 0o644) + if agentErr != nil { + keep = true + t.Errorf("run %d: agent command failed: %v\n--- transcript tail ---\n%s", i, agentErr, tail(transcript, 40)) + return + } + + s, err := observe(bin, dir) + if err != nil { + keep = true + t.Errorf("run %d: could not read the workspace the agent left behind: %v\n"+ + "(a workspace that no longer loads is itself the finding — e.g. settings.default_board "+ + "naming a board whose file was not written yet is a hard load failure)\n--- transcript tail ---\n%s", + i, err, tail(transcript, 40)) + return + } + + for _, c := range checks { + var cerr error + if c.name == "snapshot_roundtrips" { + cerr = snapshotRoundtrips(bin, dir, s) + } else { + cerr = c.fn(s) + } + if cerr != nil { + keep = true + t.Errorf("run %d: %s: %v", i, c.name, cerr) + continue + } + passed[c.name]++ + } +} + +func runAgent(t *testing.T, dir, agentCmd string) (string, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), agentTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-c", agentCmd) + cmd.Dir = dir + cmd.Stdin = strings.NewReader(fixture) + // Some harnesses read the prompt from the environment rather than stdin. + cmd.Env = append(os.Environ(), "CARDS_SMOKE_PROMPT="+fixture) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// observe reads the world through the real binary, so the oracle exercises the +// same load path a user would rather than parsing definitions by hand. +func observe(bin, dir string) (*state, error) { + ws := filepath.Join(dir, ".cards") + raw, err := exec.Command(bin, "--workspace", ws, "workspace", "show").Output() + if err != nil { + return nil, fmt.Errorf("workspace show: %w", err) + } + s := &state{dir: dir} + if err := json.Unmarshal(raw, &s.board); err != nil { + return nil, fmt.Errorf("parse workspace: %w", err) + } + listed, err := exec.Command(bin, "--workspace", ws, "list", "--include", "links", "--limit", "200").Output() + if err != nil { + return nil, fmt.Errorf("list: %w", err) + } + for _, line := range strings.Split(string(listed), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var c card + if err := json.Unmarshal([]byte(line), &c); err != nil { + return nil, fmt.Errorf("parse card: %w", err) + } + s.cards = append(s.cards, c) + } + return s, nil +} + +// snapshotRoundtrips proves the board is actually portable: export, restore +// into a fresh workspace built from the same definitions, and compare the card +// id sets. A board that cannot survive a machine move is not persisted, however +// good it looks locally. +func snapshotRoundtrips(bin, dir string, s *state) error { + ws := filepath.Join(dir, ".cards") + snap := filepath.Join(dir, "roundtrip.jsonl") + if out, err := exec.Command(bin, "--workspace", ws, "export", "--state-only", "--out", snap).CombinedOutput(); err != nil { + return fmt.Errorf("export: %v: %s", err, out) + } + fresh := filepath.Join(dir, "restored") + if err := os.MkdirAll(fresh, 0o755); err != nil { + return err + } + if out, err := exec.Command("cp", "-R", filepath.Join(ws, "definitions"), filepath.Join(fresh, "definitions")).CombinedOutput(); err != nil { + return fmt.Errorf("copy definitions: %v: %s", err, out) + } + if out, err := exec.Command(bin, "--workspace", fresh, "import", "--in", snap).CombinedOutput(); err != nil { + return fmt.Errorf("import into a fresh workspace: %v: %s", err, out) + } + restored, err := exec.Command(bin, "--workspace", fresh, "list", "--limit", "200").Output() + if err != nil { + return fmt.Errorf("list restored: %w", err) + } + got := map[string]bool{} + for _, line := range strings.Split(string(restored), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var c card + if err := json.Unmarshal([]byte(line), &c); err != nil { + return err + } + got[c.ID] = true + } + for _, c := range s.cards { + if !got[c.ID] { + return fmt.Errorf("card %q (%s) did not survive export/import", c.Title, c.ID) + } + } + return nil +} + +func tail(s string, n int) string { + lines := strings.Split(strings.TrimRight(s, "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") +} diff --git a/mkdocs.yml b/mkdocs.yml index a9b3e0d..1815ba0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -89,7 +89,9 @@ not_in_nav: | design/themes.md design/tui-bus-disposition.md plans/2026-07-18-sprint-plan.md + plans/2026-07-19-sprint-plan.md plans/2026-07-22-sprint-plan.md + plans/2026-08-08-sprint-plan.md nav: - Home: index.md diff --git a/scripts/smoke-adoption.sh b/scripts/smoke-adoption.sh new file mode 100755 index 0000000..aed41aa --- /dev/null +++ b/scripts/smoke-adoption.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# smoke-adoption.sh — deterministic end-to-end smoke for the install and +# adoption surfaces. No API key, no agent, no network: this is the regression +# net that belongs in the PR gate. +# +# The agentic half — can a real agent actually set a board up from the installed +# skill? — lives in internal/smoke/adoption_test.go and needs a model: +# +# CARDS_AGENT_CMD='claude -p' go test -tags smoke ./internal/smoke/ +# +# Usage: +# scripts/smoke-adoption.sh # builds ./cmd/cards into a temp binary +# CARDS_BIN=/path/to/cards scripts/smoke-adoption.sh +# +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +SCRATCH="$(mktemp -d)" +CARDS="${CARDS_BIN:-$SCRATCH/cards-under-test}" +if [[ -z "${CARDS_BIN:-}" ]]; then + echo "building ./cmd/cards" + go build -o "$CARDS" ./cmd/cards +fi + +pass=0 fail=0 +cleanup() { rm -rf "$SCRATCH"; } +trap cleanup EXIT + +ok() { pass=$((pass+1)); printf ' \033[32mok\033[0m %s\n' "$1"; } +bad() { fail=$((fail+1)); printf ' \033[31mFAIL\033[0m %s\n' "$1"; } +check(){ if eval "$2" >/dev/null 2>&1; then ok "$1"; else bad "$1"; fi; } + +echo "scratch: $SCRATCH" + +# ── A. Fresh init: workspace, skill, and a short handshake ─────────────────── +echo; echo "A. fresh init" +mkdir -p "$SCRATCH/fresh" +init_out="$("$CARDS" init "$SCRATCH/fresh")" + +check "workspace scaffolded" "test -f '$SCRATCH/fresh/.cards/definitions/workspace.json'" +check "skill installed" "test -f '$SCRATCH/fresh/.claude/skills/cards/SKILL.md'" +check "cli reference installed" "test -f '$SCRATCH/fresh/.claude/skills/cards/references/cli-reference.md'" +check "adoption playbook installed" "test -f '$SCRATCH/fresh/.claude/skills/cards/references/project-practices.md'" +check "skill is beside .cards, not in" "test ! -e '$SCRATCH/fresh/.cards/.claude'" +check "no staging debris" "! ls -a '$SCRATCH/fresh/.claude/skills/' | grep -q staging" +check "init reported both artifacts" "grep -q 'initialized workspace' <<<'$init_out' && grep -q 'installed the cards agent skill' <<<'$init_out'" +check "workspace loads" "'$CARDS' --workspace '$SCRATCH/fresh/.cards' workspace show" + +# The handshake sits in every MCP session's prompt prefix; the caps are the +# enforcement that it stays cheap. Guarded in Go too, asserted here end to end. +hs_bytes=$("$CARDS" mcp --print-instructions | wc -c | tr -d ' ') +hs_lines=$("$CARDS" mcp --print-instructions | wc -l | tr -d ' ') +check "handshake <= 2048 bytes ($hs_bytes)" "[ '$hs_bytes' -le 2048 ]" +check "handshake <= 40 lines ($hs_lines)" "[ '$hs_lines' -le 40 ]" +check "handshake needs no workspace" "cd / && '$CARDS' mcp --print-instructions" + +# ── B. No-clobber, existing projects, and --global ─────────────────────────── +echo; echo "B. install paths" +echo 'locally edited' > "$SCRATCH/fresh/.claude/skills/cards/SKILL.md" +reinit_out="$("$CARDS" init "$SCRATCH/fresh")" +check "local edits survive re-init" "grep -q 'locally edited' '$SCRATCH/fresh/.claude/skills/cards/SKILL.md'" +check "re-init reports no-clobber" "grep -q 'not overwritten' <<<'$reinit_out'" + +mkdir -p "$SCRATCH/existing" +"$CARDS" init --quiet --no-skill "$SCRATCH/existing" +check "--no-skill installs nothing" "test ! -e '$SCRATCH/existing/.claude/skills/cards'" +"$CARDS" init --quiet "$SCRATCH/existing" +check "existing board gains the skill" "test -f '$SCRATCH/existing/.claude/skills/cards/SKILL.md'" + +FAKE_HOME="$SCRATCH/home"; FAKE_CARDS="$SCRATCH/cardshome"; mkdir -p "$FAKE_HOME" "$FAKE_CARDS" +HOME="$FAKE_HOME" CARDS_HOME="$FAKE_CARDS" "$CARDS" init --quiet --global +check "--global follows HOME" "test -f '$FAKE_HOME/.claude/skills/cards/SKILL.md'" +check "--global ignores CARDS_HOME" "test ! -e '$FAKE_CARDS/.claude'" + +# Debris from an interrupted install must fail loudly and still report the +# workspace it created, rather than masquerading as a protected user skill. +mkdir -p "$SCRATCH/debris/.claude/skills/cards/references" +set +e +debris_out="$("$CARDS" init "$SCRATCH/debris" 2>&1)"; debris_rc=$? +set -e +check "debris exits non-zero" "[ $debris_rc -ne 0 ]" +check "debris names the remedy" "grep -q 'has no SKILL.md' <<<'$debris_out'" +check "debris still reports workspace" "grep -q 'initialized workspace' <<<'$debris_out'" + +# ── C. The starter is a tutorial, not the ladder ───────────────────────────── +# What an adopting agent has to overcome. If any of these start passing, the +# starter changed and the adoption playbook needs rereading. +echo; echo "C. adoption starting conditions" +"$CARDS" --workspace "$SCRATCH/existing/.cards" workspace show > "$SCRATCH/starter.json" +cat > "$SCRATCH/check_starter.py" <<'PYEOF' +import json, sys +d = json.load(open(sys.argv[1])) +which = sys.argv[2] +if which == "no-links": + sys.exit(0 if not d["workspace"].get("link_types") else 1) +if which == "no-default-board": + sys.exit(0 if not d["workspace"]["settings"].get("default_board") else 1) +if which == "welcome-board": + sys.exit(0 if "welcome" in d["boards"] else 1) +sys.exit(2) +PYEOF + +check "starter has no link types" "python3 '$SCRATCH/check_starter.py' '$SCRATCH/starter.json' no-links" +check "starter has no default_board" "python3 '$SCRATCH/check_starter.py' '$SCRATCH/starter.json' no-default-board" +check "starter ships the welcome board" "python3 '$SCRATCH/check_starter.py' '$SCRATCH/starter.json' welcome-board" + +# ── D. MCP handshake against a live workspace ──────────────────────────────── +echo; echo "D. mcp handshake" +# Via files, not shell arguments: the handshake body contains quotes, backticks +# and newlines, and round-tripping it through `eval` mangles it. +printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ + | "$CARDS" mcp --workspace "$SCRATCH/fresh/.cards" | head -1 > "$SCRATCH/handshake.json" +"$CARDS" mcp --print-instructions > "$SCRATCH/printed.txt" + +cat > "$SCRATCH/check_handshake.py" <<'PYEOF' +import json, sys +r = json.load(open(sys.argv[1]))["result"] +printed = open(sys.argv[2]).read() +which = sys.argv[3] +if which == "served": + sys.exit(0 if r.get("instructions", "").strip() else 1) +if which == "matches": + sys.exit(0 if r.get("instructions") == printed else 1) +if which == "version": + sys.exit(0 if r.get("serverInfo", {}).get("version") not in ("", "poc", None) else 1) +sys.exit(2) +PYEOF + +check "initialize serves instructions" "python3 '$SCRATCH/check_handshake.py' '$SCRATCH/handshake.json' '$SCRATCH/printed.txt' served" +check "instructions == --print output" "python3 '$SCRATCH/check_handshake.py' '$SCRATCH/handshake.json' '$SCRATCH/printed.txt' matches" +check "version is not the poc stub" "python3 '$SCRATCH/check_handshake.py' '$SCRATCH/handshake.json' '$SCRATCH/printed.txt' version" + +echo +printf 'smoke: %d passed, %d failed\n' "$pass" "$fail" +[ "$fail" -eq 0 ]