From 5444e5a38876ad91220c3005cdbaac850cd5e81a Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Sun, 9 Aug 2026 02:44:14 +0300 Subject: [PATCH] fix: align todos-plan with deployment doctrine Agent: vitruvius --- CLAUDE.md | 15 +- skills/todos-plan/SKILL.md | 509 ++++++++++++++++++---------- src/lib/agent-sync.test.ts | 10 +- src/lib/agent-sync.ts | 76 ++++- src/lib/todos-plan-contract.test.ts | 106 ++++++ 5 files changed, 524 insertions(+), 192 deletions(-) create mode 100644 src/lib/todos-plan-contract.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 1ae2961e..e0c38030 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -400,12 +400,15 @@ argument to `skills mcp --register `. The one deliberate exception is the last-mile **agent-folder sync** (`skills sync`, `src/lib/agent-sync.ts`). It writes a per-tool-adapted `SKILL.md` from the corpus into each coding agent's *global* skills directory (`~/.claude/skills//SKILL.md`, -`~/.codex/…`, `~/.config/opencode/…`, `~/.cursor/…`) so the agent auto-loads it — -instruction skills as prose, executable skills as a pointer. `installSkillForAgent()` is -its single-skill entry point (no longer a stub). Writes are **non-clobbering**: every -directory the sync writes carries a `.hasna-skills.json` marker, and a skill directory -without that marker is treated as the user's own and skipped unless `--force`. `.skills/` -itself remains output state and is never an install target: +`~/.codewith/…`, `~/.codex/…`, `~/.config/opencode/…`, `~/.cursor/…`) so the agent +auto-loads it — instruction skills as prose, executable portable skills as a pointer. +An explicitly named bundled skill resolves from the package when the portable corpus +does not contain it, allowing a reviewed source skill to repair a stale agent copy. +`installSkillForAgent()` is its single-skill entry point (no longer a stub). Writes are +**non-clobbering**: every directory the sync writes carries a `.hasna-skills.json` +marker, and a skill directory without that marker is treated as the user's own and +skipped unless `--force`. `.skills/` itself remains output state and is never an +install target: ``` .skills/ diff --git a/skills/todos-plan/SKILL.md b/skills/todos-plan/SKILL.md index 6f485742..01ad8732 100644 --- a/skills/todos-plan/SKILL.md +++ b/skills/todos-plan/SKILL.md @@ -1,194 +1,363 @@ --- name: todos-plan -description: Use when Codewith needs to create, edit, save, verify, execute, route, or sync todos plans for local @hasna/todos work or hosted todos.md/platform-todos work. Triggers include todos plan authoring, local plan Markdown files, plan IDs, task-triggered workers, launch verification checklists, platform plan sync, dry-run plan generation, and updating Codewith workers that must use Todos CLI task IDs as source of truth. +description: "Create, edit, verify, execute, route, or synchronize Todos plans while preserving provider roles, authenticated authority, full stable IDs, and durable task evidence." --- # Todos Plan Use the Todos CLI as the source of truth. Markdown plan files are durable -human-readable artifacts; they do not replace task or plan rows. Never edit -SQLite, RDS rows, or generated task stores directly. - -## Known Launch Plans - -- Platform launch plan: `fc27c6c1-7f26-4bea-bbda-dc0731a9d972` -- Codewith skills plan: `4bc933b6-8b0a-4e38-8807-aa11b034a9af` - -When a prompt includes task IDs, inspect and update each task one at a time: -Commands containing angle-bracket placeholders are illustrative; replace the -placeholders with real task, plan, org, or file values before running them. - -```bash -todos plans --show -todos inspect -todos --agent start -todos comment "Progress note" -todos record-verification "" --status passed --summary "" --agent -todos --agent done --files-changed "" --test-results "" --notes "" -``` +human-readable artifacts; they do not replace task or plan rows. Never edit a +Todos database, hosted row, generated registry, or cached task store directly. + +## Required Operation-Authority Gate + +Apply this gate before every non-help Todos read or write. + +1. Feature-detect the installed surface with metadata-only help: + + ```bash + todos --help + todos --help + ``` + + A command or flag absent from installed help is unsupported. Help presence + is necessary but not sufficient: the selected authenticated authority must + also support the exact invocation. On `REMOTE_COMMAND_UNSUPPORTED` or an + equivalent capability error, stop instead of substituting another CLI, + bootstrapping a localhost service, extracting internal source, or silently + changing the data path. +2. Record a non-secret provider-role table from package-owned configuration and + authenticated readback: + + | Role | Required proof | + |---|---| + | Product story | `user-hosted` or `Hasna SaaS`. Hasna-operated AWS dogfood is customer-zero of the user-hosted story. | + | Client data path | A local SQLite file or the server HTTP API. A client never opens PostgreSQL directly. | + | Server operator | The party operating the authenticated Todos server. | + | Authenticated authority | Exact API origin, tenant, and principal returned by the installed client. | + | Server backend | `sqlite` or `postgresql`, selected by server database configuration. This is storage, not authorization. | + | Plan-artifact writer | The package-owned atomic writer and receipt lookup used for Markdown artifacts. | + + These are independent facts. Hasna SaaS is a product story, not a backend. + The server backend does not authorize a task operation. Do not derive + authority from a location word, provider name, hostname guess, or retired + placement enum. +3. Before production task data access, run the installed authenticated health + check: + + ```bash + todos health --json + ``` + + Require a successful authenticated HTTP authority with the intended tenant + and no silent fallback. A local SQLite result is valid only for an explicitly + selected on-box user-hosted workflow. Ambiguous authority, failed auth, + unavailable routing, unsupported commands, or an unexpected backend fails + closed. Never fall back from an authenticated production authority to a + local file after an error. +4. Resolve the project through the authenticated authority, capture its full + UUID, and prove scoped status: + + ```bash + todos --json projects --show + todos status --project --json + ``` + + Require the returned project identity, tenant, and authority to match the + intended operation. Never continue from a short ID, stale slug, pathname + guess, or cross-project result. +5. Before an object-specific operation, prove the exact full stable UUID with + the corresponding singleton read: + + ```bash + todos inspect + todos plans --show + todos --json lists --show + todos template-preview + ``` + + Use the full UUID returned by the authority for every later read or + mutation. If exact identity cannot be proven, stop. + +Never print credentials or raw authentication material. + +## Production Collection and Completeness Gate + +Prefer exact full-ID singleton reads. If a production list, search, history, or +export is required to resolve or verify an object, use only a package-owned +producer and reviewed collector that enforce caller- or policy-selected +positive aggregate caps for calls, items, serialized bytes, and wall time +before materialization. The collector must own every producer call under the +remaining budgets, validate complete responses incrementally without uncapped +buffering, stop at the first excess, and discard the entire result on failure. + +Drain every page within those aggregate caps. Reject repeated cursors, invalid +or partial JSON, trailing unvalidated data, producer failure, timeout, any cap +hit, scope drift, or a missing or ambiguous terminal signal. A larger cap, +consumer-side truncation, shell pipeline, generic timeout wrapper, or absence +from an incomplete result cannot prove completeness. Only an explicit terminal +marker such as `has_more=false` or equivalent exhaustion metadata can. + +## Fresh Production Mutation Gate + +Immediately before every production mutation: + +1. Re-read the exact full-UUID target and every exact parent object from the + same authenticated authority. +2. Preserve the authority-issued revision, version, ETag, or equivalent + compare-and-swap token for the object being changed. Revalidate project, + plan, task-list, task, lock, dependency, and approval scope as applicable. +3. Submit the mutation conditionally against that fresh token. Each mutation, + including a comment, verification record, update, approval, completion, + deletion, lock change, or dependency change, needs its own fresh pre-read + and token. Never reuse a token or treat one successful write as authority + for a later write. +4. Re-read every exact affected UUID from the same authority and verify the + intended transition while preserving unrelated fields. + +A read followed by an unconditional write is not a drift gate. If installed +help and the active authority do not expose a version token and conditional +mutation control for the exact operation, that production mutation is +non-runnable. + +For every production create or append operation, additionally require: + +- every exact stable parent UUID advertised by the data model; +- separate caller-stable operation and step identities plus the + authority-documented deterministic C3 key bound to exact immutable + semantics, parents, payload, and preconditions; and +- reconciliation through the same authority by the returned full UUID or an + exact idempotency-key lookup. + +After a timeout or ambiguous response, reconcile before any retry. One matching +accepted object may be accepted only after its exact step-bound receipt and +full readback. Zero accepted effects is not success. A single retry is +permitted only under C8. Any accepted step enters C9 forward repair or +receipt-scoped compensation instead of retry. Never issue a blind replacement +create. + +## Named C1-C9 Production Mutation Envelope + +Every production create, update, completion, approval, assignment, dependency +or lock change, delete, comment, verification/evidence record, export, plan +artifact write, or other state mutation must close one immutable envelope whose +fields are explicitly named `C1` through `C9`. Existing outcome authorization +is enough; do not insert a duplicate approval. If the authenticated authority +or package-owned plan-artifact writer cannot prove a clause, that path is +non-runnable. + +- **C1 Authority, provider roles, full targets, and immediate preimage.** + Record the product story, provider-role table, authenticated authority and + tenant, server backend, full project UUID, and every full plan, task-list, + task, template, export, and artifact target. Immediately before submission, + perform bounded complete same-authority reads of every existing target and + parent, preserving required full fields and revisions/ETags. For create, + require exact bounded terminal authority-issued absence; for an artifact, + capture exact absence or the full byte preimage and SHA-256. +- **C2 Authority-enforced exact-prestate precondition.** Require + create-if-absent for row creates/appends and a fresh exact full-target-UUID + CAS/`If-Match` for every existing-row mutation. Bind each precondition to the + full UUID and complete C1 state or authority-issued revision. Artifact + creation must be atomic no-overwrite; replacement must be an atomic expected + full-preimage mutation bound to the prior SHA-256 and bytes. Client-side + checks, editor saves, shell redirection, and read-then-unconditional-write do + not qualify. +- **C3 Stable distinct step identities and deterministic keys.** Create one + caller-stable operation identity and a distinct caller-stable step identity + for every ordered manifest entry, including a singleton. Supply the + authority-documented deterministic key for each step, bound to operation ID, + step ID, ordinal, immutable semantics, all full targets, canonical payload + or artifact digest, and exact C1/C2 state. Never invent a key algorithm or + reuse identities or keys for a resubmission. +- **C4 Immutable step-bound receipts.** Require exactly one immutable receipt + from the authority or package-owned artifact writer for every ordered step. + It must bind operation ID, distinct step ID, ordinal, deterministic key, + authority/tenant, full targets, consumed pre-state, canonical payload digest, + disposition, accepted effect if any, resulting revision or artifact + SHA-256/byte length, and exact receipt identity. Multi-step work also needs an + immutable coordinator receipt binding the ordered step receipts and terminal + aggregate state. +- **C5 Exact terminal lookup with hard caps.** Look up the exact receipt, + operation, or deterministic key through the same authority under positive + finite authority-enforced aggregate caps for calls, items, serialized bytes, + and wall time. The artifact writer must provide an equivalent exact terminal + receipt lookup. Client-only limits, manual cursor chasing, shell timeouts, or + an uncapped filesystem scan do not qualify. +- **C6 Fail closed.** Do not claim success or execute a dependent step if any + cap is missing, zero, unlimited, client-only, or hit; output is partial, + incomplete, nonterminal, invalid, or scope-drifted; or lookup returns zero or + multiple matches. A terminal zero-effect result is nonacceptance, not + success, and may enter only C8. +- **C7 Exactly-one result, full readback, and duplicate proof.** Require + exactly one matching immutable step receipt and exactly one accepted effect + for every accepted ordered step, plus the matching coordinator receipt for + multi-step work. Perform bounded complete readback of every affected UUID + from the same authority and exact full-byte readback of every artifact by + path and SHA-256. Verify requested and preserved fields, revisions, + identities, ordinals, parent scope, and that each deterministic key maps to + the same receipt, target, payload/preimage digest, exact pre-state, and + precondition. +- **C8 Restricted fresh-identity retry.** Allow at most one mutation retry, + only after exact C5 lookup proves exactly one immutable terminal + nonacceptance receipt for every submitted step and zero accepted effects for + the operation. Immediately before resubmission, repeat the full C1 read, + capture fresh C2 state, freeze a new ordered manifest, and create a new + operation ID, new distinct step IDs, and new deterministic keys. Ambiguous, + accepted, zero-match, nonterminal, incomplete, or multiply matched outcomes + forbid retry. +- **C9 Ordered immutable manifest, compensation, and repair.** Before the + first write, freeze an ordered immutable manifest, including a one-step + manifest for a singleton. Every entry contains its ordinal, operation ID, + distinct step ID, dependencies, full targets and parents, exact C1 state, C2 + preconditions, deterministic key, intended receipt identity, and + authority-documented forward-repair or receipt-scoped compensation route. + Once a step is submitted, do not rewrite or blind-rerun the manifest. + Compensation is permitted only for an exactly identified accepted receipt; + every compensating write is a new operation with its own C1-C9 envelope. + +Task and plan titles, descriptions, comments, verification commands and +summaries, completion notes, export contents, plan Markdown, and any other +private payload must not appear in argv, shell history, diagnostics, +environment variables, or ad hoc temporary files. Submit bytes only through a +package-owned protected input channel identified by a non-secret opaque +descriptor. The descriptor may appear in argv only when it neither reveals nor +encodes the private value. If the installed package exposes only direct +positional or flag values for a private field, that production mutation is +non-runnable. + +## Inputs + +Resolve these at runtime; none has a built-in default: + +- ``: authoritative project UUID. +- ``: authoritative plan UUID when the plan exists. +- ``: authoritative task UUID for the current work item. +- ``: caller- or policy-selected stable lowercase slug. +- ``: inherited agent identity when task state is updated. + +Commands containing angle-bracket placeholders are illustrative. Replace every +placeholder with an authority-proven value before execution. ## Authoring Workflow -1. Start a native Codewith goal for non-trivial work. -2. Read the relevant plan with `todos plans --show `. -3. Inspect the current task before editing anything. -4. Create or update the local Markdown artifact under - `.hasna/todos/plans//.md`. -5. Use CLI commands for task/plan state changes: - `todos plans --add`, `todos add --plan`, `todos update`, - `todos comment`, `todos record-verification`, and `todos done`. -6. Use the Markdown file for intent, acceptance criteria, dependency notes, - rollout notes, and evidence links. -7. Run validation commands and record them on the task. -8. Complete only the task whose acceptance criteria are satisfied. - -Use direct file edits for plan text. Use Todos CLI for state transitions, -assignments, comments, dependencies, and verification evidence. - -## Local Plan Files - -Preferred location: - -```text -.hasna/todos/plans//.md -``` - -Use stable lowercase slugs. Include the Todos plan ID when one exists. If a -plan is still draft-only, set `plan_id: pending` and create or link the plan -before workers execute it. - -Example: - -```markdown ---- -plan_id: 4bc933b6-8b0a-4e38-8807-aa11b034a9af -project_id: open-skills -plan_slug: codewith-todos-plan-skills -status: active -source: todos-cli -updated: 2026-06-30 ---- - -# Codewith Todos Plan Skills - -## Scope -- Create a `todos-plan` Codewith skill. -- Sync it to the active Codewith skills directory. - -## Tasks -- [ ] `4a32ec84` Define authoring workflow. -- [ ] `36e08375` Document local plan file naming. -- [ ] `904a4e78` Validate command examples. +1. Apply the Required Operation-Authority Gate. +2. Inspect the exact current task and, when present, the exact plan: + + ```bash + todos inspect + todos plans --show + ``` + +3. Create or update the relative Markdown artifact through the package-owned + protected artifact writer: + + ```text + .hasna/todos/plans//.md + ``` + + Pass Markdown bytes by opaque descriptor, not argv or shell redirection. + For create, require atomic no-overwrite and a receipt with path, byte length, + SHA-256, operation ID, step ID, and deterministic key. For update, supply the + exact expected preimage bytes and SHA-256, require atomic replacement, and + retain the immutable before/after receipt. Immediately perform exact + full-byte readback and hash verification. If the package-owned writer lacks + these controls, do not write the artifact. +4. Use Todos CLI commands for authoritative state. Private values below are + represented only by package-owned opaque descriptors: + + ```bash + todos add --project --plan + todos update + todos comment + todos record-verification --agent + ``` + + The descriptors and `` are capability requirements, not + literal flags. Resolve only package-documented forms from current help and + the authenticated authority contract. Run a production form only when every + required control exists. +5. Read back every created or changed object using its returned full UUID. +6. Record verification as work proceeds. Complete only the task whose + acceptance criteria are satisfied: + + ```bash + todos --agent done + ``` + + Task completion requires a new exact task read and fresh token after the + final preceding mutation. + +## Plan Artifact Requirements + +The relative Markdown artifact must include: + +- full Todos project, plan, and task UUIDs where applicable; +- scope and out-of-scope boundaries; +- acceptance criteria and dependencies; +- rollout or recovery notes when relevant; +- verification commands and evidence references; and +- residual risks. + +Draft-only artifacts must say that no authoritative plan row exists. Do not +invent placeholder UUIDs or allow workers to execute against a draft identity. -## Status Transitions -- `pending`: task is not started. -- `in_progress`: one agent owns it and has a lock. -- `blocked`: a named prerequisite is missing. -- `completed`: verification is recorded and accepted. - -## Verification -- `python3 .../quick_validate.py skills/todos-plan` -- `todos plans --show 4bc933b6-8b0a-4e38-8807-aa11b034a9af` -``` - -## Hosted Sync - -Hosted sync is explicit. Confirm tenant, auth, and billing before writing. - -```bash -platform-todos --json auth status -platform-todos --json auth whoami -platform-todos --json billing status -platform-todos docs catalog --surface api --json -platform-todos docs catalog --surface mcp --json -``` +## Worker Routing -Preview first: +Task-triggered workers receive: -```bash -platform-todos plans generate \ - --org \ - --objective "Create project tasks, dependencies, and verification gates" \ - --dry-run true \ - --approval-before-create true -``` +- full task, plan, and project UUIDs; +- repository root plus repo-relative allowed and out-of-scope paths; +- Todos CLI source-of-truth instruction; +- validation and evidence requirements; and +- adversarial verification requirement. -Create only after approval: +Use isolated work scopes. Do not route new repo-mutating work through prompt +paste. Native Codewith goals and loops are Codewith mechanisms; OpenLoops +workflows and runs are separate and must be named and recorded as OpenLoops +only when that system is actually used. -```bash -platform-todos plans generate \ - --org \ - --objective "Create project tasks, dependencies, and verification gates" \ - --dry-run false \ - --approval-before-create true -``` +## Destructive Plan Operations -For local imports, prefer a dry run and a conflict strategy: +Plan completion or deletion requires exact scope already authorized by the +caller and the Fresh Production Mutation Gate: ```bash -platform-todos import local-sqlite \ - --org \ - --dry-run true \ - --conflict-strategy skip \ - --idempotency-key +todos plans --show +todos plans --complete +todos plans --delete ``` -Record evidence on the task: command, status, org/project ID, plan ID, and -artifact path. Do not record API keys, raw tokens, secret values, or private -payloads. - -## Worker Routing - -Task-triggered launch workers must receive: - -- task ID and plan ID -- repo path and allowed paths -- source-of-truth instruction: use Todos CLI, not messages or tmux panes -- native Codewith goal requirement -- validation gates and evidence expectations -- explicit out-of-scope paths -- requirement for adversarial verification before completion - -Use isolated work scopes. Do not route new repo-mutating work by pasting prompts -into existing tmux panes. If the routing automation is missing, create a task -for the missing automation instead of using a hidden fallback. - -Prompt shape: - -```text -Task: -Plan: -Repo: -Allowed paths: -Out of scope: -Use Todos CLI as source of truth. -Start a native Codewith goal. -Record verification and changed files on the task before done. -``` - -## Launch Checklist - -For todos.md launch tasks, verify the relevant surfaces and record evidence: +These forms are syntax references only. If the client cannot submit the exact +operation conditionally against the freshly read plan version, do not run it +in production. Re-read the exact plan after completion. After deletion require +an authoritative exact-ID tombstone or not-found result distinguishable from +auth, routing, transport, and incomplete collection failure. -- Auth: `platform-todos auth login`, `auth status`, `auth whoami`, API key - creation/revocation/rotation. -- Billing: `platform-todos billing status`, `usage`, `checkout`, `portal`, - Stripe webhook lifecycle, quota errors. -- Plan CRUD: local `todos plans --add/show`, hosted `platform-todos plans - templates/create/generate/refine`, dry-run before create. -- Hosted runs: list/create/show/logs/artifacts/cancel plus controls, pause, - resume, emergency stop, usage evidence, and sandbox policy. -- Audit export: redacted export command, hash/manifest evidence, no secrets. -- Storage: signed upload/download/delete/export behavior and quota gates. -- Landing and docs: desktop/mobile screenshot review, docs links, billing entry, - no unsupported dashboard or OAuth promises. +Do not add a duplicate approval gate when the caller already authorized the +exact outcome. If the exact plan identity or destructive scope is new, stop. ## Done Criteria -- The plan file is under `.hasna/todos/plans//`. -- Todos CLI reflects the task/plan state. -- Command examples were run or clearly marked illustrative. -- Verification is recorded with `todos record-verification`. -- Changed files and residual risks are noted on the task. -- For substantial work, an adversarial verifier or explicit adversarial - self-review is reconciled before marking the task done. +- The Required Operation-Authority Gate passed. +- The provider-role table, authenticated authority, and server backend were + recorded as independent facts. +- Every required collection was producer-bounded and explicitly terminal + without hitting an aggregate cap. +- All commands and flags exist in installed help and on the active authority. +- The plan artifact is relative and linked to authority-proven full UUIDs. +- Every production mutation has a complete immutable named C1-C9 envelope. +- Every artifact create/update used atomic no-overwrite or exact-preimage + semantics, immutable receipts, and full byte/hash readback. +- Every existing-object mutation used a fresh same-authority CAS token and was + read back from the same authority. +- Every create or append used exact parent UUIDs, separate operation/step + identities, the authority-documented deterministic C3 key, and bounded + terminal reconciliation before success was claimed. +- Verification, changed files, and residual risks are recorded. +- Adversarial verification is reconciled before completion. + +## Related + +- [[todos-plans]] — plan, template, and task-list CLI operations. +- [[todos-progress]] — comments, locking, dependencies, and history. +- [[todos-filter]] — bounded task listing, filtering, and export views. diff --git a/src/lib/agent-sync.test.ts b/src/lib/agent-sync.test.ts index db5f6bfd..14d4117a 100644 --- a/src/lib/agent-sync.test.ts +++ b/src/lib/agent-sync.test.ts @@ -54,8 +54,8 @@ describe("adaptSkillMdForAgent", () => { expect(out).toContain("user_invocable: true"); }); - test("Codex / OpenCode / Cursor strip user_invocable", () => { - for (const agent of ["codex", "opencode", "cursor"] as const) { + test("Codewith / Codex / OpenCode / Cursor strip user_invocable", () => { + for (const agent of ["codewith", "codex", "opencode", "cursor"] as const) { const out = adaptSkillMdForAgent(INSTRUCTION_MD, agent); expect(out).not.toContain("user_invocable"); // Body is preserved verbatim. @@ -69,6 +69,7 @@ describe("agentGlobalSkillsDir", () => { test("resolves per-tool global paths under a given home", () => { const home = "/home/somebody"; expect(agentGlobalSkillsDir("claude", home)).toBe(join(home, ".claude", "skills")); + expect(agentGlobalSkillsDir("codewith", home)).toBe(join(home, ".codewith", "skills")); expect(agentGlobalSkillsDir("codex", home)).toBe(join(home, ".codex", "skills")); expect(agentGlobalSkillsDir("cursor", home)).toBe(join(home, ".cursor", "skills")); expect(agentGlobalSkillsDir("opencode", home)).toBe(join(home, ".config", "opencode", "skills")); @@ -81,7 +82,7 @@ describe("resolveSyncAgents", () => { expect(resolveSyncAgents(undefined)).toEqual([...SYNC_AGENTS]); }); test("a single named agent", () => { - expect(resolveSyncAgents("codex")).toEqual(["codex"]); + expect(resolveSyncAgents("codewith")).toEqual(["codewith"]); }); test("rejects an unknown agent", () => { expect(() => resolveSyncAgents("gemini")).toThrow("Unknown agent"); @@ -126,6 +127,9 @@ describe("syncSkillsToAgents", () => { const codexMd = readFileSync(join(home, ".codex", "skills", "deploy-runbook", "SKILL.md"), "utf-8"); expect(codexMd).not.toContain("user_invocable"); + const codewithMd = readFileSync(join(home, ".codewith", "skills", "deploy-runbook", "SKILL.md"), "utf-8"); + expect(codewithMd).not.toContain("user_invocable"); + const openCodeMd = readFileSync(join(home, ".config", "opencode", "skills", "deploy-runbook", "SKILL.md"), "utf-8"); expect(openCodeMd).not.toContain("user_invocable"); } finally { diff --git a/src/lib/agent-sync.ts b/src/lib/agent-sync.ts index 876bfbab..dacab0e7 100644 --- a/src/lib/agent-sync.ts +++ b/src/lib/agent-sync.ts @@ -11,18 +11,22 @@ */ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { listPortableSkills, normalizePortableSkillName, readPortableSkillManifest } from "./portable-skills.js"; import type { SkillKind } from "./registry-types.js"; /** - * The coding agents `skills sync` targets by default. Claude Code, Codex, OpenCode, and - * Cursor — the four that load a `//SKILL.md`. (Gemini is retired; Windsurf/pi - * are addressable through installSkillForAgent but are not in the default fan-out.) + * The coding agents `skills sync` targets by default. These runtimes load a + * `/./skills//SKILL.md` tree (OpenCode is the one path exception). + * Gemini is retired; Windsurf/pi are addressable through installSkillForAgent but are + * not in the default fan-out. */ -export type SyncAgent = "claude" | "codex" | "opencode" | "cursor"; -export const SYNC_AGENTS: readonly SyncAgent[] = ["claude", "codex", "opencode", "cursor"] as const; +export type SyncAgent = "claude" | "codewith" | "codex" | "opencode" | "cursor"; +export const SYNC_AGENTS: readonly SyncAgent[] = ["claude", "codewith", "codex", "opencode", "cursor"] as const; + +const __dirname = dirname(fileURLToPath(import.meta.url)); /** * Ownership marker written beside every SKILL.md this tool syncs. Its presence is how a @@ -140,6 +144,12 @@ export interface SyncSkillsResult { actions: AgentSyncAction[]; } +interface SyncSource { + name: string; + path: string; + source: "bundled" | "corpus"; +} + export function syncSkillsToAgents(options: SyncSkillsOptions = {}): SyncSkillsResult { const agents = options.agents?.length ? options.agents : [...SYNC_AGENTS]; const homeDir = options.homeDir ?? homedir(); @@ -150,11 +160,24 @@ export function syncSkillsToAgents(options: SyncSkillsOptions = {}): SyncSkillsR const actions: AgentSyncAction[] = []; const requested = normalizeRequested(options.names); - let targets = corpus; + let targets: SyncSource[] = corpus.map((skill) => ({ + name: skill.name, + path: skill.path, + source: "corpus", + })); if (requested) { - const present: string[] = []; + const present: SyncSource[] = []; const missing: string[] = []; - for (const name of requested) (byName.has(name) ? present : missing).push(name); + for (const name of requested) { + const portable = byName.get(name); + if (portable) { + present.push({ name: portable.name, path: portable.path, source: "corpus" }); + continue; + } + const bundled = findBundledSkillSource(name); + if (bundled) present.push(bundled); + else missing.push(name); + } for (const name of missing) { for (const agent of agents) { actions.push({ @@ -166,19 +189,26 @@ export function syncSkillsToAgents(options: SyncSkillsOptions = {}): SyncSkillsR }); } } - targets = present.map((name) => byName.get(name)!).filter(Boolean); + targets = present; } for (const skill of targets) { const manifest = readPortableSkillManifest(skill.path, skill.name); const kind: SkillKind = manifest.kind ?? "executable"; - const source = sourceSkillMd(skill.path, skill.name, manifest.description, kind); + const source = sourceSkillMd( + skill.path, + skill.name, + manifest.description, + kind, + skill.source === "bundled", + ); for (const agent of agents) { const adapted = adaptSkillMdForAgent(source, agent); actions.push(writeManagedAgentSkill({ skill: skill.name, agent, skillMd: adapted, + source: skill.source, homeDir, dryRun: options.dryRun, force: options.force, @@ -193,6 +223,7 @@ export interface WriteManagedAgentSkillParams { skill: string; agent: SyncAgent; skillMd: string; + source?: "bundled" | "corpus"; homeDir?: string; dryRun?: boolean; force?: boolean; @@ -210,6 +241,7 @@ export function writeManagedAgentSkill(params: WriteManagedAgentSkillParams): Ag const dir = join(agentGlobalSkillsDir(params.agent, homeDir), params.skill); const result = writeManagedSkillDir(dir, params.skillMd, { skill: params.skill, + source: params.source, dryRun: params.dryRun, force: params.force, }); @@ -278,8 +310,14 @@ export function removeManagedAgentSkill(skill: string, agent: SyncAgent, homeDir return true; } -function sourceSkillMd(skillPath: string, name: string, description: string, kind: SkillKind): string { - if (kind === "instruction") { +function sourceSkillMd( + skillPath: string, + name: string, + description: string, + kind: SkillKind, + preferBundledDocs = false, +): string { + if (kind === "instruction" || preferBundledDocs) { const skillMdPath = join(skillPath, "SKILL.md"); if (existsSync(skillMdPath)) return readFileSync(skillMdPath, "utf-8"); } @@ -289,6 +327,18 @@ function sourceSkillMd(skillPath: string, name: string, description: string, kin return pointerSkillMd(name, description); } +function findBundledSkillSource(name: string): SyncSource | null { + let dir = __dirname; + for (let i = 0; i < 5; i += 1) { + const path = join(dir, "skills", name); + if (existsSync(path) && existsSync(join(path, "SKILL.md"))) { + return { name, path, source: "bundled" }; + } + dir = dirname(dir); + } + return null; +} + function corpusLocation(options: SyncSkillsOptions): { rootDir?: string; homeDir?: string } { const out: { rootDir?: string; homeDir?: string } = {}; if (options.rootDir) out.rootDir = options.rootDir; diff --git a/src/lib/todos-plan-contract.test.ts b/src/lib/todos-plan-contract.test.ts new file mode 100644 index 00000000..5a12280f --- /dev/null +++ b/src/lib/todos-plan-contract.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { SYNC_MARKER_FILE, syncSkillsToAgents } from "./agent-sync.js"; +import { useDefaultTestTimeout } from "../test-preload.js"; + +useDefaultTestTimeout(); + +const TODOS_PLAN_SOURCE = join(import.meta.dir, "..", "..", "skills", "todos-plan", "SKILL.md"); + +const RETIRED_DEPLOYMENT_MODE_PATTERNS: ReadonlyArray<[string, RegExp]> = [ + ["self_hosted enum token", /\bself_hosted\b/i], + ["three-way deployment mode list", /\blocal\s*\|\s*self[-_ ]?hosted\s*\|\s*cloud\b/i], + ["deployment classification requirement", /\bdeployment classification\b/i], + ["Hasna AWS mode assignment", /\bHasna AWS authority is\b/i], + ["Todos SaaS mode assignment", /\bTodos SaaS authority is\b/i], + [ + "explicit self-hosted/cloud mode authorization", + /\brequire an explicit\s+`?(?:self[_ -]?hosted|cloud)`?\s+classification\b/i, + ], + [ + "mode-to-local fallback wording", + /\bnever switch from\s+`?(?:self[_ -]?hosted|cloud)`?\s+to local storage\b/i, + ], +]; + +function retiredDeploymentModeFindings(content: string): string[] { + return RETIRED_DEPLOYMENT_MODE_PATTERNS + .filter(([, pattern]) => pattern.test(content)) + .map(([name]) => name); +} + +describe("todos-plan production contract", () => { + test("the vocabulary guard permits ordinary doctrine-compliant location and product words", () => { + const allowed = + "A local SQLite file can serve the user-hosted product. Hasna SaaS is a cloud service whose server may use PostgreSQL."; + expect(retiredDeploymentModeFindings(allowed)).toEqual([]); + }); + + test("the canonical source preserves the production envelope without retired deployment modes", () => { + const source = readFileSync(TODOS_PLAN_SOURCE, "utf-8"); + + expect(retiredDeploymentModeFindings(source)).toEqual([]); + expect(source).toContain("## Required Operation-Authority Gate"); + expect(source).toContain("## Production Collection and Completeness Gate"); + expect(source).toContain("## Fresh Production Mutation Gate"); + expect(source).toContain("## Named C1-C9 Production Mutation Envelope"); + expect(source).toMatch(/\bfull (?:stable )?UUID\b/i); + expect(source).toMatch(/\b(?:compare-and-swap|CAS|If-Match)\b/); + expect(source).toMatch(/\bprotected input channel\b/i); + expect(source).toMatch(/\breceipt\b/i); + expect(source).toMatch(/\bcompensation\b/i); + expect(source).toMatch(/\bfail closed\b/i); + for (let clause = 1; clause <= 9; clause += 1) { + expect(source).toMatch(new RegExp(`\\*\\*C${clause}\\b`)); + } + }); + + test("a named bundled skill can force-update an unmanaged Codewith copy", () => { + const corpus = mkdtempSync(join(tmpdir(), "todos-plan-empty-corpus-")); + const home = mkdtempSync(join(tmpdir(), "todos-plan-codewith-home-")); + try { + const installedDir = join(home, ".codewith", "skills", "todos-plan"); + mkdirSync(installedDir, { recursive: true }); + writeFileSync( + join(installedDir, "SKILL.md"), + "---\nname: todos-plan\ndescription: stale\n---\n\nHasna AWS authority is `self_hosted`.\n", + ); + + const { actions } = syncSkillsToAgents({ + rootDir: corpus, + homeDir: home, + names: ["todos-plan"], + agents: ["codewith"], + force: true, + }); + + expect(actions).toEqual([ + { + skill: "todos-plan", + agent: "codewith", + path: join(installedDir, "SKILL.md"), + action: "update", + }, + ]); + expect(existsSync(join(installedDir, SYNC_MARKER_FILE))).toBe(true); + + const canonical = readFileSync(TODOS_PLAN_SOURCE, "utf-8"); + const installed = readFileSync(join(installedDir, "SKILL.md"), "utf-8"); + expect(installed).toBe(canonical); + expect(retiredDeploymentModeFindings(installed)).toEqual([]); + } finally { + rmSync(corpus, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + } + }); +});