From 11836ca58f51eaa993d4f64b54c085195f369855 Mon Sep 17 00:00:00 2001 From: Jarod Taylor Date: Tue, 21 Jul 2026 15:38:56 -0500 Subject: [PATCH 1/2] =?UTF-8?q?docs(records):=20U10=20U2=20shipped=20+=20m?= =?UTF-8?q?erged=20=E2=80=94=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U10 U2 (config-write engine opaque whole-file text format) merged (PR #45, fa9e4b2). START-HERE ▶ NEXT repointed to /unit-loop U3 or U4 (both parallel- eligible off merged U1); PRODUCT.md U10 row 1/7 → 2/7. No new decision fork (implementation unit; the byte-exact-noop + batch-guard learnings live in the compound doc; the 3 U10-wrap-up ledger rows wait for U7-complete). --- docs/PRODUCT.md | 6 +++--- docs/START-HERE.md | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 0ce5639..74125bd 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -3,7 +3,7 @@ > **For humans.** Plain language, outcomes first, short on purpose. The deep/agent-grade state lives in [`START-HERE.md`](START-HERE.md) + [`DECISIONS.md`](DECISIONS.md); this page is their product-level projection. > **Freshness rule:** `/handoff` updates this page whenever something ships. If this page and reality ever disagree, that's a bug — flag it. -_Last updated: 2026-07-21 · Status: **v0.1 "Continuity" in progress — 11 of 15 units shipped**; latest: **U10 BUILDING** — the provisioning engine's first sub-unit (the manifest contract + blueprint loader + shared front-gate) shipped + merged (PR #44, decisions #53–#54). Next: **U10 sub-units U2–U4** (parallel-eligible). · Roadmap postures locked by the 2026-07-10 interview (decisions #32–#41)_ +_Last updated: 2026-07-21 · Status: **v0.1 "Continuity" in progress — 11 of 15 units shipped**; latest: **U10 BUILDING** — 2 of 7 provisioning sub-units merged: the manifest contract + loader + shared front-gate (PR #44, decisions #53–#54), and the config-write engine's opaque whole-file `text` write primitive (PR #45). Next: **U10 sub-units U3–U4** (parallel-eligible). · Roadmap postures locked by the 2026-07-10 interview (decisions #32–#41)_ ## TL;DR @@ -48,7 +48,7 @@ _Last updated: 2026-07-21 · Status: **v0.1 "Continuity" in progress — 11 of 1 **Recently shipped:** **U9** inventory scanners (PR #38 — the Observe half) · **#21** config-engine targeted removal (PR #34) · **#24** single-source Codex credential (PR #41 — retired the duplicate token file). *(#21/#24 are follow-up issues, not among the 15 plan units.)* Also **dogfood run 1** (below) — a validation exercise, not a v0.1 unit. **Still to build:** -- **U10 (building — 1 of 7 sub-units in) — project provisioning:** a project carries one versioned "blueprint" (which roles run in which harness, on which model, with which files), and agent-os pushes it into Claude Code, Codex, and Cursor natively — reversibly, with a dry-run preview and a drift report. First sub-unit shipped (PR #44): the typed manifest contract + a pure/total loader + the shared **front-gate** that certifies a blueprint (valid schema, no secrets, no machine-specific paths) before any provisioning verb runs. Rescoped from the earlier "parity actions" framing by lived dogfood evidence (decision #52). +- **U10 (building — 1 of 7 sub-units in) — project provisioning:** a project carries one versioned "blueprint" (which roles run in which harness, on which model, with which files), and agent-os pushes it into Claude Code, Codex, and Cursor natively — reversibly, with a dry-run preview and a drift report. Two sub-units shipped: the typed manifest contract + a pure/total loader + the shared **front-gate** that certifies a blueprint (valid schema, no secrets, no machine-specific paths) before any provisioning verb runs (PR #44), and the config-write engine's opaque whole-file `text` write primitive the copy/compose transforms need — verbatim, byte-identical, on the same backup/atomic-write/undo discipline as structured configs (PR #45). Rescoped from the earlier "parity actions" framing by lived dogfood evidence (decision #52). - **U11 — the first screen:** projects + where-they-left-off + the inventory grid + provision buttons. - **U12 — roster spike:** find the read/write surfaces for Hermes / Cursor / Antigravity / OpenCode (decides their lanes). - **U15 — always-on:** server survives reboots/crashes (launchd); hooks re-install for keeps; the pending live Codex check runs then. @@ -116,7 +116,7 @@ A shared task board across harnesses *and* instances, plus Hermes as the always- | U7 | `/handoff` rewire | One authoritative continuity record; docs render it | ✅ | | U8 | Codex integration | Second harness on the shared brain (capture + read + installer) | ✅ *live check pending* | | U9 | Inventory scanners | See every skill/MCP server/plugin across harnesses — CC + Codex now; others join as one-line registry rows as they enter rotation | ✅ *PR #38* | -| U10 | Provisioning engine | Blueprint → native harness setup (roles/models/files), with undo | 🔨 building (1/7 — contract + loader + front-gate in) | +| U10 | Provisioning engine | Blueprint → native harness setup (roles/models/files), with undo | 🔨 building (2/7 — contract+loader+front-gate, whole-file write primitive in) | | U11 | Thin human view | The first screen | ⏳ | | U12 | Roster lane spike | What's possible for Hermes / Cursor / Antigravity / OpenCode | ⏳ | | U15 | Always-on (launchd) | Server survives reboots; hooks installed for keeps; live Codex check | ⏳ | diff --git a/docs/START-HERE.md b/docs/START-HERE.md index 37defb2..172b2d1 100644 --- a/docs/START-HERE.md +++ b/docs/START-HERE.md @@ -57,7 +57,8 @@ Jarod's own **Agent OS**: a local-first personal control plane that **acts** on - ✅ **SIDE QUEST — handoff HARDENED (Phases 1–2 DONE)** (2026-07-20, decision **#51**; plan [`docs/plans/2026-07-20-002-harden-handoff-port-from-agent-hud.md`](plans/2026-07-20-002-harden-handoff-port-from-agent-hud.md)). Ported agent-hud's crash-safety hooks (Stop `session-breadcrumb.sh` + SessionStart `session-resume-check.sh` → local-only in gitignored `.claude/`, both verified by direct run) and **reframed `/handoff` substrate-OPTIONAL**: START-HERE ▶ NEXT IS the record today, the substrate becomes primary at U15 — **retiring the every-handoff "substrate not written" apology** (the noise that triggered this side quest). `/handoff` also made **model-invocable** + given an **idempotence guard**; decision #8 amended (#51); the substrate-first solution doc reconciled. **This very handoff dogfoods the reframe — no apology emitted.** **Phase 3 (promote to `~/.claude`) deferred — later.** - ✅ **U10 PLANNED — the provision engine is implementation-ready** (2026-07-20, decision **#52**; plan [`docs/plans/2026-07-20-003-feat-u10-provisioning-engine-plan.md`](plans/2026-07-20-003-feat-u10-provisioning-engine-plan.md)). One session ran the full pipeline: **ce-brainstorm** (Jarod's felt moment: scaffold/adopt a project and assign roles+harnesses+models per workflow; 4 forks locked — apply + **seed template** · **role-bundle manifest** (roles → harness + model + files, file-ops only) · **Cursor joins as the third target** (amends #45; its own trigger fired at run 1) · **CLI + propose-only MCP** (supersedes slice-1 KTD7)) → **interactive ce-doc-review** (6 personas; 9 findings folded — headline: the run-1 fixture alone would have shipped an overfit engine, secret hygiene made enforced-not-aspirational, `transform` type demoted to extension point) → **ce-plan enrichment** (7 units U1–U7 · 10 KTDs — opaque `text` format on the U14 engine, batch undo, semantic diff, shared front-gate with the secret classifier, no-lock scoped decision · the lived ceremony = acceptance fixture AE1 + generalization AE7; 3 research agents grounded it in verified code surfaces) → **headless round-2 review** (5 personas). **Naming ruling: the cross-harness run file is `RUN_STATE.md`** (was the dogfood's `HANDOFF.md`) — "handoff" now means ONLY session-to-session continuity. `CONCEPTS.md` gained the provisioning vocabulary. All 7 round-2 findings were folded the same session (headline adds: **KTD11** — a known-projects registry so the propose MCP tools never path-join a caller string, closing the P1; batch identity moved INTO the undo journal; `apply` acts on the semantic diff verdict; verbatim `text` writes; the foreign-key `mcp.json` merge fixture). **The plan is build-ready.** - ✅ **U10 U1 — manifest contract + blueprint loader + front-gate SHIPPED + MERGED** (PR #44, merge `e0d3b3a`, 2026-07-21; decisions **#53–#54**). The typed blueprint the Act half (U2–U7) inherits: a discriminated-union `Manifest` schema in the contract seam (KTD10) + a **pure/total loader** + the shared **front-gate** every verb runs (KTD5: version-compat → cardinality preflight → strict parse → secret + machine-abspath scan → typed `BlueprintLoad`, never throws; bounded reads mirror `src/scan`/`src/codex-credential`). Full loop: ce-work (opus subagent) → simplify → **ce-code-review (6 reviewers incl. the redaction-boundary-reviewer)** → **Codex gate 4 rounds + a logged Option-B marker advance** (companion hung twice mid-session; the only un-gated delta was the path-containment fold = codex's own round-4 recommendation, Jarod-authorized). **Two proof-first bug fixes** — JSON-escaped-secret **R4 gate bypass** (empirically reproduced by the redaction reviewer: raw-byte scan misses `\uXXXX`, `JSON.parse` decodes it into `loaded.manifest` → now scans raw **and** normalized forms) and **`schemaVersion≤0` misroute**. **Folds**: manifest cardinality preflight (no-hang) + schema-level path containment (R1/R9, POSIX-only). **Reverted**: the classifier ReDoS `{0,64}` quantizer-bound (regressive false-negative + whack-a-mole) → linear-scanner **#42**; config-source effective-form scan (copy+merge) → **#43** (owner U5/apply). Bots folded (doc accuracy) or declined-with-rationale (Windows-path + symlink, both #45-scoped). **527 tests green, tsc clean.** Learning compounded → `docs/solutions/architecture-patterns/content-safety-gate-scan-effective-form-bound-the-work.md`; `CONCEPTS.md` gains **Front-gate**. -- ⏳ **▶ NEXT — `/unit-loop U2`** of the [U10 plan](plans/2026-07-20-003-feat-u10-provisioning-engine-plan.md), **in a FRESH session** — the codex companion hung twice this session; the adversarial gate needs a clean CC session (2 hangs, 0 output; a CLI re-login won't clear it). **U2, U3, U4 are parallel-eligible** (each depends only on the now-merged U1 — run any, or parallelize), then U5 (needs U2–U4), then U6 ∥ U7. Run each unit's gate with the decision-#45 threat-model pointer, and **keep backticks out of the gate focus text** (they command-substitute in the wrapper's shell and wedge codex — cost 2h+ this session). Don't re-litigate U1's rulings: **POSIX-only paths**; **R4 secret-detection is best-effort per the plan's own spec** (completeness tracked in **#42** + **#43**); the `Manifest` schema is contract-frozen at `schemaVersion 1`. **U15** (launchd always-on) follows; the **MVP-definition session** lands before/at **U11**. Deferred, unchanged: handoff **Phase 3** (→ `~/.claude`); the ClaudeOS delta walkthrough; the feature-catalog `Call` pass; **the CE salience-layer investigation** (memory-captured 2026-07-21 — the checkpoint rule loads but doesn't fire mid-task; wants an active hook trigger). +- ✅ **U10 U2 — config-write engine opaque whole-file `text` format SHIPPED + MERGED** (PR #45, merge `fa9e4b2`, 2026-07-21). The whole-file write primitive U10's copy/compose transforms need (R7/KTD1/KTD2): a 4th `ConfigFormat` `text` + **`writeTextFile`** (verbatim, byte-identical, format forced) on the shared `publish()` core, an `allowText` guard keeping `text` off the merge/removal paths, and optional recorded-only `batchId`/`projectRoot` on `UndoEntry`/`MergeOptions` (U5 batch-undo consumer). Full loop: ce-work → simplify (**0 findings**) → **ce-code-review (10-persona full roster; 9 clean + 1 P2 header doc-drift folded)** → **Codex gate — no-ship #1 caught 2 real findings** (batch write/read schema asymmetry made a partial/non-string batch value applied-but-un-undoable → **fail-closed both-or-neither guard**; a lossy-utf8 no-op broke byte-identity → **byte-exact `text` compare**; re-run **APPROVED**) → **CodeRabbit (Major: guard now rejects truthy non-strings) + Copilot (×3 doc rewording) folded**. **546 tests green, tsc clean.** Learning compounded → `docs/solutions/architecture-patterns/byte-identity-contract-needs-byte-exact-noop-not-just-verbatim-serialize.md` (a byte-identity contract must be byte-exact on BOTH the serialize AND the no-op-read sides — the *inverse* of presence-semantics on the same no-op surface; both surfaced by the adversarial gate escalating an in-process residual). +- ⏳ **▶ NEXT — `/unit-loop U3`** (render + diff) **or `/unit-loop U4`** (target registry + Cursor scanner row) of the [U10 plan](plans/2026-07-20-003-feat-u10-provisioning-engine-plan.md), **in a FRESH session**. **U3 and U4 are both parallel-eligible** — each depends only on the now-merged U1 (U2 is merged too); run either, or parallelize. Then **U5** (needs U2–U4), then **U6 ∥ U7**. Run each unit's gate with the decision-#45 threat-model pointer, and **keep backticks AND parens out of the gate focus text** (they command-substitute in the wrapper's shell). Don't re-litigate settled rulings: **POSIX-only paths**; **R4 secret-detection is best-effort per the plan spec** (completeness → **#42** + **#43**); the `Manifest` schema is contract-frozen at `schemaVersion 1`; the `text` format's byte-identity + batch-field guards are settled. **U4 execution note:** re-verify Cursor's `.cursor/agents` / `.cursor/skills` / `.cursor/mcp.json` shapes against a live install + current docs before encoding descriptors (run-1 evidence is days old; a mismatch is a stop condition). **U10 wrap-up (at U7 complete, not before):** log the 3 decision-ledger rows — Cursor roster (amends #45), trigger surface (supersedes KTD7), `RUN_STATE.md` naming. **U15** (launchd always-on) follows; the **MVP-definition session** lands before/at **U11**. Deferred, unchanged: handoff **Phase 3** (→ `~/.claude`); the ClaudeOS delta walkthrough; the feature-catalog `Call` pass; **the CE salience-layer investigation** (checkpoint rule loads but doesn't fire mid-task; wants an active hook trigger). ## Read next (in order) 1. `docs/plans/2026-07-01-001-feat-slice-1-substrate-parity-plan.md` — THE plan (scan headings: Goal Capsule → unit index → U13). From f96ea41143b49c275dfb9e332d3ee8a92541c262 Mon Sep 17 00:00:00 2001 From: Jarod Taylor Date: Tue, 21 Jul 2026 16:59:42 -0500 Subject: [PATCH 2/2] feat(provision): add target registry and Cursor scanner Define verified project-scoped harness surfaces with fail-closed compatibility checks, and register a fail-soft user-scoped Cursor inventory scanner. Cover path containment, malformed configs, symlink handling, and scanner isolation. --- src/provision/targets.ts | 311 ++++++++++++++++++++++++++++++++ src/scan/cursor.ts | 50 +++++ src/scan/index.ts | 7 +- tests/provision-targets.test.ts | 190 +++++++++++++++++++ tests/scan.test.ts | 76 +++++++- 5 files changed, 629 insertions(+), 5 deletions(-) create mode 100644 src/provision/targets.ts create mode 100644 src/scan/cursor.ts create mode 100644 tests/provision-targets.test.ts diff --git a/src/provision/targets.ts b/src/provision/targets.ts new file mode 100644 index 0000000..82785df --- /dev/null +++ b/src/provision/targets.ts @@ -0,0 +1,311 @@ +/** + * Provisioning target registry (U10/U4): build-time knowledge of the three writable harnesses' project + * surfaces. A caller supplies a manifest destination; this module proves that it belongs to the selected + * harness, resolves it under the project root, and checks the live path shape before any write begins. + * + * The registry is deliberately data, not live discovery (R10/KTD4). Surface locations and formats were + * verified against current harness documentation and live installations; runtime inspection only answers + * whether the known destination is safe to create/overwrite/merge. Every path is project-relative (R9). + */ +import { posix } from "node:path"; +import { parse as parseToml } from "smol-toml"; +import { parse as parseYaml } from "yaml"; +import type { Runtime } from "../contract/index"; +import type { ConfigFormat } from "../configwrite/index"; + +export type ProvisionHarness = Extract; +export type TargetSurfaceId = "instructions" | "agents" | "skills" | "mcp"; +export type TargetShape = "create" | "merge"; + +export interface TargetSurface { + id: TargetSurfaceId; + /** Project-relative exact file or directory root. */ + location: string; + /** Human-facing surface name used in loud compatibility failures. */ + label: string; + layout: "file" | "directory"; + format: ConfigFormat; + shape: TargetShape; + /** For a directory surface whose immediate children are target files (`*.md`, `*.toml`). */ + extension?: string; + /** For a directory surface whose nested entries end in a fixed file (`/SKILL.md`). */ + entrypoint?: string; +} + +export interface TargetDescriptor { + harness: ProvisionHarness; + surfaces: readonly TargetSurface[]; +} + +const CLAUDE_CODE: TargetDescriptor = { + harness: "claude-code", + surfaces: [ + { id: "instructions", location: "CLAUDE.md", label: "CLAUDE.md", layout: "file", format: "text", shape: "create" }, + { + id: "agents", + location: ".claude/agents", + label: ".claude/agents/*.md", + layout: "directory", + format: "text", + shape: "create", + extension: ".md", + }, + { id: "mcp", location: ".mcp.json", label: ".mcp.json", layout: "file", format: "json", shape: "merge" }, + ], +}; + +const CODEX: TargetDescriptor = { + harness: "codex", + surfaces: [ + { id: "instructions", location: "AGENTS.md", label: "AGENTS.md", layout: "file", format: "text", shape: "create" }, + { + id: "agents", + location: ".codex/agents", + label: ".codex/agents/*.toml", + layout: "directory", + format: "toml", + shape: "create", + extension: ".toml", + }, + { + id: "mcp", + location: ".codex/config.toml", + label: ".codex/config.toml MCP tables", + layout: "file", + format: "toml", + shape: "merge", + }, + // Codex skills intentionally absent: authoritative discovery remains deferred to issue #36. The naive + // `.codex/skills` root is known-wrong, so encoding it here would violate R10's verified-knowledge rule. + ], +}; + +const CURSOR: TargetDescriptor = { + harness: "cursor", + surfaces: [ + { + id: "agents", + location: ".cursor/agents", + label: ".cursor/agents/*.md", + layout: "directory", + format: "text", + shape: "create", + extension: ".md", + }, + { + id: "skills", + location: ".cursor/skills", + label: ".cursor/skills/**/SKILL.md", + layout: "directory", + format: "text", + shape: "create", + entrypoint: "SKILL.md", + }, + { + id: "mcp", + location: ".cursor/mcp.json", + label: ".cursor/mcp.json", + layout: "file", + format: "json", + shape: "merge", + }, + ], +}; + +/** Exactly one descriptor row per U10 provisioning harness (R13). */ +export const TARGETS: Readonly> = { + "claude-code": CLAUDE_CODE, + codex: CODEX, + cursor: CURSOR, +}; + +export interface ResolvedTarget { + harness: ProvisionHarness; + surface: TargetSurface; + projectRoot: string; + /** The normalized project-relative manifest destination. */ + relativeDestination: string; + /** The resolved project-scoped destination. */ + destination: string; + /** Absolute/project-scoped root of a directory-shaped surface; equal to destination for exact files. */ + surfaceRoot: string; +} + +/** + * Resolve a manifest destination only when it matches a known surface for that harness. Returns `null` for + * unknown, absolute, or escaping destinations; the Manifest contract already rejects those paths, but this + * boundary stays total when called directly. + */ +export function resolveTarget( + projectRoot: string, + harness: ProvisionHarness, + destination: string, +): ResolvedTarget | null { + const normalized = normalizeRelative(destination); + if (normalized === null) return null; + + const surface = TARGETS[harness].surfaces.find((candidate) => matchesSurface(candidate, normalized)); + if (!surface) return null; + + return { + harness, + surface, + projectRoot, + relativeDestination: normalized, + destination: posix.join(projectRoot, normalized), + surfaceRoot: posix.join(projectRoot, surface.location), + }; +} + +function normalizeRelative(path: string): string | null { + if (path.length === 0 || path.includes("\0") || posix.isAbsolute(path)) return null; + const normalized = posix.normalize(path); + if (normalized === "." || normalized === ".." || normalized.startsWith("../")) return null; + return normalized; +} + +function matchesSurface(surface: TargetSurface, destination: string): boolean { + if (surface.layout === "file") return destination === surface.location; + + const prefix = `${surface.location}/`; + if (!destination.startsWith(prefix)) return false; + const child = destination.slice(prefix.length); + if (child.length === 0) return false; + + if (surface.extension) return !child.includes("/") && child.endsWith(surface.extension) && child !== surface.extension; + if (surface.entrypoint) return child.includes("/") && child.endsWith(`/${surface.entrypoint}`); + return true; +} + +export type TargetPathState = "absent" | "file" | "directory" | "symlink" | "other"; + +/** Injected runtime reads keep compatibility checking deterministic and independently testable. */ +export interface TargetInspection { + stat(path: string): TargetPathState; + read(path: string): string | null; +} + +export type TargetCompatibility = + | { compatible: true } + | { + compatible: false; + surface: string; + path: string; + reason: "file-where-directory" | "symlink" | "non-file" | "unreadable" | "malformed" | "inspection-failed"; + message: string; + }; + +/** + * Fail-closed compatibility check for AE4/R10. Absence is normal for every create-shaped destination. A + * merge-shaped existing config must be a readable, parseable object. Symlinks are refused at every inspected + * parent and at the target itself so provisioning never writes through an indirection it did not describe. + */ +export function checkTargetCompatibility(target: ResolvedTarget, io: TargetInspection): TargetCompatibility { + const { surface } = target; + + if (surface.layout === "directory") { + const rootCheck = requireDirectoryOrAbsent(target.surfaceRoot, surface, io); + if (rootCheck) return rootCheck; + } + + for (const parent of destinationParents(target)) { + if (parent === target.surfaceRoot) continue; // directory surfaces already inspected their root above + const parentCheck = requireDirectoryOrAbsent(parent, surface, io); + if (parentCheck) return parentCheck; + } + + const targetState = inspectState(target.destination, surface, io); + if (typeof targetState !== "string") return targetState; + if (targetState === "absent") return { compatible: true }; + if (targetState === "symlink") return incompatible(surface, target.destination, "symlink", "is a symlink"); + if (targetState !== "file") { + return incompatible(surface, target.destination, "non-file", "must be a regular file when present"); + } + + if (surface.shape === "create") return { compatible: true }; + + let content: string | null; + try { + content = io.read(target.destination); + } catch { + return incompatible(surface, target.destination, "inspection-failed", "could not be inspected safely"); + } + if (content === null) return incompatible(surface, target.destination, "unreadable", "is not readable"); + if (!isValidMergeConfig(surface.format, content)) { + return incompatible(surface, target.destination, "malformed", `is not valid ${surface.format} object config`); + } + return { compatible: true }; +} + +function destinationParents(target: ResolvedTarget): string[] { + const relativeParent = posix.dirname(target.relativeDestination); + if (relativeParent === ".") return []; + + const parents: string[] = []; + let current = ""; + for (const segment of relativeParent.split("/")) { + current = current.length === 0 ? segment : `${current}/${segment}`; + parents.push(posix.join(target.projectRoot, current)); + } + return parents; +} + +function requireDirectoryOrAbsent(path: string, surface: TargetSurface, io: TargetInspection): TargetCompatibility | null { + const state = inspectState(path, surface, io); + if (typeof state !== "string") return state; + if (state === "absent" || state === "directory") return null; + if (state === "symlink") return incompatible(surface, path, "symlink", "requires a directory but is a symlink"); + if (state === "file") return incompatible(surface, path, "file-where-directory", "requires a directory but is a regular file"); + return incompatible(surface, path, "non-file", "requires a directory but has an incompatible path type"); +} + +function inspectState(path: string, surface: TargetSurface, io: TargetInspection): TargetPathState | TargetCompatibility { + try { + return io.stat(path); + } catch { + return incompatible(surface, path, "inspection-failed", "could not be inspected safely"); + } +} + +function incompatible( + surface: TargetSurface, + path: string, + reason: Exclude["reason"], + detail: string, +): TargetCompatibility { + return { + compatible: false, + surface: surface.label, + path, + reason, + message: `Provisioning surface '${surface.label}' is incompatible at '${path}': ${detail}`, + }; +} + +function isValidMergeConfig(format: ConfigFormat, content: string): boolean { + try { + let parsed: unknown; + switch (format) { + case "json": + parsed = JSON.parse(content); + break; + case "toml": + parsed = parseToml(content); + break; + case "yaml": + parsed = parseYaml(content); + break; + case "text": + return false; // text has no merge semantics + default: + return assertNever(format); + } + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed); + } catch { + return false; + } +} + +function assertNever(value: never): never { + throw new Error(`targets: unhandled config format '${String(value)}'`); +} diff --git a/src/scan/cursor.ts b/src/scan/cursor.ts new file mode 100644 index 0000000..4137f67 --- /dev/null +++ b/src/scan/cursor.ts @@ -0,0 +1,50 @@ +/** + * Cursor inventory scanner (U10/U4): user-scoped, live, and fail-soft like the existing U9 scanners. + * + * Verified surfaces: + * - `~/.cursor/agents/*.md` custom-agent files (represented by InventoryItem's existing `plugin` extension + * kind; U4 deliberately changes no contract enum), + * - `~/.cursor/skills//SKILL.md`, and + * - `~/.cursor/mcp.json .mcpServers`. + * + * Project-scoped Cursor observation is deferred to U11/issue #35; this row preserves U9's current user-scope + * axis. Each surface fails soft independently, while `scanAll` remains the whole-runtime backstop. + */ +import { readdirSync, statSync, type Dirent } from "node:fs"; +import { join } from "node:path"; +import type { InventoryItem } from "../contract/index"; +import { listSkills, makeItem, namedItems, readJson, type ScanContext } from "./internal"; + +export function scanCursor(ctx: ScanContext): InventoryItem[] { + const cursorRoot = join(ctx.homeDir, ".cursor"); + const config = readJson(join(cursorRoot, "mcp.json")); + return [ + ...listAgents(ctx, join(cursorRoot, "agents")), + ...listSkills(ctx, "cursor", join(cursorRoot, "skills")), + ...namedItems(ctx, "cursor", "mcp", config?.mcpServers), + ]; +} + +function listAgents(ctx: ScanContext, agentsRoot: string): InventoryItem[] { + let entries: Dirent[]; + try { + entries = readdirSync(agentsRoot, { withFileTypes: true }); + } catch { + return []; + } + + return entries + .filter(({ name }) => !name.startsWith(".") && name.endsWith(".md") && name.length > ".md".length) + .filter((entry) => isAgentFile(agentsRoot, entry)) + .map(({ name }) => makeItem(ctx, "cursor", "plugin", name.slice(0, -".md".length))); +} + +function isAgentFile(root: string, entry: Dirent): boolean { + if (entry.isFile()) return true; + if (entry.isDirectory()) return false; + try { + return statSync(join(root, entry.name)).isFile(); // follow a valid symlink; reject broken/non-regular entries + } catch { + return false; + } +} diff --git a/src/scan/index.ts b/src/scan/index.ts index e33b14e..da989a1 100644 --- a/src/scan/index.ts +++ b/src/scan/index.ts @@ -5,7 +5,7 @@ * (Hermes/Cursor/Antigravity/OpenCode/Grok Build, as each enters real rotation — decisions #40/#44) is a * one-line change: write a `src/scan/.ts` returning `InventoryItem[]`, add its `Runtime` to the * contract's enum, and add ONE row to `SCANNERS`. No spine rework — that is the property the unit exists to - * protect. Slice-1 roster (Jarod's call, anchored to the shipped contract): Claude Code + Codex only. + * protect. Cursor joined the live roster in U10/U4 after its decision-#45 promotion trigger fired. * * Crash-safety is layered: each scanner's helpers fail soft per surface (`internal.ts`), and `scanAll` * wraps every source so a scanner that throws anyway degrades ONLY its own runtime to empty — the rest of @@ -19,16 +19,19 @@ import type { InventoryItem, Runtime } from "../contract/index"; import { scanClaudeCode } from "./claude-code"; import { scanCodex } from "./codex"; +import { scanCursor } from "./cursor"; import type { ScanContext, SourceScanner } from "./internal"; export type { ScanContext, SourceScanner } from "./internal"; export { scanClaudeCode } from "./claude-code"; export { scanCodex } from "./codex"; +export { scanCursor } from "./cursor"; -/** One scanner per source. Slice-1 roster only; append a row per harness as it enters real rotation. */ +/** One scanner per source; append one row per harness as it enters real rotation. */ const SCANNERS: ReadonlyArray<{ runtime: Runtime; scan: SourceScanner }> = [ { runtime: "claude-code", scan: scanClaudeCode }, { runtime: "codex", scan: scanCodex }, + { runtime: "cursor", scan: scanCursor }, ]; /** diff --git a/tests/provision-targets.test.ts b/tests/provision-targets.test.ts new file mode 100644 index 0000000..11688a0 --- /dev/null +++ b/tests/provision-targets.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, test } from "bun:test"; +import { + TARGETS, + checkTargetCompatibility, + resolveTarget, + type TargetInspection, + type TargetPathState, +} from "../src/provision/targets"; + +const PROJECT = "/work/project"; + +function inspection(states: Record, contents: Record = {}): TargetInspection { + return { + stat: (path) => states[path] ?? "absent", + read: (path) => contents[path] ?? null, + }; +} + +describe("provision target registry", () => { + test("carries the verified project-scoped surfaces, formats, and write shapes for all three harnesses", () => { + expect(TARGETS["claude-code"].surfaces).toEqual([ + expect.objectContaining({ location: "CLAUDE.md", format: "text", shape: "create" }), + expect.objectContaining({ location: ".claude/agents", format: "text", shape: "create", extension: ".md" }), + expect.objectContaining({ location: ".mcp.json", format: "json", shape: "merge" }), + ]); + expect(TARGETS.codex.surfaces).toEqual([ + expect.objectContaining({ location: "AGENTS.md", format: "text", shape: "create" }), + expect.objectContaining({ location: ".codex/agents", format: "toml", shape: "create", extension: ".toml" }), + expect.objectContaining({ location: ".codex/config.toml", format: "toml", shape: "merge" }), + ]); + expect(TARGETS.cursor.surfaces).toEqual([ + expect.objectContaining({ location: ".cursor/agents", format: "text", shape: "create", extension: ".md" }), + expect.objectContaining({ location: ".cursor/skills", format: "text", shape: "create", entrypoint: "SKILL.md" }), + expect.objectContaining({ location: ".cursor/mcp.json", format: "json", shape: "merge" }), + ]); + expect(TARGETS.codex.surfaces.some((surface) => surface.id === "skills")).toBe(false); // issue #36 + }); + + test("resolves only known project-relative destinations for each harness", () => { + expect(resolveTarget(PROJECT, "claude-code", ".claude/agents/reviewer.md")?.destination).toBe( + "/work/project/.claude/agents/reviewer.md", + ); + expect(resolveTarget(PROJECT, "codex", ".codex/agents/executor.toml")?.destination).toBe( + "/work/project/.codex/agents/executor.toml", + ); + expect(resolveTarget(PROJECT, "cursor", ".cursor/skills/qa-gate/SKILL.md")?.destination).toBe( + "/work/project/.cursor/skills/qa-gate/SKILL.md", + ); + expect(resolveTarget(PROJECT, "cursor", ".cursor/mcp.json")?.destination).toBe( + "/work/project/.cursor/mcp.json", + ); + + expect(resolveTarget(PROJECT, "cursor", "/Users/me/.cursor/mcp.json")).toBeNull(); + expect(resolveTarget(PROJECT, "cursor", "../.cursor/mcp.json")).toBeNull(); + expect(resolveTarget(PROJECT, "cursor", ".cursor/agents/not-markdown.txt")).toBeNull(); + expect(resolveTarget(PROJECT, "codex", ".codex/skills/guessed/SKILL.md")).toBeNull(); // issue #36 + }); + + test("AE4: a regular file where the Cursor agents directory must be fails loudly with the surface named", () => { + const target = resolveTarget(PROJECT, "cursor", ".cursor/agents/qa.md")!; + const result = checkTargetCompatibility( + target, + inspection({ + "/work/project/.cursor/agents": "file", + }), + ); + + expect(result.compatible).toBe(false); + if (!result.compatible) { + expect(result.surface).toBe(".cursor/agents/*.md"); + expect(result.message).toContain(".cursor/agents/*.md"); + expect(result.message).toContain("directory"); + } + }); + + test("AE4: an unparseable Cursor mcp.json merge parent fails loudly with the surface named", () => { + const target = resolveTarget(PROJECT, "cursor", ".cursor/mcp.json")!; + const result = checkTargetCompatibility( + target, + inspection( + { "/work/project/.cursor/mcp.json": "file" }, + { "/work/project/.cursor/mcp.json": "{ not valid json" }, + ), + ); + + expect(result.compatible).toBe(false); + if (!result.compatible) { + expect(result.surface).toBe(".cursor/mcp.json"); + expect(result.message).toContain(".cursor/mcp.json"); + expect(result.message).toContain("valid json"); + } + }); + + test("AE4: an absent create-shaped destination is compatible", () => { + const target = resolveTarget(PROJECT, "cursor", ".cursor/agents/qa.md")!; + expect(checkTargetCompatibility(target, inspection({}))).toEqual({ compatible: true }); + }); + + test("a symlinked target is incompatible, while a valid existing merge config is compatible", () => { + const agent = resolveTarget(PROJECT, "cursor", ".cursor/agents/qa.md")!; + const symlink = checkTargetCompatibility( + agent, + inspection({ + "/work/project/.cursor/agents": "directory", + "/work/project/.cursor/agents/qa.md": "symlink", + }), + ); + expect(symlink.compatible).toBe(false); + if (!symlink.compatible) expect(symlink.message).toContain("symlink"); + + const mcp = resolveTarget(PROJECT, "cursor", ".cursor/mcp.json")!; + expect( + checkTargetCompatibility( + mcp, + inspection( + { "/work/project/.cursor/mcp.json": "file" }, + { "/work/project/.cursor/mcp.json": '{"mcpServers":{"playwright":{"command":"npx"}}}' }, + ), + ), + ).toEqual({ compatible: true }); + }); + + test("inspection exceptions fail closed for both stat and read operations", () => { + const agent = resolveTarget(PROJECT, "cursor", ".cursor/agents/qa.md")!; + const statFailure = checkTargetCompatibility(agent, { + stat: () => { + throw new Error("permission denied"); + }, + read: () => null, + }); + expect(statFailure).toEqual( + expect.objectContaining({ + compatible: false, + surface: ".cursor/agents/*.md", + reason: "inspection-failed", + }), + ); + + const mcp = resolveTarget(PROJECT, "cursor", ".cursor/mcp.json")!; + const readFailure = checkTargetCompatibility(mcp, { + stat: (path) => (path === "/work/project/.cursor/mcp.json" ? "file" : "directory"), + read: () => { + throw new Error("read failed"); + }, + }); + expect(readFailure).toEqual( + expect.objectContaining({ + compatible: false, + surface: ".cursor/mcp.json", + reason: "inspection-failed", + }), + ); + }); + + test("symlinked ancestors and intermediate skill directories fail closed", () => { + const target = resolveTarget(PROJECT, "cursor", ".cursor/skills/qa-gate/SKILL.md")!; + + for (const path of ["/work/project/.cursor", "/work/project/.cursor/skills/qa-gate"]) { + const result = checkTargetCompatibility(target, inspection({ [path]: "symlink" })); + expect(result).toEqual( + expect.objectContaining({ + compatible: false, + path, + reason: "symlink", + surface: ".cursor/skills/**/SKILL.md", + }), + ); + } + }); + + test("a create-shaped target that is already a directory is incompatible", () => { + const target = resolveTarget(PROJECT, "cursor", ".cursor/agents/qa.md")!; + const result = checkTargetCompatibility( + target, + inspection({ + "/work/project/.cursor": "directory", + "/work/project/.cursor/agents": "directory", + "/work/project/.cursor/agents/qa.md": "directory", + }), + ); + + expect(result).toEqual( + expect.objectContaining({ + compatible: false, + reason: "non-file", + surface: ".cursor/agents/*.md", + }), + ); + }); +}); diff --git a/tests/scan.test.ts b/tests/scan.test.ts index a125396..83119db 100644 --- a/tests/scan.test.ts +++ b/tests/scan.test.ts @@ -3,7 +3,15 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { InventoryItem, type ItemKind, type Runtime } from "../src/contract/index"; -import { runScanners, scanAll, scanClaudeCode, scanCodex, type ScanContext, type SourceScanner } from "../src/scan/index"; +import { + runScanners, + scanAll, + scanClaudeCode, + scanCodex, + scanCursor, + type ScanContext, + type SourceScanner, +} from "../src/scan/index"; // Fixture-HOME pattern (like tests/capture-*.test.ts): a temp dir per test standing in for `~`, torn down // after. Scanners are pure disk reads, so no db/repo is needed — the disk IS the state under test. @@ -136,6 +144,56 @@ describe("scanCodex", () => { }); }); +// ───────────────────────────────────────────────────────────────────────────── +describe("scanCursor", () => { + test("enumerates user-scoped custom agents, skills, and MCP servers from ~/.cursor", () => { + writeRaw(join(home, ".cursor", "agents", "qa-smoke.md"), "---\nname: qa-smoke\n---\n"); + writeRaw(join(home, ".cursor", "agents", "qa-browser-e2e.md"), "---\nname: qa-browser-e2e\n---\n"); + writeRaw(join(home, ".cursor", "agents", "README.txt"), "not an agent"); + makeSkill(join(home, ".cursor", "skills"), "qa-gate"); + writeJson(join(home, ".cursor", "mcp.json"), { + mcpServers: { playwright: { command: "npx", args: ["@playwright/mcp"] } }, + }); + + const items = scanCursor(ctx()); + + // InventoryItem has no agent kind; Cursor custom agents occupy the existing extension/plugin slot. + expect(names(items, "cursor", "plugin")).toEqual(["qa-browser-e2e", "qa-smoke"]); + expect(names(items, "cursor", "skill")).toEqual(["qa-gate"]); + expect(names(items, "cursor", "mcp")).toEqual(["playwright"]); + }); + + test("a malformed mcp.json degrades that surface without hiding valid agents and skills", () => { + writeRaw(join(home, ".cursor", "agents", "qa.md"), "# QA\n"); + makeSkill(join(home, ".cursor", "skills"), "qa-gate"); + writeRaw(join(home, ".cursor", "mcp.json"), "{ definitely not json"); + + const items = scanCursor(ctx()); + + expect(names(items, "cursor", "mcp")).toEqual([]); + expect(names(items, "cursor", "plugin")).toEqual(["qa"]); + expect(names(items, "cursor", "skill")).toEqual(["qa-gate"]); + }); + + test("follows a valid agent symlink and skips a broken agent symlink", () => { + const agentsRoot = join(home, ".cursor", "agents"); + const realAgent = join(home, "external", "linked-agent.md"); + writeRaw(realAgent, "# Linked agent\n"); + mkdirSync(agentsRoot, { recursive: true }); + symlinkSync(realAgent, join(agentsRoot, "linked-agent.md")); + symlinkSync(join(home, "nowhere.md"), join(agentsRoot, "broken-agent.md")); + + const agents = names(scanCursor(ctx()), "cursor", "plugin"); + + expect(agents).toContain("linked-agent"); + expect(agents).not.toContain("broken-agent"); + }); + + test("an absent ~/.cursor yields an empty inventory, not a throw", () => { + expect(scanCursor(ctx())).toEqual([]); + }); +}); + // ───────────────────────────────────────────────────────────────────────────── describe("scanAll — composition, crash-safety, contract conformance", () => { function richFixture(): void { @@ -143,6 +201,7 @@ describe("scanAll — composition, crash-safety, contract conformance", () => { makeSkill(join(home, ".claude", "skills"), "cc-skill"); writeJson(join(home, ".claude", "settings.json"), { enabledPlugins: { "cc-plug@mkt": true } }); writeRaw(join(home, ".codex", "config.toml"), `[mcp_servers.cx-mcp]\nurl = "http://z"\n`); + writeJson(join(home, ".cursor", "mcp.json"), { mcpServers: { "cursor-mcp": {} } }); } test("AE4: a corrupt runtime degrades ONLY itself — the other stays complete, scan succeeds", async () => { @@ -159,6 +218,17 @@ describe("scanAll — composition, crash-safety, contract conformance", () => { expect(all.filter((i) => i.runtime === "codex")).toEqual([]); }); + test("a corrupt Cursor config degrades only Cursor while Claude Code and Codex remain complete", async () => { + richFixture(); + writeRaw(join(home, ".cursor", "mcp.json"), "{ broken cursor json"); + + const all = await scanAll(ctx()); + + expect(names(all, "claude-code", "mcp")).toEqual(["cc-mcp"]); + expect(names(all, "codex", "mcp")).toEqual(["cx-mcp"]); + expect(all.filter((item) => item.runtime === "cursor")).toEqual([]); + }); + test("AE5: a rescan reflects disk both ways — new items appear, removed items vanish (no phantoms)", async () => { const skillsRoot = join(home, ".claude", "skills"); makeSkill(skillsRoot, "first"); @@ -185,10 +255,10 @@ describe("scanAll — composition, crash-safety, contract conformance", () => { } }); - test("only roster runtimes are emitted (claude-code, codex) — no off-roster sources", async () => { + test("only roster runtimes are emitted (claude-code, codex, cursor) — no off-roster sources", async () => { richFixture(); const runtimes = new Set((await scanAll(ctx())).map((i) => i.runtime)); - expect([...runtimes].sort()).toEqual(["claude-code", "codex"]); + expect([...runtimes].sort()).toEqual(["claude-code", "codex", "cursor"]); }); test("an empty HOME yields an empty inventory, not a throw", async () => {