diff --git a/.github/setup-branch-protection.sh b/.github/setup-branch-protection.sh deleted file mode 100755 index ef47d22..0000000 --- a/.github/setup-branch-protection.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Sets up branch protection rules for EddaCraft/kindling. -# Requires: gh CLI authenticated with admin access. -# -# Usage: bash .github/setup-branch-protection.sh - -set -euo pipefail - -REPO="EddaCraft/kindling" - -echo "Setting default branch to dev..." -gh repo edit "$REPO" --default-branch dev - -echo "Protecting dev branch..." -gh api -X PUT "repos/$REPO/branches/dev/protection" \ - --input - <<'EOF' -{ - "required_status_checks": { - "strict": true, - "contexts": ["check"] - }, - "enforce_admins": false, - "required_pull_request_reviews": { - "required_approving_review_count": 1 - }, - "restrictions": null -} -EOF - -echo "Protecting main branch..." -gh api -X PUT "repos/$REPO/branches/main/protection" \ - --input - <<'EOF' -{ - "required_status_checks": { - "strict": true, - "contexts": ["check"] - }, - "enforce_admins": true, - "required_pull_request_reviews": { - "required_approving_review_count": 1 - }, - "restrictions": null -} -EOF - -echo "Done. Branch protections applied." -echo " dev — requires CI + 1 approval, default branch" -echo " main — requires CI + 1 approval, enforced for admins" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..5e8fbcc --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,85 @@ +name: rust + +on: + push: + branches: [dev, main] + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/rust.yml' + pull_request: + branches: [dev, main] + paths: + - 'crates/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/rust.yml' + +concurrency: + group: rust-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + fmt: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - run: cargo fmt --all -- --check + + clippy: + name: cargo clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy --workspace --all-targets -- -D warnings + + test: + name: cargo test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --workspace + + ts-bindings: + name: ts-rs bindings up to date + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test -p kindling-types --features ts-rs + - name: Fail if bindings drifted + run: | + if ! git diff --exit-code -- crates/kindling-types/bindings; then + echo "::error::TypeScript bindings are out of date. Run: cargo test -p kindling-types --features ts-rs" + exit 1 + fi + + build: + name: cargo build --release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo build --workspace --release diff --git a/.gitignore b/.gitignore index 1520bb6..90b0123 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,8 @@ settings.local.json # Admin-only files (publishing, plans, internal checklists) admin/ + +# Rust +target/ +**/*.rs.bk +*.pdb diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5e694..fb83050 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,49 @@ All notable changes to the Kindling project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +> **Heads-up on the road to 1.0.0.** Kindling is being re-implemented in Rust +> as the canonical engine, with `@eddacraft/kindling` repurposed as a thin +> HTTP-over-UDS client that downloads the Rust binary at install time. The +> existing TypeScript implementation packages (`-core`, `-store-sqlite`, +> `-store-sqljs`, `-provider-local`, `-server`, `-cli`) are deprecated and +> will be removed at 1.0.0. The 0.1.x line continues to receive maintenance +> until the Rust cutover lands. See +> `plans/specs/2026-05-03-rust-canonical-thin-client-design.md`. + +## [0.1.3] - 2026-05-08 + +### Added + +- **CLI**: new `kindling log` and `kindling capsule` write commands, plus an + install script and a substantially expanded README with cross-platform + install guides. +- **Claude Code plugin**: `recall` skill for agent memory retrieval, with + auto-invoke triggers and an explicit `/kindling:recall ` command. +- **Cross-language schema contract**: `schema/schema.sql` and + `schema/version.json` are now the canonical SQLite schema for both the + TypeScript store and the upcoming Rust store (SCHEMA-001..005). +- **Main package README** with cross-platform install instructions for + `@eddacraft/kindling`. + +### Changed + +- **Dependencies**: bumped `fastify` 5.7.4 → 5.8.1 in `kindling-server`. +- **Repository structure**: `packages/kindling-api-server/` renamed to + `packages/kindling-server/` to match the npm package name. The published + package name (`@eddacraft/kindling-server`) is unchanged — no consumer + action required. + +### Internal + +- Adopted the `main`/`dev` branching model: feature work merges to `dev`, + releases promote `dev` → `main`, GitHub Releases on `main` trigger + `publish.yml`. Documented in `docs/guides/`. +- Replaced `prettier` with `oxfmt` for formatting. +- Rebuilt the Claude Code plugin bundle. +- Began the Rust port (Phase 1, foundation): workspace scaffold landed in + `crates/`. The crates are not published to npm and have no impact on this + release — see the heads-up above. + ## [0.1.2] - 2026-02-16 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index b3a8ecc..12d1175 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,14 +127,34 @@ ESM-only (`"type": "module"`) with `.js` extensions in imports. ## Branching Workflow -``` -feature branches → dev (default) → main (releases/publishing) -``` +This repository uses a `main`/`dev` model that supports multiple active +streams in parallel worktrees. + +- `main` is the stable release branch. +- `dev` is the active integration branch. +- normal feat, fix, docs, and chore branches are created from `dev`. +- hotfix branches are created from `main` or the active `release/*` branch. + +Keep `main` and `dev` as the only permanent worktrees. Treat all other +worktrees as disposable and remove them once the branch is merged, replaced, +or paused. + +Release guidance: + +- small releases may promote directly from `dev` to `main` +- larger releases should use a short-lived `release/*` branch +- any fix that lands during release stabilisation must be merged back to `dev` + immediately after release +- tagging `vX.Y.Z` on `main` and creating a GitHub Release triggers + `.github/workflows/publish.yml`, which publishes all packages to npm + +See the detailed guides for the full policy: + +- `docs/guides/branching-strategy.md` +- `docs/guides/worktree-policy.md` +- `docs/guides/release-runbook.md` -- **`dev`** is the default branch. All feature branches are created from and merged into `dev` via PR. -- **`main`** is the release branch. Only `dev` → `main` PRs are merged here, and these trigger npm publishing. -- CI runs on pushes and PRs to both `dev` and `main`. -- Never push directly to `main` or `dev` — always use pull requests. +Never push directly to `main` or `dev` — always use pull requests. ## PocketFlow (Vendored) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bf6d4e5..5c9348c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,27 +56,43 @@ pnpm clean ## Branching Model -This project uses a two-tier branching workflow: +Kindling uses a two-branch model that supports multiple active streams in +parallel worktrees: -``` -feature branches → dev (default) → main (releases) -``` +- `main` is the stable release branch. Always publishable to npm. +- `dev` is the active integration branch and the default branch. +- normal feat, fix, docs, and chore branches are created from `dev`. +- hotfix branches are created from `main` or the active `release/*` branch. + +Keep `main` and `dev` as the only permanent worktrees. Treat all other +worktrees as disposable and remove them once the branch is merged, replaced, +or paused. + +Release guidance: + +- small releases may promote directly from `dev` to `main` +- larger releases should use a short-lived `release/*` branch +- any fix that lands during release stabilisation must be merged back to `dev` + immediately after release + +See the detailed guides for the full policy: -- **`dev`** is the default branch and integration target for all development. -- **`main`** is the release branch. Merges into `main` trigger publishing. -- **Feature branches** are created from `dev` and merged back via PR. -- PRs into both `dev` and `main` require CI to pass. -- Only maintainers merge `dev` → `main` for releases. +- [`docs/guides/branching-strategy.md`](docs/guides/branching-strategy.md) +- [`docs/guides/worktree-policy.md`](docs/guides/worktree-policy.md) +- [`docs/guides/release-runbook.md`](docs/guides/release-runbook.md) ## Pull Request Process 1. **Open an issue first** for significant changes to discuss approach -2. **Create a feature branch** from `dev` +2. **Create a branch from `dev`** for normal work, or from `main` only for + production hotfixes 3. **Write tests** for new functionality 4. **Update documentation** if behavior changes -5. **Keep PRs focused** - one logical change per PR -6. **Ensure CI passes** before requesting review -7. **Target `dev`** — PRs should target the `dev` branch, not `main` +5. **Keep PRs focused** — one logical change per PR +6. **Run the full test suite**: `pnpm build && pnpm test && pnpm lint` +7. **Ensure CI passes** before requesting review +8. **Target `dev`** — feature PRs should target `dev`, not `main`. Only + release-promotion and hotfix PRs target `main`. ### Commit Messages diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..e8566a6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,220 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kindling" +version = "0.0.1" + +[[package]] +name = "kindling-cli" +version = "0.0.1" + +[[package]] +name = "kindling-client" +version = "0.0.1" + +[[package]] +name = "kindling-filter" +version = "0.0.1" + +[[package]] +name = "kindling-hook" +version = "0.0.1" + +[[package]] +name = "kindling-provider" +version = "0.0.1" + +[[package]] +name = "kindling-server" +version = "0.0.1" + +[[package]] +name = "kindling-service" +version = "0.0.1" + +[[package]] +name = "kindling-store" +version = "0.0.1" + +[[package]] +name = "kindling-types" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", + "ts-rs", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ts-rs" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4994acea2522cd2b3b85c1d9529a55991e3ad5e25cdcd3de9d505972c4379424" +dependencies = [ + "serde_json", + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6ff59666c9cbaec3533964505d39154dc4e0a56151fdea30a09ed0301f62e2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "termcolor", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8e5501e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +resolver = "2" +members = ["crates/*"] + +[workspace.package] +version = "0.0.1" +edition = "2021" +license = "Apache-2.0" +repository = "https://github.com/eddacraft/kindling" +authors = ["aneki "] +rust-version = "1.85" + +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +all = { level = "warn", priority = -1 } + +[profile.release] +lto = "thin" +strip = "symbols" +codegen-units = 1 diff --git a/crates/kindling-cli/Cargo.toml b/crates/kindling-cli/Cargo.toml new file mode 100644 index 0000000..1947d63 --- /dev/null +++ b/crates/kindling-cli/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-cli" +description = "Kindling CLI command implementations (clap) — init, log, capsule open/close, status, search, list, pin, unpin, export, import, serve." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-cli/src/lib.rs b/crates/kindling-cli/src/lib.rs new file mode 100644 index 0000000..b9d3208 --- /dev/null +++ b/crates/kindling-cli/src/lib.rs @@ -0,0 +1,8 @@ +//! Kindling CLI commands. +//! +//! Defines the `clap` command tree and handlers for the 12 CLI verbs. Default +//! is in-process via `kindling-service`; `--via-daemon` switches to +//! `kindling-client` for safe concurrent use alongside other Kindling tools. +//! Wired into the umbrella binary by PORT-013. +//! +//! Filled in by PORT-012. diff --git a/crates/kindling-client/Cargo.toml b/crates/kindling-client/Cargo.toml new file mode 100644 index 0000000..08432a4 --- /dev/null +++ b/crates/kindling-client/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-client" +description = "Rust HTTP-over-UDS client for the Kindling daemon. Auto-spawns the daemon on first call. Used by the hook subcommand and by Anvil for concurrent-safe integration." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-client/src/lib.rs b/crates/kindling-client/src/lib.rs new file mode 100644 index 0000000..8b82299 --- /dev/null +++ b/crates/kindling-client/src/lib.rs @@ -0,0 +1,9 @@ +//! Rust client for the Kindling daemon. +//! +//! Speaks HTTP/1 over the Unix domain socket at `~/.kindling/kindling.sock` +//! (TCP fallback on Windows). On `ECONNREFUSED` or missing socket, spawns +//! `kindling serve --daemonize` and polls until the socket appears. Method +//! shape mirrors `kindling-service` for ergonomic in-process / via-daemon +//! interchangeability. +//! +//! Filled in by PORT-008. diff --git a/crates/kindling-filter/Cargo.toml b/crates/kindling-filter/Cargo.toml new file mode 100644 index 0000000..18ac85c --- /dev/null +++ b/crates/kindling-filter/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-filter" +description = "Content filtering for observations — secret masking, truncation, excluded-path filtering. Server-side enforcement so consumers cannot bypass." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-filter/src/lib.rs b/crates/kindling-filter/src/lib.rs new file mode 100644 index 0000000..9d40119 --- /dev/null +++ b/crates/kindling-filter/src/lib.rs @@ -0,0 +1,7 @@ +//! Content filtering for Kindling observations. +//! +//! Secret masking, length truncation, excluded-path filtering. Owned by the +//! server side (daemon) so non-Rust consumers cannot accidentally bypass the +//! redactions. +//! +//! Filled in by PORT-004. diff --git a/crates/kindling-hook/Cargo.toml b/crates/kindling-hook/Cargo.toml new file mode 100644 index 0000000..8ead435 --- /dev/null +++ b/crates/kindling-hook/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-hook" +description = "Claude Code hook handlers for Kindling — thin client to the daemon over UDS. All 7 hook types (session-start, post-tool-use, …) handled via stdin/stdout JSON." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-hook/src/lib.rs b/crates/kindling-hook/src/lib.rs new file mode 100644 index 0000000..931a5b9 --- /dev/null +++ b/crates/kindling-hook/src/lib.rs @@ -0,0 +1,8 @@ +//! Claude Code hook handlers. +//! +//! Reads hook context from stdin, dispatches the appropriate observation +//! through `kindling-client`, returns the response JSON on stdout matching +//! the Node.js script contract byte-for-byte. Invoked as `kindling hook +//! ` by the umbrella binary. +//! +//! Filled in by PORT-009. diff --git a/crates/kindling-provider/Cargo.toml b/crates/kindling-provider/Cargo.toml new file mode 100644 index 0000000..f86200f --- /dev/null +++ b/crates/kindling-provider/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-provider" +description = "Local FTS5 retrieval provider for Kindling — BM25 normalization, tiered retrieval (pins → current summary → ranked candidates)." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-provider/src/lib.rs b/crates/kindling-provider/src/lib.rs new file mode 100644 index 0000000..a1a7da3 --- /dev/null +++ b/crates/kindling-provider/src/lib.rs @@ -0,0 +1,7 @@ +//! Local FTS5 retrieval provider. +//! +//! Implements deterministic, tiered retrieval over the SQLite FTS5 index: +//! pins first (non-evictable), then the current capsule summary, then BM25- +//! ranked candidates normalised to [0, 1]. +//! +//! Filled in by PORT-005. diff --git a/crates/kindling-server/Cargo.toml b/crates/kindling-server/Cargo.toml new file mode 100644 index 0000000..462a91b --- /dev/null +++ b/crates/kindling-server/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-server" +description = "Long-running Kindling daemon — HTTP/1 over Unix domain socket (TCP fallback on Windows). Auto-spawn, idle shutdown, per-project DB routing." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-server/src/lib.rs b/crates/kindling-server/src/lib.rs new file mode 100644 index 0000000..c5fb815 --- /dev/null +++ b/crates/kindling-server/src/lib.rs @@ -0,0 +1,7 @@ +//! Kindling daemon — HTTP API over Unix domain socket. +//! +//! Long-running per-user process. Listens on `~/.kindling/kindling.sock` +//! (mode `0600`) on Unix; localhost TCP fallback on Windows. Auto-spawned +//! by clients, shuts down on idle. Routes per-project to the right SQLite DB. +//! +//! Filled in by PORT-007. diff --git a/crates/kindling-service/Cargo.toml b/crates/kindling-service/Cargo.toml new file mode 100644 index 0000000..82aa081 --- /dev/null +++ b/crates/kindling-service/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-service" +description = "In-process Kindling orchestration API — open/close capsule, append observation, retrieve, pin, unpin. Used by the daemon and by direct Rust consumers (Anvil, headless workflows)." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-service/src/lib.rs b/crates/kindling-service/src/lib.rs new file mode 100644 index 0000000..eb23b69 --- /dev/null +++ b/crates/kindling-service/src/lib.rs @@ -0,0 +1,7 @@ +//! In-process Kindling orchestration. +//! +//! `KindlingService` exposes capsule lifecycle, observation append, retrieval, +//! and pin management. Consumed in-process by the daemon (`kindling-server`), +//! by the CLI, and directly by Rust integrators (e.g. Anvil headless flows). +//! +//! Filled in by PORT-006. diff --git a/crates/kindling-store/Cargo.toml b/crates/kindling-store/Cargo.toml new file mode 100644 index 0000000..c7fb9fb --- /dev/null +++ b/crates/kindling-store/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "kindling-store" +description = "SQLite persistence for Kindling against schema/schema.sql — rusqlite (bundled), FTS5, WAL mode, per-project DB isolation." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true diff --git a/crates/kindling-store/src/lib.rs b/crates/kindling-store/src/lib.rs new file mode 100644 index 0000000..7436d61 --- /dev/null +++ b/crates/kindling-store/src/lib.rs @@ -0,0 +1,7 @@ +//! SQLite persistence layer for Kindling. +//! +//! Implements the cross-language schema contract from `schema/schema.sql` +//! against `rusqlite` with the `bundled` SQLite + FTS5. WAL mode enabled, +//! per-project DB isolation under `~/.kindling/projects//`. +//! +//! Filled in by PORT-003. diff --git a/crates/kindling-types/Cargo.toml b/crates/kindling-types/Cargo.toml new file mode 100644 index 0000000..7e136da --- /dev/null +++ b/crates/kindling-types/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "kindling-types" +description = "Canonical Kindling domain types — observations, capsules, retrieval. ts-rs derives generate the TypeScript projection." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[features] +# Enable ts-rs derives so `cargo test --features ts-rs` writes the TypeScript +# projection of every public type into `bindings/`. Off by default to keep the +# crate's normal compile cheap and free of dev-only dependencies. +ts-rs = ["dep:ts-rs"] + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ts-rs = { version = "11", optional = true, features = ["serde-json-impl"] } diff --git a/crates/kindling-types/README.md b/crates/kindling-types/README.md new file mode 100644 index 0000000..75a1035 --- /dev/null +++ b/crates/kindling-types/README.md @@ -0,0 +1,33 @@ +# kindling-types + +Canonical Rust types for Kindling — observations, capsules, summaries, pins, +retrieval. The wire format mirrors the existing TypeScript shapes in +`packages/kindling-core/src/types/` and is the contract every consumer +(Rust, TS-via-daemon, future bindings) must agree on. + +## TypeScript projection + +The `ts-rs` feature derives a TypeScript projection of every public type and +writes it under `bindings/` when tests run: + +```sh +cargo test -p kindling-types --features ts-rs +``` + +The resulting `.ts` files are checked in. CI runs the same command and fails +if any binding has drifted, so a contributor changing a type cannot land the +Rust change without also refreshing the bindings. + +## Wire-format conventions + +- All structs are camelCase on the wire (`scope_ids` → `scopeIds`). +- Enum variants are snake_case strings (`ToolCall` → `tool_call`). +- Optional fields are omitted from JSON when `None`, never serialised as `null`. +- `Timestamp` and counts are plain JSON numbers. `Timestamp` is an `i64` + alias (epoch milliseconds) and is the only integer wider than `i32` in + public types; every field that holds a `Timestamp` carries a + `#[ts(type = "number")]` override so the TypeScript projection emits + `number`, not `bigint`. New fields with `Timestamp` must carry the same + override — the round-trip and bindings tests will fail otherwise. +- `RetrievedEntity` is an untagged union of `Observation | Summary` so it + matches the structural union TS already uses. diff --git a/crates/kindling-types/bindings/.gitattributes b/crates/kindling-types/bindings/.gitattributes new file mode 100644 index 0000000..87acd4f --- /dev/null +++ b/crates/kindling-types/bindings/.gitattributes @@ -0,0 +1 @@ +* linguist-generated=true diff --git a/crates/kindling-types/bindings/CandidateResult.ts b/crates/kindling-types/bindings/CandidateResult.ts new file mode 100644 index 0000000..4cabd21 --- /dev/null +++ b/crates/kindling-types/bindings/CandidateResult.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RetrievedEntity } from "./RetrievedEntity"; + +/** + * Candidate (observation or summary) ranked by score. + */ +export type CandidateResult = { entity: RetrievedEntity, score: number, matchContext?: string, }; diff --git a/crates/kindling-types/bindings/Capsule.ts b/crates/kindling-types/bindings/Capsule.ts new file mode 100644 index 0000000..b7bf660 --- /dev/null +++ b/crates/kindling-types/bindings/Capsule.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CapsuleStatus } from "./CapsuleStatus"; +import type { CapsuleType } from "./CapsuleType"; +import type { ScopeIds } from "./ScopeIds"; + +/** + * Bounded unit of meaning grouping related observations. + * + * Mirrors `Capsule` in `packages/kindling-core/src/types/capsule.ts`. + */ +export type Capsule = { id: string, type: CapsuleType, intent: string, status: CapsuleStatus, openedAt: number, closedAt?: number, scopeIds: ScopeIds, observationIds: Array, summaryId?: string, }; diff --git a/crates/kindling-types/bindings/CapsuleInput.ts b/crates/kindling-types/bindings/CapsuleInput.ts new file mode 100644 index 0000000..095a8d3 --- /dev/null +++ b/crates/kindling-types/bindings/CapsuleInput.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CapsuleStatus } from "./CapsuleStatus"; +import type { CapsuleType } from "./CapsuleType"; +import type { ScopeIds } from "./ScopeIds"; + +/** + * Input for creating a new capsule. Optional fields are auto-generated. + */ +export type CapsuleInput = { id?: string, type: CapsuleType, intent: string, status?: CapsuleStatus, openedAt?: number, closedAt?: number, scopeIds: ScopeIds, observationIds?: Array, summaryId?: string, }; diff --git a/crates/kindling-types/bindings/CapsuleStatus.ts b/crates/kindling-types/bindings/CapsuleStatus.ts new file mode 100644 index 0000000..1037e48 --- /dev/null +++ b/crates/kindling-types/bindings/CapsuleStatus.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Capsule lifecycle status. + */ +export type CapsuleStatus = "open" | "closed"; diff --git a/crates/kindling-types/bindings/CapsuleType.ts b/crates/kindling-types/bindings/CapsuleType.ts new file mode 100644 index 0000000..ac08133 --- /dev/null +++ b/crates/kindling-types/bindings/CapsuleType.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Types of capsules. + */ +export type CapsuleType = "session" | "pocketflow_node"; diff --git a/crates/kindling-types/bindings/Observation.ts b/crates/kindling-types/bindings/Observation.ts new file mode 100644 index 0000000..78c6736 --- /dev/null +++ b/crates/kindling-types/bindings/Observation.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ObservationKind } from "./ObservationKind"; +import type { ScopeIds } from "./ScopeIds"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Atomic, immutable record of an event captured during development. + * + * Mirrors `Observation` in `packages/kindling-core/src/types/observation.ts`. + * The `redacted` flag is the only mutable field (via explicit redaction APIs). + */ +export type Observation = { id: string, kind: ObservationKind, content: string, +/** + * Source-specific metadata stored as a JSON object. + */ +provenance: { [key in string]?: JsonValue }, ts: number, scopeIds: ScopeIds, redacted: boolean, }; diff --git a/crates/kindling-types/bindings/ObservationInput.ts b/crates/kindling-types/bindings/ObservationInput.ts new file mode 100644 index 0000000..806c5ac --- /dev/null +++ b/crates/kindling-types/bindings/ObservationInput.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ObservationKind } from "./ObservationKind"; +import type { ScopeIds } from "./ScopeIds"; +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Input for creating a new observation. Optional fields are auto-generated. + */ +export type ObservationInput = { id?: string, kind: ObservationKind, content: string, provenance?: { [key in string]?: JsonValue }, ts?: number, scopeIds: ScopeIds, redacted?: boolean, }; diff --git a/crates/kindling-types/bindings/ObservationKind.ts b/crates/kindling-types/bindings/ObservationKind.ts new file mode 100644 index 0000000..20448aa --- /dev/null +++ b/crates/kindling-types/bindings/ObservationKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Types of observations that can be captured. + */ +export type ObservationKind = "tool_call" | "command" | "file_diff" | "error" | "message" | "node_start" | "node_end" | "node_output" | "node_error"; diff --git a/crates/kindling-types/bindings/Pin.ts b/crates/kindling-types/bindings/Pin.ts new file mode 100644 index 0000000..25e25fc --- /dev/null +++ b/crates/kindling-types/bindings/Pin.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PinTargetType } from "./PinTargetType"; +import type { ScopeIds } from "./ScopeIds"; + +/** + * Pinned reference to an observation or summary. + * + * Mirrors `Pin` in `packages/kindling-core/src/types/pin.ts`. + */ +export type Pin = { id: string, targetType: PinTargetType, targetId: string, reason?: string, createdAt: number, expiresAt?: number, scopeIds: ScopeIds, }; diff --git a/crates/kindling-types/bindings/PinInput.ts b/crates/kindling-types/bindings/PinInput.ts new file mode 100644 index 0000000..52c7778 --- /dev/null +++ b/crates/kindling-types/bindings/PinInput.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PinTargetType } from "./PinTargetType"; +import type { ScopeIds } from "./ScopeIds"; + +/** + * Input for creating a new pin. Optional fields are auto-generated. + */ +export type PinInput = { id?: string, targetType: PinTargetType, targetId: string, reason?: string, createdAt?: number, expiresAt?: number, scopeIds: ScopeIds, }; diff --git a/crates/kindling-types/bindings/PinResult.ts b/crates/kindling-types/bindings/PinResult.ts new file mode 100644 index 0000000..7909b66 --- /dev/null +++ b/crates/kindling-types/bindings/PinResult.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Pin } from "./Pin"; +import type { RetrievedEntity } from "./RetrievedEntity"; + +/** + * Pin together with the observation or summary it points at. + */ +export type PinResult = { pin: Pin, target: RetrievedEntity, }; diff --git a/crates/kindling-types/bindings/PinTargetType.ts b/crates/kindling-types/bindings/PinTargetType.ts new file mode 100644 index 0000000..3d20176 --- /dev/null +++ b/crates/kindling-types/bindings/PinTargetType.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Type of entity that can be pinned. + */ +export type PinTargetType = "observation" | "summary"; diff --git a/crates/kindling-types/bindings/ProviderSearchOptions.ts b/crates/kindling-types/bindings/ProviderSearchOptions.ts new file mode 100644 index 0000000..5cdbfe9 --- /dev/null +++ b/crates/kindling-types/bindings/ProviderSearchOptions.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ScopeIds } from "./ScopeIds"; + +/** + * Search options for a retrieval provider. + */ +export type ProviderSearchOptions = { query: string, scopeIds: ScopeIds, maxResults?: number, excludeIds?: Array, includeRedacted?: boolean, }; diff --git a/crates/kindling-types/bindings/ProviderSearchResult.ts b/crates/kindling-types/bindings/ProviderSearchResult.ts new file mode 100644 index 0000000..f635edb --- /dev/null +++ b/crates/kindling-types/bindings/ProviderSearchResult.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RetrievedEntity } from "./RetrievedEntity"; + +/** + * A single result from a retrieval provider. + */ +export type ProviderSearchResult = { entity: RetrievedEntity, score: number, matchContext?: string, }; diff --git a/crates/kindling-types/bindings/RetrieveOptions.ts b/crates/kindling-types/bindings/RetrieveOptions.ts new file mode 100644 index 0000000..ca422e6 --- /dev/null +++ b/crates/kindling-types/bindings/RetrieveOptions.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ScopeIds } from "./ScopeIds"; + +/** + * Options for a retrieval request. + */ +export type RetrieveOptions = { query: string, scopeIds: ScopeIds, +/** + * Deprecated: token-budget assembly is a downstream-system responsibility. + * Prefer `max_candidates` for bounded result sets. + */ +tokenBudget?: number, maxCandidates?: number, includeRedacted?: boolean, }; diff --git a/crates/kindling-types/bindings/RetrieveProvenance.ts b/crates/kindling-types/bindings/RetrieveProvenance.ts new file mode 100644 index 0000000..93ff010 --- /dev/null +++ b/crates/kindling-types/bindings/RetrieveProvenance.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ScopeIds } from "./ScopeIds"; + +/** + * Provenance for a retrieval result. + */ +export type RetrieveProvenance = { query: string, scopeIds: ScopeIds, totalCandidates: number, returnedCandidates: number, truncatedDueToTokenBudget: boolean, providerUsed: string, }; diff --git a/crates/kindling-types/bindings/RetrieveResult.ts b/crates/kindling-types/bindings/RetrieveResult.ts new file mode 100644 index 0000000..529febd --- /dev/null +++ b/crates/kindling-types/bindings/RetrieveResult.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CandidateResult } from "./CandidateResult"; +import type { PinResult } from "./PinResult"; +import type { RetrieveProvenance } from "./RetrieveProvenance"; +import type { Summary } from "./Summary"; + +/** + * Complete retrieval result: pins, optional current summary, ranked candidates, provenance. + */ +export type RetrieveResult = { pins: Array, currentSummary?: Summary, candidates: Array, provenance: RetrieveProvenance, }; diff --git a/crates/kindling-types/bindings/RetrievedEntity.ts b/crates/kindling-types/bindings/RetrievedEntity.ts new file mode 100644 index 0000000..2748676 --- /dev/null +++ b/crates/kindling-types/bindings/RetrievedEntity.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Observation } from "./Observation"; +import type { Summary } from "./Summary"; + +/** + * An entity returned from retrieval — either an observation or a summary. + * Untagged so the wire format matches the TS structural union `Observation | Summary`. + */ +export type RetrievedEntity = Observation | Summary; diff --git a/crates/kindling-types/bindings/ScopeIds.ts b/crates/kindling-types/bindings/ScopeIds.ts new file mode 100644 index 0000000..8eaa813 --- /dev/null +++ b/crates/kindling-types/bindings/ScopeIds.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Scope identifiers for multi-dimensional isolation. + * + * All fields are optional to support partial scoping. Mirrors `ScopeIds` + * in `packages/kindling-core/src/types/common.ts`. + */ +export type ScopeIds = { sessionId?: string, repoId?: string, agentId?: string, userId?: string, taskId?: string, }; diff --git a/crates/kindling-types/bindings/Summary.ts b/crates/kindling-types/bindings/Summary.ts new file mode 100644 index 0000000..dc6ca6b --- /dev/null +++ b/crates/kindling-types/bindings/Summary.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * High-level description of a capsule's content (typically LLM-generated). + * + * Mirrors `Summary` in `packages/kindling-core/src/types/summary.ts`. + */ +export type Summary = { id: string, capsuleId: string, content: string, +/** + * Quality/confidence score in `[0.0, 1.0]`. + */ +confidence: number, createdAt: number, evidenceRefs: Array, }; diff --git a/crates/kindling-types/bindings/SummaryInput.ts b/crates/kindling-types/bindings/SummaryInput.ts new file mode 100644 index 0000000..797ceb4 --- /dev/null +++ b/crates/kindling-types/bindings/SummaryInput.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Input for creating a new summary. Optional fields are auto-generated. + */ +export type SummaryInput = { id?: string, capsuleId: string, content: string, confidence: number, createdAt?: number, evidenceRefs: Array, }; diff --git a/crates/kindling-types/bindings/ValidationError.ts b/crates/kindling-types/bindings/ValidationError.ts new file mode 100644 index 0000000..e4613a1 --- /dev/null +++ b/crates/kindling-types/bindings/ValidationError.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Validation error details. + */ +export type ValidationError = { field: string, message: string, value?: JsonValue, }; diff --git a/crates/kindling-types/bindings/serde_json/JsonValue.ts b/crates/kindling-types/bindings/serde_json/JsonValue.ts new file mode 100644 index 0000000..3ad5da8 --- /dev/null +++ b/crates/kindling-types/bindings/serde_json/JsonValue.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type JsonValue = number | string | boolean | Array | { [key in string]?: JsonValue } | null; diff --git a/crates/kindling-types/src/capsule.rs b/crates/kindling-types/src/capsule.rs new file mode 100644 index 0000000..b9e7830 --- /dev/null +++ b/crates/kindling-types/src/capsule.rs @@ -0,0 +1,87 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::common::{Id, ScopeIds, Timestamp}; + +/// Types of capsules. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "snake_case")] +pub enum CapsuleType { + Session, + PocketflowNode, +} + +impl CapsuleType { + pub const ALL: &'static [CapsuleType] = &[CapsuleType::Session, CapsuleType::PocketflowNode]; +} + +/// Capsule lifecycle status. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "snake_case")] +pub enum CapsuleStatus { + Open, + Closed, +} + +impl CapsuleStatus { + pub const ALL: &'static [CapsuleStatus] = &[CapsuleStatus::Open, CapsuleStatus::Closed]; +} + +/// Bounded unit of meaning grouping related observations. +/// +/// Mirrors `Capsule` in `packages/kindling-core/src/types/capsule.ts`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct Capsule { + pub id: Id, + #[serde(rename = "type")] + #[cfg_attr(feature = "ts-rs", ts(rename = "type"))] + pub kind: CapsuleType, + pub intent: String, + pub status: CapsuleStatus, + #[cfg_attr(feature = "ts-rs", ts(type = "number"))] + pub opened_at: Timestamp, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub closed_at: Option, + pub scope_ids: ScopeIds, + pub observation_ids: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub summary_id: Option, +} + +/// Input for creating a new capsule. Optional fields are auto-generated. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct CapsuleInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub id: Option, + #[serde(rename = "type")] + #[cfg_attr(feature = "ts-rs", ts(rename = "type"))] + pub kind: CapsuleType, + pub intent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub opened_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub closed_at: Option, + pub scope_ids: ScopeIds, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub observation_ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub summary_id: Option, +} diff --git a/crates/kindling-types/src/common.rs b/crates/kindling-types/src/common.rs new file mode 100644 index 0000000..7708815 --- /dev/null +++ b/crates/kindling-types/src/common.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +/// Unique identifier for entities. Implementation uses UUIDv4 format. +pub type Id = String; + +/// Timestamp in epoch milliseconds. +/// +/// Aliased to `i64` to keep arithmetic ergonomic in Rust. Every public field +/// that holds a `Timestamp` MUST carry a `#[cfg_attr(feature = "ts-rs", +/// ts(type = "number"))]` override (use `ts(optional, type = "number")` for +/// `Option`); without it the `ts-rs` projection would emit +/// `bigint`, breaking the JSON `number` wire contract. Round-trip and +/// bindings tests will fail if the override is missed. +pub type Timestamp = i64; + +/// Scope identifiers for multi-dimensional isolation. +/// +/// All fields are optional to support partial scoping. Mirrors `ScopeIds` +/// in `packages/kindling-core/src/types/common.ts`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct ScopeIds { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub repo_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub agent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub user_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub task_id: Option, +} + +/// Validation error details. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct ValidationError { + pub field: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub value: Option, +} diff --git a/crates/kindling-types/src/lib.rs b/crates/kindling-types/src/lib.rs new file mode 100644 index 0000000..a416877 --- /dev/null +++ b/crates/kindling-types/src/lib.rs @@ -0,0 +1,27 @@ +//! Canonical Kindling domain types. +//! +//! This crate is the source of truth for the wire-format shapes shared between +//! the Rust implementation and TypeScript consumers. Each public type +//! round-trips through the JSON encoding produced by the existing TypeScript +//! definitions in `packages/kindling-core/src/types/`. +//! +//! Enable the `ts-rs` feature to derive the TypeScript projection — running +//! `cargo test -p kindling-types --features ts-rs` writes the corresponding +//! `.ts` files into `crates/kindling-types/bindings/`. + +pub mod capsule; +pub mod common; +pub mod observation; +pub mod pin; +pub mod retrieval; +pub mod summary; + +pub use capsule::{Capsule, CapsuleInput, CapsuleStatus, CapsuleType}; +pub use common::{Id, ScopeIds, Timestamp, ValidationError}; +pub use observation::{Observation, ObservationInput, ObservationKind}; +pub use pin::{is_pin_active, Pin, PinInput, PinTargetType}; +pub use retrieval::{ + CandidateResult, PinResult, ProviderSearchOptions, ProviderSearchResult, RetrieveOptions, + RetrieveProvenance, RetrieveResult, RetrievedEntity, +}; +pub use summary::{is_valid_confidence, Summary, SummaryInput}; diff --git a/crates/kindling-types/src/observation.rs b/crates/kindling-types/src/observation.rs new file mode 100644 index 0000000..f57a7a5 --- /dev/null +++ b/crates/kindling-types/src/observation.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::common::{Id, ScopeIds, Timestamp}; + +/// Types of observations that can be captured. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "snake_case")] +pub enum ObservationKind { + ToolCall, + Command, + FileDiff, + Error, + Message, + NodeStart, + NodeEnd, + NodeOutput, + NodeError, +} + +impl ObservationKind { + /// All variants in declaration order. Mirrors `OBSERVATION_KINDS` in TS. + pub const ALL: &'static [ObservationKind] = &[ + ObservationKind::ToolCall, + ObservationKind::Command, + ObservationKind::FileDiff, + ObservationKind::Error, + ObservationKind::Message, + ObservationKind::NodeStart, + ObservationKind::NodeEnd, + ObservationKind::NodeOutput, + ObservationKind::NodeError, + ]; +} + +/// Atomic, immutable record of an event captured during development. +/// +/// Mirrors `Observation` in `packages/kindling-core/src/types/observation.ts`. +/// The `redacted` flag is the only mutable field (via explicit redaction APIs). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct Observation { + pub id: Id, + pub kind: ObservationKind, + pub content: String, + /// Source-specific metadata stored as a JSON object. + pub provenance: Map, + #[cfg_attr(feature = "ts-rs", ts(type = "number"))] + pub ts: Timestamp, + pub scope_ids: ScopeIds, + pub redacted: bool, +} + +/// Input for creating a new observation. Optional fields are auto-generated. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct ObservationInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub id: Option, + pub kind: ObservationKind, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub provenance: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub ts: Option, + pub scope_ids: ScopeIds, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub redacted: Option, +} diff --git a/crates/kindling-types/src/pin.rs b/crates/kindling-types/src/pin.rs new file mode 100644 index 0000000..f91a94d --- /dev/null +++ b/crates/kindling-types/src/pin.rs @@ -0,0 +1,70 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::common::{Id, ScopeIds, Timestamp}; + +/// Type of entity that can be pinned. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "snake_case")] +pub enum PinTargetType { + Observation, + Summary, +} + +impl PinTargetType { + pub const ALL: &'static [PinTargetType] = &[PinTargetType::Observation, PinTargetType::Summary]; +} + +/// Pinned reference to an observation or summary. +/// +/// Mirrors `Pin` in `packages/kindling-core/src/types/pin.ts`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct Pin { + pub id: Id, + pub target_type: PinTargetType, + pub target_id: Id, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub reason: Option, + #[cfg_attr(feature = "ts-rs", ts(type = "number"))] + pub created_at: Timestamp, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub expires_at: Option, + pub scope_ids: ScopeIds, +} + +/// Input for creating a new pin. Optional fields are auto-generated. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct PinInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub id: Option, + pub target_type: PinTargetType, + pub target_id: Id, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub reason: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub created_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub expires_at: Option, + pub scope_ids: ScopeIds, +} + +/// True iff the pin has not expired at `now` (epoch ms). +pub fn is_pin_active(pin: &Pin, now: Timestamp) -> bool { + match pin.expires_at { + None => true, + Some(exp) => exp > now, + } +} diff --git a/crates/kindling-types/src/retrieval.rs b/crates/kindling-types/src/retrieval.rs new file mode 100644 index 0000000..e6a4979 --- /dev/null +++ b/crates/kindling-types/src/retrieval.rs @@ -0,0 +1,116 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::common::{Id, ScopeIds}; +use crate::observation::Observation; +use crate::pin::Pin; +use crate::summary::Summary; + +/// An entity returned from retrieval — either an observation or a summary. +/// Untagged so the wire format matches the TS structural union `Observation | Summary`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(untagged)] +pub enum RetrievedEntity { + Observation(Observation), + Summary(Summary), +} + +/// Options for a retrieval request. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct RetrieveOptions { + pub query: String, + pub scope_ids: ScopeIds, + /// Deprecated: token-budget assembly is a downstream-system responsibility. + /// Prefer `max_candidates` for bounded result sets. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub token_budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub max_candidates: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub include_redacted: Option, +} + +/// Pin together with the observation or summary it points at. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct PinResult { + pub pin: Pin, + pub target: RetrievedEntity, +} + +/// Candidate (observation or summary) ranked by score. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct CandidateResult { + pub entity: RetrievedEntity, + pub score: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub match_context: Option, +} + +/// Provenance for a retrieval result. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct RetrieveProvenance { + pub query: String, + pub scope_ids: ScopeIds, + pub total_candidates: u32, + pub returned_candidates: u32, + pub truncated_due_to_token_budget: bool, + pub provider_used: String, +} + +/// Complete retrieval result: pins, optional current summary, ranked candidates, provenance. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct RetrieveResult { + pub pins: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub current_summary: Option, + pub candidates: Vec, + pub provenance: RetrieveProvenance, +} + +/// Search options for a retrieval provider. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct ProviderSearchOptions { + pub query: String, + pub scope_ids: ScopeIds, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub max_results: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub exclude_ids: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub include_redacted: Option, +} + +/// A single result from a retrieval provider. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct ProviderSearchResult { + pub entity: RetrievedEntity, + pub score: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub match_context: Option, +} diff --git a/crates/kindling-types/src/summary.rs b/crates/kindling-types/src/summary.rs new file mode 100644 index 0000000..4ac8dbf --- /dev/null +++ b/crates/kindling-types/src/summary.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::common::{Id, Timestamp}; + +/// High-level description of a capsule's content (typically LLM-generated). +/// +/// Mirrors `Summary` in `packages/kindling-core/src/types/summary.ts`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct Summary { + pub id: Id, + pub capsule_id: Id, + pub content: String, + /// Quality/confidence score in `[0.0, 1.0]`. + pub confidence: f64, + #[cfg_attr(feature = "ts-rs", ts(type = "number"))] + pub created_at: Timestamp, + pub evidence_refs: Vec, +} + +/// Input for creating a new summary. Optional fields are auto-generated. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "ts-rs", derive(TS), ts(export, export_to = "../bindings/"))] +#[serde(rename_all = "camelCase")] +pub struct SummaryInput { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional))] + pub id: Option, + pub capsule_id: Id, + pub content: String, + pub confidence: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "ts-rs", ts(optional, type = "number"))] + pub created_at: Option, + pub evidence_refs: Vec, +} + +/// Validate that confidence score is in `[0.0, 1.0]` and not NaN. +pub fn is_valid_confidence(value: f64) -> bool { + !value.is_nan() && (0.0..=1.0).contains(&value) +} diff --git a/crates/kindling-types/tests/fixtures/capsule_closed.json b/crates/kindling-types/tests/fixtures/capsule_closed.json new file mode 100644 index 0000000..39bbc14 --- /dev/null +++ b/crates/kindling-types/tests/fixtures/capsule_closed.json @@ -0,0 +1,16 @@ +{ + "id": "01J8XS7CAPSULE0000000000002", + "type": "pocketflow_node", + "intent": "Run retrieval node", + "status": "closed", + "openedAt": 1746662400000, + "closedAt": 1746662460000, + "scopeIds": { + "sessionId": "01J8XS7ABCDEF0123456789ABC", + "repoId": "/home/dev/kindling" + }, + "observationIds": [ + "01J8XS7OBSERVATION0000000003" + ], + "summaryId": "01J8XS7SUMMARY00000000000001" +} diff --git a/crates/kindling-types/tests/fixtures/capsule_open.json b/crates/kindling-types/tests/fixtures/capsule_open.json new file mode 100644 index 0000000..f50ebe1 --- /dev/null +++ b/crates/kindling-types/tests/fixtures/capsule_open.json @@ -0,0 +1,14 @@ +{ + "id": "01J8XS7CAPSULE0000000000001", + "type": "session", + "intent": "Fix authentication bug", + "status": "open", + "openedAt": 1746662400000, + "scopeIds": { + "sessionId": "01J8XS7ABCDEF0123456789ABC" + }, + "observationIds": [ + "01J8XS7OBSERVATION0000000001", + "01J8XS7OBSERVATION0000000002" + ] +} diff --git a/crates/kindling-types/tests/fixtures/observation_full.json b/crates/kindling-types/tests/fixtures/observation_full.json new file mode 100644 index 0000000..a27d6cc --- /dev/null +++ b/crates/kindling-types/tests/fixtures/observation_full.json @@ -0,0 +1,16 @@ +{ + "id": "01J8XS7OBSERVATION0000000001", + "kind": "tool_call", + "content": "rg --files src/", + "provenance": { + "toolName": "ripgrep", + "exitCode": 0, + "durationMs": 47 + }, + "ts": 1746662400000, + "scopeIds": { + "sessionId": "01J8XS7ABCDEF0123456789ABC", + "repoId": "/home/dev/kindling" + }, + "redacted": false +} diff --git a/crates/kindling-types/tests/fixtures/pin.json b/crates/kindling-types/tests/fixtures/pin.json new file mode 100644 index 0000000..ff0faee --- /dev/null +++ b/crates/kindling-types/tests/fixtures/pin.json @@ -0,0 +1,11 @@ +{ + "id": "01J8XS7PIN0000000000000000001", + "targetType": "observation", + "targetId": "01J8XS7OBSERVATION0000000001", + "reason": "Critical context for auth flow", + "createdAt": 1746662400000, + "expiresAt": 1746748800000, + "scopeIds": { + "sessionId": "01J8XS7ABCDEF0123456789ABC" + } +} diff --git a/crates/kindling-types/tests/fixtures/retrieve_result.json b/crates/kindling-types/tests/fixtures/retrieve_result.json new file mode 100644 index 0000000..5574891 --- /dev/null +++ b/crates/kindling-types/tests/fixtures/retrieve_result.json @@ -0,0 +1,52 @@ +{ + "pins": [ + { + "pin": { + "id": "01J8XS7PIN0000000000000000001", + "targetType": "observation", + "targetId": "01J8XS7OBSERVATION0000000001", + "createdAt": 1746662400000, + "scopeIds": { + "sessionId": "01J8XS7ABCDEF0123456789ABC" + } + }, + "target": { + "id": "01J8XS7OBSERVATION0000000001", + "kind": "tool_call", + "content": "rg --files src/", + "provenance": { + "toolName": "ripgrep" + }, + "ts": 1746662400000, + "scopeIds": { + "sessionId": "01J8XS7ABCDEF0123456789ABC" + }, + "redacted": false + } + } + ], + "candidates": [ + { + "entity": { + "id": "01J8XS7SUMMARY00000000000001", + "capsuleId": "01J8XS7CAPSULE0000000000002", + "content": "Identified missing token rotation in auth middleware.", + "confidence": 0.82, + "createdAt": 1746662460000, + "evidenceRefs": [] + }, + "score": 0.91, + "matchContext": "auth middleware" + } + ], + "provenance": { + "query": "auth middleware", + "scopeIds": { + "sessionId": "01J8XS7ABCDEF0123456789ABC" + }, + "totalCandidates": 12, + "returnedCandidates": 1, + "truncatedDueToTokenBudget": false, + "providerUsed": "local-fts" + } +} diff --git a/crates/kindling-types/tests/fixtures/scope_ids_full.json b/crates/kindling-types/tests/fixtures/scope_ids_full.json new file mode 100644 index 0000000..7859f1b --- /dev/null +++ b/crates/kindling-types/tests/fixtures/scope_ids_full.json @@ -0,0 +1,7 @@ +{ + "sessionId": "01J8XS7ABCDEF0123456789ABC", + "repoId": "/home/dev/kindling", + "agentId": "claude-code", + "userId": "josh", + "taskId": "BEADS-42" +} diff --git a/crates/kindling-types/tests/fixtures/scope_ids_partial.json b/crates/kindling-types/tests/fixtures/scope_ids_partial.json new file mode 100644 index 0000000..ca1796f --- /dev/null +++ b/crates/kindling-types/tests/fixtures/scope_ids_partial.json @@ -0,0 +1,4 @@ +{ + "sessionId": "01J8XS7ABCDEF0123456789ABC", + "repoId": "/home/dev/kindling" +} diff --git a/crates/kindling-types/tests/fixtures/summary.json b/crates/kindling-types/tests/fixtures/summary.json new file mode 100644 index 0000000..589beaa --- /dev/null +++ b/crates/kindling-types/tests/fixtures/summary.json @@ -0,0 +1,11 @@ +{ + "id": "01J8XS7SUMMARY00000000000001", + "capsuleId": "01J8XS7CAPSULE0000000000002", + "content": "Identified missing token rotation in auth middleware.", + "confidence": 0.82, + "createdAt": 1746662460000, + "evidenceRefs": [ + "01J8XS7OBSERVATION0000000003", + "01J8XS7OBSERVATION0000000004" + ] +} diff --git a/crates/kindling-types/tests/round_trip.rs b/crates/kindling-types/tests/round_trip.rs new file mode 100644 index 0000000..5350ea4 --- /dev/null +++ b/crates/kindling-types/tests/round_trip.rs @@ -0,0 +1,234 @@ +//! JSON round-trip tests against fixtures shaped like the existing TypeScript +//! types in `packages/kindling-core/src/types/`. +//! +//! Each test: +//! 1. Reads a fixture written in the TS wire shape (camelCase, snake_case +//! enum values, optional fields absent rather than null). +//! 2. Deserialises it into the Rust type. +//! 3. Re-serialises the Rust value and compares it to the fixture as a +//! `serde_json::Value` (so cosmetic formatting doesn't break the test). +//! +//! When this fails it almost always means the Rust type drifted from the TS +//! definition — fix the Rust side, not the fixture. + +use kindling_types::*; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::{json, Value}; +use std::fs; +use std::path::PathBuf; + +fn fixture(name: &str) -> Value { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("tests"); + p.push("fixtures"); + p.push(name); + let raw = fs::read_to_string(&p).unwrap_or_else(|e| panic!("read {}: {e}", p.display())); + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("parse {}: {e}", p.display())) +} + +fn round_trip(name: &str) -> T { + let value = fixture(name); + let parsed: T = + serde_json::from_value(value.clone()).unwrap_or_else(|e| panic!("deserialize {name}: {e}")); + let reserialised: Value = + serde_json::to_value(&parsed).unwrap_or_else(|e| panic!("serialize {name}: {e}")); + assert_eq!(reserialised, value, "round-trip mismatch for {name}"); + parsed +} + +#[test] +fn scope_ids_full() { + let s: ScopeIds = round_trip("scope_ids_full.json"); + assert_eq!(s.session_id.as_deref(), Some("01J8XS7ABCDEF0123456789ABC")); + assert_eq!(s.repo_id.as_deref(), Some("/home/dev/kindling")); + assert_eq!(s.agent_id.as_deref(), Some("claude-code")); + assert_eq!(s.user_id.as_deref(), Some("josh")); + assert_eq!(s.task_id.as_deref(), Some("BEADS-42")); +} + +#[test] +fn scope_ids_partial_omits_absent_fields() { + let s: ScopeIds = round_trip("scope_ids_partial.json"); + assert!(s.agent_id.is_none()); + assert!(s.user_id.is_none()); + assert!(s.task_id.is_none()); +} + +#[test] +fn scope_ids_empty_serialises_as_empty_object() { + let v = serde_json::to_value(ScopeIds::default()).unwrap(); + assert_eq!( + v, + json!({}), + "absent optional fields must not appear in JSON" + ); +} + +#[test] +fn observation_full() { + let o: Observation = round_trip("observation_full.json"); + assert_eq!(o.kind, ObservationKind::ToolCall); + assert_eq!(o.content, "rg --files src/"); + assert_eq!(o.ts, 1_746_662_400_000); + assert!(!o.redacted); + assert_eq!( + o.provenance.get("toolName").and_then(Value::as_str), + Some("ripgrep"), + ); +} + +#[test] +fn observation_kind_serialises_as_snake_case_strings() { + for (kind, expected) in [ + (ObservationKind::ToolCall, "tool_call"), + (ObservationKind::Command, "command"), + (ObservationKind::FileDiff, "file_diff"), + (ObservationKind::Error, "error"), + (ObservationKind::Message, "message"), + (ObservationKind::NodeStart, "node_start"), + (ObservationKind::NodeEnd, "node_end"), + (ObservationKind::NodeOutput, "node_output"), + (ObservationKind::NodeError, "node_error"), + ] { + let s = serde_json::to_value(kind).unwrap(); + assert_eq!(s, Value::String(expected.into()), "kind={kind:?}"); + } +} + +#[test] +fn observation_kind_all_lists_every_variant_in_order() { + assert_eq!(ObservationKind::ALL.len(), 9); + assert_eq!(ObservationKind::ALL[0], ObservationKind::ToolCall); + assert_eq!(ObservationKind::ALL[8], ObservationKind::NodeError); +} + +#[test] +fn capsule_open_minimal() { + let c: Capsule = round_trip("capsule_open.json"); + assert_eq!(c.kind, CapsuleType::Session); + assert_eq!(c.status, CapsuleStatus::Open); + assert!(c.closed_at.is_none()); + assert!(c.summary_id.is_none()); + assert_eq!(c.observation_ids.len(), 2); +} + +#[test] +fn capsule_closed_full() { + let c: Capsule = round_trip("capsule_closed.json"); + assert_eq!(c.kind, CapsuleType::PocketflowNode); + assert_eq!(c.status, CapsuleStatus::Closed); + assert_eq!(c.closed_at, Some(1_746_662_460_000)); + assert_eq!( + c.summary_id.as_deref(), + Some("01J8XS7SUMMARY00000000000001") + ); +} + +#[test] +fn capsule_type_field_uses_the_keyword_name_on_the_wire() { + let c = Capsule { + id: "x".into(), + kind: CapsuleType::Session, + intent: "demo".into(), + status: CapsuleStatus::Open, + opened_at: 0, + closed_at: None, + scope_ids: ScopeIds::default(), + observation_ids: vec![], + summary_id: None, + }; + let v = serde_json::to_value(&c).unwrap(); + assert!(v.get("type").is_some(), "expected 'type' key, got {v}"); + assert!(v.get("kind").is_none()); +} + +#[test] +fn summary_round_trip() { + let s: Summary = round_trip("summary.json"); + assert!(is_valid_confidence(s.confidence)); + assert_eq!(s.evidence_refs.len(), 2); +} + +#[test] +fn is_valid_confidence_rejects_out_of_range_and_nan() { + assert!(is_valid_confidence(0.0)); + assert!(is_valid_confidence(0.5)); + assert!(is_valid_confidence(1.0)); + assert!(!is_valid_confidence(-0.1)); + assert!(!is_valid_confidence(1.1)); + assert!(!is_valid_confidence(f64::NAN)); +} + +#[test] +fn pin_round_trip() { + let p: Pin = round_trip("pin.json"); + assert_eq!(p.target_type, PinTargetType::Observation); + assert_eq!(p.expires_at, Some(1_746_748_800_000)); + assert_eq!(p.reason.as_deref(), Some("Critical context for auth flow")); +} + +#[test] +fn pin_active_when_no_expiry_and_when_future_expiry() { + let mut p = Pin { + id: "x".into(), + target_type: PinTargetType::Observation, + target_id: "y".into(), + reason: None, + created_at: 0, + expires_at: None, + scope_ids: ScopeIds::default(), + }; + assert!(is_pin_active(&p, 1_000_000)); + p.expires_at = Some(2_000_000); + assert!(is_pin_active(&p, 1_000_000)); + assert!(!is_pin_active(&p, 2_000_000)); + assert!(!is_pin_active(&p, 3_000_000)); +} + +#[test] +fn retrieve_result_with_observation_pin_and_summary_candidate() { + let r: RetrieveResult = round_trip("retrieve_result.json"); + assert_eq!(r.pins.len(), 1); + match &r.pins[0].target { + RetrievedEntity::Observation(o) => assert_eq!(o.kind, ObservationKind::ToolCall), + RetrievedEntity::Summary(_) => panic!("expected observation in pin target"), + } + assert_eq!(r.candidates.len(), 1); + match &r.candidates[0].entity { + RetrievedEntity::Summary(s) => { + assert!((s.confidence - 0.82).abs() < f64::EPSILON); + } + RetrievedEntity::Observation(_) => panic!("expected summary in candidate"), + } + assert_eq!(r.provenance.provider_used, "local-fts"); + assert_eq!(r.provenance.total_candidates, 12); +} + +#[test] +fn validation_error_with_value() { + let v = ValidationError { + field: "confidence".into(), + message: "out of range".into(), + value: Some(json!(1.5)), + }; + let json = serde_json::to_value(&v).unwrap(); + assert_eq!(json["field"], "confidence"); + assert_eq!(json["value"], 1.5); + let parsed: ValidationError = serde_json::from_value(json).unwrap(); + assert_eq!(parsed, v); +} + +#[test] +fn validation_error_omits_value_when_none() { + let v = ValidationError { + field: "missing".into(), + message: "required".into(), + value: None, + }; + let json = serde_json::to_value(&v).unwrap(); + assert!( + json.get("value").is_none(), + "value must be absent, got: {json}" + ); +} diff --git a/crates/kindling-types/tests/ts_export.rs b/crates/kindling-types/tests/ts_export.rs new file mode 100644 index 0000000..ddf82e0 --- /dev/null +++ b/crates/kindling-types/tests/ts_export.rs @@ -0,0 +1,62 @@ +//! Drives `ts_rs` export so `cargo test -p kindling-types --features ts-rs` +//! writes the TypeScript projection of every public type into +//! `crates/kindling-types/bindings/`. +//! +//! Without the `ts-rs` feature the test compiles to nothing. + +#![cfg(feature = "ts-rs")] + +use kindling_types::*; +use std::path::PathBuf; +use ts_rs::TS; + +fn bindings_dir() -> PathBuf { + let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + p.push("bindings"); + p +} + +#[test] +fn export_all_types_to_bindings_dir() { + // `export_all_to` writes (or overwrites) one file per type. ts-rs handles + // creating the directory if needed. + let dir = bindings_dir(); + let exports: Vec> = vec![ + ScopeIds::export_all_to(&dir), + ValidationError::export_all_to(&dir), + Observation::export_all_to(&dir), + ObservationInput::export_all_to(&dir), + Capsule::export_all_to(&dir), + CapsuleInput::export_all_to(&dir), + Summary::export_all_to(&dir), + SummaryInput::export_all_to(&dir), + Pin::export_all_to(&dir), + PinInput::export_all_to(&dir), + RetrieveOptions::export_all_to(&dir), + RetrieveResult::export_all_to(&dir), + RetrieveProvenance::export_all_to(&dir), + PinResult::export_all_to(&dir), + CandidateResult::export_all_to(&dir), + ProviderSearchOptions::export_all_to(&dir), + ProviderSearchResult::export_all_to(&dir), + ]; + + for (i, r) in exports.into_iter().enumerate() { + if let Err(e) = r { + panic!("ts-rs export #{i} failed: {e}"); + } + } + + // Spot-check that the directory now contains the expected files. + for f in [ + "Observation.ts", + "Capsule.ts", + "Summary.ts", + "Pin.ts", + "RetrieveResult.ts", + "ScopeIds.ts", + ] { + let path = bindings_dir().join(f); + assert!(path.exists(), "expected {} after export", path.display()); + } +} diff --git a/crates/kindling/Cargo.toml b/crates/kindling/Cargo.toml new file mode 100644 index 0000000..c5e6c21 --- /dev/null +++ b/crates/kindling/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "kindling" +description = "Kindling — local memory and continuity engine for AI-assisted development. Single-binary entry point dispatching to serve / hook / CLI subcommands." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true +rust-version.workspace = true + +[[bin]] +name = "kindling" +path = "src/main.rs" + +[lints] +workspace = true diff --git a/crates/kindling/src/main.rs b/crates/kindling/src/main.rs new file mode 100644 index 0000000..72d66d8 --- /dev/null +++ b/crates/kindling/src/main.rs @@ -0,0 +1,8 @@ +//! Kindling — single-binary entry point. +//! +//! Dispatches to `serve`, `hook`, or one of the CLI verbs based on the +//! invoked subcommand. Wired up by PORT-013. + +fn main() { + println!("kindling {}", env!("CARGO_PKG_VERSION")); +} diff --git a/docs/guides/branching-strategy.md b/docs/guides/branching-strategy.md new file mode 100644 index 0000000..316b0f1 --- /dev/null +++ b/docs/guides/branching-strategy.md @@ -0,0 +1,123 @@ +# Branching Strategy + +## Overview + +Kindling uses a two-branch model that supports active development across +multiple parallel streams while keeping releases stable and predictable: + +- `main` is the stable release branch. +- `dev` is the active integration branch. + +The key rule is cadence: `dev` is a short-horizon integration branch, not a +long-lived alternate product line. The model only works if release promotion +is frequent and every `main`-only fix is merged back quickly. + +## Branches + +| Branch | Purpose | Protection | +| -------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------- | +| `main` | Stable branch for npm releases. Always publishable. | PRs only. Full release CI gate. | +| `dev` | Active integration branch for day-to-day work from multiple streams. | PRs required. Standard CI. | +| `release/x.y` or `release/x.y.z` | Temporary release stabilisation branch cut from `dev`. | PRs or maintainer-only pushes during release hardening. | +| `feat/*`, `fix/*`, `docs/*`, `chore/*` | Short-lived work branches created from `dev`. | Disposable. | +| `hotfix/*` | Urgent production fix branch created from `main` or the active release branch. | Disposable. | + +## Workflow + +```text +feat/* ──PR──► dev ──PR──► main +fix/* ──PR──► dev ──PR──► main +docs/* ──PR──► dev ──PR──► main + +dev ──cut──► release/x.y.z ──PR──► main ──merge back──► dev +main ──branch──► hotfix/* ──PR──► main ──merge back──► dev +``` + +## Normal Development + +1. Create feature, fix, docs, and chore branches from `dev`. +2. Merge completed work into `dev` continuously. +3. Keep branches small and short-lived. +4. Use APS plans (in `plans/`) and work-item IDs for planning. Branch structure + should reflect code flow, not roadmap ownership. + +## Release Flow + +1. Promote `dev` to `main` frequently. +2. For low-risk releases, open a direct `dev → main` release PR. +3. For higher-risk releases, cut `release/x.y` or `release/x.y.z` from `dev`. +4. Allow only release hardening on `release/*`: bug fixes, packaging, docs, + changelog, and version bumps. +5. Merge `release/*` into `main`, tag the release, then merge the release branch + back into `dev` immediately. +6. Tagging `vX.Y.Z` on `main` and creating a GitHub Release triggers + `.github/workflows/publish.yml`, which publishes all packages to npm. + +See the [release runbook](release-runbook.md) for the full step-by-step. + +## Hotfix Flow + +1. Branch `hotfix/*` from `main` or the active `release/*` branch. +2. Merge the fix into the release target first. +3. Tag the patch release if needed. +4. Merge the same fix back into `dev` on the same day. + +## Cadence Rules + +1. Promote `dev → main` at least weekly while there is active development. +2. During heavy development, prefer promotion every 2–3 days. +3. Do not allow `release/*` branches to live for weeks. +4. If the `dev → main` PR feels too large to review comfortably, promotion is + already overdue. +5. If a fix lands on `main`, it is not complete until `dev` has it too. + +## Divergence Guardrails + +1. `main` and `dev` must stay close enough that promotion remains routine. +2. Stop queuing new release work if `main...dev` grows beyond a small, + reviewable change set. +3. Avoid long-lived release-only changes on `main`. + +## Branch Naming + +- `feat/recall-skill` +- `fix/plugin-review-feedback` +- `docs/branching-strategy` +- `chore/dependency-bumps` +- `release/0.3.0` +- `hotfix/sqlite-migration-rollback` + +## CI Tiers + +### PRs to `dev` (lightweight) + +- Build +- Type check +- Lint +- Unit tests (Linux, current LTS Node) + +### PRs to `main` (release gate) + +All of the above plus: + +- Cross-platform smoke tests (macOS and Windows) — verifies prebuilt binary + targets before publish. + +### Publish (`publish.yml`) + +- Triggered by GitHub Releases (tag push on `main`). +- Re-runs CI as a gate, then publishes all workspace packages with npm + provenance. + +## Why this model + +Kindling regularly has multiple active streams in flight (DX hardening, plugin +work, Rust port planning, adapter changes). `dev` provides a safe integration +target before release while `main` stays publishable at any time. The process +fails when promotion waits too long, because release fixes accumulate on `main` +and structural work continues on `dev`. + +## Related Docs + +- [Release Runbook](release-runbook.md) +- [Worktree Policy](worktree-policy.md) diff --git a/docs/guides/release-runbook.md b/docs/guides/release-runbook.md new file mode 100644 index 0000000..2d6d4b7 --- /dev/null +++ b/docs/guides/release-runbook.md @@ -0,0 +1,194 @@ +# Kindling Release Runbook + +Purpose: ship Kindling's npm packages safely and consistently. + +Kindling publishes 9 workspace packages to the `@eddacraft/*` scope on npm. All +publishing is automated via `.github/workflows/publish.yml`, which is triggered +by GitHub Releases. + +## Release policy + +- **Distribution:** npm registry, public scope `@eddacraft/*`, with provenance. +- **Trigger:** GitHub Release published from a tag on `main`. +- **Workflow source of truth:** `.github/workflows/publish.yml`. +- **Version source of truth:** root `package.json` `version`. The publish + workflow asserts the release tag matches. + +See the [branching strategy](branching-strategy.md) for the branch model that +this runbook assumes. + +## 1. Preflight (required) + +Run from the repo root with a clean working tree on the latest `dev`: + +```bash +git switch dev && git pull --ff-only origin dev +pnpm install --frozen-lockfile +pnpm run build +pnpm run type-check +pnpm run lint +pnpm run test +``` + +All four checks must pass. If any fails, stop and fix on `dev` before +continuing. + +Sanity assertions before promoting: + +- Root `package.json` version matches the tag you intend to push. +- All workspace packages share the same version (lockstep release). +- `CHANGELOG.md` (if present) has notes for this version. +- README install instructions still work. + +## 2. Promote `dev` to `main` + +All day-to-day work lands on `dev`. Releases are promoted from `dev` into +`main`. For small, low-risk releases, a direct `dev → main` PR is acceptable. +For anything larger, cut a short-lived `release/*` branch from `dev` and do +stabilisation there. + +### Option A: direct promotion (small releases) + +Use this when the change set is small, reviewable, and already stable on `dev`. + +1. Ensure `dev` is green on CI. +2. Open a PR from `dev` to `main`. +3. Title convention: `release: vX.Y.Z`. +4. Once the release gate passes, merge the PR. + +```bash +gh pr create --base main --head dev --title "release: vX.Y.Z" \ + --body "Promote dev to main for release vX.Y.Z" +``` + +### Option B: stabilise on `release/*` (non-trivial releases) + +Use this when you want a short hardening window for packaging, docs, final bug +fixes, or release validation. + +1. Ensure `dev` is green. +2. Create `release/x.y.z` from `dev`. +3. Allow only release hardening on the release branch (version bumps, + changelog, packaging, docs, last-mile fixes). +4. Open a PR from `release/x.y.z` to `main`. +5. Once the release gate passes, merge the PR. + +```bash +git switch dev && git pull --ff-only origin dev +git switch -c release/x.y.z +git push -u origin release/x.y.z + +gh pr create --base main --head release/x.y.z --title "release: vX.Y.Z" \ + --body "Promote release/x.y.z to main for release vX.Y.Z" +``` + +## 3. Bump versions on `dev` + +Do version bumps on `dev` first, then promote. + +1. Bump root `package.json` version. +2. Bump every workspace `packages/*/package.json` version to the same value. +3. Update internal `@eddacraft/kindling-*` dependency ranges to the new + version. +4. Update `CHANGELOG.md` with release notes. +5. Update README install snippets if any pin a version. +6. Commit on `dev`: + + ```bash + git add package.json packages/*/package.json CHANGELOG.md README.md + git commit -m "chore(release): prepare vX.Y.Z" + ``` + +7. Promote `dev → main` per Option A or B above. + +## 4. Tag and create the GitHub Release + +After the `dev → main` (or `release/* → main`) PR is merged: + +```bash +git switch main && git pull --ff-only origin main +git tag -a vX.Y.Z -m "vX.Y.Z" +git push origin vX.Y.Z +``` + +Then create the GitHub Release: + +```bash +gh release create vX.Y.Z \ + --title "vX.Y.Z" \ + --notes-from-tag \ + --target main +``` + +Publishing the GitHub Release triggers `publish.yml`, which: + +1. Re-runs CI as a gate. +2. Validates the tag matches the root `package.json` version. +3. Publishes every workspace package with `pnpm publish -r --access public` + and npm provenance enabled. + +Monitor the workflow until it completes: + +```bash +gh run watch +``` + +## 5. Merge release work back into `dev` + +If you used Option B or made any commits on `main` during release (hotfix, +last-mile fix, tagging-only commits), merge `main` back into `dev` +immediately: + +```bash +git switch dev && git pull --ff-only origin dev +git merge --no-ff main +git push origin dev +``` + +This is non-negotiable. Skipping this step is the most common source of branch +divergence. + +## 6. Verify the release + +- `npm view @eddacraft/kindling version` returns the new version. +- `npx @eddacraft/kindling-cli@latest --version` works. +- The GitHub Release page lists the tag and the publish workflow run is green. + +## Hotfix flow + +If a critical bug is discovered on a published version: + +1. Branch `hotfix/` from `main` (or the active `release/*` if one + exists). +2. Land the fix and bump the patch version. +3. PR `hotfix/* → main`. Merge after CI passes. +4. Tag `vX.Y.Z+1` on `main`, create the GitHub Release. The publish workflow + handles the rest. +5. Merge `main` back into `dev` the same day. + +## Dry-run publishing + +You can test the publish pipeline without releasing: + +```bash +gh workflow run publish.yml -f dry-run=true +``` + +This runs `npm pack --dry-run` for every workspace package and skips the +actual publish step. + +## Troubleshooting + +- **Tag/version mismatch:** the publish workflow refuses to publish when the + release tag doesn't match `package.json`. Bump the version on `dev`, + promote, retag. +- **CI gate fails:** the publish job re-runs CI before pushing to npm. Fix on + `dev`, promote a follow-up release. +- **Partial publish:** if `pnpm publish -r` fails partway through, do not + delete the partial publish. Bump the patch version and re-release the full + set; npm doesn't allow republishing the same version. + +## Related Docs + +- [Branching Strategy](branching-strategy.md) +- [Worktree Policy](worktree-policy.md) diff --git a/docs/guides/worktree-policy.md b/docs/guides/worktree-policy.md new file mode 100644 index 0000000..166455b --- /dev/null +++ b/docs/guides/worktree-policy.md @@ -0,0 +1,117 @@ +# Worktree Policy + +## Overview + +Use git worktrees as lightweight execution spaces for active branches. + +Kindling keeps two permanent anchor worktrees and treats everything else as +disposable. + +## Permanent Worktrees + +Keep exactly two long-lived worktrees: + +1. `main` +2. `dev` + +Suggested directories: + +- `../kindling.main` +- `../kindling.dev` + +These are the stable anchors for release and integration work. + +## Disposable Worktrees + +Create disposable worktrees for active streams only: + +- `feat/*` +- `fix/*` +- `docs/*` +- `chore/*` +- `release/*` +- `hotfix/*` +- short-lived spikes + +Suggested directory pattern: + +- `../wt-` + +Examples: + +- `../wt-recall-skill` +- `../wt-plugin-sqlite` +- `../wt-release-0.3.0` +- `../wt-hotfix-fts-tokenizer` + +## Why disposable is the default + +Disposable worktrees reduce drift and maintenance overhead. + +Permanent feature worktrees tend to accumulate: + +- stale branches +- hidden divergence from `dev` +- rebasing overhead +- unfinished work that feels active but is not moving + +The branch or PR is the unit of work. The worktree is just the workspace. + +## Branch Creation Rules + +1. Create normal work branches from `dev`. +2. Create release branches from `dev`. +3. Create hotfix branches from `main` or the active `release/*` branch. +4. Merge completed work into its target branch, then remove the worktree. + +## Age Limits + +Use these limits as hygiene rules rather than hard technical constraints: + +- feat, fix, docs, chore: target under 5 active days +- release worktree: target under 3 days of stabilisation +- spike worktree: target under 2 days before convert-or-close + +Any disposable worktree older than 7 days should be reviewed and either: + +- merged +- split into smaller branches +- rebased and continued with intent +- closed and removed + +## WIP Limits + +1. Keep no more than 4–5 disposable worktrees open at once. +2. If you reach the limit, do not create another until one is merged, paused, + or removed. +3. If a stream is blocked and you are not returning within 48 hours, remove the + worktree and keep the branch reference only if needed. + +## Cleanup Rules + +Remove disposable worktrees when: + +1. the branch is merged +2. the branch is abandoned +3. the branch is superseded by a replacement branch +4. the branch is blocked with no near-term next action + +Delete merged disposable branches and remove their worktrees on the same day. + +## Review Rhythm + +Review open worktrees at least twice a week. Check for: + +1. merged branches that still have a worktree +2. stale branches with no recent progress +3. branches that should be split or rebased +4. streams that should be promoted into `dev` + +## Relationship to Branching + +Worktree policy supports the [branching strategy](branching-strategy.md): + +1. `main` remains stable. +2. `dev` remains the integration branch. +3. Disposable worktrees support parallel execution without turning every stream + into a permanent branch. diff --git a/package.json b/package.json index a18d3fe..98d6773 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kindling-monorepo", - "version": "0.1.2", + "version": "0.1.3", "type": "module", "private": true, "description": "Local memory and continuity engine for AI-assisted development", diff --git a/packages/kindling-adapter-claude-code/package.json b/packages/kindling-adapter-claude-code/package.json index a379e4c..e7012cb 100644 --- a/packages/kindling-adapter-claude-code/package.json +++ b/packages/kindling-adapter-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-adapter-claude-code", - "version": "0.1.2", + "version": "0.1.3", "description": "Claude Code adapter for Kindling - capture tool calls and session context via hooks for memory continuity", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling-adapter-opencode/package.json b/packages/kindling-adapter-opencode/package.json index 466ce95..c00b010 100644 --- a/packages/kindling-adapter-opencode/package.json +++ b/packages/kindling-adapter-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-adapter-opencode", - "version": "0.1.2", + "version": "0.1.3", "description": "OpenCode session adapter for Kindling - capture tool calls, commands, and file changes from AI coding sessions", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling-adapter-pocketflow/package.json b/packages/kindling-adapter-pocketflow/package.json index cccfe3e..38d6b59 100644 --- a/packages/kindling-adapter-pocketflow/package.json +++ b/packages/kindling-adapter-pocketflow/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-adapter-pocketflow", - "version": "0.1.2", + "version": "0.1.3", "description": "PocketFlow workflow adapter for Kindling - capture node executions with intent and confidence", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling-cli/package.json b/packages/kindling-cli/package.json index 66380c1..ac2d7bf 100644 --- a/packages/kindling-cli/package.json +++ b/packages/kindling-cli/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-cli", - "version": "0.1.2", + "version": "0.1.3", "description": "Command-line interface for Kindling - inspect, search, and manage your local AI memory", "type": "module", "bin": { diff --git a/packages/kindling-core/package.json b/packages/kindling-core/package.json index 9877723..9175418 100644 --- a/packages/kindling-core/package.json +++ b/packages/kindling-core/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-core", - "version": "0.1.2", + "version": "0.1.3", "description": "Core domain model and orchestration for Kindling - local memory engine for AI-assisted development", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling-provider-local/package.json b/packages/kindling-provider-local/package.json index e767857..df2dc8b 100644 --- a/packages/kindling-provider-local/package.json +++ b/packages/kindling-provider-local/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-provider-local", - "version": "0.1.2", + "version": "0.1.3", "description": "Local FTS-based retrieval provider for Kindling with deterministic, explainable ranking", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling-server/package.json b/packages/kindling-server/package.json index b1edac8..5e11fb9 100644 --- a/packages/kindling-server/package.json +++ b/packages/kindling-server/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-server", - "version": "0.1.2", + "version": "0.1.3", "description": "HTTP API server for Kindling - enables multi-agent concurrency", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling-store-sqlite/package.json b/packages/kindling-store-sqlite/package.json index 1d9826d..637e3b1 100644 --- a/packages/kindling-store-sqlite/package.json +++ b/packages/kindling-store-sqlite/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-store-sqlite", - "version": "0.1.2", + "version": "0.1.3", "description": "SQLite persistence layer for Kindling with FTS5 full-text search and WAL mode", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling-store-sqljs/package.json b/packages/kindling-store-sqljs/package.json index f466a95..f668da5 100644 --- a/packages/kindling-store-sqljs/package.json +++ b/packages/kindling-store-sqljs/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-store-sqljs", - "version": "0.1.2", + "version": "0.1.3", "description": "sql.js (WASM) persistence layer for Kindling - browser and cross-platform compatible", "type": "module", "main": "./dist/index.js", diff --git a/packages/kindling/package.json b/packages/kindling/package.json index 1505ef9..7fb10a1 100644 --- a/packages/kindling/package.json +++ b/packages/kindling/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling", - "version": "0.1.2", + "version": "0.1.3", "description": "Local memory and continuity engine for AI-assisted development", "type": "module", "main": "./dist/index.js", diff --git a/plans/index.aps.md b/plans/index.aps.md index 6bc8d0a..2ff83a8 100644 --- a/plans/index.aps.md +++ b/plans/index.aps.md @@ -5,67 +5,74 @@ | Status | In Progress | | Owner | @aneki | | Created | 2026-03-14 | -| Updated | 2026-04-15 | +| Updated | 2026-05-03 | ## Problem -Kindling is functional (596 tests passing, 10 packages building) but not yet published or optimized for production use. The remaining work falls into three phases: get the TypeScript packages published to npm, ship the intent capture primitive, then port the production surface to Rust so Anvil can integrate directly without a TypeScript bridge. +Kindling is functional (596 tests passing, 10 packages building) and the TypeScript packages are published to npm at v0.1.2. The remaining work is to port Kindling to Rust as the **only** implementation. Non-Rust consumers reach Kindling via a long-running local daemon (`kindling serve`) over a Unix domain socket, accessed by a thin TypeScript HTTP client distributed as `@eddacraft/kindling` on npm. The current TypeScript implementation packages are deprecated and removed after the cutover. ## Success Criteria -- [ ] All packages published to npm under `@eddacraft` scope -- [ ] Claude Code plugin installable without Node.js/C++ toolchain -- [ ] Hook invocations complete in <10ms (currently ~50-90ms) -- [ ] Single-binary distribution for Linux, macOS, Windows +- [x] All packages published to npm under `@eddacraft` scope +- [ ] Single statically-linked `kindling` binary distributed via cargo, brew, curl|sh, and npm postinstall +- [ ] `kindling serve` daemon: auto-spawn on first call, idle shutdown after 30 min default, UDS transport (TCP fallback on Windows) +- [ ] All 7 Claude Code hook types complete in <10ms warm, <100ms cold +- [ ] Anvil emits observations directly via `kindling-client` or `kindling-service` — no TS bridge +- [ ] `pnpm add @eddacraft/kindling` installs the binary and exposes a typed thin client with no native deps +- [ ] All deprecated TS implementation packages removed from this repo at `1.0.0` ## Constraints -- Small team — work must be sequenced, not parallelized across milestones -- TypeScript adapters and browser store must remain (npm ecosystem consumers) +- Single-operator project — sole consumer is also the maintainer; no external migration coordination required - Claude Code hook interface (stdin JSON, stdout JSON) must not change -- Existing 596 tests must continue to pass throughout +- Existing 596 tests must continue to pass throughout, until the corresponding TS package is deprecated and removed +- `schema/schema.sql` and `schema/version.json` remain the cross-language schema contract; both implementations read from them during the transition ## Modules -| Module | Purpose | Status | Dependencies | -| --------------------------------------------------------------------- | ------------------------------------------------------- | ---------- | ---------------------- | -| [01-npm-publish](./modules/01-npm-publish.aps.md) | Package metadata, READMEs, publish scripts, CI | Ready | — | -| [02-rust-hook-binary](./modules/02-rust-hook-binary.aps.md) | Rust binary for Claude Code hook invocations | Superseded | by 05 | -| [03-rust-cli](./modules/03-rust-cli.aps.md) | Full Rust CLI replacing Commander.js | Superseded | by 05 | -| [04-intent-capture-events](./modules/04-intent-capture-events.aps.md) | Kindling-native intent event primitive + export | Ready | 01 | -| [04-schema-contract](./modules/04-schema-contract.aps.md) | Cross-language SQLite schema contract for Rust+TS | Done | — | -| [05-rust-port](./modules/05-rust-port.aps.md) | Dual-maintain Rust port; Rust-canonical types via ts-rs | Ready | 01, 04-schema-contract | +| Module | Purpose | Status | Dependencies | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------ | ---------- | ------------------- | +| [01-npm-publish](./modules/01-npm-publish.aps.md) | Package metadata, READMEs, publish scripts, CI | Done | — | +| [02-rust-hook-binary](./modules/02-rust-hook-binary.aps.md) | Rust binary for Claude Code hook invocations | Superseded | by 05 | +| [03-rust-cli](./modules/03-rust-cli.aps.md) | Full Rust CLI replacing Commander.js | Superseded | by 05 | +| [04-intent-capture-events](./modules/04-intent-capture-events.aps.md) | Kindling-native intent event primitive + export | Ready | — | +| [04-schema-contract](./modules/04-schema-contract.aps.md) | Cross-language SQLite schema contract for Rust+TS | Done | — | +| [05-rust-port](./modules/05-rust-port.aps.md) | Rust-canonical Kindling + thin TS client over local daemon (UDS) | Ready | 04-schema-contract | -See `plans/specs/2026-04-15-rust-port-design.md` for the rationale behind superseding 02 and 03 with 05. +See `plans/specs/2026-05-03-rust-canonical-thin-client-design.md` for the current design (daemon, transport, distribution, TS deprecation strategy). The earlier dual-maintain spec at `plans/specs/2026-04-15-rust-port-design.md` is superseded but retained for historical context. ## Schedule -| Phase | Modules | Target | -| ----- | ------------------------ | ---------------------------------------------------------------- | -| Next | 01-npm-publish | Merge open PRs, publish to npm | -| Next | 04-intent-capture-events | Ship intent capture primitive + export | -| Then | 05-rust-port (Phase 1-2) | Foundation crates + hook binary; Anvil unblocks | -| Later | 05-rust-port (Phase 3-4) | CLI + server + distribution; TS packages consume generated types | +| Phase | Modules | Target | +| ------- | ------------------------- | ----------------------------------------------------------------------------------- | +| Now | 05-rust-port (Phase 1) | Foundation crates: workspace, types, store, filter | +| Next | 05-rust-port (Phase 2) | Service + daemon + hook + Rust client; Anvil unblocks | +| Then | 05-rust-port (Phase 3) | CLI + umbrella binary + cross-platform builds + cargo/brew/curl distribution | +| Then | 05-rust-port (Phase 4) | Thin TS client SDK on npm; deprecate TS implementation packages and Anvil bridge | +| Backlog | 04-intent-capture-events | Ship intent capture primitive + export (independent of the Rust port) | ## Risks -| Risk | Impact | Mitigation | -| ------------------------------------------- | ------ | ---------------------------------------------------------- | -| `@eddacraft/kindling` npm scope unavailable | High | Check availability early, have fallback scope | -| Rust cross-compilation edge cases | Medium | Use `cross` or `cargo-zigbuild`, CI matrix for all targets | -| Two build systems (cargo + pnpm) | Medium | Keep Rust binary self-contained, no circular deps | -| TypeScript/Rust JSON schema drift | Medium | Generate TS types from Rust structs via `ts-rs` crate | +| Risk | Impact | Mitigation | +| ----------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- | +| Rust cross-compilation edge cases | Medium | `cargo-zigbuild` from a single Linux runner; CI matrix smoke-tests every target | +| npm postinstall download fails behind corp proxy | Medium | Honour `npm_config_proxy` and standard env vars; document offline binary install | +| Daemon process orphaned / stale PID files pile up | Medium | PID file with stale-PID cleanup on next spawn; `kindling serve --health` for ops | +| Cold-spawn latency exceeds 100ms on slow disks | Low | Measure on dogfood; spool fallback only if measured as a real problem | +| Schema drift between binary and client expectations | Medium | `/v1/health` reports schemaVersion; client checks on first call, fails loud | ## Open Questions -- [ ] Is `@eddacraft/kindling` npm scope claimable? -- [ ] Should `kindling-types` include Anvil-specific observation kinds, or stay generic? (see spec, open question 1 — leaning generic) -- [ ] Does the Rust workspace live at `crates/` in this repo, or in a separate repo? (see spec, open question 2 — leaning `crates/` here) -- [ ] When does `kindling-store-sqljs` start consuming generated types — Phase 1 or Phase 4? (see spec, open question 3) +- [x] Is `@eddacraft/kindling` npm scope claimable? — yes, published at v0.1.2 +- [ ] Single per-user daemon vs. one daemon per project? (spec leans single per-user) +- [ ] Idle shutdown default — 30 min, or longer? (spec leans 30 min, re-tune after dogfooding) +- [ ] Wire format — JSON or MessagePack for v1? (spec leans JSON for debuggability) +- [ ] Hook spool fallback if daemon spawn fails — defer until measured? (spec defers) ## Decisions - **D-001:** ~~Hybrid Rust approach (not full rewrite)~~ — _superseded by D-003_ - **D-002:** ~~Phase the Rust work (hooks first, CLI second)~~ — _superseded by D-003_ -- **D-003:** Dual-maintain Rust + TypeScript with Rust-canonical types — _decided 2026-04-15_ — Rust becomes the source of truth for domain types via `ts-rs`; TS packages continue shipping to npm as a thin projection. Driven by Anvil going nearly 100% Rust, which made the TS bridge the primary integration tax. See `plans/specs/2026-04-15-rust-port-design.md`. +- **D-003:** ~~Dual-maintain Rust + TypeScript with Rust-canonical types~~ — _superseded by D-005_ - **D-004:** Supersede modules 02 and 03 with module 05 — _decided 2026-04-15_ — The hybrid phasing no longer models the work correctly. 02 and 03 remain in the repo for historical reference but are marked Superseded in this index. +- **D-005:** Rust-canonical Kindling with thin TS HTTP client over local daemon — _decided 2026-05-03_ — Rust becomes the only implementation. Non-Rust consumers reach Kindling via `kindling serve` (long-running per-user daemon) over a Unix domain socket. `@eddacraft/kindling` is repurposed as a thin HTTP client with an npm postinstall that downloads the platform binary. All other TS implementation packages are deprecated and removed after the cutover. Driven by: sole-operator project means no external migration coordination, every realistic TS consumer can hit a localhost daemon, dual-maintain pays a real tax for a use case nobody asked for. See `plans/specs/2026-05-03-rust-canonical-thin-client-design.md`. diff --git a/plans/modules/01-npm-publish.aps.md b/plans/modules/01-npm-publish.aps.md index e74a55e..730287a 100644 --- a/plans/modules/01-npm-publish.aps.md +++ b/plans/modules/01-npm-publish.aps.md @@ -2,7 +2,7 @@ | ID | Owner | Status | | ------- | ------ | ------ | -| PUBLISH | @aneki | Ready | +| PUBLISH | @aneki | Done | ## Purpose diff --git a/plans/modules/05-rust-port.aps.md b/plans/modules/05-rust-port.aps.md index f393ef0..1515ed6 100644 --- a/plans/modules/05-rust-port.aps.md +++ b/plans/modules/05-rust-port.aps.md @@ -6,67 +6,70 @@ ## Purpose -Port Kindling's production surface to Rust as a dual-maintain project alongside the existing TypeScript packages, with Rust as the canonical source of truth for domain types. Replace the awkward TypeScript bridge between Anvil (nearly 100% Rust) and Kindling with direct Rust-to-Rust integration, and ship a single statically-linked `kindling` binary that covers hooks, CLI, and HTTP server. +Port Kindling to Rust as the **only** implementation. Non-Rust consumers reach Kindling via a long-running local daemon (`kindling serve`) over a Unix domain socket, accessed by a thin TypeScript HTTP client distributed as `@eddacraft/kindling` on npm. The current TypeScript implementation packages are deprecated and removed after the cutover. -Supersedes `02-rust-hook-binary` and `03-rust-cli`. Full rationale in `plans/specs/2026-04-15-rust-port-design.md`. +Supersedes `02-rust-hook-binary` and `03-rust-cli`. Replaces the dual-maintain plan from spec `2026-04-15-rust-port-design.md`. Full rationale and design in `plans/specs/2026-05-03-rust-canonical-thin-client-design.md`. ## In Scope -- Nine-crate Rust workspace (`kindling-types`, `-store`, `-provider`, `-service`, `-filter`, `-hook`, `-server`, `-cli`, umbrella `kindling`) -- Rust as the source of truth for domain types; `ts-rs` generates TypeScript `.d.ts` -- All 7 Claude Code hook types handled by `kindling-hook` (stdin/stdout JSON contract unchanged) +- 10-crate Rust workspace — workspace root `Cargo.toml` at the repo root, member crates under `crates/` (`kindling-types`, `-store`, `-filter`, `-provider`, `-service`, `-server`, `-client`, `-hook`, `-cli`, umbrella `kindling`) +- Single statically-linked `kindling` binary covering hook, CLI, and daemon +- `kindling serve` long-running daemon: Unix domain socket transport (TCP fallback on Windows), per-user single instance, idle shutdown, project routing, WAL-mode SQLite +- HTTP/1 over UDS API (v1) covering capsules, observations, retrieval, pins, health +- All 7 Claude Code hook types handled by `kindling hook` as a thin client to the daemon - All 12 CLI commands in `kindling-cli` (clap) -- HTTP API server in `kindling-server` (axum, same endpoints as Fastify) -- FTS5 retrieval with BM25 normalization (tiered: pins → summary → candidates) -- Content filtering (secret masking, truncation) -- Cross-platform release binaries (Linux x86_64/aarch64/musl, macOS x86_64/aarch64, Windows x86_64) -- Distribution via `cargo install kindling`, Homebrew tap, and `curl | sh` install script -- Direct Rust-to-Rust Anvil integration (`use kindling_service::KindlingService`) -- Deprecation and removal of `@eddacraft/anvil-kindling-integration` after Anvil cuts over +- FTS5 retrieval with BM25 normalisation (tiered: pins → summary → candidates) +- Server-side content filtering (secret masking, truncation) +- Cross-platform release binaries: Linux (x86_64/aarch64, gnu + musl), macOS (x86_64/aarch64), Windows (x86_64) +- Distribution: `cargo install kindling`, Homebrew tap, `curl | sh` script, **npm postinstall** via `@eddacraft/kindling` +- Thin TS client SDK: HTTP-over-UDS client + auto-spawn + types generated by `ts-rs`, shipped as the new `@eddacraft/kindling` +- Direct Rust-to-Rust Anvil integration (`use kindling_service::*` in-process or `use kindling_client::*` via daemon) +- Deprecation and removal of the existing TS implementation packages and the Anvil TS bridge ## Out of Scope - Semantic search / embeddings (future work) -- Browser WASM store rewrite — `@eddacraft/kindling-store-sqljs` stays TypeScript -- Adapter packages (OpenCode, PocketFlow) — remain TypeScript consumers -- Removing the TypeScript npm surface — `@eddacraft/kindling-core` continues shipping, consuming generated types -- Schema migrations in Rust — TypeScript store remains the migration author; Rust implements against `schema/schema.sql` +- Browser / WASM consumer surface — `@eddacraft/kindling-store-sqljs` is deprecated and removed; revisit only when a real consumer asks +- Multi-user / shared-host daemon scenarios — v1 is localhost, single user, filesystem-permission auth +- Schema migrations in Rust — `schema/schema.sql` and `schema/version.json` remain the cross-language contract; Rust implements against them +- New domain features — this is a port + transport + distribution change, no new observation kinds or retrieval semantics ## Interfaces **Depends on:** -- `01-npm-publish` — stable npm surface so TS consumers have something to depend on while Rust catches up -- `04-schema-contract` (Done) — `schema/schema.sql` and `schema/version.json` are the cross-language contract both implementations read -- `schema/version.json` `PRAGMA user_version = 5` — Rust checks compat at startup +- `04-schema-contract` (Done) — `schema/schema.sql` and `schema/version.json` are the canonical schema; Rust checks compatibility at startup +- `01-npm-publish` (Done) — `@eddacraft/kindling` v0.1.2 already on npm; the new thin-client release ships as `0.2.0` **Exposes:** -- `crates/` workspace at repo root, built with `cargo build --release` -- `kindling` binary (single statically-linked artifact) — `kindling-hook`, `kindling`, `kindling serve` subcommands -- Generated TypeScript types at `packages/kindling-core/src/generated/` from `ts-rs` -- `cargo install kindling` and Homebrew formula +- Cargo workspace — root `Cargo.toml` at the repo root, member crates under `crates/` — built with `cargo build --workspace --release` from the repo root +- `kindling` binary (single statically-linked artifact) — `kindling serve`, `kindling hook`, `kindling ` +- `@eddacraft/kindling` (thin client, postinstall-binary) on npm +- Generated TypeScript types via `ts-rs`, bundled into `@eddacraft/kindling` +- `cargo install kindling`, Homebrew formula, `curl -sSL install.kindling.dev | sh` **Supersedes:** -- `02-rust-hook-binary` — Phase 2 of this module absorbs HOOK-001..HOOK-008 -- `03-rust-cli` — Phase 3 of this module absorbs CLI-001..CLI-005 +- `02-rust-hook-binary` +- `03-rust-cli` +- `plans/specs/2026-04-15-rust-port-design.md` (dual-maintain design) ## Ready Checklist - [x] Purpose and scope are clear - [x] Dependencies identified -- [x] Design spec written (`plans/specs/2026-04-15-rust-port-design.md`) +- [x] Design spec written (`plans/specs/2026-05-03-rust-canonical-thin-client-design.md`) - [x] Tasks broken down by phase ## Phases -| Phase | Tasks | Outcome | -| ----- | ------------- | ------------------------------------------------------------------- | -| 1 | PORT-001..004 | Foundation: workspace, types, store, filter | -| 2 | PORT-005..010 | Service + Hook: Anvil unblocks, hook binary ships | -| 3 | PORT-011..014 | CLI + Server: single `kindling` binary distributed everywhere | -| 4 | PORT-015..017 | Type bridge: TS packages consume generated types; TS bridge retired | +| Phase | Tasks | Outcome | +| ----- | ------------- | --------------------------------------------------------------------------------------------------- | +| 1 | PORT-001..004 | Foundation: workspace, types, store, filter | +| 2 | PORT-005..011 | Service + daemon + hook + Rust client; Anvil unblocks | +| 3 | PORT-012..015 | CLI + umbrella binary + cross-platform builds + native distribution (cargo, brew, curl) | +| 4 | PORT-016..020 | Thin TS client SDK + npm postinstall release; deprecate TS implementation packages and Anvil bridge | ## Tasks @@ -74,141 +77,165 @@ Supersedes `02-rust-hook-binary` and `03-rust-cli`. Full rationale in `plans/spe #### PORT-001: Rust workspace scaffold -- **Intent:** Cargo workspace initialized with the 9 crates and CI baseline -- **Expected Outcome:** `crates/` directory at repo root with `Cargo.toml` workspace manifest; all 9 crate skeletons compile (`cargo build --workspace`); `.github/workflows/rust.yml` runs `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo test --workspace` on every push +- **Intent:** Cargo workspace initialised with the 10 crates and CI baseline +- **Expected Outcome:** `crates/` directory at repo root with `Cargo.toml` workspace manifest; all 10 crate skeletons compile (`cargo build --workspace`); `.github/workflows/rust.yml` runs `cargo fmt --check`, `cargo clippy -- -D warnings`, and `cargo test --workspace` on every push - **Validation:** `cargo build --workspace --release` succeeds locally and in CI -- **Status:** Ready +- **Status:** Done — landed on `feat/rust-port-scaffold`. Local verification: build + fmt + clippy + test all green. CI workflow runs on push/PR to dev/main. #### PORT-002: kindling-types crate - **Intent:** Canonical Rust definitions for `Observation`, `Capsule`, `Retrieval`, `ScopeIds`, `Id`, `Timestamp`, `Result` with `ts-rs` derives -- **Expected Outcome:** Types in `kindling-types` match shapes in `packages/kindling-core/src/types/*.ts`; `#[derive(TS)]` produces `.d.ts` output that round-trips JSON with TS originals -- **Validation:** `cargo test -p kindling-types` passes including round-trip tests against sample JSON fixtures; `ts-rs` export runs clean -- **Status:** Ready +- **Expected Outcome:** Types in `kindling-types` match shapes in `packages/kindling-core/src/types/*.ts`; `#[derive(TS)]` produces `.d.ts` output that round-trips JSON with the existing TS shapes; export task wired up +- **Validation:** `cargo test -p kindling-types` passes including round-trip tests against sample JSON fixtures; `cargo test -p kindling-types --features ts-rs` writes `.d.ts` files cleanly +- **Status:** Done — `feat/rust-port-types`. 16 round-trip tests + 22 ts-rs derive tests + 1 export test pass; bindings written to `crates/kindling-types/bindings/` and committed; CI gate `ts-bindings` job fails on drift. `Result` excluded as it's a TS control-flow shape — Rust uses native `std::result::Result`. `Timestamp` is the only integer wider than `i32` in public types (an `i64` alias for epoch ms); every field that uses it carries a `#[ts(type = "number")]` override so the TS projection emits `number`, not `bigint`, matching the existing wire format. - **Dependencies:** PORT-001 #### PORT-003: kindling-store crate -- **Intent:** SQLite persistence layer implemented in Rust against `schema/schema.sql` +- **Intent:** SQLite persistence layer in Rust against `schema/schema.sql` - **Expected Outcome:** `rusqlite` with `bundled` feature; reads `PRAGMA user_version` and asserts compatibility with `schema/version.json`; supports open/close capsules, append observation, attach observation to capsule, insert pin/unpin, and all FTS-indexed writes; WAL mode enabled; per-project database isolation (`~/.kindling/projects//`) -- **Validation:** `cargo test -p kindling-store` passes integration tests against a temp database; a database created by the TypeScript store is readable by the Rust store (golden file test) +- **Validation:** `cargo test -p kindling-store` passes integration tests against a temp database; a database created by the existing TypeScript store is readable by the Rust store (golden file test) - **Status:** Ready - **Dependencies:** PORT-002, schema contract (module 04, Done) #### PORT-004: kindling-filter crate -- **Intent:** Content filtering (secret masking, truncation, excluded-path filtering) matching Node.js behavior byte-for-byte +- **Intent:** Content filtering (secret masking, truncation, excluded-path filtering) matching Node.js behaviour byte-for-byte - **Expected Outcome:** API keys, tokens, and passwords redacted with the same patterns as the Node.js filter; content truncated at the same limits; excluded paths filtered using the same rules - **Validation:** `cargo test -p kindling-filter` passes filter tests with known secret patterns; snapshot tests compare filter output against Node.js fixtures - **Status:** Ready - **Dependencies:** PORT-001 -### Phase 2 — Service + Hook +### Phase 2 — Service + Daemon + Hook #### PORT-005: kindling-provider crate -- **Intent:** Local FTS retrieval provider with BM25 normalization and tiered retrieval -- **Expected Outcome:** FTS5 search with BM25 scoring normalized to [0, 1]; tiered retrieval (pins → current summary → ranked candidates); deterministic ordering; `RetrieveResult` shape matches the TS provider -- **Validation:** `cargo test -p kindling-provider` passes; identical queries against the same database produce the same ranked results in Rust and TS (cross-implementation parity test) +- **Intent:** Local FTS retrieval provider with BM25 normalisation and tiered retrieval +- **Expected Outcome:** FTS5 search with BM25 scoring normalised to [0, 1]; tiered retrieval (pins → current summary → ranked candidates); deterministic ordering; `RetrieveResult` shape matches the Rust types crate +- **Validation:** `cargo test -p kindling-provider` passes; identical queries against the same database produce the same ranked results as the existing TS provider (cross-implementation parity test, retired after Phase 4) - **Status:** Ready - **Dependencies:** PORT-003 -#### PORT-006: kindling-service crate +#### PORT-006: kindling-service crate (in-process API) -- **Intent:** Full orchestration layer — `openCapsule`, `closeCapsule`, `appendObservation`, `retrieve`, `pin`, `unpin` — available as a library -- **Expected Outcome:** `KindlingService::new(config)` returns a service handle; all six methods behave identically to `@eddacraft/kindling-core`'s `KindlingService`; errors propagate via the Result type pattern -- **Validation:** `cargo test -p kindling-service` passes; contract tests comparing service outputs against the TS service for identical inputs +- **Intent:** Full orchestration layer — `open_capsule`, `close_capsule`, `append_observation`, `retrieve`, `pin`, `unpin` — available as a Rust library for direct in-process use (Anvil headless workflows, daemon, CLI) +- **Expected Outcome:** `KindlingService::new(config)` returns a service handle; all six methods behave identically to `@eddacraft/kindling-core`'s `KindlingService`; errors propagate via the Result type pattern; server-side filtering applied at the service boundary +- **Validation:** `cargo test -p kindling-service` passes; contract tests comparing service outputs against the TS service for identical inputs (retired after Phase 4) - **Status:** Ready - **Dependencies:** PORT-003, PORT-004, PORT-005 -#### PORT-007: kindling-hook crate +#### PORT-007: kindling-server crate (UDS daemon) -- **Intent:** All 7 Claude Code hook types (session-start, post-tool-use, post-tool-use-failure, user-prompt-submit, subagent-stop, pre-compact, stop) handled via stdin JSON -- **Expected Outcome:** Binary reads Claude Code hook context from stdin, performs the correct DB operation via `kindling-service`, returns JSON response on stdout matching the Node.js script contract exactly -- **Validation:** Integration tests pipe JSON fixtures through the binary and verify DB state; hook latency <10ms measured over the 7 hook types +- **Intent:** Long-running local daemon serving the v1 HTTP API over a Unix domain socket +- **Expected Outcome:** `kindling serve` listens on `~/.kindling/kindling.sock` (mode `0600`) with TCP fallback on Windows; axum routes for `/v1/health`, `/v1/capsules`, `/v1/observations`, `/v1/retrieve`, `/v1/pins`; per-project DB routing via `X-Kindling-Project` header or body field; PID file with stale-PID cleanup; idle shutdown after configurable timeout (default 30 min) +- **Validation:** Integration tests hit the daemon over UDS with `hyper`; concurrent writes from two clients land cleanly under WAL mode; stale-PID cleanup test verifies a previous crashed daemon doesn't block a fresh spawn - **Status:** Ready - **Dependencies:** PORT-006 -#### PORT-008: Context injection +#### PORT-008: kindling-client crate (Rust HTTP-over-UDS client) -- **Intent:** SessionStart and PreCompact hooks inject prior context -- **Expected Outcome:** SessionStart returns pins + recent observations in the injected context JSON; PreCompact returns pins + latest summary; structure identical to Node.js hook output -- **Validation:** Integration test verifies injected context JSON structure matches Node.js output byte-for-byte on identical fixtures +- **Intent:** Rust client library for talking to the daemon — used by the hook subcommand, optionally by the CLI, and by Anvil for concurrent-safe integration +- **Expected Outcome:** `Client::new()` auto-detects the default socket path; on `ECONNREFUSED` or missing socket, `exec`s `kindling serve --daemonize` and polls for up to 1s; methods mirror `kindling-service` API (`open_capsule`, `append_observation`, `retrieve`, `pin`, `unpin`, plus `health`); `health` checks `schemaVersion` against the client's expected version and fails loud on mismatch +- **Validation:** Integration tests cover cold-spawn, warm-call, schema-mismatch, and connection-refused-without-binary paths; cold-spawn latency measured under 100ms on a typical dev machine - **Status:** Ready - **Dependencies:** PORT-007 -#### PORT-009: Cross-platform CI hook builds +#### PORT-009: kindling-hook crate -- **Intent:** Hook binary builds for all target platforms from Phase 2 onward -- **Expected Outcome:** GitHub Actions produces release artifacts for Linux (x86_64, aarch64, musl), macOS (x86_64, aarch64), Windows (x86_64); `cross` or `cargo-zigbuild` handles cross-targets; artifacts are downloadable from the release page -- **Validation:** CI matrix green; each artifact runs `kindling-hook --version` successfully on the target platform +- **Intent:** All 7 Claude Code hook types (session-start, post-tool-use, post-tool-use-failure, user-prompt-submit, subagent-stop, pre-compact, stop) handled via stdin JSON, dispatched through `kindling-client` +- **Expected Outcome:** `kindling hook ` reads Claude Code hook context from stdin, calls the daemon via `kindling-client`, returns JSON response on stdout matching the Node.js script contract exactly; SessionStart and PreCompact return injected context (pins + recent observations / pins + latest summary) +- **Validation:** Integration tests pipe JSON fixtures through the binary and verify daemon DB state; injected context JSON byte-for-byte matches Node.js output on identical fixtures; warm-hook latency <10ms measured over the 7 hook types; cold-hook (with daemon spawn) <100ms - **Status:** Ready -- **Dependencies:** PORT-007 +- **Dependencies:** PORT-008 + +#### PORT-010: Cross-platform CI hook builds + +- **Intent:** Hook (and full binary) builds for all target platforms from Phase 2 onward +- **Expected Outcome:** GitHub Actions produces release artefacts for Linux (x86_64, aarch64, both gnu + musl), macOS (x86_64, aarch64), Windows (x86_64); `cargo-zigbuild` handles cross-targets from a single Linux runner; artefacts attached to GitHub Releases +- **Validation:** CI matrix green; each artefact runs `kindling --version` successfully on the target platform (smoke-test step in CI) +- **Status:** Ready +- **Dependencies:** PORT-009 -#### PORT-010: Anvil integration proof +#### PORT-011: Anvil integration proof -- **Intent:** Demonstrate direct Rust-to-Rust integration from Anvil without the TS bridge -- **Expected Outcome:** One Anvil crate (pick the observation emitter that would otherwise call the TS bridge) depends on `kindling-service` and `kindling-types`, emits an observation directly to the Kindling database, and matches the output produced by the TS bridge for the same input +- **Intent:** Demonstrate direct Rust-to-Rust integration from Anvil with no TS bridge involvement +- **Expected Outcome:** One Anvil crate (pick the observation emitter that would otherwise call the TS bridge) depends on `kindling-client` (daemon path) and emits an observation directly to the Kindling daemon; output matches what the TS bridge produces for the same input - **Validation:** Parity test in Anvil comparing TS-bridge-emitted observation vs. Rust-direct-emitted observation for the same input; both land identically in the Kindling database - **Status:** Ready -- **Dependencies:** PORT-006 +- **Dependencies:** PORT-008 -### Phase 3 — CLI + Server +### Phase 3 — CLI + Distribution -#### PORT-011: kindling-cli crate +#### PORT-012: kindling-cli crate - **Intent:** All 12 CLI commands via `clap` (init, log, capsule open/close, status, search, list, pin, unpin, export, import, serve) -- **Expected Outcome:** `kindling status`, `kindling search`, `kindling list`, etc. all work with both JSON and text output modes; flags and output shapes match the Commander.js CLI -- **Validation:** Integration tests for each command; snapshot tests comparing JSON output against the TS CLI for identical inputs +- **Expected Outcome:** Commands work with both JSON and text output modes; flags and output shapes match the Commander.js CLI; commands default to in-process service (`kindling-service`) but `--via-daemon` switches to `kindling-client` for safe concurrent operation +- **Validation:** Integration tests for each command; snapshot tests comparing JSON output against the existing TS CLI for identical inputs (retired after Phase 4) - **Status:** Ready -- **Dependencies:** PORT-006 - -#### PORT-012: kindling-server crate - -- **Intent:** HTTP API server in `axum` with the same endpoints as the Fastify server -- **Expected Outcome:** `kindling serve` starts the HTTP API on `127.0.0.1:8080`; every route from `@eddacraft/kindling-server` is implemented with identical request/response shapes -- **Validation:** Existing API integration tests (from `packages/kindling-server/`) pass against the Rust server; contract test hits both servers and compares responses -- **Status:** Ready -- **Dependencies:** PORT-006 +- **Dependencies:** PORT-006, PORT-008 #### PORT-013: Umbrella `kindling` binary -- **Intent:** Single binary entry point that dispatches to hook, CLI, or server based on subcommand or invocation name -- **Expected Outcome:** One artifact: `kindling hook`, `kindling `, `kindling serve`; symlink-aware so `kindling-hook` continues to work as a drop-in replacement for the Node.js hook scripts +- **Intent:** Single binary entry point that dispatches to hook, CLI, or server based on subcommand +- **Expected Outcome:** One artefact: `kindling serve`, `kindling hook `, `kindling `; symlink-aware so `kindling-hook` continues to work as a drop-in name for hook scripts if needed - **Validation:** Single binary size under 20 MB stripped; all three surfaces tested via the umbrella binary - **Status:** Ready -- **Dependencies:** PORT-007, PORT-011, PORT-012 +- **Dependencies:** PORT-009, PORT-012 -#### PORT-014: Distribution +#### PORT-014: Native distribution channels -- **Intent:** Multiple install paths for the unified binary -- **Expected Outcome:** `cargo install kindling` publishes to crates.io; Homebrew tap at `eddacraft/kindling` with formula; `curl -sSL install.kindling.dev | sh` installs the latest release binary for the detected platform; plugin `hooks.json` updated to call the installed binary +- **Intent:** Install paths for Rust-native and platform-native consumers +- **Expected Outcome:** `cargo install kindling` publishes to crates.io; Homebrew tap at `eddacraft/tap` with formula; `curl -sSL install.kindling.dev | sh` installs the latest release binary for the detected platform (Linux/macOS); install script verifies SHA256 against the GitHub Release manifest - **Validation:** Fresh install on Linux, macOS, and Windows via each method; post-install Claude Code session exercises all 7 hook types end-to-end - **Status:** Draft -- **Dependencies:** PORT-009, PORT-013 +- **Dependencies:** PORT-010, PORT-013 + +#### PORT-015: Plugin hook cutover -### Phase 4 — Type bridge + deprecation +- **Intent:** `plugins/kindling-claude-code` invokes the Rust binary instead of the Node.js hook scripts +- **Expected Outcome:** Plugin `hooks.json` calls `kindling hook ` (resolved via PATH or bundled binary location); Node.js hook scripts removed from the plugin; plugin install instructions updated +- **Validation:** Fresh plugin install on a clean machine: hooks fire end-to-end via the Rust binary; observation rows present in the per-project DB +- **Status:** Draft +- **Dependencies:** PORT-014 -#### PORT-015: ts-rs export pipeline +### Phase 4 — Thin TS Client + Deprecation -- **Intent:** Generated TypeScript type definitions available for the npm packages to consume -- **Expected Outcome:** `cargo test -p kindling-types --features ts-rs` writes `.d.ts` files to `packages/kindling-core/src/generated/`; generated files are committed; CI fails if generated output drifts from the committed files -- **Validation:** CI job re-runs `ts-rs` and `git diff --exit-code packages/kindling-core/src/generated/` — passes if no drift +#### PORT-016: ts-rs export pipeline + +- **Intent:** Generated TypeScript type definitions available for the thin TS client to consume +- **Expected Outcome:** `cargo test -p kindling-types --features ts-rs` writes `.d.ts` files to a stable location (`crates/kindling-types/bindings/`); a copy step bundles them into `packages/kindling/src/generated/`; CI fails if generated output drifts from the committed files +- **Validation:** CI job re-runs `ts-rs` and `git diff --exit-code` on generated files — passes if no drift - **Status:** Draft - **Dependencies:** PORT-002 -#### PORT-016: TypeScript core refactor +#### PORT-017: `@eddacraft/kindling` thin client SDK + +- **Intent:** Repurpose the primary npm package as the thin HTTP-over-UDS client + auto-spawn + generated types +- **Expected Outcome:** `packages/kindling/` rewritten — removes better-sqlite3, FTS, server, CLI; adds `Kindling` client class with the same method shape as the old service (`openCapsule`, `closeCapsule`, `appendObservation`, `retrieve`, `pin`, `unpin`); auto-spawns daemon on first call; uses generated types from PORT-016; ships with no native dependencies +- **Validation:** `pnpm test` in `packages/kindling/` passes against a running daemon; integration test spawns daemon from a clean state on first call; published package contains no native modules +- **Status:** Draft +- **Dependencies:** PORT-013, PORT-016 + +#### PORT-018: npm postinstall binary download + +- **Intent:** `pnpm add @eddacraft/kindling` puts the platform binary in place automatically +- **Expected Outcome:** Postinstall script detects platform, downloads the matching `kindling` binary from the GitHub Release matching the package version, places it in `node_modules/@eddacraft/kindling/bin/`; if the binary is already on PATH, postinstall is a no-op; honours `npm_config_proxy` and standard env vars; offline / failed-download case warns and defers (client throws on first call with a clear error pointing at install docs) +- **Validation:** Fresh `pnpm add` in a clean project on Linux/macOS/Windows installs binary and exposes a working client; offline test confirms warn-and-defer behaviour with a clear error message on first call +- **Status:** Draft +- **Dependencies:** PORT-014, PORT-017 + +#### PORT-019: Adapter packages cutover -- **Intent:** `@eddacraft/kindling-core` becomes a thin wrapper over generated types -- **Expected Outcome:** Domain types in `kindling-core/src/types/` re-export from `src/generated/`; validation helpers stay hand-written; public API unchanged for downstream adapter consumers; `kindling-store-sqljs` consumes generated types for in-browser round-tripping -- **Validation:** `pnpm run type-check` and `pnpm run test` pass across all TS packages; adapter packages compile without changes +- **Intent:** Existing TS adapter packages (`-claude-code`, `-opencode`, `-pocketflow`) consume the new thin client +- **Expected Outcome:** Each adapter's dependency on `@eddacraft/kindling-core` (and any direct store/provider deps) replaced with the new `@eddacraft/kindling` thin client; adapter code calls daemon-backed APIs; adapters published as compatible minor versions +- **Validation:** Each adapter's existing test suite passes against a running daemon; example apps in each adapter README run end-to-end - **Status:** Draft -- **Dependencies:** PORT-015 +- **Dependencies:** PORT-017 -#### PORT-017: Anvil TS bridge deprecation +#### PORT-020: Deprecate and remove TS implementation packages -- **Intent:** Remove the TypeScript bridge after Anvil crates cut over to `kindling-service` -- **Expected Outcome:** Anvil's observation emitters across all relevant crates use `kindling_service::KindlingService` directly; `@eddacraft/anvil-kindling-integration` package is marked deprecated on npm with a terminal warning on install; eventual removal tracked in the Anvil repo -- **Validation:** Anvil builds without depending on `@eddacraft/anvil-kindling-integration`; a full EddaCraft dev session emits observations end-to-end via Rust-only path +- **Intent:** Retire the TS implementation surface that the Rust daemon now owns +- **Expected Outcome:** `@eddacraft/kindling-core`, `-store-sqlite`, `-store-sqljs`, `-provider-local`, `-server`, `-cli` published with a `console.warn` deprecation in their entry point and an `npm deprecate` notice pointing at `@eddacraft/kindling`; Anvil TS bridge `@eddacraft/anvil-kindling-integration` deprecated and removed in tandem with the Anvil cutover (tracked in EddaCraft repo); package source removed from this repo at `1.0.0` cut +- **Validation:** `npm view ` shows deprecation notice for each retired package; this repo no longer contains the deprecated package source after the `1.0.0` tag; Anvil builds without `@eddacraft/anvil-kindling-integration` - **Status:** Draft -- **Dependencies:** PORT-010, PORT-016 +- **Dependencies:** PORT-019, PORT-011 diff --git a/plans/specs/2026-04-15-rust-port-design.md b/plans/specs/2026-04-15-rust-port-design.md index d4c81e7..332677e 100644 --- a/plans/specs/2026-04-15-rust-port-design.md +++ b/plans/specs/2026-04-15-rust-port-design.md @@ -1,12 +1,16 @@ # Rust Port — Design Spec -| Field | Value | -| ---------- | ------------------------------------ | -| Status | Ready for review | -| Owner | @aneki | -| Created | 2026-04-15 | -| Supersedes | `02-rust-hook-binary`, `03-rust-cli` | -| APS Module | `plans/modules/05-rust-port.aps.md` | +| Field | Value | +| ------------ | -------------------------------------------------------------- | +| Status | Superseded | +| Owner | @aneki | +| Created | 2026-04-15 | +| Superseded | 2026-05-03 | +| Superseded by | `plans/specs/2026-05-03-rust-canonical-thin-client-design.md` | +| Supersedes | `02-rust-hook-binary`, `03-rust-cli` | +| APS Module | `plans/modules/05-rust-port.aps.md` | + +> **Note (2026-05-03):** This spec proposed *dual-maintain* Rust + TS with Rust-canonical types via `ts-rs`. After review, the plan changed: Rust becomes the **only** implementation, accessed by non-Rust consumers via a long-running local daemon (`kindling serve`) over a Unix domain socket. The TypeScript surface collapses to a single thin HTTP client package. See the superseding spec for the daemon model, transport, distribution, and TS package deprecation strategy. ## Context diff --git a/plans/specs/2026-05-03-rust-canonical-thin-client-design.md b/plans/specs/2026-05-03-rust-canonical-thin-client-design.md new file mode 100644 index 0000000..6a43e49 --- /dev/null +++ b/plans/specs/2026-05-03-rust-canonical-thin-client-design.md @@ -0,0 +1,214 @@ +# Rust-Canonical Kindling with Thin TS Client — Design Spec + +| Field | Value | +| ---------- | ----------------------------------------------------------- | +| Status | Decided | +| Owner | @aneki | +| Created | 2026-05-03 | +| Supersedes | `plans/specs/2026-04-15-rust-port-design.md` (dual-maintain) | +| APS Module | `plans/modules/05-rust-port.aps.md` | + +## Context + +The previous spec (2026-04-15) committed to dual-maintaining Rust and TypeScript implementations of Kindling, with Rust as the canonical type source via `ts-rs`. That assumed the TypeScript implementation had real consumers worth keeping. + +Reassessment of the consumer set: + +- **Anvil** is nearly 100% Rust. Native Rust integration is the goal, no TS bridge. +- **Other planned consumers** are TypeScript projects owned by the same operator. They need *access* to Kindling, not in-process embedding — an HTTP client over a local socket is sufficient for every realistic use case. +- **Browser / sql.js consumers** do not exist. The `@eddacraft/kindling-store-sqljs` package was speculative. +- **Adapter packages** (Claude Code, OpenCode, PocketFlow) are thin wrappers — they translate framework events into observation calls. They can wrap an HTTP client just as easily as an in-process service. + +Dual-maintain pays a real tax (schema sync, type drift risk, two test suites, two CI matrices) for a use case nobody asked for. Collapse to one implementation. + +## Decision + +**Rust is the only implementation.** Non-Rust consumers reach Kindling via a long-running local daemon (`kindling serve`) over a Unix domain socket (Linux/macOS) or localhost TCP (Windows). + +- One binary: `kindling`. Subcommands: `serve`, `hook`, plus the CLI verbs (`search`, `list`, `pin`, …). +- One npm package: `@eddacraft/kindling`. Postinstall downloads the platform binary; module exports a thin HTTP client. +- All existing TS implementation packages (`-store-sqlite`, `-store-sqljs`, `-provider-local`, `-server`, `-cli`, `-core`) are deprecated and removed from the npm registry after the cutover. + +`ts-rs` stays in the picture, but only to generate `.d.ts` types for the thin TS client — not to keep two implementations aligned. + +## Approaches Considered + +| Approach | TS consumer story | Maintenance | Distribution | +| --------------------------------------------------------- | ------------------------------------------ | --------------- | --------------------------------------------- | +| **(A) Rust-canonical, thin TS HTTP client** ✅ | `pnpm add @eddacraft/kindling` → daemon spawned on first call | One implementation | Single binary + thin npm wrapper | +| (B) Dual-maintain Rust + TS (prior spec) | First-class TS implementation | Two impls forever, ts-rs sync gate | Two artifacts, both first-class | +| (C) Rust-only, no TS surface | TS consumers write their own HTTP client | Cleanest | Binary only — every TS consumer rebuilds the wheel | + +**Why A over C:** The thin client is ~50KB of TypeScript that auto-spawns the daemon and exposes a typed API. Shipping it once eliminates per-consumer boilerplate and gives the TS adapter packages something to wrap. + +**Why A over B:** Dual-maintain only makes sense if the TS implementation has real consumers that can't tolerate IPC latency. None exist. Local UDS round-trip is sub-millisecond — invisible compared to the SQLite write itself. + +## Runtime Model + +### Daemon + +- **Single per-user process.** Owns all SQLite databases under `~/.kindling/projects//`. +- **WAL mode** SQLite, single writer per database. Daemon serialises writes; concurrent readers fan out. +- **Project routing** — every request carries `X-Kindling-Project: ` (or `projectId` in body); daemon opens / caches one connection per project. +- **Idle shutdown** — daemon exits after N minutes with no in-flight requests. Default 30 min, configurable. + +### Transport + +- **Unix domain socket** at `~/.kindling/kindling.sock`, mode `0600` (filesystem permissions = authn). +- **TCP fallback** on `127.0.0.1` for Windows; opt-in elsewhere via flag. +- **HTTP/1** over the socket — debuggable with `curl --unix-socket`, no protocol code in clients. +- No TLS, no tokens — localhost-only, single user. + +### API surface (v1) + +``` +GET /v1/health → { version, schemaVersion, projects: [...] } +POST /v1/capsules → open capsule +PATCH /v1/capsules/:id/close → close capsule +POST /v1/observations → append observation +POST /v1/retrieve → ranked retrieval +POST /v1/pins → pin +DELETE /v1/pins/:id → unpin +``` + +Request/response bodies are JSON shapes generated from `kindling-types` via `ts-rs`. Schema version returned in `/v1/health` lets clients fail loud on mismatch. + +### Auto-spawn protocol (clients) + +1. Client opens UDS connection at default path. +2. On `ECONNREFUSED` or missing socket → client `exec`s `kindling serve --daemonize`. +3. Client polls socket for up to 1s (10ms intervals). +4. Once socket appears → make request normally. +5. Subsequent calls hit the warm daemon (sub-ms). + +Cold-spawn cost: ~50ms one-time per session. Acceptable for hooks (Claude Code's first hook fires on `SessionStart` which has slack); invisible for TS consumers (called from long-lived processes). + +### Hook special case + +Claude Code hooks run as subprocesses. They become thin clients: + +- `kindling hook session-start` reads context from stdin, makes one HTTP-over-UDS call, writes response to stdout, exits. +- Binary itself starts in microseconds. All work is in the daemon. +- Cold session: first hook spawns daemon (~50ms). Warm: <5ms total. + +### Anvil integration + +Two equally valid integration paths; per-call site choice: + +- **In-process:** `use kindling_service::KindlingService;` — zero IPC. Use when Anvil owns the database lock for the session (headless, single-process workflows). +- **Via daemon:** `use kindling_client::Client;` — same shape. Use when Claude Code (or any other tool) might write concurrently. + +Default Anvil to the daemon path for any session that could overlap with interactive Claude Code. + +## Distribution Model + +One binary, multiple install channels, all converge on `~/.local/bin/kindling` (or platform equivalent): + +| Channel | Audience | Command | +| --- | --- | --- | +| crates.io | Rust devs | `cargo install kindling` | +| Homebrew tap | macOS/Linux devs | `brew install eddacraft/tap/kindling` | +| Install script | Anyone, CI | `curl -sSL install.kindling.dev \| sh` | +| GitHub Releases | Manual / scripted | Raw binaries per platform | +| **npm postinstall** | TS projects | `pnpm add @eddacraft/kindling` | + +The npm postinstall is the unlock for TS consumers: one `pnpm add` installs the client SDK *and* downloads the platform binary into `node_modules/@eddacraft/kindling/bin/`. Same model as `esbuild`, `swc`, `biome`. If the binary is already on PATH, the postinstall is a no-op. + +Cross-platform release matrix: + +- Linux x86_64 (gnu + musl) +- Linux aarch64 (gnu + musl) +- macOS x86_64 +- macOS aarch64 +- Windows x86_64 + +Built via `cargo-zigbuild` on a single Linux runner (or matrix per OS, decide in PORT-010). + +## TS Surface Strategy + +### Single npm package + +`@eddacraft/kindling` (the existing primary package, repurposed): + +- **Removes** all current implementation code (better-sqlite3, FTS, server, CLI). +- **Adds** thin HTTP-over-UDS client + auto-spawn logic. +- **Adds** types generated from Rust via `ts-rs`. +- **Adds** postinstall script that downloads the platform binary if not on PATH. + +Public API stays roughly the same shape (`KindlingService` → `Kindling` client class with the same method names) so consumer migration is a few imports + a `new Kindling()` instead of `new KindlingService({ store, provider })`. + +### Deprecated packages + +These get a `0.x.0` deprecation release with a `console.warn` on import, then removal at next major: + +- `@eddacraft/kindling-core` +- `@eddacraft/kindling-store-sqlite` +- `@eddacraft/kindling-store-sqljs` +- `@eddacraft/kindling-provider-local` +- `@eddacraft/kindling-server` +- `@eddacraft/kindling-cli` + +Adapters (`@eddacraft/kindling-adapter-claude-code`, `-opencode`, `-pocketflow`) get rewritten to depend on `@eddacraft/kindling` (the new thin client). + +The Anvil TS bridge (`@eddacraft/anvil-kindling-integration`) gets deprecated and removed once Anvil cuts over to direct Rust integration. + +### Browser / WASM + +Out of scope. The `kindling-store-sqljs` package goes away. If a real browser consumer appears later, revisit by exposing the daemon API over a different transport (WebSocket, BroadcastChannel + SharedWorker, or recompile the daemon to WASM and run it in a SharedWorker). + +## Crate Layout + +10 crates in `crates/`: + +| Crate | Purpose | +| ---------------------- | ------- | +| `kindling-types` | Domain types, `ts-rs` derives | +| `kindling-store` | SQLite persistence (rusqlite + bundled FTS5) | +| `kindling-filter` | Secret masking, truncation | +| `kindling-provider` | Local FTS retrieval, BM25 normalisation | +| `kindling-service` | In-process API (`open_capsule`, `append_observation`, `retrieve`, …) | +| `kindling-server` | UDS daemon (axum), auto-spawn, idle shutdown | +| `kindling-client` | Rust HTTP-over-UDS client (used by hook, CLI, Anvil) | +| `kindling-hook` | Thin hook subcommand wrapping `kindling-client` | +| `kindling-cli` | clap subcommands wrapping `kindling-service` or `-client` | +| `kindling` | Umbrella binary — dispatches to hook / CLI / server | + +The workspace root `Cargo.toml` lives at the repo root with `members = ["crates/*"]`; member crates live under `crates/`. Existing `packages/` directory shrinks to: `@eddacraft/kindling` (thin client) + adapter packages, all of which become consumers of the daemon. + +## Migration Path + +1. Land Rust foundation (Phase 1) without touching `packages/`. +2. Land daemon + hook + Anvil integration (Phase 2). At this point Anvil is unblocked and Claude Code plugin can switch to the Rust hook. +3. Land CLI + distribution (Phase 3). `kindling` binary on cargo, brew, curl|sh. +4. Rewrite `@eddacraft/kindling` as the thin client + postinstall package (Phase 4). Ship as `0.2.0`. +5. Deprecate the old TS implementation packages (Phase 4). Final removal at `1.0.0`. +6. Anvil TS bridge deprecation (Phase 4, after Anvil's cutover lands in EddaCraft repo). + +## Open Questions + +1. **Single daemon for all projects, or one daemon per project?** Lean single per-user daemon — simpler, cheaper, avoids spawn storms. +2. **Idle shutdown default — 30 min or longer?** Lean 30 min. Re-tune after dogfooding. +3. **Wire format — JSON or MessagePack?** Lean JSON for v1 (debuggable). Reconsider only if benchmarks justify it. +4. **Hook spool fallback** — if the daemon fails to spawn within timeout, should the hook write to a small spool file the daemon drains on next start? Edge case; defer until measured as a problem. +5. **Server-side filtering vs client-side filtering** — daemon should own secret filtering so consumers can't accidentally bypass it. Confirmed. +6. **Postinstall behaviour offline** — if the binary download fails, should `pnpm add` fail or warn-and-defer? Lean warn-and-defer: client throws on first call with a clear error. +7. **OIDC / multi-user case** — out of scope for v1 (localhost, single user). Revisit when a real shared-host use case appears. + +## Risks + +| Risk | Impact | Mitigation | +| ----------------------------------------------------- | ------ | ------------------------------------------------------------ | +| npm postinstall download fails behind corp proxy | Medium | Honour `npm_config_proxy` / standard env vars; document offline binary install path | +| Daemon process gets orphaned / piles up | Medium | PID file with stale-PID cleanup on next spawn; `kindling serve --health` for ops | +| Cold-spawn latency exceeds 100ms on slow disks | Low | Measure on dogfood; spool fallback if needed (open question 4) | +| Existing TS consumers (just you) need migration | Low | You own them; coordinate cutover with `0.2.0` release | +| Schema drift between binary and client expectations | Medium | `/v1/health` reports schemaVersion; client checks on first call and fails loud | + +## Success Criteria + +- [ ] `kindling serve` runs as a long-lived daemon with auto-spawn and idle shutdown +- [ ] All 7 Claude Code hook types complete in <10ms warm, <100ms cold +- [ ] Anvil emits observations directly via `kindling-client` or `kindling-service` — no TS bridge +- [ ] `pnpm add @eddacraft/kindling` installs the binary and exposes a typed client +- [ ] Single statically-linked binary distributed via cargo, brew, curl|sh, npm +- [ ] All deprecated TS implementation packages removed by `1.0.0` diff --git a/plugins/kindling-claude-code/package.json b/plugins/kindling-claude-code/package.json index 1309ea7..2f48b3c 100644 --- a/plugins/kindling-claude-code/package.json +++ b/plugins/kindling-claude-code/package.json @@ -1,6 +1,6 @@ { "name": "@eddacraft/kindling-plugin-claude-code", - "version": "0.1.2", + "version": "0.1.3", "description": "Memory continuity for Claude Code — remember what you worked on across sessions", "author": "EddaCraft", "license": "Apache-2.0", diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..575e180 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,8 @@ +# Pinned to a specific stable release so rustfmt / clippy results are +# reproducible across machines and over time. Bump intentionally — a new +# stable can introduce lints that fail CI without any code change here. +# Keep this >= the workspace `rust-version` (MSRV) in Cargo.toml. +[toolchain] +channel = "1.95.0" +components = ["rustfmt", "clippy"] +profile = "minimal"