diff --git a/.agents/specs/SPEC-040-sqlite-backed-loaf-operational-state.md b/.agents/specs/SPEC-040-sqlite-backed-loaf-operational-state.md index 3a3f71af..a51e760a 100644 --- a/.agents/specs/SPEC-040-sqlite-backed-loaf-operational-state.md +++ b/.agents/specs/SPEC-040-sqlite-backed-loaf-operational-state.md @@ -47,7 +47,7 @@ SPEC-010 deliberately introduced `TASKS.json` as structured metadata because dir ## Solution Direction -Introduce a project-scoped SQLite store for Loaf operational state, stored outside the repository under XDG paths and accessed only through the Loaf CLI. +Introduce a single SQLite store for Loaf operational state, stored outside the repository under XDG paths, partitioned by stable project ID, and accessed only through the Loaf CLI. The SQLite state layer starts in Go. ADR-014 makes Go the intended home for Loaf's stateful runtime and lower-dependency command surface. The existing TypeScript CLI remains a compatibility implementation during migration, but new SQLite-backed runtime work should not deepen the Node/TypeScript dependency footprint. @@ -393,7 +393,7 @@ Decision criteria: not sufficient; SQLite driver upgrades also need release-note review because embedded SQLite code may carry non-Go vulnerability context. - **Testability:** Track A must include a real SQLite smoke test that opens the - project database, applies migrations, writes a row, reads it back, and runs + global project-partitioned database, applies migrations, writes a row, reads it back, and runs with `CGO_ENABLED=0`. Implementation constraints for Track A: @@ -449,8 +449,8 @@ Implementation constraints for Track A: ## Open Questions -- [x] Exact XDG split: should the project database live under `$XDG_STATE_HOME/loaf/` or `$XDG_DATA_HOME/loaf/`? Decision: SQLite operational state lives under `$XDG_STATE_HOME/loaf/projects//loaf.sqlite`, with platform fallbacks handled by the Go `PathResolver`; it is state, not portable user data. -- [x] How should project identity be derived for moved repositories: absolute path hash, git remote, git common-dir, explicit project UUID, or a combination? Decision: SPEC-040 hashes the resolved canonical project root. Git linked worktrees resolve through `git-common-dir` to the main checkout root, so sibling worktrees share state. Move-stable project UUIDs remain future migration work. +- [x] Exact XDG split: should the database live under `$XDG_STATE_HOME/loaf/` or `$XDG_DATA_HOME/loaf/`? Decision: SQLite operational state lives in one global database at `$XDG_DATA_HOME/loaf/loaf.sqlite`, with rows partitioned by project ID and platform fallbacks handled by the Go `PathResolver`. +- [x] How should project identity be derived for moved repositories: absolute path hash, git remote, git common-dir, explicit project UUID, or a combination? Decision: new projects get a generated stable project ID stored in SQLite, plus a friendly name and path mapping. Legacy path hashes remain only as an adoption key for imported/migrated rows. Use `loaf project rename ` for friendly-name changes and `loaf project move --from ` when a checkout path changes. - [x] What is the exact TypeScript delegation mechanism for unmigrated commands: subprocess to bundled JS, embedded assets, or npm-package-local path? Decision: the Go front controller runs a subprocess through `node dist-cli/index.js`, resolving the bundled script from the working tree/project root/executable-relative paths, with `LOAF_LEGACY_CLI` as an override for development. - [x] Should Markdown compatibility views be generated automatically after every mutation, or only by explicit export commands? Decision: SQLite-backed commands do not write repository Markdown as a side effect. Compatibility Markdown remains import/fallback input, and reviewable views are produced by explicit export/report commands unless a later compatibility task deliberately adds a generated view command. - [x] What is the minimum session transcript row shape that works across Claude Code, Codex, OpenCode, Cursor, Gemini, and Amp? Decision: store structured session rows plus journal summaries/pointers first: session alias, harness session ID, branch, status, optional source, and journal rows with type, scope, message, observed branch/worktree/harness ID, and nullable session/spec/task links. Raw transcript capture stays harness-native/out of scope until redaction controls are designed. @@ -460,7 +460,7 @@ Implementation constraints for Track A: ## Test Conditions -- [x] `loaf state init` creates a project-scoped SQLite database outside the repository and prints its path without creating secrets. +- [x] `loaf state init` creates the global project-partitioned SQLite database outside the repository and prints its path without creating secrets. - [x] The public `loaf` command can dispatch Go-native `state` commands while unmigrated commands still delegate to the existing TypeScript CLI. - [x] Build and test workflows prove the Go runtime and TypeScript compatibility bridge can coexist without exposing two public command names. - [x] `loaf state path` prints the same path from the main worktree and linked worktrees for the same project. diff --git a/.agents/tasks/TASK-196-state-status-doctor-diagnostics.md b/.agents/tasks/TASK-196-state-status-doctor-diagnostics.md index 85099ddf..7a0c899f 100644 --- a/.agents/tasks/TASK-196-state-status-doctor-diagnostics.md +++ b/.agents/tasks/TASK-196-state-status-doctor-diagnostics.md @@ -30,7 +30,7 @@ completed_at: '2026-05-28T17:08:01Z' Add the approved `github.com/ncruces/go-sqlite3/driver` dependency and wire the first real storage lifecycle commands. `loaf state init` should create the -project-scoped SQLite database outside the repository and apply Go-owned schema +global project-partitioned SQLite database outside the repository and apply Go-owned schema migrations. `loaf state status` and `loaf state doctor` should report the resolved project root, intended database path, DB presence, schema version, and Markdown fallback state. @@ -38,7 +38,7 @@ Markdown fallback state. ## Acceptance Criteria - [x] `github.com/ncruces/go-sqlite3` is pinned in `go.mod`/`go.sum`. -- [x] `loaf state init` creates the project-scoped SQLite database outside the repository. +- [x] `loaf state init` creates the global project-partitioned SQLite database outside the repository. - [x] `state init` applies the ordered Go-owned migrations and records checksums in `schema_migrations`. - [x] `state init` is idempotent and detects migration checksum drift. - [x] `loaf state status` prints project root, database path, database presence, mode, and schema version. diff --git a/.agents/tasks/TASK-234-state-init-safety-proof.md b/.agents/tasks/TASK-234-state-init-safety-proof.md index 2107434e..3ef102f5 100644 --- a/.agents/tasks/TASK-234-state-init-safety-proof.md +++ b/.agents/tasks/TASK-234-state-init-safety-proof.md @@ -16,7 +16,7 @@ files: verify: >- go test ./internal/state ./internal/cli && go test ./... done: >- - Tests prove `loaf state init` creates a project-scoped SQLite database outside + Tests prove `loaf state init` creates a global project-partitioned SQLite database outside the repository, prints the database path, avoids repository `.agents` writes, and initializes a schema without secret-storage columns. --- diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 716de3c8..ece39cf8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Loaf - An Opinionated Agentic Framework", - "version": "2.0.0-dev.49" + "version": "2.0.0-pre.20260614235428" }, "plugins": [ { "name": "loaf", "description": "Loaf - An Opinionated Agentic Framework", "source": "./plugins/loaf", - "version": "2.0.0-dev.49", + "version": "2.0.0-pre.20260614235428", "license": "MIT", "repository": "https://github.com/levifig/loaf" } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c75483f6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,126 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + tag: + description: 'Release tag to publish, for example v2.0.0-dev.49' + required: true + update_tap: + description: 'Update levifig/homebrew-tap after uploading assets' + required: true + default: 'true' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Resolve release ref + id: release + shell: bash + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + tag="${{ inputs.tag }}" + else + tag="${GITHUB_REF_NAME}" + fi + if [[ -z "$tag" || "$tag" != v* ]]; then + echo "Release tag must start with v." >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "ref=refs/tags/$tag" >> "$GITHUB_OUTPUT" + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ steps.release.outputs.ref }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install dependencies + run: npm ci + + - name: Verify release version + id: version + shell: bash + run: | + version="$(node -p "require('./package.json').version")" + expected_tag="v$version" + if [[ "${{ steps.release.outputs.tag }}" != "$expected_tag" ]]; then + echo "Tag ${{ steps.release.outputs.tag }} does not match package version $version." >&2 + exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Build release targets + run: npm run build:release + + - name: Verify tests + run: go test ./... + + - name: Package release archives + run: npm run package:release + + - name: Upload release assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.release.outputs.tag }} + shell: bash + run: | + if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then + gh release upload "$RELEASE_TAG" dist/release/* --clobber + else + gh release create "$RELEASE_TAG" dist/release/* --title "$RELEASE_TAG" --generate-notes + fi + + - name: Checkout Homebrew tap + if: ${{ github.event_name != 'workflow_dispatch' || inputs.update_tap == 'true' }} + uses: actions/checkout@v4 + with: + repository: levifig/homebrew-tap + path: homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + + - name: Update Homebrew formula + if: ${{ github.event_name != 'workflow_dispatch' || inputs.update_tap == 'true' }} + run: | + node cli/scripts/update-homebrew-formula.mjs \ + --formula homebrew-tap/Formula/loaf.rb \ + --checksums dist/release/checksums.txt \ + --version "${{ steps.version.outputs.version }}" \ + --repo levifig/loaf + + - name: Commit Homebrew tap update + if: ${{ github.event_name != 'workflow_dispatch' || inputs.update_tap == 'true' }} + working-directory: homebrew-tap + shell: bash + run: | + if git diff --quiet -- Formula/loaf.rb; then + echo "Homebrew formula already current." + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add Formula/loaf.rb + git commit -m "chore: update loaf to ${{ steps.version.outputs.version }}" + git push diff --git a/.gitignore b/.gitignore index 44268dcb..bea96052 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ dist-cli/ # Temporary backups dist-backup/ plugins-backup/ +dist/release/ # OS files .DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c0a748d..37086872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,15 +6,167 @@ is a Loaf workflow staging section for curated entries before release. ## [Unreleased] +- _No unreleased changes yet._ + +## [2.0.0-pre.20260614235428] - 2026-06-14 + ### Changed +- Added Homebrew-ready release packaging and CI/CD so tagged Loaf releases can build native archives, upload checksummed assets, and update `levifig/homebrew-tap`. +- Completed the boring-reliable state/CLI audit, tying the single global SQLite database contract, durable project identity, repair guidance, backup/export/restore evidence, backend/Linear diagnostics, human help, and agent JSON surfaces to tests, docs, SPEC-040, native cutover guardrails, and live primary-checkout dogfood. +- State, project, repair, backup, and migration terminal help now names the JSON contract fields instead of using generic `Output JSON`, including readiness, diagnostics, repair plans, backup restore guidance, migration context, durable project identity, and applied status. +- Utility and knowledge-base help surfaces now describe `kb`, `check`, `housekeeping`, and `trace` JSON output in terms of knowledge metadata, hook results, cleanup sections/signals, traced entities, global database scope, and project identity across agent help, command help, and generated CLI reference output. +- Entity-family help surfaces now describe `brainstorm`, `idea`, `spark`, `tag`, `bundle`, and `link` JSON output in terms of global database scope, project identity, relationships, events, tags, and bundle membership across agent help, command help, and generated CLI reference output. +- `loaf session report --json` now returns the same session Markdown export contract as state/report generation aliases instead of advertising `--json` and rejecting it; session, task, spec, and report help now describe their JSON scope, project identity, diagnostics, events, and compatibility summaries precisely. +- Agent help and generated CLI reference output now describe critical state JSON contracts precisely for `state path|init|status|doctor`, guarded repairs, backups, top-level migration aliases, restore guidance, global database scope, and project identity instead of using generic raw/details wording. +- Agent help, command help, and generated CLI reference output now describe migration/report JSON contracts consistently, including state migration aliases, project context, global database paths, and report command metadata. +- `loaf state migrate storage-home --dry-run --json` now includes the durable project ID, friendly project name, and current project path when the global data-home database already contains the current project. +- `loaf report generate ... --json` success payloads now include the JSON contract version, report command, global database scope, project export scope, and durable project identity; external reports omit local database and project paths while internal session reports retain them for agent routing. +- Human missing-state errors from `loaf state backup` and Markdown `loaf state export ...` commands now include the global database scope, target database path, and safe next actions while preserving concise JSON errors for agents. +- `loaf state migrate markdown --dry-run --json` now includes the global database scope, target database path, project import scope, project name/path, and `applied: false` without creating SQLite state. +- `loaf state doctor --json` and exported state snapshots now classify local Markdown import and stale compatibility export warnings with structured category, policy, and details fields for safer agent routing. +- `loaf state export all --format json` now carries current state diagnostics and repair-plan actions alongside the raw project tables, so backend/Linear repair follow-up exports preserve the reason and policy that led to the export. +- `loaf state migrate markdown --apply|--resume --json` now includes an explicit `action` field, and human output prints the same action so agents and humans can distinguish fresh imports from resumed imports without relying on argv context. +- `loaf state backup verify --json` now includes the current checkout's restore target, preserve path, and validation commands without reading or recreating live SQLite state; human verify output prints the same concrete restore paths. +- `loaf state doctor --json` backend and Linear diagnostics now include structured `details` fields, so agents can route invalid local backend rows, drift warnings, and external sync gaps without parsing prose. +- Project-specific commands now reject invalid project path invariants before showing or mutating one identity, while `project list --json` remains available for doctor-recommended inspection. +- Project commands now reject schema checksum drift before reading identity state, matching `state doctor` invalid-state behavior and pointing users at the affected global database path. +- Project command human errors for missing SQLite state now include the global database path, scope, and safe `state status` / `state init` next actions instead of a terse missing-database message. +- `loaf project move` now accepts positional absolute paths (`loaf project move [to]`) in addition to `--from/--to`, preserving the same dry-run, JSON, and path-safety checks. +- `loaf state doctor` now rejects backend mapping rows with sensitive-looking external identity values, keeping Linear/backend metadata to identifiers and URLs instead of credentials. +- `loaf state export all --json --format markdown` now returns the same machine-readable flag-conflict error as the reverse flag order instead of falling through to a generic unsupported-format message. +- `loaf report generate` now accepts its documented `--format markdown` option and supports `--json`, returning the same markdown export wrapper used by state exports with machine-readable errors for unsupported formats and missing state. +- Markdown exports from `loaf state export triage|release-readiness|spec|session` now include explicit project context; external-safe exports name the global/project scope, stable project ID, and friendly project name without exposing local paths, while internal exports also include project and database paths. +- `loaf state init|status|doctor` human output now uses the same durable project identity labels as the rest of the SQLite CLI: `project` for ID and `project name` for the friendly name. +- `loaf state backup` human output now ends with a concrete `state backup verify ` next action, and backup help/reference text names the global data-home backups directory. +- `loaf state path --verbose` now provides human-oriented command, scope, project root, and database path context while preserving raw-path default output for shell substitution and restore workflows. +- `loaf project show|identity` and `loaf project list` human output now use the same command, scope, database, project ID, friendly name, and project path labels as project identity mutations. +- `loaf state migrate markdown` and `loaf state migrate storage-home` human output now report command, global database scope, project import/migration scope, database path, project context, applied status, and dry-run next actions consistently. +- `loaf project rename|move` human output now reports command, scope, database, project identity, from/to values, applied status, and dry-run next actions consistently. +- `loaf state doctor` diagnostics now label backend mapping and Linear sync findings by policy so local data fixes, drift audits, and external sync work are easier to distinguish. +- `loaf state doctor` repair-plan commands now have regression coverage proving suggested follow-up commands run in the diagnostic mode that produced them. +- `loaf state doctor` repair plans now classify local database, backend mapping, and external sync actions for clearer human and agent follow-up. +- Added safe next-action guidance to backup verification output after dogfooding the manual restore flow, so users know how to preserve the current DB, restore the verified backup, and rerun health checks. +- Documented and verified a manual SQLite backup restore flow so users can recover the global database by verifying a backup, preserving the current DB, copying the backup into place, and running health checks. +- Completed the Gate 1 control-plane evidence pass with regression coverage for project rename/move safeguards and repair dry-runs, including durable project identity, single current path, dry-run table stability, and legacy archive preview safety. +- Added command-matrix regression coverage for critical state/project/migration JSON success contracts, including read-only no-mutation checks, migration dry-run no-copy/no-database checks, and backup verification without live state access. +- Refocused the boring-reliable state/CLI plan into gated execution criteria so future work progresses through control-plane proof, recovery confidence, and UX/policy normalization instead of broad edge chasing. +- Added command-matrix regression coverage for critical state/project/migration JSON failure contracts, including contract version, command name, silent exit code, and no database creation for pre-open failures. +- Added a focused boring-reliable state/CLI plan that turns the remaining SQLite hardening work into an explicit reliability contract, command matrix, and prioritized audit tracks. +- `loaf state export all --json` is now accepted as an agent-friendly alias for `loaf state export all --format json`, while markdown export kinds continue to require explicit `--format markdown`. +- `loaf state doctor` repair plans now route invalid backend-mapping diagnostics to `loaf state doctor --json` instead of suggesting `state export`, which refuses to run while state is invalid. +- `loaf state doctor --json` now includes non-mutating repair plans whenever diagnostics are present, even without `--dry-run`, so agents receive next actions alongside health failures. +- `loaf state backup verify --json` now includes `backup_path` in verification failure payloads after a path has been parsed, making invalid-backup diagnostics easier for agents to correlate. +- `loaf state path --json` now reports the resolved global SQLite path with contract version, project root, and database scope without creating the database. +- `loaf state doctor` now accepts project-level backend mappings, allowing a Loaf project to be linked to a Linear/external project while still rejecting mismatched project mapping IDs. +- `loaf state doctor --json` now exits nonzero for invalid SQLite state while still returning the machine-readable status payload. +- `loaf state export all --format json` now includes `project_paths` rows so project-scoped snapshots preserve checkout path history alongside durable project identity. +- `npm run build` now rebuilds the Go CLI before regenerating the CLI reference so agent-facing docs do not lag behind command metadata changes. +- `loaf state backup verify [--json]` now verifies existing SQLite backups without live-state access and reports all project identities captured in the global backup. +- `loaf task refresh|sync --json` and `loaf session enrich|housekeeping --json` compatibility summaries now include `contract_version` for agentic consumers. +- `loaf housekeeping` JSON and human output now report global database scope and durable project identity details when backed by SQLite, while Markdown fallback output keeps repository-local artifact context. +- `loaf trace` and `loaf spec show` JSON and human output now report global database scope and durable project identity details when backed by SQLite, while Markdown `spec show` fallback output keeps repository-local spec context. +- `loaf task list|show|status` and `loaf spec list` JSON and human output now report global database scope and durable project identity details when backed by SQLite, while Markdown fallback output keeps repository-local task/spec context. +- `loaf brainstorm promote|archive|list|show` JSON and human output now report global database scope and durable project identity details. +- `loaf state doctor` and SQLite-backed `loaf report list` now warn when the global database is ready but the current repo still has importable local `.agents` Markdown that has not been migrated. +- `loaf session start|end|archive|list|show|log` JSON and human output now report global database scope and durable project identity details when backed by SQLite, while Markdown fallback output keeps repository-local session context. +- `loaf spark capture|promote|resolve|list|show` JSON and human output now report global database scope and durable project identity details. +- `loaf idea capture|promote|resolve|archive|list|show` JSON and human output now report global database scope and durable project identity details. +- `loaf report create|finalize|archive|list` JSON and human output now report global database scope and durable project identity details when backed by SQLite, while Markdown fallback output keeps repository-local report context. +- `loaf bundle create|update|add|remove|list|show` JSON and human output now report global database scope and durable project identity details. +- `loaf tag add|remove|list|show` JSON and human output now report global database scope and durable project identity details. +- `loaf link create|remove|list` JSON and human output now report global database scope and durable project identity details. +- `loaf spec archive` JSON and human output now report global database scope and durable project identity details when backed by SQLite, while Markdown fallback JSON includes the contract version without database context. +- `loaf task create|update|archive` JSON and human output now report global database scope and durable project identity details when backed by SQLite, while Markdown fallback JSON includes the contract version without database context. +- `loaf project show|list|rename|move` JSON and human output now identify project metadata as global database state. +- `loaf state repair ...` JSON and human output now report global database scope and durable project identity details for guarded repair previews and applies. +- `loaf state init|status|doctor` now report global database scope consistently in JSON and human output, and human diagnostics include durable project identity details when available. +- `loaf migrate storage-home --json` and human output now report global database scope, project migration scope, and applied project identity details. +- `loaf migrate markdown --apply|--resume --json` now reports global database scope, project import scope, and durable project identity details. +- `loaf state doctor` now warns when backend mapping rows use an unknown `sync_status`, helping catch misspelled integration state without invalidating the database. +- `loaf state export all --format json` now reports `database_scope` and `export_scope` in the snapshot and manifest, making project-scoped exports from the global database explicit. +- `loaf state backup` JSON and human output now report the number of project identities captured in the global database backup. +- `loaf state backup` JSON and human output now identify backups as global database backups. +- `loaf project move` now rejects missing or non-directory target paths before previewing or recording a checkout move. +- `loaf state doctor` now flags backend mapping rows with empty backend, local entity, external entity, or sync-status fields. +- `loaf state repair ...` human output now shows `--dry-run` or `--apply` in the command header and suppresses apply guidance when no rows or files match. +- `loaf migrate markdown --json`, `loaf migrate storage-home --json`, and `loaf state repair ... --json` success payloads now include `contract_version`. +- `loaf project identity` now works as a discoverable alias for `loaf project show`. +- `loaf project show|list|rename|move --json` now include `contract_version` for agentic consumers. +- `loaf state init|status|doctor --json` now include `contract_version` for agentic consumers. +- JSON error payloads now include `contract_version` for agentic consumers. +- `loaf state backup --json` and `loaf state export all --format json` now include `contract_version` for agentic JSON consumers. +- `loaf state backup --json` and human output now include the backup file's SHA-256 digest for artifact verification. +- `loaf state backup --json` and `loaf state export all --format json` now surface project name and current project path alongside the durable project ID. +- `loaf state status` and `loaf state doctor` now inspect existing SQLite databases through read-only connections. +- SQLite backup and export verification errors now include the first foreign-key violation's table, row, parent table, and constraint details. +- `loaf state export all --format json` manifest now reports SQLite integrity and foreign-key verification checks. +- `loaf state backup` now verifies and reports backup foreign-key integrity alongside SQLite integrity checks. +- `loaf state doctor` now reports SQLite `quick_check` failures and foreign-key violations as explicit invalid-state diagnostics. +- `loaf project rename --json` now requires an existing registered project identity and no longer initializes missing SQLite state as a side effect. +- `loaf project move --json` now validates against an existing SQLite database before opening a writable handle, so rejected moves no longer create empty state. +- `loaf project show|list` now open the global SQLite database read-only and no longer initialize missing state as a side effect. +- `loaf state status` now distinguishes durable SQLite `project_id` from the path-derived `legacy_project_key`, avoiding pre-init identity confusion. +- `--agent-help` and the generated `cli-reference` skill now document the generic `loaf state export --format ` contract. +- `--agent-help` now documents `loaf build`/`install` short aliases and non-interactive install confirmation flags consistently with native help. +- `--agent-help` now documents housekeeping's legacy-compatible `--plans` and `--handoffs` filters. +- `loaf report create --help` now matches the parser by documenting `--source` and no longer advertising unsupported `--title`. +- `--agent-help` and the generated `cli-reference` skill now document `loaf migrate worktree-storage` dry-run/apply and conflict-resolution flags. +- `loaf kb ... --help` now works for knowledge-base subcommands and `--agent-help` documents KB JSON/path options for agentic use. +- `loaf report list --help`, `--agent-help`, and the generated `cli-reference` skill now document Loaf's report lifecycle statuses for `--status` filters. +- `loaf report generate --help`, `--agent-help`, and the generated `cli-reference` skill now state that `--format` expects Markdown output. +- `--agent-help` and the generated `cli-reference` skill now document concrete `loaf state export` subcommands and required `--format` contracts. +- `loaf state export all --format json` now includes a verified manifest with table order, per-table row counts, and total exported rows. +- `loaf state export all --format json` manifest now includes an explicit `table_count` for agentic consumers. +- `loaf state export ...` generation now reads SQLite through read-only connections. +- `loaf project rename|move --json` validation and safeguard failures now return machine-readable JSON error payloads instead of plain text. +- `loaf state init|status|doctor --json` validation failures now return machine-readable JSON error payloads instead of plain text. +- `loaf state migrate|repair --json` validation and safeguard failures now return machine-readable JSON error payloads instead of plain text. +- `loaf state backup --json` and `loaf state export all --format json` failures now return machine-readable JSON error payloads instead of plain text. +- `loaf trace --json` and `loaf idea capture --json` validation failures now return machine-readable JSON error payloads instead of plain text. +- `loaf link create|remove` now accepts the documented `--from` and `--to` flags, and `--json` validation failures return machine-readable JSON error payloads. +- `loaf --json` command paths now apply a central fallback so unwrapped validation failures still return machine-readable JSON error payloads. +- `--agent-help` and the generated `cli-reference` skill now document task mutation and compatibility `--json` options. +- `--agent-help` now has a regression guard against live help drift for documented `--json` options, and documents state/session/housekeeping JSON output options consistently. +- `loaf trace --help` now shows trace usage instead of reporting `--help` as an unknown option, and `--agent-help` documents trace JSON output. +- `loaf check --help` now shows registered hook usage instead of reporting `--help` as an unknown option. +- `loaf migrate markdown|storage-home --help` now shows top-level migration usage instead of reporting `--help` as an unknown option, and `--agent-help` documents their migration options. +- `loaf state doctor` now warns when Linear integration is enabled but active local task rows have no Linear backend mapping. +- `--agent-help` now documents state-backed brainstorm, idea, spark, tag, bundle, and link subcommands instead of exposing them as bare top-level command names. +- The generated `cli-reference` skill now documents top-level command options plus state-backed trace, brainstorm, idea, spark, tag, bundle, and link commands. +- `loaf task create|list|update --json` validation failures now return machine-readable JSON error payloads instead of plain text. +- `loaf task list|update` help, invalid-status errors, and agent help now name the valid task statuses. +- `loaf task create|update` help, invalid-priority errors, and agent help now name the valid task priorities. +- The generated `cli-reference` skill now uses the same task status and priority values as the native CLI help and agent help. +- `--agent-help` and the generated `cli-reference` skill now document `loaf project` identity commands and their dry-run safeguards. +- `--agent-help` and the generated `cli-reference` skill now describe `loaf project list` global database JSON fields. +- `--agent-help` and the generated `cli-reference` skill now document guarded `loaf state repair` targets and safety flags. +- `loaf state doctor` now validates backend mapping drift for Linear and other external integrations, including orphaned local entities, unknown entity kinds, and ambiguous local-to-external mappings. +- `loaf state doctor` repair plans now deduplicate repeated repair actions while preserving distinct diagnostic causes. +- `loaf state backup` now verifies backup integrity, schema version, and project identity before returning, and reports those checks in JSON and human output. +- `loaf state backup` now verifies created backups through a read-only SQLite connection so verification does not mutate backup files or create sidecars. +- `loaf project move` now supports `--dry-run` for validated path-move previews without mutating the global project identity index. +- Project rename and move dry-runs now open SQLite read-only, avoid initializing missing databases, and `loaf project rename` supports `--dry-run` previews. +- State doctor and repair JSON now keeps empty repair/archive fields as arrays instead of omitting them or returning `null`. +- `loaf state repair legacy-project-database` now previews and archives migrated per-project SQLite leftovers without deleting them. +- `loaf state repair relationship-origin` now previews and applies guarded relationship provenance backfills, creating a SQLite backup before writes. +- `loaf state doctor` now checks operational SQLite invariants for project path identity and relationship provenance, with manual repair guidance for unsafe drift. +- `loaf state doctor --dry-run` now reports an explicit repair plan in human and JSON output without mutating SQLite state or legacy databases. +- `loaf project list` now shows registered projects from the global SQLite database with stable IDs, friendly names, current paths, and JSON output. - Native Go is now the shipped Loaf runtime, with cross-platform binaries replacing the transitional TypeScript delegation path. - Existing Markdown-only Loaf projects now have a documented dry-run and apply path for adopting SQLite-backed state without rewriting source artifacts. +- SQLite project identity now uses generated stable project IDs in one global database, plus friendly names and path mappings managed by `loaf project show|rename|move`. - `agents-config` now documents and pins the fall-back-to-`projectRoot` behavior when a linked worktree's `.git` pointer file is malformed (missing `gitdir:` line or non-matching shape). This is the deliberate Case-4 fallback in `resolveEffectiveRoot` — distinct from the "main removed" case fixed in #53, which still throws. Closes a Codex review follow-up on #53. ### Fixed +- Markdown migration apply no longer requires legacy `.agents/TASKS.json` when importing Markdown-only task files. +- State-backed CLI commands now handle parent and nested `--help` consistently before parsing options or opening SQLite state. +- SQLite-backed state commands now fail on project identity mapping errors instead of silently falling back to path-derived legacy project IDs. - Storage-home migration now preserves pending SQLite writes when copying legacy state into XDG data-home storage. +- Markdown migration relationship imports now ignore empty dependency arrays, prune stale imported links by structured origin, and record imported/manual relationship provenance. +- Storage-home migration now upgrades copied legacy databases before readiness checks and rekeys legacy path-hash project rows into generated stable identities in the global database. +- Project path moves now reject unknown source paths without creating a stray project row, and SQLite enforces one current path per project. ### Removed diff --git a/README.md b/README.md index ab71dbb4..173b71b2 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ CLI commands that support the workflow pipeline: | `loaf build` | Build all targets after modifying skills/agents | | `loaf install` | Install to detected AI tools | | `loaf check` | Run enforcement hooks manually | +| `loaf project` | Manage durable project identity (show, rename, move) | | `loaf task` | Manage project tasks (list, show, update, archive) | | `loaf spec` | Manage spec lifecycle | | `loaf kb` | Knowledge base management | @@ -185,6 +186,15 @@ Build once, deploy everywhere. Skills are the universal layer; profiles and hook ## Getting Started +### Homebrew + +```bash +brew tap levifig/tap +brew install loaf +``` + +Homebrew installs the native `loaf` binary plus Loaf's packaged content under the tap-managed prefix. Use `brew upgrade loaf` after releases. + ### Claude Code ```bash @@ -214,7 +224,22 @@ loaf migrate markdown --apply loaf state status ``` -The dry run counts importable artifacts and skipped files without creating a database. The apply step imports `.agents/` Markdown into the XDG data-home SQLite database without rewriting the source Markdown files. Newer graph-oriented commands such as `loaf idea`, `loaf spark`, `loaf tag`, `loaf bundle`, and `loaf link` require initialized SQLite state; run `loaf state init` for a fresh project or `loaf migrate markdown --apply` for an existing Markdown project. +The dry run counts importable artifacts and skipped files without creating a database. The apply step imports `.agents/` Markdown into the XDG data-home SQLite database without rewriting the source Markdown files. Loaf uses one global SQLite file and partitions rows by stable project ID, so multiple projects share the same database path while project queries stay isolated. Project IDs are not bound to the checkout path or friendly name; use `loaf project rename ` for display names and `loaf project move --from ` after moving a checkout. Newer graph-oriented commands such as `loaf idea`, `loaf spark`, `loaf tag`, `loaf bundle`, and `loaf link` require initialized SQLite state; run `loaf state init` for a fresh project or `loaf migrate markdown --apply` for an existing Markdown project. + +### Recovering SQLite State From A Backup + +Loaf backups are full SQLite database copies stored outside the repository. There is not yet a `loaf state restore` command, so restore is an explicit manual procedure: + +```bash +loaf state backup verify /path/to/backup.sqlite +DB="$(loaf state path)" +cp "$DB" "$DB.before-restore" +cp /path/to/backup.sqlite "$DB" +loaf state doctor +loaf state status +``` + +Only copy a backup after `loaf state backup verify` reports `verified: true`, `integrity: ok`, `foreign keys: ok`, and the expected project identities. Preserve the current global database first so a bad restore can be reversed. For agentic restore flows, `loaf state backup verify --json` includes `restore_database_path`, `restore_preserve_path`, and `restore_validation_commands` for the current checkout. After copying the backup into `$XDG_DATA_HOME/loaf/loaf.sqlite`, run `loaf state doctor` and `loaf state status` from the affected checkout before continuing work. **Install locations:** diff --git a/bin/native/darwin-arm64/loaf b/bin/native/darwin-arm64/loaf index 9ca94204..8810d1f8 100755 Binary files a/bin/native/darwin-arm64/loaf and b/bin/native/darwin-arm64/loaf differ diff --git a/bin/native/darwin-x64/loaf b/bin/native/darwin-x64/loaf index 6ac29ce1..d6931b09 100755 Binary files a/bin/native/darwin-x64/loaf and b/bin/native/darwin-x64/loaf differ diff --git a/bin/native/linux-arm64/loaf b/bin/native/linux-arm64/loaf index 3755b476..c324c988 100755 Binary files a/bin/native/linux-arm64/loaf and b/bin/native/linux-arm64/loaf differ diff --git a/bin/native/linux-x64/loaf b/bin/native/linux-x64/loaf index cb673af3..f24fcb03 100755 Binary files a/bin/native/linux-x64/loaf and b/bin/native/linux-x64/loaf differ diff --git a/bin/native/win32-arm64/loaf.exe b/bin/native/win32-arm64/loaf.exe index e636b1c5..93414828 100755 Binary files a/bin/native/win32-arm64/loaf.exe and b/bin/native/win32-arm64/loaf.exe differ diff --git a/bin/native/win32-x64/loaf.exe b/bin/native/win32-x64/loaf.exe index a896a810..271e9729 100755 Binary files a/bin/native/win32-x64/loaf.exe and b/bin/native/win32-x64/loaf.exe differ diff --git a/cli/scripts/package-release.mjs b/cli/scripts/package-release.mjs new file mode 100644 index 00000000..fba9d760 --- /dev/null +++ b/cli/scripts/package-release.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Package native Loaf archives for GitHub Releases and Homebrew. + */ + +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { + chmodSync, + cpSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; + +const rootDir = process.cwd(); +const packageJSON = JSON.parse(readFileSync(join(rootDir, "package.json"), "utf8")); +const version = packageJSON.version; + +if (!version) { + console.error("ERROR: package.json missing version."); + process.exit(1); +} + +const releaseTargets = [ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", +]; +const requestedTargets = (process.env.LOAF_RELEASE_TARGETS || process.env.LOAF_BUILD_TARGETS || releaseTargets.join(",")) + .split(",") + .map((target) => target.trim()) + .filter(Boolean) + .filter((target, index, targets) => targets.indexOf(target) === index); + +const outDir = join(rootDir, "dist", "release"); +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); +const workDir = mkdtempSync(join(tmpdir(), "loaf-release-")); + +const checksums = []; +for (const target of requestedTargets) { + const nativeName = target.startsWith("win32-") ? "loaf.exe" : "loaf"; + const nativeSource = join(rootDir, "bin", "native", target, nativeName); + if (!existsSync(nativeSource)) { + console.error(`ERROR: missing native binary for ${target}: ${nativeSource}`); + console.error("Run `npm run build:release` before packaging release archives."); + process.exit(1); + } + + const packageName = `loaf_${version}_${target}`; + const packageRoot = join(workDir, packageName); + mkdirSync(join(packageRoot, "bin"), { recursive: true }); + cpSync(nativeSource, join(packageRoot, "bin", nativeName)); + chmodSync(join(packageRoot, "bin", nativeName), 0o755); + + for (const entry of ["package.json", "README.md", "CHANGELOG.md"]) { + copyIfPresent(join(rootDir, entry), join(packageRoot, entry)); + } + for (const dir of ["config", "content", "dist", "plugins"]) { + copyIfPresent(join(rootDir, dir), join(packageRoot, dir), { recursive: true }); + } + rmSync(join(packageRoot, "dist", "release"), { recursive: true, force: true }); + + const archiveName = `${packageName}.tar.gz`; + const archivePath = join(outDir, archiveName); + const tar = spawnSync("tar", ["-czf", archivePath, "-C", workDir, packageName], { + cwd: rootDir, + stdio: "inherit", + }); + if (tar.status !== 0) { + process.exit(tar.status ?? 1); + } + checksums.push(`${sha256(archivePath)} ${archiveName}`); + console.log(`✓ Packaged ${archiveName}`); +} + +writeFileSync(join(outDir, "checksums.txt"), checksums.join("\n") + "\n"); +rmSync(workDir, { recursive: true, force: true }); +console.log(`✓ Wrote ${join(outDir, "checksums.txt")}`); + +function copyIfPresent(from, to, options = {}) { + if (!existsSync(from)) { + return; + } + mkdirSync(dirname(to), { recursive: true }); + cpSync(from, to, { + recursive: Boolean(options.recursive), + filter: (source) => basename(source) !== ".DS_Store", + }); +} + +function sha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} diff --git a/cli/scripts/update-homebrew-formula.mjs b/cli/scripts/update-homebrew-formula.mjs new file mode 100644 index 00000000..df18802d --- /dev/null +++ b/cli/scripts/update-homebrew-formula.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/** + * Rewrite the Loaf Homebrew formula from release archive checksums. + */ + +import { readFileSync, writeFileSync } from "node:fs"; + +const options = parseArgs(process.argv.slice(2)); +const formulaPath = required(options.formula, "--formula"); +const checksumsPath = required(options.checksums, "--checksums"); +const version = required(options.version, "--version"); +const repo = options.repo || "levifig/loaf"; + +const checksums = readChecksums(checksumsPath, version); +const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"]; +for (const target of targets) { + if (!checksums[target]) { + console.error(`ERROR: checksums file missing ${target} archive.`); + process.exit(1); + } +} + +writeFileSync(formulaPath, formula(version, repo, checksums)); +console.log(`✓ Updated ${formulaPath} for Loaf ${version}`); + +function formula(versionValue, repoValue, values) { + return `class Loaf < Formula + desc "Opinionated agentic framework for AI coding assistants" + homepage "https://github.com/${repoValue}" + version "${versionValue}" + license "MIT" + + depends_on "git" + + on_macos do + if Hardware::CPU.arm? + url "https://github.com/${repoValue}/releases/download/v#{version}/loaf_#{version}_darwin-arm64.tar.gz" + sha256 "${values["darwin-arm64"]}" + else + url "https://github.com/${repoValue}/releases/download/v#{version}/loaf_#{version}_darwin-x64.tar.gz" + sha256 "${values["darwin-x64"]}" + end + end + + on_linux do + if Hardware::CPU.arm? + url "https://github.com/${repoValue}/releases/download/v#{version}/loaf_#{version}_linux-arm64.tar.gz" + sha256 "${values["linux-arm64"]}" + else + url "https://github.com/${repoValue}/releases/download/v#{version}/loaf_#{version}_linux-x64.tar.gz" + sha256 "${values["linux-x64"]}" + end + end + + def install + libexec.install "bin", "package.json", "config", "content", "dist", "plugins" + bin.write_exec_script libexec/"bin/loaf" + end + + test do + output = shell_output("#{bin}/loaf --version") + assert_match "loaf", output + assert_match version.to_s, output + end +end +`; +} + +function readChecksums(path, versionValue) { + const result = {}; + for (const line of readFileSync(path, "utf8").split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const match = trimmed.match(/^([a-f0-9]{64})\s+loaf_(.+)_(.+)\.tar\.gz$/); + if (!match) { + console.error(`ERROR: invalid checksum line: ${line}`); + process.exit(1); + } + if (match[2] !== versionValue) { + console.error(`ERROR: checksum line version ${match[2]} does not match ${versionValue}`); + process.exit(1); + } + result[match[3]] = match[1]; + } + return result; +} + +function parseArgs(args) { + const parsed = {}; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (!arg.startsWith("--")) { + console.error(`ERROR: unexpected argument ${arg}`); + process.exit(1); + } + const key = arg.slice(2); + const value = args[i + 1]; + if (!value || value.startsWith("--")) { + console.error(`ERROR: ${arg} requires a value`); + process.exit(1); + } + parsed[key] = value; + i++; + } + return parsed; +} + +function required(value, name) { + if (!value) { + console.error(`ERROR: ${name} is required.`); + process.exit(1); + } + return value; +} diff --git a/cmd/loaf/command_surface_test.go b/cmd/loaf/command_surface_test.go index 96f5f8d4..a6bf141d 100644 --- a/cmd/loaf/command_surface_test.go +++ b/cmd/loaf/command_surface_test.go @@ -53,7 +53,7 @@ func nativeGoCommands(t *testing.T, root string) map[string]bool { t.Fatalf("could not isolate Runner.Run dispatcher in %s", cliPath) } dispatcher := source[start:end] - matches := regexp.MustCompile(`case "([^"]+)":\s*\n\s*return r\.run`).FindAllStringSubmatch(dispatcher, -1) + matches := regexp.MustCompile(`case "([^"]+)":\s*\n\s*(?:return|dispatchErr =) r\.run`).FindAllStringSubmatch(dispatcher, -1) if len(matches) == 0 { t.Fatalf("no Go-native command dispatch cases found in %s", cliPath) } diff --git a/cmd/loaf/main_test.go b/cmd/loaf/main_test.go index fc5d00e4..8f73cd1a 100644 --- a/cmd/loaf/main_test.go +++ b/cmd/loaf/main_test.go @@ -27,8 +27,8 @@ func TestPublicBinaryDispatchesStateVersionAndReleasePostMergeNatively(t *testin t.Fatalf("loaf state path error = %v\n%s", err, output) } statePath := strings.TrimSpace(output) - if !strings.HasPrefix(statePath, filepath.Join(dataHome, "loaf", "projects")+string(filepath.Separator)) { - t.Fatalf("state path = %q, want under data home %q", statePath, dataHome) + if statePath != filepath.Join(dataHome, "loaf", "loaf.sqlite") { + t.Fatalf("state path = %q, want global database under data home %q", statePath, dataHome) } if strings.HasPrefix(statePath, workingDir+string(filepath.Separator)) { t.Fatalf("state path = %q, want outside working dir %q", statePath, workingDir) diff --git a/content/skills/cli-reference/SKILL.md b/content/skills/cli-reference/SKILL.md index 7f36d1ee..b470ffa7 100644 --- a/content/skills/cli-reference/SKILL.md +++ b/content/skills/cli-reference/SKILL.md @@ -45,6 +45,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target ` - Build a specific target only + **Usage:** ```bash loaf build @@ -57,6 +61,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to ` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -69,6 +80,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -81,6 +96,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump ` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base ` - Use commits since instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base +- `--post-merge` - Finalize release after squash-merge +- `--version-file ` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -98,6 +127,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify `, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -106,47 +143,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin ` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format ` - Output format for the selected export kind + +- `loaf state export all`: + - `--format ` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format ` - Output format: markdown + +- `loaf state export session`: + - `--format ` - Output format: markdown + +- `loaf state export spec`: + - `--format ` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format ` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - ` [to]` - Previous and optional new absolute project paths + - `--from ` - Previous absolute project path + - `--to ` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -170,12 +310,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -211,32 +356,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status ` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status ` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title ` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -267,13 +419,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -307,22 +459,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -355,24 +508,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -412,6 +565,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -419,11 +582,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/dist/amp/plugins/loaf.js b/dist/amp/plugins/loaf.js index 5b586a95..fc8b907e 100644 --- a/dist/amp/plugins/loaf.js +++ b/dist/amp/plugins/loaf.js @@ -2,7 +2,7 @@ /** * Amp Plugin - Agent Skills Hooks * Auto-generated by loaf build system - * @version 2.0.0-dev.49 + * @version 2.0.0-pre.20260614235428 * @experimental This plugin uses the experimental Amp plugin API */ diff --git a/dist/amp/skills/architecture/SKILL.md b/dist/amp/skills/architecture/SKILL.md index 421a3c92..b94b3ac3 100644 --- a/dist/amp/skills/architecture/SKILL.md +++ b/dist/amp/skills/architecture/SKILL.md @@ -11,7 +11,7 @@ description: >- owning skill), or local choices changeable in a single PR (session-log decision() instead). The ADR log is append-only — when circumstances change, write a new ADR that supersedes the old one. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Architecture diff --git a/dist/amp/skills/bootstrap/SKILL.md b/dist/amp/skills/bootstrap/SKILL.md index 87915666..37123270 100644 --- a/dist/amp/skills/bootstrap/SKILL.md +++ b/dist/amp/skills/bootstrap/SKILL.md @@ -6,7 +6,7 @@ description: >- I start a new project?", "set up Loaf," or "bootstrap my project." Produces populated project documents and setup recommendations. Not for shaping features (use shape) or brainstorming ideas (use brainstorm). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Bootstrap diff --git a/dist/amp/skills/brainstorm/SKILL.md b/dist/amp/skills/brainstorm/SKILL.md index 4fe04aa6..bda7ffd5 100644 --- a/dist/amp/skills/brainstorm/SKILL.md +++ b/dist/amp/skills/brainstorm/SKILL.md @@ -5,7 +5,7 @@ description: >- analysis. Use when the user asks "help me think through this," "what are the options," or is exploring tradeoffs. Produces docs with sparks. Not for quick ideas or shaping. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Brainstorm diff --git a/dist/amp/skills/breakdown/SKILL.md b/dist/amp/skills/breakdown/SKILL.md index e2c003b0..8b94fad1 100644 --- a/dist/amp/skills/breakdown/SKILL.md +++ b/dist/amp/skills/breakdown/SKILL.md @@ -5,7 +5,7 @@ description: >- Use when the user asks "break this down" or "create tasks for this spec." Produces task files with estimates, dependencies, and acceptance criteria. Not for shaping ideas (use shape) or implementation work (use implement). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Breakdown diff --git a/dist/amp/skills/cli-reference/SKILL.md b/dist/amp/skills/cli-reference/SKILL.md index 7fc9437e..3a03d390 100644 --- a/dist/amp/skills/cli-reference/SKILL.md +++ b/dist/amp/skills/cli-reference/SKILL.md @@ -5,7 +5,7 @@ description: >- /implement, /implement, and all loaf subcommands. Use when you need to know which CLI command to invoke. Not for skill documentation (use the skill's own SKILL.md) or for understanding build internals. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Loaf CLI Reference @@ -45,6 +45,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target <name>` - Build a specific target only + **Usage:** ```bash loaf build @@ -57,6 +61,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to <target>` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -69,6 +80,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -81,6 +96,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump <type>` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base <ref>` - Use commits since <ref> instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base <auto-detected> +- `--post-merge` - Finalize release after squash-merge +- `--version-file <path>` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -98,6 +127,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify <backup>`, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify <backup> --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -106,47 +143,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin <imported|manual>` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format <format>` - Output format for the selected export kind + +- `loaf state export all`: + - `--format <format>` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format <format>` - Output format: markdown + +- `loaf state export session`: + - `--format <format>` - Output format: markdown + +- `loaf state export spec`: + - `--format <format>` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format <format>` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - `<from> [to]` - Previous and optional new absolute project paths + - `--from <path>` - Previous absolute project path + - `--to <path>` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -170,12 +310,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -211,32 +356,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title <title>` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -267,13 +419,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -307,22 +459,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -355,24 +508,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -412,6 +565,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -419,11 +582,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/dist/amp/skills/council/SKILL.md b/dist/amp/skills/council/SKILL.md index a9a9a6de..e1fc5ddb 100644 --- a/dist/amp/skills/council/SKILL.md +++ b/dist/amp/skills/council/SKILL.md @@ -7,7 +7,7 @@ description: >- the user wants a structured debate between domain-specific viewpoints. Not for single-perspective research (use research) or architectural decisions that don't need multi-agent deliberation (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Council diff --git a/dist/amp/skills/database-design/SKILL.md b/dist/amp/skills/database-design/SKILL.md index 3f9e7f58..fea2a9f0 100644 --- a/dist/amp/skills/database-design/SKILL.md +++ b/dist/amp/skills/database-design/SKILL.md @@ -7,7 +7,7 @@ description: >- database administration and development. Not for ORM usage in application code (use language-specific development skills) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Database Skill diff --git a/dist/amp/skills/debugging/SKILL.md b/dist/amp/skills/debugging/SKILL.md index f2bca471..b6b28467 100644 --- a/dist/amp/skills/debugging/SKILL.md +++ b/dist/amp/skills/debugging/SKILL.md @@ -6,7 +6,7 @@ description: >- flaky tests. Provides methodology for root cause analysis and issue resolution. Not for writing new tests (use development skills) or security analysis (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Debugging diff --git a/dist/amp/skills/documentation-standards/SKILL.md b/dist/amp/skills/documentation-standards/SKILL.md index becf5fb9..26987a9f 100644 --- a/dist/amp/skills/documentation-standards/SKILL.md +++ b/dist/amp/skills/documentation-standards/SKILL.md @@ -6,7 +6,7 @@ description: >- reviewing documentation quality, or creating architecture diagrams. Not for inline code comments (use code style guides) or project READMEs (use project-specific conventions). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Documentation Standards diff --git a/dist/amp/skills/foundations/SKILL.md b/dist/amp/skills/foundations/SKILL.md index 5e59c5d2..6ede2352 100644 --- a/dist/amp/skills/foundations/SKILL.md +++ b/dist/amp/skills/foundations/SKILL.md @@ -6,7 +6,7 @@ description: >- setting up project standards. Covers naming, TDD, verification, and review workflows. Not for git workflow (use git-workflow), debugging (use debugging), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Code Standards diff --git a/dist/amp/skills/git-workflow/SKILL.md b/dist/amp/skills/git-workflow/SKILL.md index a6d3e82b..7907909b 100644 --- a/dist/amp/skills/git-workflow/SKILL.md +++ b/dist/amp/skills/git-workflow/SKILL.md @@ -6,7 +6,7 @@ description: >- PRs, or managing git history. Provides patterns for collaborative git workflows. Not for code style (use foundations) or CI/CD pipelines (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Git Workflow diff --git a/dist/amp/skills/go-development/SKILL.md b/dist/amp/skills/go-development/SKILL.md index d28de813..690c7441 100644 --- a/dist/amp/skills/go-development/SKILL.md +++ b/dist/amp/skills/go-development/SKILL.md @@ -6,7 +6,7 @@ description: >- Follows Effective Go principles and community conventions. Not for database schema design (use database-design) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Go Skill diff --git a/dist/amp/skills/handoff/SKILL.md b/dist/amp/skills/handoff/SKILL.md index 27e99dc6..beabd13b 100644 --- a/dist/amp/skills/handoff/SKILL.md +++ b/dist/amp/skills/handoff/SKILL.md @@ -7,7 +7,7 @@ description: >- parked for later. Not for routine session continuity (use orchestration) or session shutdown (use wrap). Produces a disposable handoff artifact that housekeeping deletes after confirmed deprecation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Handoff diff --git a/dist/amp/skills/housekeeping/SKILL.md b/dist/amp/skills/housekeeping/SKILL.md index 2bc63293..e2b655cd 100644 --- a/dist/amp/skills/housekeeping/SKILL.md +++ b/dist/amp/skills/housekeeping/SKILL.md @@ -7,7 +7,7 @@ description: >- hygiene recommendations, archives completed work, and ensures extracted knowledge is preserved. Not for strategic reflection (use reflect) or knowledge management (use knowledge-base). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Housekeeping diff --git a/dist/amp/skills/idea/SKILL.md b/dist/amp/skills/idea/SKILL.md index 5f6147a5..1022d6f1 100644 --- a/dist/amp/skills/idea/SKILL.md +++ b/dist/amp/skills/idea/SKILL.md @@ -6,7 +6,7 @@ description: >- actionable concept crystallizes during conversation. For reviewing and processing the intake queue (sparks + raw ideas), use triage instead. Not for deep exploration (use brainstorm) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Idea diff --git a/dist/amp/skills/implement/SKILL.md b/dist/amp/skills/implement/SKILL.md index 2ff47519..19ec34e4 100644 --- a/dist/amp/skills/implement/SKILL.md +++ b/dist/amp/skills/implement/SKILL.md @@ -6,7 +6,7 @@ description: >- and code changes. Produces session files, agent spawn plans, and progress tracking. Not for shaping (use shape), breakdown (use breakdown), research, or review. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Implement diff --git a/dist/amp/skills/infrastructure-management/SKILL.md b/dist/amp/skills/infrastructure-management/SKILL.md index fd7a5b51..392b5f50 100644 --- a/dist/amp/skills/infrastructure-management/SKILL.md +++ b/dist/amp/skills/infrastructure-management/SKILL.md @@ -6,7 +6,7 @@ description: >- managing deployments. Provides patterns for infrastructure as code. Not for application code (use development skills), database schema (use database-design), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Infrastructure diff --git a/dist/amp/skills/interface-design/SKILL.md b/dist/amp/skills/interface-design/SKILL.md index a7fa59c8..4782bdae 100644 --- a/dist/amp/skills/interface-design/SKILL.md +++ b/dist/amp/skills/interface-design/SKILL.md @@ -6,7 +6,7 @@ description: >- ensuring accessibility compliance. Not for frontend code (use typescript-development) or API design (use architecture or language-specific skills). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Design Principles diff --git a/dist/amp/skills/knowledge-base/SKILL.md b/dist/amp/skills/knowledge-base/SKILL.md index a540a8b0..ff924628 100644 --- a/dist/amp/skills/knowledge-base/SKILL.md +++ b/dist/amp/skills/knowledge-base/SKILL.md @@ -6,7 +6,7 @@ description: >- covers: field, and the review workflow. Not for retrieval or search (use QMD directly), architectural decisions (use ADRs), or agent instructions (use CLAUDE.md). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Knowledge Base diff --git a/dist/amp/skills/orchestration/SKILL.md b/dist/amp/skills/orchestration/SKILL.md index fd897c3b..6602735a 100644 --- a/dist/amp/skills/orchestration/SKILL.md +++ b/dist/amp/skills/orchestration/SKILL.md @@ -6,7 +6,7 @@ description: >- agents, or coordinating cross-cutting work across multiple agents. Not for single-task implementation (use direct tool delegation) or solo research (use research). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Orchestration diff --git a/dist/amp/skills/power-systems-modeling/SKILL.md b/dist/amp/skills/power-systems-modeling/SKILL.md index 4ba20f4c..59c8d39d 100644 --- a/dist/amp/skills/power-systems-modeling/SKILL.md +++ b/dist/amp/skills/power-systems-modeling/SKILL.md @@ -6,7 +6,7 @@ description: >- thermal calculations, validating conductors, or computing sag and resistance. Not for infrastructure deployment (use infrastructure-management) or system architecture. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Power Systems Reference diff --git a/dist/amp/skills/python-development/SKILL.md b/dist/amp/skills/python-development/SKILL.md index 089365e7..dfa16dc2 100644 --- a/dist/amp/skills/python-development/SKILL.md +++ b/dist/amp/skills/python-development/SKILL.md @@ -6,7 +6,7 @@ description: >- models, or tests. Provides patterns for modern Python development. Not for schema design (use database-design), infrastructure (use infrastructure-management), or frontend code (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Python Development diff --git a/dist/amp/skills/refactor-deepen/SKILL.md b/dist/amp/skills/refactor-deepen/SKILL.md index bf714714..95c1e098 100644 --- a/dist/amp/skills/refactor-deepen/SKILL.md +++ b/dist/amp/skills/refactor-deepen/SKILL.md @@ -7,7 +7,7 @@ description: >- improvements, or when the user asks "is this module too shallow?" or "where should we deepen this code?" Produces either a read-only report or a PLAN file with candidates, dependency categories, and proposed deepened modules. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Refactor-Deepen diff --git a/dist/amp/skills/reflect/SKILL.md b/dist/amp/skills/reflect/SKILL.md index e00c79cf..a3fa71bb 100644 --- a/dist/amp/skills/reflect/SKILL.md +++ b/dist/amp/skills/reflect/SKILL.md @@ -6,7 +6,7 @@ description: >- VISION.md, STRATEGY.md, and ARCHITECTURE.md based on implementation experience. Not for pre-implementation strategy (use strategy) or ADRs (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Reflect diff --git a/dist/amp/skills/release/SKILL.md b/dist/amp/skills/release/SKILL.md index 2e164060..1cbc823b 100644 --- a/dist/amp/skills/release/SKILL.md +++ b/dist/amp/skills/release/SKILL.md @@ -6,7 +6,7 @@ description: >- "merge this PR," "ready to merge," or "ship it." Produces version bumps, changelog updates, and merged code. Not for creating PRs (use git-workflow) or reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Release diff --git a/dist/amp/skills/research/SKILL.md b/dist/amp/skills/research/SKILL.md index 50eb13f9..a689663a 100644 --- a/dist/amp/skills/research/SKILL.md +++ b/dist/amp/skills/research/SKILL.md @@ -6,7 +6,7 @@ description: >- Produces state assessments, research findings with ranked options, or vision change proposals. Not for multi-agent coordination (use orchestration) or implementation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Research diff --git a/dist/amp/skills/ruby-development/SKILL.md b/dist/amp/skills/ruby-development/SKILL.md index a9f4c306..d58ba660 100644 --- a/dist/amp/skills/ruby-development/SKILL.md +++ b/dist/amp/skills/ruby-development/SKILL.md @@ -6,7 +6,7 @@ description: >- following Rails patterns. Follows DHH/37signals conventions. Not for database schema design (use database-design) or frontend outside Hotwire (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Ruby Development diff --git a/dist/amp/skills/security-compliance/SKILL.md b/dist/amp/skills/security-compliance/SKILL.md index 259bf2a4..41e5b440 100644 --- a/dist/amp/skills/security-compliance/SKILL.md +++ b/dist/amp/skills/security-compliance/SKILL.md @@ -5,7 +5,7 @@ description: >- verification. Use when reviewing code for security, managing secrets, performing threat analysis, or running compliance audits. Not for debugging (use debugging) or general code review (use foundations). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Security & Compliance diff --git a/dist/amp/skills/shape/SKILL.md b/dist/amp/skills/shape/SKILL.md index 9b9cdd4b..26a76dd8 100644 --- a/dist/amp/skills/shape/SKILL.md +++ b/dist/amp/skills/shape/SKILL.md @@ -6,7 +6,7 @@ description: >- idea has accumulated enough constraints to bound. Produces specs with acceptance criteria. Not for brainstorming (use brainstorm) or task breakdown (use breakdown). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Shape diff --git a/dist/amp/skills/strategy/SKILL.md b/dist/amp/skills/strategy/SKILL.md index 02541e77..73fb3a7a 100644 --- a/dist/amp/skills/strategy/SKILL.md +++ b/dist/amp/skills/strategy/SKILL.md @@ -6,7 +6,7 @@ description: >- personas, market landscape analysis, and problem space definitions. Not for architecture (use architecture) or post-implementation reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Strategy diff --git a/dist/amp/skills/triage/SKILL.md b/dist/amp/skills/triage/SKILL.md index 97caa70c..9f4f1b2c 100644 --- a/dist/amp/skills/triage/SKILL.md +++ b/dist/amp/skills/triage/SKILL.md @@ -7,7 +7,7 @@ description: >- "what's in my backlog?" Produces promoted ideas, archived discards, and resolve(spark) journal entries. Not for capturing new ideas (use idea) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Triage diff --git a/dist/amp/skills/typescript-development/SKILL.md b/dist/amp/skills/typescript-development/SKILL.md index ecac3fcb..a7a3aedf 100644 --- a/dist/amp/skills/typescript-development/SKILL.md +++ b/dist/amp/skills/typescript-development/SKILL.md @@ -5,7 +5,7 @@ description: >- CSS, and Vitest testing. Use when writing TypeScript applications, React components, or Node.js services. Not for UI/UX design (use interface-design), database schema (use database-design), or Python (use python-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # TypeScript Development diff --git a/dist/amp/skills/wrap/SKILL.md b/dist/amp/skills/wrap/SKILL.md index 2fd3c5c1..f0e8fd95 100644 --- a/dist/amp/skills/wrap/SKILL.md +++ b/dist/amp/skills/wrap/SKILL.md @@ -7,7 +7,7 @@ description: >- the user asks "wrap up." Not for archiving (use housekeeping) or capturing ideas (use idea). Produces a Session Wrap-Up section and closes the session with done status. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Wrap diff --git a/dist/codex/skills/architecture/SKILL.md b/dist/codex/skills/architecture/SKILL.md index 421a3c92..b94b3ac3 100644 --- a/dist/codex/skills/architecture/SKILL.md +++ b/dist/codex/skills/architecture/SKILL.md @@ -11,7 +11,7 @@ description: >- owning skill), or local choices changeable in a single PR (session-log decision() instead). The ADR log is append-only — when circumstances change, write a new ADR that supersedes the old one. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Architecture diff --git a/dist/codex/skills/bootstrap/SKILL.md b/dist/codex/skills/bootstrap/SKILL.md index 87915666..37123270 100644 --- a/dist/codex/skills/bootstrap/SKILL.md +++ b/dist/codex/skills/bootstrap/SKILL.md @@ -6,7 +6,7 @@ description: >- I start a new project?", "set up Loaf," or "bootstrap my project." Produces populated project documents and setup recommendations. Not for shaping features (use shape) or brainstorming ideas (use brainstorm). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Bootstrap diff --git a/dist/codex/skills/brainstorm/SKILL.md b/dist/codex/skills/brainstorm/SKILL.md index 4fe04aa6..bda7ffd5 100644 --- a/dist/codex/skills/brainstorm/SKILL.md +++ b/dist/codex/skills/brainstorm/SKILL.md @@ -5,7 +5,7 @@ description: >- analysis. Use when the user asks "help me think through this," "what are the options," or is exploring tradeoffs. Produces docs with sparks. Not for quick ideas or shaping. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Brainstorm diff --git a/dist/codex/skills/breakdown/SKILL.md b/dist/codex/skills/breakdown/SKILL.md index e2c003b0..8b94fad1 100644 --- a/dist/codex/skills/breakdown/SKILL.md +++ b/dist/codex/skills/breakdown/SKILL.md @@ -5,7 +5,7 @@ description: >- Use when the user asks "break this down" or "create tasks for this spec." Produces task files with estimates, dependencies, and acceptance criteria. Not for shaping ideas (use shape) or implementation work (use implement). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Breakdown diff --git a/dist/codex/skills/cli-reference/SKILL.md b/dist/codex/skills/cli-reference/SKILL.md index 7fc9437e..3a03d390 100644 --- a/dist/codex/skills/cli-reference/SKILL.md +++ b/dist/codex/skills/cli-reference/SKILL.md @@ -5,7 +5,7 @@ description: >- /implement, /implement, and all loaf subcommands. Use when you need to know which CLI command to invoke. Not for skill documentation (use the skill's own SKILL.md) or for understanding build internals. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Loaf CLI Reference @@ -45,6 +45,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target <name>` - Build a specific target only + **Usage:** ```bash loaf build @@ -57,6 +61,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to <target>` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -69,6 +80,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -81,6 +96,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump <type>` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base <ref>` - Use commits since <ref> instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base <auto-detected> +- `--post-merge` - Finalize release after squash-merge +- `--version-file <path>` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -98,6 +127,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify <backup>`, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify <backup> --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -106,47 +143,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin <imported|manual>` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format <format>` - Output format for the selected export kind + +- `loaf state export all`: + - `--format <format>` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format <format>` - Output format: markdown + +- `loaf state export session`: + - `--format <format>` - Output format: markdown + +- `loaf state export spec`: + - `--format <format>` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format <format>` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - `<from> [to]` - Previous and optional new absolute project paths + - `--from <path>` - Previous absolute project path + - `--to <path>` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -170,12 +310,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -211,32 +356,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title <title>` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -267,13 +419,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -307,22 +459,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -355,24 +508,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -412,6 +565,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -419,11 +582,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/dist/codex/skills/council/SKILL.md b/dist/codex/skills/council/SKILL.md index a9a9a6de..e1fc5ddb 100644 --- a/dist/codex/skills/council/SKILL.md +++ b/dist/codex/skills/council/SKILL.md @@ -7,7 +7,7 @@ description: >- the user wants a structured debate between domain-specific viewpoints. Not for single-perspective research (use research) or architectural decisions that don't need multi-agent deliberation (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Council diff --git a/dist/codex/skills/database-design/SKILL.md b/dist/codex/skills/database-design/SKILL.md index 3f9e7f58..fea2a9f0 100644 --- a/dist/codex/skills/database-design/SKILL.md +++ b/dist/codex/skills/database-design/SKILL.md @@ -7,7 +7,7 @@ description: >- database administration and development. Not for ORM usage in application code (use language-specific development skills) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Database Skill diff --git a/dist/codex/skills/debugging/SKILL.md b/dist/codex/skills/debugging/SKILL.md index f2bca471..b6b28467 100644 --- a/dist/codex/skills/debugging/SKILL.md +++ b/dist/codex/skills/debugging/SKILL.md @@ -6,7 +6,7 @@ description: >- flaky tests. Provides methodology for root cause analysis and issue resolution. Not for writing new tests (use development skills) or security analysis (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Debugging diff --git a/dist/codex/skills/documentation-standards/SKILL.md b/dist/codex/skills/documentation-standards/SKILL.md index becf5fb9..26987a9f 100644 --- a/dist/codex/skills/documentation-standards/SKILL.md +++ b/dist/codex/skills/documentation-standards/SKILL.md @@ -6,7 +6,7 @@ description: >- reviewing documentation quality, or creating architecture diagrams. Not for inline code comments (use code style guides) or project READMEs (use project-specific conventions). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Documentation Standards diff --git a/dist/codex/skills/foundations/SKILL.md b/dist/codex/skills/foundations/SKILL.md index 5e59c5d2..6ede2352 100644 --- a/dist/codex/skills/foundations/SKILL.md +++ b/dist/codex/skills/foundations/SKILL.md @@ -6,7 +6,7 @@ description: >- setting up project standards. Covers naming, TDD, verification, and review workflows. Not for git workflow (use git-workflow), debugging (use debugging), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Code Standards diff --git a/dist/codex/skills/git-workflow/SKILL.md b/dist/codex/skills/git-workflow/SKILL.md index a6d3e82b..7907909b 100644 --- a/dist/codex/skills/git-workflow/SKILL.md +++ b/dist/codex/skills/git-workflow/SKILL.md @@ -6,7 +6,7 @@ description: >- PRs, or managing git history. Provides patterns for collaborative git workflows. Not for code style (use foundations) or CI/CD pipelines (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Git Workflow diff --git a/dist/codex/skills/go-development/SKILL.md b/dist/codex/skills/go-development/SKILL.md index d28de813..690c7441 100644 --- a/dist/codex/skills/go-development/SKILL.md +++ b/dist/codex/skills/go-development/SKILL.md @@ -6,7 +6,7 @@ description: >- Follows Effective Go principles and community conventions. Not for database schema design (use database-design) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Go Skill diff --git a/dist/codex/skills/handoff/SKILL.md b/dist/codex/skills/handoff/SKILL.md index 27e99dc6..beabd13b 100644 --- a/dist/codex/skills/handoff/SKILL.md +++ b/dist/codex/skills/handoff/SKILL.md @@ -7,7 +7,7 @@ description: >- parked for later. Not for routine session continuity (use orchestration) or session shutdown (use wrap). Produces a disposable handoff artifact that housekeeping deletes after confirmed deprecation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Handoff diff --git a/dist/codex/skills/housekeeping/SKILL.md b/dist/codex/skills/housekeeping/SKILL.md index 2bc63293..e2b655cd 100644 --- a/dist/codex/skills/housekeeping/SKILL.md +++ b/dist/codex/skills/housekeeping/SKILL.md @@ -7,7 +7,7 @@ description: >- hygiene recommendations, archives completed work, and ensures extracted knowledge is preserved. Not for strategic reflection (use reflect) or knowledge management (use knowledge-base). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Housekeeping diff --git a/dist/codex/skills/idea/SKILL.md b/dist/codex/skills/idea/SKILL.md index 5f6147a5..1022d6f1 100644 --- a/dist/codex/skills/idea/SKILL.md +++ b/dist/codex/skills/idea/SKILL.md @@ -6,7 +6,7 @@ description: >- actionable concept crystallizes during conversation. For reviewing and processing the intake queue (sparks + raw ideas), use triage instead. Not for deep exploration (use brainstorm) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Idea diff --git a/dist/codex/skills/implement/SKILL.md b/dist/codex/skills/implement/SKILL.md index 2ff47519..19ec34e4 100644 --- a/dist/codex/skills/implement/SKILL.md +++ b/dist/codex/skills/implement/SKILL.md @@ -6,7 +6,7 @@ description: >- and code changes. Produces session files, agent spawn plans, and progress tracking. Not for shaping (use shape), breakdown (use breakdown), research, or review. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Implement diff --git a/dist/codex/skills/infrastructure-management/SKILL.md b/dist/codex/skills/infrastructure-management/SKILL.md index fd7a5b51..392b5f50 100644 --- a/dist/codex/skills/infrastructure-management/SKILL.md +++ b/dist/codex/skills/infrastructure-management/SKILL.md @@ -6,7 +6,7 @@ description: >- managing deployments. Provides patterns for infrastructure as code. Not for application code (use development skills), database schema (use database-design), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Infrastructure diff --git a/dist/codex/skills/interface-design/SKILL.md b/dist/codex/skills/interface-design/SKILL.md index a7fa59c8..4782bdae 100644 --- a/dist/codex/skills/interface-design/SKILL.md +++ b/dist/codex/skills/interface-design/SKILL.md @@ -6,7 +6,7 @@ description: >- ensuring accessibility compliance. Not for frontend code (use typescript-development) or API design (use architecture or language-specific skills). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Design Principles diff --git a/dist/codex/skills/knowledge-base/SKILL.md b/dist/codex/skills/knowledge-base/SKILL.md index a540a8b0..ff924628 100644 --- a/dist/codex/skills/knowledge-base/SKILL.md +++ b/dist/codex/skills/knowledge-base/SKILL.md @@ -6,7 +6,7 @@ description: >- covers: field, and the review workflow. Not for retrieval or search (use QMD directly), architectural decisions (use ADRs), or agent instructions (use CLAUDE.md). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Knowledge Base diff --git a/dist/codex/skills/orchestration/SKILL.md b/dist/codex/skills/orchestration/SKILL.md index fd897c3b..6602735a 100644 --- a/dist/codex/skills/orchestration/SKILL.md +++ b/dist/codex/skills/orchestration/SKILL.md @@ -6,7 +6,7 @@ description: >- agents, or coordinating cross-cutting work across multiple agents. Not for single-task implementation (use direct tool delegation) or solo research (use research). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Orchestration diff --git a/dist/codex/skills/power-systems-modeling/SKILL.md b/dist/codex/skills/power-systems-modeling/SKILL.md index 4ba20f4c..59c8d39d 100644 --- a/dist/codex/skills/power-systems-modeling/SKILL.md +++ b/dist/codex/skills/power-systems-modeling/SKILL.md @@ -6,7 +6,7 @@ description: >- thermal calculations, validating conductors, or computing sag and resistance. Not for infrastructure deployment (use infrastructure-management) or system architecture. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Power Systems Reference diff --git a/dist/codex/skills/python-development/SKILL.md b/dist/codex/skills/python-development/SKILL.md index 089365e7..dfa16dc2 100644 --- a/dist/codex/skills/python-development/SKILL.md +++ b/dist/codex/skills/python-development/SKILL.md @@ -6,7 +6,7 @@ description: >- models, or tests. Provides patterns for modern Python development. Not for schema design (use database-design), infrastructure (use infrastructure-management), or frontend code (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Python Development diff --git a/dist/codex/skills/refactor-deepen/SKILL.md b/dist/codex/skills/refactor-deepen/SKILL.md index bf714714..95c1e098 100644 --- a/dist/codex/skills/refactor-deepen/SKILL.md +++ b/dist/codex/skills/refactor-deepen/SKILL.md @@ -7,7 +7,7 @@ description: >- improvements, or when the user asks "is this module too shallow?" or "where should we deepen this code?" Produces either a read-only report or a PLAN file with candidates, dependency categories, and proposed deepened modules. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Refactor-Deepen diff --git a/dist/codex/skills/reflect/SKILL.md b/dist/codex/skills/reflect/SKILL.md index e00c79cf..a3fa71bb 100644 --- a/dist/codex/skills/reflect/SKILL.md +++ b/dist/codex/skills/reflect/SKILL.md @@ -6,7 +6,7 @@ description: >- VISION.md, STRATEGY.md, and ARCHITECTURE.md based on implementation experience. Not for pre-implementation strategy (use strategy) or ADRs (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Reflect diff --git a/dist/codex/skills/release/SKILL.md b/dist/codex/skills/release/SKILL.md index 2e164060..1cbc823b 100644 --- a/dist/codex/skills/release/SKILL.md +++ b/dist/codex/skills/release/SKILL.md @@ -6,7 +6,7 @@ description: >- "merge this PR," "ready to merge," or "ship it." Produces version bumps, changelog updates, and merged code. Not for creating PRs (use git-workflow) or reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Release diff --git a/dist/codex/skills/research/SKILL.md b/dist/codex/skills/research/SKILL.md index 50eb13f9..a689663a 100644 --- a/dist/codex/skills/research/SKILL.md +++ b/dist/codex/skills/research/SKILL.md @@ -6,7 +6,7 @@ description: >- Produces state assessments, research findings with ranked options, or vision change proposals. Not for multi-agent coordination (use orchestration) or implementation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Research diff --git a/dist/codex/skills/ruby-development/SKILL.md b/dist/codex/skills/ruby-development/SKILL.md index a9f4c306..d58ba660 100644 --- a/dist/codex/skills/ruby-development/SKILL.md +++ b/dist/codex/skills/ruby-development/SKILL.md @@ -6,7 +6,7 @@ description: >- following Rails patterns. Follows DHH/37signals conventions. Not for database schema design (use database-design) or frontend outside Hotwire (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Ruby Development diff --git a/dist/codex/skills/security-compliance/SKILL.md b/dist/codex/skills/security-compliance/SKILL.md index 259bf2a4..41e5b440 100644 --- a/dist/codex/skills/security-compliance/SKILL.md +++ b/dist/codex/skills/security-compliance/SKILL.md @@ -5,7 +5,7 @@ description: >- verification. Use when reviewing code for security, managing secrets, performing threat analysis, or running compliance audits. Not for debugging (use debugging) or general code review (use foundations). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Security & Compliance diff --git a/dist/codex/skills/shape/SKILL.md b/dist/codex/skills/shape/SKILL.md index 9b9cdd4b..26a76dd8 100644 --- a/dist/codex/skills/shape/SKILL.md +++ b/dist/codex/skills/shape/SKILL.md @@ -6,7 +6,7 @@ description: >- idea has accumulated enough constraints to bound. Produces specs with acceptance criteria. Not for brainstorming (use brainstorm) or task breakdown (use breakdown). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Shape diff --git a/dist/codex/skills/strategy/SKILL.md b/dist/codex/skills/strategy/SKILL.md index 02541e77..73fb3a7a 100644 --- a/dist/codex/skills/strategy/SKILL.md +++ b/dist/codex/skills/strategy/SKILL.md @@ -6,7 +6,7 @@ description: >- personas, market landscape analysis, and problem space definitions. Not for architecture (use architecture) or post-implementation reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Strategy diff --git a/dist/codex/skills/triage/SKILL.md b/dist/codex/skills/triage/SKILL.md index 97caa70c..9f4f1b2c 100644 --- a/dist/codex/skills/triage/SKILL.md +++ b/dist/codex/skills/triage/SKILL.md @@ -7,7 +7,7 @@ description: >- "what's in my backlog?" Produces promoted ideas, archived discards, and resolve(spark) journal entries. Not for capturing new ideas (use idea) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Triage diff --git a/dist/codex/skills/typescript-development/SKILL.md b/dist/codex/skills/typescript-development/SKILL.md index ecac3fcb..a7a3aedf 100644 --- a/dist/codex/skills/typescript-development/SKILL.md +++ b/dist/codex/skills/typescript-development/SKILL.md @@ -5,7 +5,7 @@ description: >- CSS, and Vitest testing. Use when writing TypeScript applications, React components, or Node.js services. Not for UI/UX design (use interface-design), database schema (use database-design), or Python (use python-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # TypeScript Development diff --git a/dist/codex/skills/wrap/SKILL.md b/dist/codex/skills/wrap/SKILL.md index 2fd3c5c1..f0e8fd95 100644 --- a/dist/codex/skills/wrap/SKILL.md +++ b/dist/codex/skills/wrap/SKILL.md @@ -7,7 +7,7 @@ description: >- the user asks "wrap up." Not for archiving (use housekeeping) or capturing ideas (use idea). Produces a Session Wrap-Up section and closes the session with done status. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Wrap diff --git a/dist/cursor/agents/background-runner.md b/dist/cursor/agents/background-runner.md index 532369d2..8cd373f0 100644 --- a/dist/cursor/agents/background-runner.md +++ b/dist/cursor/agents/background-runner.md @@ -167,4 +167,4 @@ Background Agent ID: bg-20260123-143000-auth-security 4. Update `.agents/sessions/20260123-140000-auth-feature.md` frontmatter --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/cursor/agents/implementer.md b/dist/cursor/agents/implementer.md index e264b828..a1194ab4 100644 --- a/dist/cursor/agents/implementer.md +++ b/dist/cursor/agents/implementer.md @@ -33,4 +33,4 @@ You are an implementer. You have full write access to the codebase: code, tests, - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/cursor/agents/librarian.md b/dist/cursor/agents/librarian.md index ceae1758..ce271251 100644 --- a/dist/cursor/agents/librarian.md +++ b/dist/cursor/agents/librarian.md @@ -38,4 +38,4 @@ You are a librarian. You shepherd session files through their lifecycle and tend - Scope all file operations to `.agents/` paths. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/cursor/agents/researcher.md b/dist/cursor/agents/researcher.md index 88025456..6f18537d 100644 --- a/dist/cursor/agents/researcher.md +++ b/dist/cursor/agents/researcher.md @@ -32,4 +32,4 @@ You are a researcher. You have read access to the codebase and web access to the - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/cursor/agents/reviewer.md b/dist/cursor/agents/reviewer.md index a40254fb..b0849ca7 100644 --- a/dist/cursor/agents/reviewer.md +++ b/dist/cursor/agents/reviewer.md @@ -30,4 +30,4 @@ You are a reviewer. You have read-only access to the codebase. This is not a lim - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/cursor/skills/architecture/SKILL.md b/dist/cursor/skills/architecture/SKILL.md index 421a3c92..b94b3ac3 100644 --- a/dist/cursor/skills/architecture/SKILL.md +++ b/dist/cursor/skills/architecture/SKILL.md @@ -11,7 +11,7 @@ description: >- owning skill), or local choices changeable in a single PR (session-log decision() instead). The ADR log is append-only — when circumstances change, write a new ADR that supersedes the old one. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Architecture diff --git a/dist/cursor/skills/bootstrap/SKILL.md b/dist/cursor/skills/bootstrap/SKILL.md index 87915666..37123270 100644 --- a/dist/cursor/skills/bootstrap/SKILL.md +++ b/dist/cursor/skills/bootstrap/SKILL.md @@ -6,7 +6,7 @@ description: >- I start a new project?", "set up Loaf," or "bootstrap my project." Produces populated project documents and setup recommendations. Not for shaping features (use shape) or brainstorming ideas (use brainstorm). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Bootstrap diff --git a/dist/cursor/skills/brainstorm/SKILL.md b/dist/cursor/skills/brainstorm/SKILL.md index 4fe04aa6..bda7ffd5 100644 --- a/dist/cursor/skills/brainstorm/SKILL.md +++ b/dist/cursor/skills/brainstorm/SKILL.md @@ -5,7 +5,7 @@ description: >- analysis. Use when the user asks "help me think through this," "what are the options," or is exploring tradeoffs. Produces docs with sparks. Not for quick ideas or shaping. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Brainstorm diff --git a/dist/cursor/skills/breakdown/SKILL.md b/dist/cursor/skills/breakdown/SKILL.md index e2c003b0..8b94fad1 100644 --- a/dist/cursor/skills/breakdown/SKILL.md +++ b/dist/cursor/skills/breakdown/SKILL.md @@ -5,7 +5,7 @@ description: >- Use when the user asks "break this down" or "create tasks for this spec." Produces task files with estimates, dependencies, and acceptance criteria. Not for shaping ideas (use shape) or implementation work (use implement). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Breakdown diff --git a/dist/cursor/skills/cli-reference/SKILL.md b/dist/cursor/skills/cli-reference/SKILL.md index 7fc9437e..3a03d390 100644 --- a/dist/cursor/skills/cli-reference/SKILL.md +++ b/dist/cursor/skills/cli-reference/SKILL.md @@ -5,7 +5,7 @@ description: >- /implement, /implement, and all loaf subcommands. Use when you need to know which CLI command to invoke. Not for skill documentation (use the skill's own SKILL.md) or for understanding build internals. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Loaf CLI Reference @@ -45,6 +45,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target <name>` - Build a specific target only + **Usage:** ```bash loaf build @@ -57,6 +61,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to <target>` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -69,6 +80,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -81,6 +96,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump <type>` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base <ref>` - Use commits since <ref> instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base <auto-detected> +- `--post-merge` - Finalize release after squash-merge +- `--version-file <path>` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -98,6 +127,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify <backup>`, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify <backup> --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -106,47 +143,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin <imported|manual>` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format <format>` - Output format for the selected export kind + +- `loaf state export all`: + - `--format <format>` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format <format>` - Output format: markdown + +- `loaf state export session`: + - `--format <format>` - Output format: markdown + +- `loaf state export spec`: + - `--format <format>` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format <format>` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - `<from> [to]` - Previous and optional new absolute project paths + - `--from <path>` - Previous absolute project path + - `--to <path>` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -170,12 +310,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -211,32 +356,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title <title>` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -267,13 +419,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -307,22 +459,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -355,24 +508,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -412,6 +565,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -419,11 +582,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/dist/cursor/skills/council/SKILL.md b/dist/cursor/skills/council/SKILL.md index a9a9a6de..e1fc5ddb 100644 --- a/dist/cursor/skills/council/SKILL.md +++ b/dist/cursor/skills/council/SKILL.md @@ -7,7 +7,7 @@ description: >- the user wants a structured debate between domain-specific viewpoints. Not for single-perspective research (use research) or architectural decisions that don't need multi-agent deliberation (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Council diff --git a/dist/cursor/skills/database-design/SKILL.md b/dist/cursor/skills/database-design/SKILL.md index 3f9e7f58..fea2a9f0 100644 --- a/dist/cursor/skills/database-design/SKILL.md +++ b/dist/cursor/skills/database-design/SKILL.md @@ -7,7 +7,7 @@ description: >- database administration and development. Not for ORM usage in application code (use language-specific development skills) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Database Skill diff --git a/dist/cursor/skills/debugging/SKILL.md b/dist/cursor/skills/debugging/SKILL.md index f2bca471..b6b28467 100644 --- a/dist/cursor/skills/debugging/SKILL.md +++ b/dist/cursor/skills/debugging/SKILL.md @@ -6,7 +6,7 @@ description: >- flaky tests. Provides methodology for root cause analysis and issue resolution. Not for writing new tests (use development skills) or security analysis (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Debugging diff --git a/dist/cursor/skills/documentation-standards/SKILL.md b/dist/cursor/skills/documentation-standards/SKILL.md index becf5fb9..26987a9f 100644 --- a/dist/cursor/skills/documentation-standards/SKILL.md +++ b/dist/cursor/skills/documentation-standards/SKILL.md @@ -6,7 +6,7 @@ description: >- reviewing documentation quality, or creating architecture diagrams. Not for inline code comments (use code style guides) or project READMEs (use project-specific conventions). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Documentation Standards diff --git a/dist/cursor/skills/foundations/SKILL.md b/dist/cursor/skills/foundations/SKILL.md index 5e59c5d2..6ede2352 100644 --- a/dist/cursor/skills/foundations/SKILL.md +++ b/dist/cursor/skills/foundations/SKILL.md @@ -6,7 +6,7 @@ description: >- setting up project standards. Covers naming, TDD, verification, and review workflows. Not for git workflow (use git-workflow), debugging (use debugging), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Code Standards diff --git a/dist/cursor/skills/git-workflow/SKILL.md b/dist/cursor/skills/git-workflow/SKILL.md index a6d3e82b..7907909b 100644 --- a/dist/cursor/skills/git-workflow/SKILL.md +++ b/dist/cursor/skills/git-workflow/SKILL.md @@ -6,7 +6,7 @@ description: >- PRs, or managing git history. Provides patterns for collaborative git workflows. Not for code style (use foundations) or CI/CD pipelines (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Git Workflow diff --git a/dist/cursor/skills/go-development/SKILL.md b/dist/cursor/skills/go-development/SKILL.md index d28de813..690c7441 100644 --- a/dist/cursor/skills/go-development/SKILL.md +++ b/dist/cursor/skills/go-development/SKILL.md @@ -6,7 +6,7 @@ description: >- Follows Effective Go principles and community conventions. Not for database schema design (use database-design) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Go Skill diff --git a/dist/cursor/skills/handoff/SKILL.md b/dist/cursor/skills/handoff/SKILL.md index 27e99dc6..beabd13b 100644 --- a/dist/cursor/skills/handoff/SKILL.md +++ b/dist/cursor/skills/handoff/SKILL.md @@ -7,7 +7,7 @@ description: >- parked for later. Not for routine session continuity (use orchestration) or session shutdown (use wrap). Produces a disposable handoff artifact that housekeeping deletes after confirmed deprecation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Handoff diff --git a/dist/cursor/skills/housekeeping/SKILL.md b/dist/cursor/skills/housekeeping/SKILL.md index 2bc63293..e2b655cd 100644 --- a/dist/cursor/skills/housekeeping/SKILL.md +++ b/dist/cursor/skills/housekeeping/SKILL.md @@ -7,7 +7,7 @@ description: >- hygiene recommendations, archives completed work, and ensures extracted knowledge is preserved. Not for strategic reflection (use reflect) or knowledge management (use knowledge-base). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Housekeeping diff --git a/dist/cursor/skills/idea/SKILL.md b/dist/cursor/skills/idea/SKILL.md index 5f6147a5..1022d6f1 100644 --- a/dist/cursor/skills/idea/SKILL.md +++ b/dist/cursor/skills/idea/SKILL.md @@ -6,7 +6,7 @@ description: >- actionable concept crystallizes during conversation. For reviewing and processing the intake queue (sparks + raw ideas), use triage instead. Not for deep exploration (use brainstorm) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Idea diff --git a/dist/cursor/skills/implement/SKILL.md b/dist/cursor/skills/implement/SKILL.md index 2ff47519..19ec34e4 100644 --- a/dist/cursor/skills/implement/SKILL.md +++ b/dist/cursor/skills/implement/SKILL.md @@ -6,7 +6,7 @@ description: >- and code changes. Produces session files, agent spawn plans, and progress tracking. Not for shaping (use shape), breakdown (use breakdown), research, or review. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Implement diff --git a/dist/cursor/skills/infrastructure-management/SKILL.md b/dist/cursor/skills/infrastructure-management/SKILL.md index fd7a5b51..392b5f50 100644 --- a/dist/cursor/skills/infrastructure-management/SKILL.md +++ b/dist/cursor/skills/infrastructure-management/SKILL.md @@ -6,7 +6,7 @@ description: >- managing deployments. Provides patterns for infrastructure as code. Not for application code (use development skills), database schema (use database-design), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Infrastructure diff --git a/dist/cursor/skills/interface-design/SKILL.md b/dist/cursor/skills/interface-design/SKILL.md index a7fa59c8..4782bdae 100644 --- a/dist/cursor/skills/interface-design/SKILL.md +++ b/dist/cursor/skills/interface-design/SKILL.md @@ -6,7 +6,7 @@ description: >- ensuring accessibility compliance. Not for frontend code (use typescript-development) or API design (use architecture or language-specific skills). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Design Principles diff --git a/dist/cursor/skills/knowledge-base/SKILL.md b/dist/cursor/skills/knowledge-base/SKILL.md index a540a8b0..ff924628 100644 --- a/dist/cursor/skills/knowledge-base/SKILL.md +++ b/dist/cursor/skills/knowledge-base/SKILL.md @@ -6,7 +6,7 @@ description: >- covers: field, and the review workflow. Not for retrieval or search (use QMD directly), architectural decisions (use ADRs), or agent instructions (use CLAUDE.md). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Knowledge Base diff --git a/dist/cursor/skills/orchestration/SKILL.md b/dist/cursor/skills/orchestration/SKILL.md index fd897c3b..6602735a 100644 --- a/dist/cursor/skills/orchestration/SKILL.md +++ b/dist/cursor/skills/orchestration/SKILL.md @@ -6,7 +6,7 @@ description: >- agents, or coordinating cross-cutting work across multiple agents. Not for single-task implementation (use direct tool delegation) or solo research (use research). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Orchestration diff --git a/dist/cursor/skills/power-systems-modeling/SKILL.md b/dist/cursor/skills/power-systems-modeling/SKILL.md index 4ba20f4c..59c8d39d 100644 --- a/dist/cursor/skills/power-systems-modeling/SKILL.md +++ b/dist/cursor/skills/power-systems-modeling/SKILL.md @@ -6,7 +6,7 @@ description: >- thermal calculations, validating conductors, or computing sag and resistance. Not for infrastructure deployment (use infrastructure-management) or system architecture. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Power Systems Reference diff --git a/dist/cursor/skills/python-development/SKILL.md b/dist/cursor/skills/python-development/SKILL.md index 089365e7..dfa16dc2 100644 --- a/dist/cursor/skills/python-development/SKILL.md +++ b/dist/cursor/skills/python-development/SKILL.md @@ -6,7 +6,7 @@ description: >- models, or tests. Provides patterns for modern Python development. Not for schema design (use database-design), infrastructure (use infrastructure-management), or frontend code (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Python Development diff --git a/dist/cursor/skills/refactor-deepen/SKILL.md b/dist/cursor/skills/refactor-deepen/SKILL.md index bf714714..95c1e098 100644 --- a/dist/cursor/skills/refactor-deepen/SKILL.md +++ b/dist/cursor/skills/refactor-deepen/SKILL.md @@ -7,7 +7,7 @@ description: >- improvements, or when the user asks "is this module too shallow?" or "where should we deepen this code?" Produces either a read-only report or a PLAN file with candidates, dependency categories, and proposed deepened modules. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Refactor-Deepen diff --git a/dist/cursor/skills/reflect/SKILL.md b/dist/cursor/skills/reflect/SKILL.md index e00c79cf..a3fa71bb 100644 --- a/dist/cursor/skills/reflect/SKILL.md +++ b/dist/cursor/skills/reflect/SKILL.md @@ -6,7 +6,7 @@ description: >- VISION.md, STRATEGY.md, and ARCHITECTURE.md based on implementation experience. Not for pre-implementation strategy (use strategy) or ADRs (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Reflect diff --git a/dist/cursor/skills/release/SKILL.md b/dist/cursor/skills/release/SKILL.md index 2e164060..1cbc823b 100644 --- a/dist/cursor/skills/release/SKILL.md +++ b/dist/cursor/skills/release/SKILL.md @@ -6,7 +6,7 @@ description: >- "merge this PR," "ready to merge," or "ship it." Produces version bumps, changelog updates, and merged code. Not for creating PRs (use git-workflow) or reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Release diff --git a/dist/cursor/skills/research/SKILL.md b/dist/cursor/skills/research/SKILL.md index 50eb13f9..a689663a 100644 --- a/dist/cursor/skills/research/SKILL.md +++ b/dist/cursor/skills/research/SKILL.md @@ -6,7 +6,7 @@ description: >- Produces state assessments, research findings with ranked options, or vision change proposals. Not for multi-agent coordination (use orchestration) or implementation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Research diff --git a/dist/cursor/skills/ruby-development/SKILL.md b/dist/cursor/skills/ruby-development/SKILL.md index a9f4c306..d58ba660 100644 --- a/dist/cursor/skills/ruby-development/SKILL.md +++ b/dist/cursor/skills/ruby-development/SKILL.md @@ -6,7 +6,7 @@ description: >- following Rails patterns. Follows DHH/37signals conventions. Not for database schema design (use database-design) or frontend outside Hotwire (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Ruby Development diff --git a/dist/cursor/skills/security-compliance/SKILL.md b/dist/cursor/skills/security-compliance/SKILL.md index 259bf2a4..41e5b440 100644 --- a/dist/cursor/skills/security-compliance/SKILL.md +++ b/dist/cursor/skills/security-compliance/SKILL.md @@ -5,7 +5,7 @@ description: >- verification. Use when reviewing code for security, managing secrets, performing threat analysis, or running compliance audits. Not for debugging (use debugging) or general code review (use foundations). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Security & Compliance diff --git a/dist/cursor/skills/shape/SKILL.md b/dist/cursor/skills/shape/SKILL.md index 9b9cdd4b..26a76dd8 100644 --- a/dist/cursor/skills/shape/SKILL.md +++ b/dist/cursor/skills/shape/SKILL.md @@ -6,7 +6,7 @@ description: >- idea has accumulated enough constraints to bound. Produces specs with acceptance criteria. Not for brainstorming (use brainstorm) or task breakdown (use breakdown). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Shape diff --git a/dist/cursor/skills/strategy/SKILL.md b/dist/cursor/skills/strategy/SKILL.md index 02541e77..73fb3a7a 100644 --- a/dist/cursor/skills/strategy/SKILL.md +++ b/dist/cursor/skills/strategy/SKILL.md @@ -6,7 +6,7 @@ description: >- personas, market landscape analysis, and problem space definitions. Not for architecture (use architecture) or post-implementation reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Strategy diff --git a/dist/cursor/skills/triage/SKILL.md b/dist/cursor/skills/triage/SKILL.md index 97caa70c..9f4f1b2c 100644 --- a/dist/cursor/skills/triage/SKILL.md +++ b/dist/cursor/skills/triage/SKILL.md @@ -7,7 +7,7 @@ description: >- "what's in my backlog?" Produces promoted ideas, archived discards, and resolve(spark) journal entries. Not for capturing new ideas (use idea) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Triage diff --git a/dist/cursor/skills/typescript-development/SKILL.md b/dist/cursor/skills/typescript-development/SKILL.md index ecac3fcb..a7a3aedf 100644 --- a/dist/cursor/skills/typescript-development/SKILL.md +++ b/dist/cursor/skills/typescript-development/SKILL.md @@ -5,7 +5,7 @@ description: >- CSS, and Vitest testing. Use when writing TypeScript applications, React components, or Node.js services. Not for UI/UX design (use interface-design), database schema (use database-design), or Python (use python-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # TypeScript Development diff --git a/dist/cursor/skills/wrap/SKILL.md b/dist/cursor/skills/wrap/SKILL.md index 2fd3c5c1..f0e8fd95 100644 --- a/dist/cursor/skills/wrap/SKILL.md +++ b/dist/cursor/skills/wrap/SKILL.md @@ -7,7 +7,7 @@ description: >- the user asks "wrap up." Not for archiving (use housekeeping) or capturing ideas (use idea). Produces a Session Wrap-Up section and closes the session with done status. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Wrap diff --git a/dist/gemini/skills/architecture/SKILL.md b/dist/gemini/skills/architecture/SKILL.md index 421a3c92..b94b3ac3 100644 --- a/dist/gemini/skills/architecture/SKILL.md +++ b/dist/gemini/skills/architecture/SKILL.md @@ -11,7 +11,7 @@ description: >- owning skill), or local choices changeable in a single PR (session-log decision() instead). The ADR log is append-only — when circumstances change, write a new ADR that supersedes the old one. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Architecture diff --git a/dist/gemini/skills/bootstrap/SKILL.md b/dist/gemini/skills/bootstrap/SKILL.md index 87915666..37123270 100644 --- a/dist/gemini/skills/bootstrap/SKILL.md +++ b/dist/gemini/skills/bootstrap/SKILL.md @@ -6,7 +6,7 @@ description: >- I start a new project?", "set up Loaf," or "bootstrap my project." Produces populated project documents and setup recommendations. Not for shaping features (use shape) or brainstorming ideas (use brainstorm). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Bootstrap diff --git a/dist/gemini/skills/brainstorm/SKILL.md b/dist/gemini/skills/brainstorm/SKILL.md index 4fe04aa6..bda7ffd5 100644 --- a/dist/gemini/skills/brainstorm/SKILL.md +++ b/dist/gemini/skills/brainstorm/SKILL.md @@ -5,7 +5,7 @@ description: >- analysis. Use when the user asks "help me think through this," "what are the options," or is exploring tradeoffs. Produces docs with sparks. Not for quick ideas or shaping. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Brainstorm diff --git a/dist/gemini/skills/breakdown/SKILL.md b/dist/gemini/skills/breakdown/SKILL.md index e2c003b0..8b94fad1 100644 --- a/dist/gemini/skills/breakdown/SKILL.md +++ b/dist/gemini/skills/breakdown/SKILL.md @@ -5,7 +5,7 @@ description: >- Use when the user asks "break this down" or "create tasks for this spec." Produces task files with estimates, dependencies, and acceptance criteria. Not for shaping ideas (use shape) or implementation work (use implement). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Breakdown diff --git a/dist/gemini/skills/cli-reference/SKILL.md b/dist/gemini/skills/cli-reference/SKILL.md index 7fc9437e..3a03d390 100644 --- a/dist/gemini/skills/cli-reference/SKILL.md +++ b/dist/gemini/skills/cli-reference/SKILL.md @@ -5,7 +5,7 @@ description: >- /implement, /implement, and all loaf subcommands. Use when you need to know which CLI command to invoke. Not for skill documentation (use the skill's own SKILL.md) or for understanding build internals. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Loaf CLI Reference @@ -45,6 +45,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target <name>` - Build a specific target only + **Usage:** ```bash loaf build @@ -57,6 +61,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to <target>` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -69,6 +80,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -81,6 +96,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump <type>` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base <ref>` - Use commits since <ref> instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base <auto-detected> +- `--post-merge` - Finalize release after squash-merge +- `--version-file <path>` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -98,6 +127,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify <backup>`, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify <backup> --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -106,47 +143,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin <imported|manual>` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format <format>` - Output format for the selected export kind + +- `loaf state export all`: + - `--format <format>` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format <format>` - Output format: markdown + +- `loaf state export session`: + - `--format <format>` - Output format: markdown + +- `loaf state export spec`: + - `--format <format>` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format <format>` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - `<from> [to]` - Previous and optional new absolute project paths + - `--from <path>` - Previous absolute project path + - `--to <path>` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -170,12 +310,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -211,32 +356,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title <title>` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -267,13 +419,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -307,22 +459,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -355,24 +508,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -412,6 +565,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -419,11 +582,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/dist/gemini/skills/council/SKILL.md b/dist/gemini/skills/council/SKILL.md index a9a9a6de..e1fc5ddb 100644 --- a/dist/gemini/skills/council/SKILL.md +++ b/dist/gemini/skills/council/SKILL.md @@ -7,7 +7,7 @@ description: >- the user wants a structured debate between domain-specific viewpoints. Not for single-perspective research (use research) or architectural decisions that don't need multi-agent deliberation (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Council diff --git a/dist/gemini/skills/database-design/SKILL.md b/dist/gemini/skills/database-design/SKILL.md index 3f9e7f58..fea2a9f0 100644 --- a/dist/gemini/skills/database-design/SKILL.md +++ b/dist/gemini/skills/database-design/SKILL.md @@ -7,7 +7,7 @@ description: >- database administration and development. Not for ORM usage in application code (use language-specific development skills) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Database Skill diff --git a/dist/gemini/skills/debugging/SKILL.md b/dist/gemini/skills/debugging/SKILL.md index f2bca471..b6b28467 100644 --- a/dist/gemini/skills/debugging/SKILL.md +++ b/dist/gemini/skills/debugging/SKILL.md @@ -6,7 +6,7 @@ description: >- flaky tests. Provides methodology for root cause analysis and issue resolution. Not for writing new tests (use development skills) or security analysis (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Debugging diff --git a/dist/gemini/skills/documentation-standards/SKILL.md b/dist/gemini/skills/documentation-standards/SKILL.md index becf5fb9..26987a9f 100644 --- a/dist/gemini/skills/documentation-standards/SKILL.md +++ b/dist/gemini/skills/documentation-standards/SKILL.md @@ -6,7 +6,7 @@ description: >- reviewing documentation quality, or creating architecture diagrams. Not for inline code comments (use code style guides) or project READMEs (use project-specific conventions). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Documentation Standards diff --git a/dist/gemini/skills/foundations/SKILL.md b/dist/gemini/skills/foundations/SKILL.md index 5e59c5d2..6ede2352 100644 --- a/dist/gemini/skills/foundations/SKILL.md +++ b/dist/gemini/skills/foundations/SKILL.md @@ -6,7 +6,7 @@ description: >- setting up project standards. Covers naming, TDD, verification, and review workflows. Not for git workflow (use git-workflow), debugging (use debugging), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Code Standards diff --git a/dist/gemini/skills/git-workflow/SKILL.md b/dist/gemini/skills/git-workflow/SKILL.md index a6d3e82b..7907909b 100644 --- a/dist/gemini/skills/git-workflow/SKILL.md +++ b/dist/gemini/skills/git-workflow/SKILL.md @@ -6,7 +6,7 @@ description: >- PRs, or managing git history. Provides patterns for collaborative git workflows. Not for code style (use foundations) or CI/CD pipelines (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Git Workflow diff --git a/dist/gemini/skills/go-development/SKILL.md b/dist/gemini/skills/go-development/SKILL.md index d28de813..690c7441 100644 --- a/dist/gemini/skills/go-development/SKILL.md +++ b/dist/gemini/skills/go-development/SKILL.md @@ -6,7 +6,7 @@ description: >- Follows Effective Go principles and community conventions. Not for database schema design (use database-design) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Go Skill diff --git a/dist/gemini/skills/handoff/SKILL.md b/dist/gemini/skills/handoff/SKILL.md index 27e99dc6..beabd13b 100644 --- a/dist/gemini/skills/handoff/SKILL.md +++ b/dist/gemini/skills/handoff/SKILL.md @@ -7,7 +7,7 @@ description: >- parked for later. Not for routine session continuity (use orchestration) or session shutdown (use wrap). Produces a disposable handoff artifact that housekeeping deletes after confirmed deprecation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Handoff diff --git a/dist/gemini/skills/housekeeping/SKILL.md b/dist/gemini/skills/housekeeping/SKILL.md index 2bc63293..e2b655cd 100644 --- a/dist/gemini/skills/housekeeping/SKILL.md +++ b/dist/gemini/skills/housekeeping/SKILL.md @@ -7,7 +7,7 @@ description: >- hygiene recommendations, archives completed work, and ensures extracted knowledge is preserved. Not for strategic reflection (use reflect) or knowledge management (use knowledge-base). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Housekeeping diff --git a/dist/gemini/skills/idea/SKILL.md b/dist/gemini/skills/idea/SKILL.md index 5f6147a5..1022d6f1 100644 --- a/dist/gemini/skills/idea/SKILL.md +++ b/dist/gemini/skills/idea/SKILL.md @@ -6,7 +6,7 @@ description: >- actionable concept crystallizes during conversation. For reviewing and processing the intake queue (sparks + raw ideas), use triage instead. Not for deep exploration (use brainstorm) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Idea diff --git a/dist/gemini/skills/implement/SKILL.md b/dist/gemini/skills/implement/SKILL.md index 2ff47519..19ec34e4 100644 --- a/dist/gemini/skills/implement/SKILL.md +++ b/dist/gemini/skills/implement/SKILL.md @@ -6,7 +6,7 @@ description: >- and code changes. Produces session files, agent spawn plans, and progress tracking. Not for shaping (use shape), breakdown (use breakdown), research, or review. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Implement diff --git a/dist/gemini/skills/infrastructure-management/SKILL.md b/dist/gemini/skills/infrastructure-management/SKILL.md index fd7a5b51..392b5f50 100644 --- a/dist/gemini/skills/infrastructure-management/SKILL.md +++ b/dist/gemini/skills/infrastructure-management/SKILL.md @@ -6,7 +6,7 @@ description: >- managing deployments. Provides patterns for infrastructure as code. Not for application code (use development skills), database schema (use database-design), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Infrastructure diff --git a/dist/gemini/skills/interface-design/SKILL.md b/dist/gemini/skills/interface-design/SKILL.md index a7fa59c8..4782bdae 100644 --- a/dist/gemini/skills/interface-design/SKILL.md +++ b/dist/gemini/skills/interface-design/SKILL.md @@ -6,7 +6,7 @@ description: >- ensuring accessibility compliance. Not for frontend code (use typescript-development) or API design (use architecture or language-specific skills). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Design Principles diff --git a/dist/gemini/skills/knowledge-base/SKILL.md b/dist/gemini/skills/knowledge-base/SKILL.md index a540a8b0..ff924628 100644 --- a/dist/gemini/skills/knowledge-base/SKILL.md +++ b/dist/gemini/skills/knowledge-base/SKILL.md @@ -6,7 +6,7 @@ description: >- covers: field, and the review workflow. Not for retrieval or search (use QMD directly), architectural decisions (use ADRs), or agent instructions (use CLAUDE.md). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Knowledge Base diff --git a/dist/gemini/skills/orchestration/SKILL.md b/dist/gemini/skills/orchestration/SKILL.md index fd897c3b..6602735a 100644 --- a/dist/gemini/skills/orchestration/SKILL.md +++ b/dist/gemini/skills/orchestration/SKILL.md @@ -6,7 +6,7 @@ description: >- agents, or coordinating cross-cutting work across multiple agents. Not for single-task implementation (use direct tool delegation) or solo research (use research). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Orchestration diff --git a/dist/gemini/skills/power-systems-modeling/SKILL.md b/dist/gemini/skills/power-systems-modeling/SKILL.md index 4ba20f4c..59c8d39d 100644 --- a/dist/gemini/skills/power-systems-modeling/SKILL.md +++ b/dist/gemini/skills/power-systems-modeling/SKILL.md @@ -6,7 +6,7 @@ description: >- thermal calculations, validating conductors, or computing sag and resistance. Not for infrastructure deployment (use infrastructure-management) or system architecture. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Power Systems Reference diff --git a/dist/gemini/skills/python-development/SKILL.md b/dist/gemini/skills/python-development/SKILL.md index 089365e7..dfa16dc2 100644 --- a/dist/gemini/skills/python-development/SKILL.md +++ b/dist/gemini/skills/python-development/SKILL.md @@ -6,7 +6,7 @@ description: >- models, or tests. Provides patterns for modern Python development. Not for schema design (use database-design), infrastructure (use infrastructure-management), or frontend code (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Python Development diff --git a/dist/gemini/skills/refactor-deepen/SKILL.md b/dist/gemini/skills/refactor-deepen/SKILL.md index bf714714..95c1e098 100644 --- a/dist/gemini/skills/refactor-deepen/SKILL.md +++ b/dist/gemini/skills/refactor-deepen/SKILL.md @@ -7,7 +7,7 @@ description: >- improvements, or when the user asks "is this module too shallow?" or "where should we deepen this code?" Produces either a read-only report or a PLAN file with candidates, dependency categories, and proposed deepened modules. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Refactor-Deepen diff --git a/dist/gemini/skills/reflect/SKILL.md b/dist/gemini/skills/reflect/SKILL.md index e00c79cf..a3fa71bb 100644 --- a/dist/gemini/skills/reflect/SKILL.md +++ b/dist/gemini/skills/reflect/SKILL.md @@ -6,7 +6,7 @@ description: >- VISION.md, STRATEGY.md, and ARCHITECTURE.md based on implementation experience. Not for pre-implementation strategy (use strategy) or ADRs (use architecture). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Reflect diff --git a/dist/gemini/skills/release/SKILL.md b/dist/gemini/skills/release/SKILL.md index 2e164060..1cbc823b 100644 --- a/dist/gemini/skills/release/SKILL.md +++ b/dist/gemini/skills/release/SKILL.md @@ -6,7 +6,7 @@ description: >- "merge this PR," "ready to merge," or "ship it." Produces version bumps, changelog updates, and merged code. Not for creating PRs (use git-workflow) or reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Release diff --git a/dist/gemini/skills/research/SKILL.md b/dist/gemini/skills/research/SKILL.md index 50eb13f9..a689663a 100644 --- a/dist/gemini/skills/research/SKILL.md +++ b/dist/gemini/skills/research/SKILL.md @@ -6,7 +6,7 @@ description: >- Produces state assessments, research findings with ranked options, or vision change proposals. Not for multi-agent coordination (use orchestration) or implementation. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Research diff --git a/dist/gemini/skills/ruby-development/SKILL.md b/dist/gemini/skills/ruby-development/SKILL.md index a9f4c306..d58ba660 100644 --- a/dist/gemini/skills/ruby-development/SKILL.md +++ b/dist/gemini/skills/ruby-development/SKILL.md @@ -6,7 +6,7 @@ description: >- following Rails patterns. Follows DHH/37signals conventions. Not for database schema design (use database-design) or frontend outside Hotwire (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Ruby Development diff --git a/dist/gemini/skills/security-compliance/SKILL.md b/dist/gemini/skills/security-compliance/SKILL.md index 259bf2a4..41e5b440 100644 --- a/dist/gemini/skills/security-compliance/SKILL.md +++ b/dist/gemini/skills/security-compliance/SKILL.md @@ -5,7 +5,7 @@ description: >- verification. Use when reviewing code for security, managing secrets, performing threat analysis, or running compliance audits. Not for debugging (use debugging) or general code review (use foundations). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Security & Compliance diff --git a/dist/gemini/skills/shape/SKILL.md b/dist/gemini/skills/shape/SKILL.md index 9b9cdd4b..26a76dd8 100644 --- a/dist/gemini/skills/shape/SKILL.md +++ b/dist/gemini/skills/shape/SKILL.md @@ -6,7 +6,7 @@ description: >- idea has accumulated enough constraints to bound. Produces specs with acceptance criteria. Not for brainstorming (use brainstorm) or task breakdown (use breakdown). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Shape diff --git a/dist/gemini/skills/strategy/SKILL.md b/dist/gemini/skills/strategy/SKILL.md index 02541e77..73fb3a7a 100644 --- a/dist/gemini/skills/strategy/SKILL.md +++ b/dist/gemini/skills/strategy/SKILL.md @@ -6,7 +6,7 @@ description: >- personas, market landscape analysis, and problem space definitions. Not for architecture (use architecture) or post-implementation reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Strategy diff --git a/dist/gemini/skills/triage/SKILL.md b/dist/gemini/skills/triage/SKILL.md index 97caa70c..9f4f1b2c 100644 --- a/dist/gemini/skills/triage/SKILL.md +++ b/dist/gemini/skills/triage/SKILL.md @@ -7,7 +7,7 @@ description: >- "what's in my backlog?" Produces promoted ideas, archived discards, and resolve(spark) journal entries. Not for capturing new ideas (use idea) or shaping (use shape). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Triage diff --git a/dist/gemini/skills/typescript-development/SKILL.md b/dist/gemini/skills/typescript-development/SKILL.md index ecac3fcb..a7a3aedf 100644 --- a/dist/gemini/skills/typescript-development/SKILL.md +++ b/dist/gemini/skills/typescript-development/SKILL.md @@ -5,7 +5,7 @@ description: >- CSS, and Vitest testing. Use when writing TypeScript applications, React components, or Node.js services. Not for UI/UX design (use interface-design), database schema (use database-design), or Python (use python-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # TypeScript Development diff --git a/dist/gemini/skills/wrap/SKILL.md b/dist/gemini/skills/wrap/SKILL.md index 2fd3c5c1..f0e8fd95 100644 --- a/dist/gemini/skills/wrap/SKILL.md +++ b/dist/gemini/skills/wrap/SKILL.md @@ -7,7 +7,7 @@ description: >- the user asks "wrap up." Not for archiving (use housekeeping) or capturing ideas (use idea). Produces a Session Wrap-Up section and closes the session with done status. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Wrap diff --git a/dist/opencode/agents/background-runner.md b/dist/opencode/agents/background-runner.md index e0edd8ce..692ea05d 100644 --- a/dist/opencode/agents/background-runner.md +++ b/dist/opencode/agents/background-runner.md @@ -168,4 +168,4 @@ Background Agent ID: bg-20260123-143000-auth-security 4. Update `.agents/sessions/20260123-140000-auth-feature.md` frontmatter --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/opencode/agents/implementer.md b/dist/opencode/agents/implementer.md index e9aeea56..69a6de23 100644 --- a/dist/opencode/agents/implementer.md +++ b/dist/opencode/agents/implementer.md @@ -32,4 +32,4 @@ You are an implementer. You have full write access to the codebase: code, tests, - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/opencode/agents/librarian.md b/dist/opencode/agents/librarian.md index 286e10a1..09fcf0ac 100644 --- a/dist/opencode/agents/librarian.md +++ b/dist/opencode/agents/librarian.md @@ -36,4 +36,4 @@ You are a librarian. You shepherd session files through their lifecycle and tend - Scope all file operations to `.agents/` paths. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/opencode/agents/researcher.md b/dist/opencode/agents/researcher.md index 924156d8..b103e367 100644 --- a/dist/opencode/agents/researcher.md +++ b/dist/opencode/agents/researcher.md @@ -27,4 +27,4 @@ You are a researcher. You have read access to the codebase and web access to the - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/opencode/agents/reviewer.md b/dist/opencode/agents/reviewer.md index 7a9246ad..bd07aab5 100644 --- a/dist/opencode/agents/reviewer.md +++ b/dist/opencode/agents/reviewer.md @@ -27,4 +27,4 @@ You are a reviewer. You have read-only access to the codebase. This is not a lim - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/dist/opencode/commands/architecture.md b/dist/opencode/commands/architecture.md index 9cc1c430..96a21481 100644 --- a/dist/opencode/commands/architecture.md +++ b/dist/opencode/commands/architecture.md @@ -11,7 +11,7 @@ description: >- decision() instead). The ADR log is append-only — when circumstances change, write a new ADR that supersedes the old one. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Architecture diff --git a/dist/opencode/commands/brainstorm.md b/dist/opencode/commands/brainstorm.md index d1629d00..b898a189 100644 --- a/dist/opencode/commands/brainstorm.md +++ b/dist/opencode/commands/brainstorm.md @@ -5,7 +5,7 @@ description: >- options," or is exploring tradeoffs. Produces docs with sparks. Not for quick ideas or shaping. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Brainstorm diff --git a/dist/opencode/commands/breakdown.md b/dist/opencode/commands/breakdown.md index c72c8af1..9e9b9f8f 100644 --- a/dist/opencode/commands/breakdown.md +++ b/dist/opencode/commands/breakdown.md @@ -5,7 +5,7 @@ description: >- Produces task files with estimates, dependencies, and acceptance criteria. Not for shaping ideas (use shape) or implementation work (use implement). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Breakdown diff --git a/dist/opencode/commands/council.md b/dist/opencode/commands/council.md index f16eff67..64ce4624 100644 --- a/dist/opencode/commands/council.md +++ b/dist/opencode/commands/council.md @@ -7,7 +7,7 @@ description: >- single-perspective research (use research) or architectural decisions that don't need multi-agent deliberation (use architecture). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Council diff --git a/dist/opencode/commands/handoff.md b/dist/opencode/commands/handoff.md index c011811b..3f07885a 100644 --- a/dist/opencode/commands/handoff.md +++ b/dist/opencode/commands/handoff.md @@ -7,7 +7,7 @@ description: >- session shutdown (use wrap). Produces a disposable handoff artifact that housekeeping deletes after confirmed deprecation. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Handoff diff --git a/dist/opencode/commands/housekeeping.md b/dist/opencode/commands/housekeeping.md index ed6f95f4..35eeda8b 100644 --- a/dist/opencode/commands/housekeeping.md +++ b/dist/opencode/commands/housekeeping.md @@ -7,7 +7,7 @@ description: >- knowledge is preserved. Not for strategic reflection (use reflect) or knowledge management (use knowledge-base). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Housekeeping diff --git a/dist/opencode/commands/idea.md b/dist/opencode/commands/idea.md index c92be03e..5d8e4804 100644 --- a/dist/opencode/commands/idea.md +++ b/dist/opencode/commands/idea.md @@ -6,7 +6,7 @@ description: >- processing the intake queue (sparks + raw ideas), use triage instead. Not for deep exploration (use brainstorm) or shaping (use shape). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Idea diff --git a/dist/opencode/commands/implement.md b/dist/opencode/commands/implement.md index bc7d30d7..6fa66118 100644 --- a/dist/opencode/commands/implement.md +++ b/dist/opencode/commands/implement.md @@ -6,7 +6,7 @@ description: >- tracking. Not for shaping (use shape), breakdown (use breakdown), research, or review. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Implement diff --git a/dist/opencode/commands/reflect.md b/dist/opencode/commands/reflect.md index eefb4e14..af60de6d 100644 --- a/dist/opencode/commands/reflect.md +++ b/dist/opencode/commands/reflect.md @@ -6,7 +6,7 @@ description: >- experience. Not for pre-implementation strategy (use strategy) or ADRs (use architecture). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Reflect diff --git a/dist/opencode/commands/research.md b/dist/opencode/commands/research.md index ecaf45d4..cb28e343 100644 --- a/dist/opencode/commands/research.md +++ b/dist/opencode/commands/research.md @@ -6,7 +6,7 @@ description: >- change proposals. Not for multi-agent coordination (use orchestration) or implementation. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Research diff --git a/dist/opencode/commands/shape.md b/dist/opencode/commands/shape.md index 54ce6570..5444ea5d 100644 --- a/dist/opencode/commands/shape.md +++ b/dist/opencode/commands/shape.md @@ -6,7 +6,7 @@ description: >- acceptance criteria. Not for brainstorming (use brainstorm) or task breakdown (use breakdown). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Shape diff --git a/dist/opencode/commands/strategy.md b/dist/opencode/commands/strategy.md index 9c34eaf9..0d0e95e4 100644 --- a/dist/opencode/commands/strategy.md +++ b/dist/opencode/commands/strategy.md @@ -6,7 +6,7 @@ description: >- architecture (use architecture) or post-implementation reflection (use reflect). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Strategy diff --git a/dist/opencode/commands/triage.md b/dist/opencode/commands/triage.md index c4534f1f..c9ffe9e0 100644 --- a/dist/opencode/commands/triage.md +++ b/dist/opencode/commands/triage.md @@ -7,7 +7,7 @@ description: >- resolve(spark) journal entries. Not for capturing new ideas (use idea) or shaping (use shape). user-invocable: true -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Triage diff --git a/dist/opencode/commands/wrap.md b/dist/opencode/commands/wrap.md index 9040390d..fa97218c 100644 --- a/dist/opencode/commands/wrap.md +++ b/dist/opencode/commands/wrap.md @@ -7,7 +7,7 @@ description: >- ideas (use idea). Produces a Session Wrap-Up section and closes the session with done status. user-invocable: true -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Wrap diff --git a/dist/opencode/plugins/hooks.ts b/dist/opencode/plugins/hooks.ts index 0073fef2..10998bf6 100644 --- a/dist/opencode/plugins/hooks.ts +++ b/dist/opencode/plugins/hooks.ts @@ -1,7 +1,7 @@ /** * OpenCode Plugin - Agent Skills Hooks * Auto-generated by loaf build system - * @version 2.0.0-dev.49 + * @version 2.0.0-pre.20260614235428 */ import { execFile } from 'child_process'; diff --git a/dist/opencode/skills/architecture/SKILL.md b/dist/opencode/skills/architecture/SKILL.md index a1cbd451..4972b4a8 100644 --- a/dist/opencode/skills/architecture/SKILL.md +++ b/dist/opencode/skills/architecture/SKILL.md @@ -12,7 +12,7 @@ description: >- decision() instead). The ADR log is append-only — when circumstances change, write a new ADR that supersedes the old one. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Architecture diff --git a/dist/opencode/skills/bootstrap/SKILL.md b/dist/opencode/skills/bootstrap/SKILL.md index 87915666..37123270 100644 --- a/dist/opencode/skills/bootstrap/SKILL.md +++ b/dist/opencode/skills/bootstrap/SKILL.md @@ -6,7 +6,7 @@ description: >- I start a new project?", "set up Loaf," or "bootstrap my project." Produces populated project documents and setup recommendations. Not for shaping features (use shape) or brainstorming ideas (use brainstorm). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Bootstrap diff --git a/dist/opencode/skills/brainstorm/SKILL.md b/dist/opencode/skills/brainstorm/SKILL.md index ec7f3712..6abbfe01 100644 --- a/dist/opencode/skills/brainstorm/SKILL.md +++ b/dist/opencode/skills/brainstorm/SKILL.md @@ -6,7 +6,7 @@ description: >- options," or is exploring tradeoffs. Produces docs with sparks. Not for quick ideas or shaping. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Brainstorm diff --git a/dist/opencode/skills/breakdown/SKILL.md b/dist/opencode/skills/breakdown/SKILL.md index ce3f1c36..9da3b203 100644 --- a/dist/opencode/skills/breakdown/SKILL.md +++ b/dist/opencode/skills/breakdown/SKILL.md @@ -6,7 +6,7 @@ description: >- Produces task files with estimates, dependencies, and acceptance criteria. Not for shaping ideas (use shape) or implementation work (use implement). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Breakdown diff --git a/dist/opencode/skills/cli-reference/SKILL.md b/dist/opencode/skills/cli-reference/SKILL.md index 7fc9437e..3a03d390 100644 --- a/dist/opencode/skills/cli-reference/SKILL.md +++ b/dist/opencode/skills/cli-reference/SKILL.md @@ -5,7 +5,7 @@ description: >- /implement, /implement, and all loaf subcommands. Use when you need to know which CLI command to invoke. Not for skill documentation (use the skill's own SKILL.md) or for understanding build internals. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Loaf CLI Reference @@ -45,6 +45,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target <name>` - Build a specific target only + **Usage:** ```bash loaf build @@ -57,6 +61,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to <target>` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -69,6 +80,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -81,6 +96,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump <type>` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base <ref>` - Use commits since <ref> instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base <auto-detected> +- `--post-merge` - Finalize release after squash-merge +- `--version-file <path>` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -98,6 +127,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify <backup>`, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify <backup> --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -106,47 +143,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin <imported|manual>` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format <format>` - Output format for the selected export kind + +- `loaf state export all`: + - `--format <format>` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format <format>` - Output format: markdown + +- `loaf state export session`: + - `--format <format>` - Output format: markdown + +- `loaf state export spec`: + - `--format <format>` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format <format>` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - `<from> [to]` - Previous and optional new absolute project paths + - `--from <path>` - Previous absolute project path + - `--to <path>` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -170,12 +310,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -211,32 +356,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title <title>` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -267,13 +419,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -307,22 +459,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -355,24 +508,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -412,6 +565,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -419,11 +582,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/dist/opencode/skills/council/SKILL.md b/dist/opencode/skills/council/SKILL.md index 17ddb21a..372090d8 100644 --- a/dist/opencode/skills/council/SKILL.md +++ b/dist/opencode/skills/council/SKILL.md @@ -8,7 +8,7 @@ description: >- single-perspective research (use research) or architectural decisions that don't need multi-agent deliberation (use architecture). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Council diff --git a/dist/opencode/skills/database-design/SKILL.md b/dist/opencode/skills/database-design/SKILL.md index 3f9e7f58..fea2a9f0 100644 --- a/dist/opencode/skills/database-design/SKILL.md +++ b/dist/opencode/skills/database-design/SKILL.md @@ -7,7 +7,7 @@ description: >- database administration and development. Not for ORM usage in application code (use language-specific development skills) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Database Skill diff --git a/dist/opencode/skills/debugging/SKILL.md b/dist/opencode/skills/debugging/SKILL.md index f2bca471..b6b28467 100644 --- a/dist/opencode/skills/debugging/SKILL.md +++ b/dist/opencode/skills/debugging/SKILL.md @@ -6,7 +6,7 @@ description: >- flaky tests. Provides methodology for root cause analysis and issue resolution. Not for writing new tests (use development skills) or security analysis (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Debugging diff --git a/dist/opencode/skills/documentation-standards/SKILL.md b/dist/opencode/skills/documentation-standards/SKILL.md index becf5fb9..26987a9f 100644 --- a/dist/opencode/skills/documentation-standards/SKILL.md +++ b/dist/opencode/skills/documentation-standards/SKILL.md @@ -6,7 +6,7 @@ description: >- reviewing documentation quality, or creating architecture diagrams. Not for inline code comments (use code style guides) or project READMEs (use project-specific conventions). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Documentation Standards diff --git a/dist/opencode/skills/foundations/SKILL.md b/dist/opencode/skills/foundations/SKILL.md index 5e59c5d2..6ede2352 100644 --- a/dist/opencode/skills/foundations/SKILL.md +++ b/dist/opencode/skills/foundations/SKILL.md @@ -6,7 +6,7 @@ description: >- setting up project standards. Covers naming, TDD, verification, and review workflows. Not for git workflow (use git-workflow), debugging (use debugging), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Code Standards diff --git a/dist/opencode/skills/git-workflow/SKILL.md b/dist/opencode/skills/git-workflow/SKILL.md index a6d3e82b..7907909b 100644 --- a/dist/opencode/skills/git-workflow/SKILL.md +++ b/dist/opencode/skills/git-workflow/SKILL.md @@ -6,7 +6,7 @@ description: >- PRs, or managing git history. Provides patterns for collaborative git workflows. Not for code style (use foundations) or CI/CD pipelines (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Git Workflow diff --git a/dist/opencode/skills/go-development/SKILL.md b/dist/opencode/skills/go-development/SKILL.md index d28de813..690c7441 100644 --- a/dist/opencode/skills/go-development/SKILL.md +++ b/dist/opencode/skills/go-development/SKILL.md @@ -6,7 +6,7 @@ description: >- Follows Effective Go principles and community conventions. Not for database schema design (use database-design) or infrastructure orchestration (use infrastructure-management). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Go Skill diff --git a/dist/opencode/skills/handoff/SKILL.md b/dist/opencode/skills/handoff/SKILL.md index 9b136aca..068795f8 100644 --- a/dist/opencode/skills/handoff/SKILL.md +++ b/dist/opencode/skills/handoff/SKILL.md @@ -8,7 +8,7 @@ description: >- session shutdown (use wrap). Produces a disposable handoff artifact that housekeeping deletes after confirmed deprecation. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Handoff diff --git a/dist/opencode/skills/housekeeping/SKILL.md b/dist/opencode/skills/housekeeping/SKILL.md index e8abbacc..6ee8628b 100644 --- a/dist/opencode/skills/housekeeping/SKILL.md +++ b/dist/opencode/skills/housekeeping/SKILL.md @@ -8,7 +8,7 @@ description: >- knowledge is preserved. Not for strategic reflection (use reflect) or knowledge management (use knowledge-base). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Housekeeping diff --git a/dist/opencode/skills/idea/SKILL.md b/dist/opencode/skills/idea/SKILL.md index fc5a39ae..077a079b 100644 --- a/dist/opencode/skills/idea/SKILL.md +++ b/dist/opencode/skills/idea/SKILL.md @@ -7,7 +7,7 @@ description: >- processing the intake queue (sparks + raw ideas), use triage instead. Not for deep exploration (use brainstorm) or shaping (use shape). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Idea diff --git a/dist/opencode/skills/implement/SKILL.md b/dist/opencode/skills/implement/SKILL.md index 399d8be5..880a2966 100644 --- a/dist/opencode/skills/implement/SKILL.md +++ b/dist/opencode/skills/implement/SKILL.md @@ -7,7 +7,7 @@ description: >- tracking. Not for shaping (use shape), breakdown (use breakdown), research, or review. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Implement diff --git a/dist/opencode/skills/infrastructure-management/SKILL.md b/dist/opencode/skills/infrastructure-management/SKILL.md index fd7a5b51..392b5f50 100644 --- a/dist/opencode/skills/infrastructure-management/SKILL.md +++ b/dist/opencode/skills/infrastructure-management/SKILL.md @@ -6,7 +6,7 @@ description: >- managing deployments. Provides patterns for infrastructure as code. Not for application code (use development skills), database schema (use database-design), or security audits (use security-compliance). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Infrastructure diff --git a/dist/opencode/skills/interface-design/SKILL.md b/dist/opencode/skills/interface-design/SKILL.md index a7fa59c8..4782bdae 100644 --- a/dist/opencode/skills/interface-design/SKILL.md +++ b/dist/opencode/skills/interface-design/SKILL.md @@ -6,7 +6,7 @@ description: >- ensuring accessibility compliance. Not for frontend code (use typescript-development) or API design (use architecture or language-specific skills). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Design Principles diff --git a/dist/opencode/skills/knowledge-base/SKILL.md b/dist/opencode/skills/knowledge-base/SKILL.md index a540a8b0..ff924628 100644 --- a/dist/opencode/skills/knowledge-base/SKILL.md +++ b/dist/opencode/skills/knowledge-base/SKILL.md @@ -6,7 +6,7 @@ description: >- covers: field, and the review workflow. Not for retrieval or search (use QMD directly), architectural decisions (use ADRs), or agent instructions (use CLAUDE.md). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Knowledge Base diff --git a/dist/opencode/skills/orchestration/SKILL.md b/dist/opencode/skills/orchestration/SKILL.md index fd897c3b..6602735a 100644 --- a/dist/opencode/skills/orchestration/SKILL.md +++ b/dist/opencode/skills/orchestration/SKILL.md @@ -6,7 +6,7 @@ description: >- agents, or coordinating cross-cutting work across multiple agents. Not for single-task implementation (use direct tool delegation) or solo research (use research). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Orchestration diff --git a/dist/opencode/skills/power-systems-modeling/SKILL.md b/dist/opencode/skills/power-systems-modeling/SKILL.md index 4ba20f4c..59c8d39d 100644 --- a/dist/opencode/skills/power-systems-modeling/SKILL.md +++ b/dist/opencode/skills/power-systems-modeling/SKILL.md @@ -6,7 +6,7 @@ description: >- thermal calculations, validating conductors, or computing sag and resistance. Not for infrastructure deployment (use infrastructure-management) or system architecture. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Power Systems Reference diff --git a/dist/opencode/skills/python-development/SKILL.md b/dist/opencode/skills/python-development/SKILL.md index 089365e7..dfa16dc2 100644 --- a/dist/opencode/skills/python-development/SKILL.md +++ b/dist/opencode/skills/python-development/SKILL.md @@ -6,7 +6,7 @@ description: >- models, or tests. Provides patterns for modern Python development. Not for schema design (use database-design), infrastructure (use infrastructure-management), or frontend code (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Python Development diff --git a/dist/opencode/skills/refactor-deepen/SKILL.md b/dist/opencode/skills/refactor-deepen/SKILL.md index bf714714..95c1e098 100644 --- a/dist/opencode/skills/refactor-deepen/SKILL.md +++ b/dist/opencode/skills/refactor-deepen/SKILL.md @@ -7,7 +7,7 @@ description: >- improvements, or when the user asks "is this module too shallow?" or "where should we deepen this code?" Produces either a read-only report or a PLAN file with candidates, dependency categories, and proposed deepened modules. -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Refactor-Deepen diff --git a/dist/opencode/skills/reflect/SKILL.md b/dist/opencode/skills/reflect/SKILL.md index 596fd9e4..9ebd4cbd 100644 --- a/dist/opencode/skills/reflect/SKILL.md +++ b/dist/opencode/skills/reflect/SKILL.md @@ -7,7 +7,7 @@ description: >- experience. Not for pre-implementation strategy (use strategy) or ADRs (use architecture). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Reflect diff --git a/dist/opencode/skills/release/SKILL.md b/dist/opencode/skills/release/SKILL.md index 2e164060..1cbc823b 100644 --- a/dist/opencode/skills/release/SKILL.md +++ b/dist/opencode/skills/release/SKILL.md @@ -6,7 +6,7 @@ description: >- "merge this PR," "ready to merge," or "ship it." Produces version bumps, changelog updates, and merged code. Not for creating PRs (use git-workflow) or reflection (use reflect). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Release diff --git a/dist/opencode/skills/research/SKILL.md b/dist/opencode/skills/research/SKILL.md index 1bc7cba1..9fc78d76 100644 --- a/dist/opencode/skills/research/SKILL.md +++ b/dist/opencode/skills/research/SKILL.md @@ -7,7 +7,7 @@ description: >- change proposals. Not for multi-agent coordination (use orchestration) or implementation. subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Research diff --git a/dist/opencode/skills/ruby-development/SKILL.md b/dist/opencode/skills/ruby-development/SKILL.md index a9f4c306..d58ba660 100644 --- a/dist/opencode/skills/ruby-development/SKILL.md +++ b/dist/opencode/skills/ruby-development/SKILL.md @@ -6,7 +6,7 @@ description: >- following Rails patterns. Follows DHH/37signals conventions. Not for database schema design (use database-design) or frontend outside Hotwire (use typescript-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Ruby Development diff --git a/dist/opencode/skills/security-compliance/SKILL.md b/dist/opencode/skills/security-compliance/SKILL.md index 259bf2a4..41e5b440 100644 --- a/dist/opencode/skills/security-compliance/SKILL.md +++ b/dist/opencode/skills/security-compliance/SKILL.md @@ -5,7 +5,7 @@ description: >- verification. Use when reviewing code for security, managing secrets, performing threat analysis, or running compliance audits. Not for debugging (use debugging) or general code review (use foundations). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Security & Compliance diff --git a/dist/opencode/skills/shape/SKILL.md b/dist/opencode/skills/shape/SKILL.md index ab1e7ddd..82d0e24d 100644 --- a/dist/opencode/skills/shape/SKILL.md +++ b/dist/opencode/skills/shape/SKILL.md @@ -7,7 +7,7 @@ description: >- acceptance criteria. Not for brainstorming (use brainstorm) or task breakdown (use breakdown). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Shape diff --git a/dist/opencode/skills/strategy/SKILL.md b/dist/opencode/skills/strategy/SKILL.md index 9cee954c..fe1bf0af 100644 --- a/dist/opencode/skills/strategy/SKILL.md +++ b/dist/opencode/skills/strategy/SKILL.md @@ -7,7 +7,7 @@ description: >- architecture (use architecture) or post-implementation reflection (use reflect). subtask: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Strategy diff --git a/dist/opencode/skills/triage/SKILL.md b/dist/opencode/skills/triage/SKILL.md index 074ba39c..7c092eba 100644 --- a/dist/opencode/skills/triage/SKILL.md +++ b/dist/opencode/skills/triage/SKILL.md @@ -8,7 +8,7 @@ description: >- resolve(spark) journal entries. Not for capturing new ideas (use idea) or shaping (use shape). user-invocable: true -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Triage diff --git a/dist/opencode/skills/typescript-development/SKILL.md b/dist/opencode/skills/typescript-development/SKILL.md index ecac3fcb..a7a3aedf 100644 --- a/dist/opencode/skills/typescript-development/SKILL.md +++ b/dist/opencode/skills/typescript-development/SKILL.md @@ -5,7 +5,7 @@ description: >- CSS, and Vitest testing. Use when writing TypeScript applications, React components, or Node.js services. Not for UI/UX design (use interface-design), database schema (use database-design), or Python (use python-development). -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # TypeScript Development diff --git a/dist/opencode/skills/wrap/SKILL.md b/dist/opencode/skills/wrap/SKILL.md index 156d5ce5..d556697c 100644 --- a/dist/opencode/skills/wrap/SKILL.md +++ b/dist/opencode/skills/wrap/SKILL.md @@ -8,7 +8,7 @@ description: >- ideas (use idea). Produces a Session Wrap-Up section and closes the session with done status. user-invocable: true -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Wrap diff --git a/dist/skills/cli-reference/SKILL.md b/dist/skills/cli-reference/SKILL.md index 657d670e..f8ff6c23 100644 --- a/dist/skills/cli-reference/SKILL.md +++ b/dist/skills/cli-reference/SKILL.md @@ -44,6 +44,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target <name>` - Build a specific target only + **Usage:** ```bash loaf build @@ -56,6 +60,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to <target>` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -68,6 +79,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -80,6 +95,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump <type>` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base <ref>` - Use commits since <ref> instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base <auto-detected> +- `--post-merge` - Finalize release after squash-merge +- `--version-file <path>` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -97,6 +126,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify <backup>`, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify <backup> --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -105,47 +142,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin <imported|manual>` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format <format>` - Output format for the selected export kind + +- `loaf state export all`: + - `--format <format>` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format <format>` - Output format: markdown + +- `loaf state export session`: + - `--format <format>` - Output format: markdown + +- `loaf state export spec`: + - `--format <format>` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format <format>` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - `<from> [to]` - Previous and optional new absolute project paths + - `--from <path>` - Previous absolute project path + - `--to <path>` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -169,12 +309,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -210,32 +355,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title <title>` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -266,13 +418,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -306,22 +458,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -354,24 +507,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -411,6 +564,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -418,11 +581,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index cbd0caf4..d4aab712 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -52,6 +52,10 @@ The former TypeScript bridge prevented a big-bang rewrite. Public commands have This changes the construction technique, not the product contract: skills still call `loaf`, hooks still enforce through `loaf`, and users still see one command surface. The implementation boundary behind that command is allowed to migrate command-by-command. +### Operational State Identity + +Loaf stores operational state in one global SQLite database at `$XDG_DATA_HOME/loaf/loaf.sqlite`, partitioned by project ID. New project IDs are generated and stored in SQLite; they are not derived from checkout path or friendly name. The `projects` row carries the friendly display name and current path, while `project_paths` records path mappings so a checkout can move without changing identity. Legacy path-hash IDs remain only as an adoption key for migrated pre-stable-identity data. + ### Targets | Target | Output | Agents | Skills | Hooks | Runtime Plugin | @@ -340,7 +344,7 @@ Knowledge files are managed by `loaf kb` — staleness detection compares file m ``` .agents/loaf.json # Project-level (knowledge dirs, integration toggles, settings) -~/.local/state/loaf/ # User-level (registered KBs, default settings) +~/.local/share/loaf/ # User-level operational data, including SQLite state ~/.config/loaf/ # User preferences ``` diff --git a/docs/reports/2026-06-10-native-go-cutover-test-map.md b/docs/reports/2026-06-10-native-go-cutover-test-map.md index dc941f5d..18372314 100644 --- a/docs/reports/2026-06-10-native-go-cutover-test-map.md +++ b/docs/reports/2026-06-10-native-go-cutover-test-map.md @@ -40,6 +40,7 @@ This audit is generated from the Go-native dispatch in `internal/cli/cli.go`, wi | `kb` | Native Go | Go dispatcher only | KB subcommands are native: `status`, `validate`, `check`, `review`, `init`, `import`, and `glossary`; top-level help plus unknown-subcommand handling are native. The obsolete TypeScript KB command source and helper library have been removed; CLI reference generation now uses native Go reference metadata. | | `link` | Native Go | Go dispatcher only | SQLite-backed relationship operations. | | `migrate` | Native Go | Go dispatcher only | Top-level `migrate markdown`, `migrate storage-home`, and `migrate worktree-storage` are native. The obsolete TypeScript migrate adapter has been removed; public native-binary tests cover the former migration e2e behavior. | +| `project` | Native Go | Go dispatcher only | Durable project identity management is native, including `show`, friendly-name `rename`, and safeguarded path `move` operations over the global SQLite database. | | `release` | Native Go | Go dispatcher only | Public release help, `--dry-run` planning, interactive confirmation, cancellation, standard pre-merge release execution, configured artifact commands, `.venv` safety, and post-merge finalization are native. The TypeScript release command, runtime registry, and bundled release tests have been removed. | | `report` | Native Go | Go dispatcher only | Top-level help and unknown-subcommand handling are native; SQLite-backed lifecycle paths are native; markdown-only `report list`, `report create`, `report finalize`, and `report archive` are native. The TypeScript command registration and obsolete TS implementation have been removed. | | `session` | Native Go | Go dispatcher only | First full cutover target; the TypeScript command registration has been removed, top-level help and unknown-subcommand handling are native, and markdown session subcommands no longer delegate to TypeScript. | diff --git a/docs/reports/2026-06-14-boring-reliable-completion-audit.md b/docs/reports/2026-06-14-boring-reliable-completion-audit.md new file mode 100644 index 00000000..2a5df608 --- /dev/null +++ b/docs/reports/2026-06-14-boring-reliable-completion-audit.md @@ -0,0 +1,123 @@ +# Boring Reliable Completion Audit + +Date: 2026-06-14 20:17 +Status: Complete +Scope: Current evidence for `docs/reports/2026-06-14-boring-reliable-state-cli-plan.md`. + +This audit maps the reliability contract to current evidence from tests, docs, SPEC-040, the native cutover test map, and live primary-checkout dogfood. It closes the current boring-reliable state/CLI goal; future schema or CLI changes still need to re-run the relevant contract rows before release. + +## Storage And Identity + +| Requirement | Evidence | Status | +|---|---|---| +| One global SQLite file under XDG data home | `docs/ARCHITECTURE.md`, `README.md`, `internal/state/path.go`, `internal/state/status_test.go` | Proven | +| Row-level project isolation via durable `project_id` | `docs/schema/0001_initial.sql`, `docs/schema/0003_project_identity_and_relationship_origin.sql`, `internal/state/schema_test.go`, `internal/state/status_test.go` | Proven | +| Generated project IDs are stable and not path/name derived | `internal/state/status_test.go`, project identity CLI tests | Proven | +| Friendly project name is mutable and independent | project rename tests in `internal/cli/cli_test.go`; schema docs include `friendly_name` | Proven | +| Current path is mutable and historical paths are preserved | project move tests; path-invariant rejection tests in `internal/cli/cli_test.go`; `docs/schema/0004_project_path_current_uniqueness.sql` | Proven | +| Read-only project commands do not create state | control-plane no-mutation tests in `internal/cli/cli_test.go` | Proven | +| Rejected rename/move operations do not create DB rows or repo files | project rename/move safeguard tests in `internal/cli/cli_test.go` | Proven | + +## Schema And Migration + +| Requirement | Evidence | Status | +|---|---|---| +| Migrations are ordered, checksummed, transactional, and mirrored into schema docs | migration/schema tests in `internal/state`; schema mirror test in `internal/state/schema_test.go` | Proven | +| Storage-home migration copies legacy DBs without deleting source or overwriting destination | storage-home CLI and migration tests in `internal/cli/cli_test.go` | Proven | +| Markdown dry-run does not create SQLite state; apply/resume preserves source Markdown | markdown migration control-plane and apply/resume tests in `internal/cli/cli_test.go` | Proven | +| Invalid schema versions or checksum drift produce doctor guidance | migration/status tests in `internal/state`; control-plane failure matrix; project-command schema-drift rejection test in `internal/cli/cli_test.go` | Proven | +| Future schema changes include data-preservation tests and repair/upgrade UX | Reliability contract and schema mirror/checksum tests establish the release guard; must be rechecked with each future schema commit | Standing guardrail | + +## Doctor And Repair + +| Requirement | Evidence | Status | +|---|---|---| +| `state doctor` is safe by default | doctor dry-run/no-mutation tests in `internal/cli/cli_test.go` | Proven | +| `state doctor --json` returns machine-readable invalid-state payloads | doctor JSON invalid-state tests in `internal/cli/cli_test.go` | Proven | +| Repair plans expose code, diagnostic code, description, safety, applied state, and command/path | `internal/state/status.go`; repair-plan tests in `internal/state/status_test.go` and `internal/cli/cli_test.go` | Proven | +| Every repair-plan command is executable in the state mode that produced it | `TestRunnerStateDoctorRepairPlanCommandsExecuteInDiagnosticMode` | Proven | +| Unsafe repairs require dry-run/apply separation and JSON output | repair tests for relationship-origin and legacy-project-database in `internal/cli/cli_test.go` | Proven | + +## Backup, Export, And Restore + +| Requirement | Evidence | Status | +|---|---|---| +| Backup creates repository-external SQLite copies and verifies checksum, bytes, schema, FK, integrity, project count, and current identity | backup tests in `internal/state/backup_test.go` and CLI backup tests in `internal/cli/cli_test.go` | Proven | +| Backup verify checks an existing backup without consulting or mutating live state | `TestRunnerStateControlPlaneJSONSuccessMatrix`; backup verify tests | Proven | +| JSON export snapshots include table order, row counts, identity, scope, and verification manifest | export tests in `internal/state/export_test.go` and CLI matrix tests | Proven | +| Markdown exports are deterministic and boundary-validated when external-safe | markdown export tests in `internal/state/export_test.go` and CLI export tests | Proven | +| Restore has documented manual procedure with validation commands | `README.md`; `TestRunnerStateBackupManualRestoreProcedure`; dogfood notes in the plan | Proven for manual restore | +| Backup verification exposes concrete restore targets without live DB access | `BackupVerificationResult` restore fields; `TestRunnerStateBackupVerifyReportsGlobalProjects`; `TestRunnerStateControlPlaneJSONSuccessMatrix`; live isolated `state backup verify --json` dogfood after removing the live DB | Proven | + +## Backend And Linear Consistency + +| Requirement | Evidence | Status | +|---|---|---| +| Backend mappings store only external identity metadata, not sensitive values | schema column guard in `internal/state/schema_test.go`; runtime diagnostic `backend-mapping-sensitive-value` in `internal/state/status.go`; `TestInspectReportsSensitiveBackendMappingValues` | Proven in this checkpoint | +| Project-level backend mappings are valid only for the same durable project ID | `TestInspectAcceptsProjectBackendMapping`; `TestInspectRejectsProjectBackendMappingToDifferentProjectID` | Proven | +| Local mappings reject unknown entity kinds and missing local entities | backend mapping invariant tests in `internal/state/status_test.go` | Proven | +| Ambiguous mappings and unknown sync statuses are visible diagnostics | backend mapping warning tests in `internal/state/status_test.go` | Proven | +| Linear-enabled projects warn about active local tasks without Linear mappings | `TestInspectWarnsOnUnmappedLocalTasksWhenLinearEnabled` | Proven | +| Repair guidance separates local DB repair from future backend sync work | diagnostic policy tests in `internal/state/status_test.go` and `internal/cli/cli_test.go`; live isolated `state doctor --json` dogfood for invalid backend rows and Linear-unmapped tasks | Proven | + +## Agentic JSON + +| Requirement | Evidence | Status | +|---|---|---| +| Commands with `--json` return JSON on success and validation/runtime failure | control-plane success and failure matrix tests in `internal/cli/cli_test.go` | Proven for critical matrix | +| JSON errors include contract version, command, and error | JSON failure matrix tests in `internal/cli/cli_test.go` | Proven | +| SQLite-backed success payloads include contract version and global scope | JSON success matrix and state/project command tests | Proven for critical matrix | +| Project-aware payloads include ID, name, and current path when available | state/project/backup/export tests | Proven for critical matrix | +| Empty collections are stable arrays | repair-plan and export JSON tests | Proven | +| Exit codes are deterministic while preserving JSON | JSON failure matrix tests | Proven for critical matrix | +| Backend/Linear diagnostics include structured routing details | `Diagnostic.Details` in `internal/state/status.go`; backend/Linear detail assertions in `internal/state/status_test.go` and `internal/cli/cli_test.go`; live isolated `state doctor --json` dogfood | Proven | + +## Human CLI + +| Requirement | Evidence | Status | +|---|---|---| +| Human output names command, scope, database, project name, ID, and path where relevant | human-output tests in `internal/cli/cli_test.go` | Proven for critical matrix | +| Dry-run output says no changes were written | migration/project/repair dry-run tests | Proven | +| Apply output says what changed and where | migration/project/repair apply tests | Proven | +| Repair output labels safe/manual/applied actions | doctor/repair tests | Proven | +| Error text points at next useful command without implying unsafe mutation | JSON/human failure tests and backup verify guidance tests; missing-state project command dogfood fix | Proven for critical matrix | +| Output remains concise and agent-scrapable when JSON is unavailable | human-output matrix tests plus live isolated dogfood of state/project/doctor/backup/export surfaces; positional `project move`, missing-state errors, and terminal-help wording were fixed during the audit | Proven for critical matrix | + +## First Weak Proof Point Fixed + +The first weak item was the backend policy requirement that mappings store only external identity metadata. Before this checkpoint, schema tests proved there were no dedicated credential columns, but runtime state could still contain sensitive-looking values inside `external_id` or `external_url` and pass doctor checks. + +This checkpoint adds a non-mutating doctor diagnostic, `backend-mapping-sensitive-value`, classifies it as `backend-mapping` / `invalid-local-data`, and keeps the repair guidance as a manual local backend-mapping audit rather than external sync work. + +## Backend/Linear Checkpoint + +The latest backend/Linear sampling pass found that human output and repair plans already separated invalid local backend data from external sync work, but JSON consumers still had to parse diagnostic prose to identify affected rows or counts. + +This checkpoint adds structured diagnostic `details` for backend mapping and Linear sync findings, covering affected fields, row counts, mapping IDs, local entity identifiers, external identifiers, and unmapped task counts. Live dogfood through the rebuilt `bin/loaf` confirmed those details appear in `state doctor --json` for both invalid backend mapping rows and Linear-unmapped local tasks. + +## Latest Checkpoint + +The latest completion-audit pass focused on the last generic terminal-help JSON wording for state, project, repair, backup, and migration commands. Agent help and generated CLI reference had already been tightened, but human `--help` still showed plain `Output JSON` for critical control-plane commands. + +This checkpoint makes terminal help name the actual JSON content for `state path|init|status|doctor|backup|backup verify`, `project list|show|rename|move`, guarded state repairs, and state/top-level migrations. `TestRunnerStateAndProjectJSONHelpNamesContracts` guards the human help, `TestRunnerGenerateCLIReferenceWritesSkillNatively` guards the tightened project reference descriptions, and live rebuilt `bin/loaf ... --help` dogfood confirms those surfaces no longer contain generic `Output JSON` wording. + +## Final Completion Audit + +Final verification from the primary checkout: + +- `go test ./...` passed. +- `npm run typecheck` passed. +- Focused CLI/state inventories confirmed the regression harness still covers state control-plane JSON success/failure, mutation safeguards, repair-plan command executability, backup restore guidance, generated CLI reference output, and state/project JSON help wording. +- Live isolated XDG dogfood confirmed `state path --json`, `state status --json`, `state doctor --dry-run --json`, `state init --json`, `project show`, `project list --json`, `state doctor`, `state backup`, `state backup verify --json`, and `state export all --json` expose global database scope, durable project identity, diagnostics, repair plans, backup verification, and export manifest fields. +- Live help scans confirmed critical state/project/migration help no longer uses generic JSON wording, and `bin/loaf --agent-help` no longer advertises raw or generic JSON descriptions. +- `README.md` documents the manual backup restore flow with validation commands, and `TestRunnerStateBackupManualRestoreProcedure` guards it. +- SPEC-040 open questions and test conditions are checked off for global XDG storage, generated project identity, project rename/move semantics, Markdown import preservation, backup/export behavior, traceability, Linear/private mapping policy, and no secret storage. +- The native cutover test map records guardrails for XDG data-home migration, schema checksums/mirror docs, Markdown import dry-run/apply/resume, state health/export, backup, and native command-surface coverage. + +## Residual Guardrails + +No immediate implementation gap remains in the current audit scope. The standing guardrails are: + +- Any new schema migration must add data-preservation tests, update schema docs, and define repair/upgrade UX before release. +- Any new state/project/migration JSON flag must join the critical command matrix or explicitly document why it is outside it. +- Any new backend/Linear integration work must keep local DB repair separate from external sync and must not store tokens, secrets, or sensitive values in SQLite. diff --git a/docs/reports/2026-06-14-boring-reliable-state-cli-plan.md b/docs/reports/2026-06-14-boring-reliable-state-cli-plan.md new file mode 100644 index 00000000..98e43020 --- /dev/null +++ b/docs/reports/2026-06-14-boring-reliable-state-cli-plan.md @@ -0,0 +1,267 @@ +# Boring Reliable State And CLI Plan + +Date: 2026-06-14 20:17 +Status: Completed checkpoint +Scope: Single global SQLite state, project identity, migrations, repair UX, backup/export, backend mappings, and human/agent CLI contracts. + +This plan paused opportunistic hardening and turned the remaining work into a measurable reliability contract. The current codebase has reached the checkpoint: the native Go runtime is authoritative, state lives in one global SQLite database, project identity is durable and path/name independent, and critical state/project/migration commands have JSON contracts, human output, repair guidance, docs, tests, and live dogfood evidence. Future work should use this contract as a regression gate rather than reopening the whole audit by default. + +## Current Baseline + +| Area | Current Evidence | Remaining Concern | +|---|---|---| +| Native runtime | `docs/reports/2026-06-10-native-go-cutover-test-map.md` maps public command coverage and native-only guards. | The map proves migration breadth, not the final reliability contract for state and CLI UX. | +| Global DB location | `README.md` and SPEC-040 document one global SQLite file under XDG data home, partitioned by stable `project_id`. | Need final audit that every state/project command reports this consistently in JSON and human output. | +| Project identity | `loaf project show/list/rename/move` support stable ID, friendly name, current path, and path move safeguards. | Need command-matrix verification that rename/move never creates accidental identities and gives actionable errors. | +| State health | `loaf state status/path/doctor` expose global scope, project details, diagnostics, and repair plans. | Need repair-plan command validation so every suggested command is executable in the diagnosed state mode. | +| Backup/export | `state backup`, `backup verify`, and `export all` verify integrity and include project identity context. | Need restore/import confidence story: backup verification is strong, but restore procedure and test coverage are not yet first-class. | +| Backend/Linear mappings | Doctor validates backend mapping drift, project-level mappings, unknown sync statuses, and Linear unmapped local tasks. | Need a fuller backend consistency policy: what is invalid, warning-only, repairable, exportable, or delegated to future Linear sync. | +| Agent JSON | Many commands include `contract_version`, scope, project identity, machine-readable errors, and stable empty arrays. | Need a command matrix that proves all critical state/project/migration paths return JSON when `--json` is requested, including failures. | +| Human CLI | Human output has improved scope/project/path/safety labels. | Need a deliberate UX pass for consistency, next actions, and terse but useful repair guidance. | + +## Reliability Contract + +A state/CLI surface is boring reliable only when all applicable checks below are true and verified by current tests or dogfood output. + +### Storage And Identity + +- The database path is one global SQLite file under XDG data home: `$XDG_DATA_HOME/loaf/loaf.sqlite`, with platform fallback handled by `PathResolver`. +- Project isolation is row-level via durable `project_id`; no new code treats per-project database files as the primary storage model. +- New project IDs are generated and stable; they are not derived from path, friendly name, remote URL, or branch. +- Friendly project name is mutable and independent of path and ID. +- Current path is mutable via `loaf project move`, with historical paths preserved in `project_paths`. +- Read-only project commands do not create databases or project identities. +- Rejected rename/move operations do not create databases, project rows, path rows, sidecars, or repo files. + +### Schema And Migration + +- Migrations are ordered, checksummed, transactional, and mirrored into schema docs when schema changes. +- Storage-home migration copies legacy DBs to XDG data home without deleting the source or overwriting an existing destination. +- Markdown migration dry-run does not create SQLite state; apply/resume imports without mutating source Markdown. +- Existing state commands reject invalid schema versions or checksum drift with doctor guidance. +- Any future schema change must include data-preservation tests and explicit repair/upgrade UX. + +### Doctor And Repair + +- `state doctor` is always safe to run and does not mutate unless an explicit safe `--fix` path is selected. +- `state doctor --json` returns a machine-readable status payload even when exiting nonzero for invalid state. +- Repair plans are non-mutating by default and include `code`, `diagnostic_code`, `description`, `safe`, `applied`, and any relevant `command` or `path`. +- Every repair-plan command is valid in the state mode that produced it. Invalid-state diagnostics must not suggest commands that refuse invalid state. +- Unsafe repairs require dry-run/apply separation, clear human labels, and JSON output. + +### Backup, Export, And Restore + +- `state backup` creates repository-external `.sqlite` backups, verifies them before success, and reports checksum, bytes, schema version, foreign-key check, integrity check, project count, and current project identity. +- `state backup verify` verifies an existing backup without consulting or mutating live state. +- `state export all --format json` and `state export all --json` produce internal project-scoped snapshots with table order, row counts, project identity, scope, and verification manifest. +- Markdown exports are explicit, deterministic, and boundary-validated when external-safe. +- Restore remains incomplete until there is either a documented manual restore procedure with validation commands or a guarded `state restore` command. + +### Backend And Linear Consistency + +- Backend mappings store only external identity metadata, not tokens or secrets. +- Project-level backend mappings are valid only when they point at the same durable project ID. +- Task/spec/session/etc. mappings reject unknown local entity kinds and missing local entities. +- Ambiguous mappings and unknown sync statuses are visible diagnostics. +- Linear-enabled projects warn about active local tasks without Linear mappings. +- Repair guidance distinguishes local database repairs from backend sync work that requires future Linear integration. + +### Agentic JSON + +- Any command that accepts `--json` returns JSON for success and validation/runtime failures. +- JSON errors include `contract_version`, `command`, and `error`. +- State/project/migration JSON success payloads include `contract_version` and global database scope when backed by SQLite. +- Critical project-aware JSON payloads include `project_id`, `project_name`, and `project_current_path` when available. +- Empty collections are stable arrays, not `null` or omitted, unless the field is explicitly optional. +- Exit codes are deterministic: success is `0`; invalid state or validation failures are nonzero while preserving JSON when requested. + +### Human CLI + +- Human output names the command, scope, database path, project name, project ID, and project path when relevant. +- Dry-run output says no changes were written. +- Apply output says exactly what changed and where. +- Repair output labels safe/manual/applied actions. +- Error text points at the next useful command without implying an unsafe mutation. +- Output is concise enough for humans to scan and structured enough for agents to scrape if JSON is not available. + +## Command Matrix + +This is the focused audit set for the next iteration. Each row should eventually have tests or dogfood evidence for success, JSON success, JSON failure, and no unintended mutation where applicable. + +| Surface | Human Success | JSON Success | JSON Failure | No-Mutation Proof | Priority | +|---|---|---|---|---|---| +| `state path` | Known good | Known good | Needs sampled matrix | Does not create DB | Medium | +| `state status` | Known good | Known good | Needs sampled matrix | Does not create DB | High | +| `state init` | Covered | Covered | Needs sampled matrix | Re-run idempotent | Medium | +| `state doctor` | Improving | Improving | Invalid state returns JSON payload | Does not mutate unless `--fix` safe path | Critical | +| `state repair relationship-origin` | Covered | Covered | Needs sampled matrix | Dry-run/apply split | High | +| `state repair legacy-project-database` | Covered | Covered | Needs sampled matrix | Dry-run/apply split | High | +| `state backup` | Covered | Covered | Covered for missing/invalid state | Creates backup only outside repo | Critical | +| `state backup verify` | Covered | Covered | Covered for invalid/missing path | Does not read live state | Critical | +| `state export all` | Covered; `--json` alias added | Covered | Covered for invalid state and markdown alias misuse | Does not mutate DB or repo files | Critical | +| `state export triage` | Covered | Not applicable | Needs JSON-format misuse checks | Does not mutate DB or repo files | High | +| `state export spec/session/release-readiness` | Covered | Not applicable | Needs JSON-format misuse checks | Does not mutate DB or repo files | High | +| `migrate markdown --dry-run` | Covered | Covered | Needs sampled matrix | Does not create DB | Critical | +| `migrate markdown --apply/--resume` | Covered | Covered | Needs interrupted/resume confidence review | Preserves source Markdown | Critical | +| `migrate storage-home --dry-run` | Covered | Covered | Needs sampled matrix | Does not copy | High | +| `migrate storage-home --apply` | Covered | Covered | Covered for overwrite/partial copy | Preserves legacy source DB | High | +| `project show/list` | Covered | Covered | Covered for missing DB and unknown path | Does not create identity | Critical | +| `project rename --dry-run/apply` | Covered | Covered | Covered for missing DB and unknown path | Preserves project ID | Critical | +| `project move --dry-run/apply` | Covered | Covered | Covered for missing DB, unknown from, missing target | Preserves project ID; one current path | Critical | +| Backend mapping diagnostics | Human via doctor | JSON via doctor | Invalid state returns JSON payload | Diagnostics only | Critical | + +Final status: all critical rows have current test or dogfood evidence in `docs/reports/2026-06-14-boring-reliable-completion-audit.md`. + +## Focused Execution Plan + +The previous shape was directionally right but broad enough to invite edge chasing. From here, use three gates and avoid starting later work until the current gate has evidence. + +### Gate 1: Prove The Control Plane + +Status: Complete as of 2026-06-14 via `TestRunnerStateControlPlaneJSONFailureMatrix`, `TestRunnerStateControlPlaneJSONSuccessMatrix`, and `TestRunnerStateControlPlaneMutationAndRepairSafeguards`. + +Finish the command matrix for the commands that can damage trust fastest: `state status`, `state doctor`, `state export all`, `state backup verify`, `project show/list`, `project rename/move`, `migrate markdown --dry-run`, and `migrate storage-home --dry-run`. + +Exit criteria: + +- JSON failures are covered for validation errors, invalid state, and missing-state cases. +- JSON successes are covered for read-only and dry-run paths. +- Read-only and dry-run commands prove they do not create databases, project identities, sidecars, or repo files. +- Each failure includes `contract_version`, `command`, deterministic exit code, and useful error text. + +### Gate 2: Make Recovery Boring + +Status: Complete as of 2026-06-14. Manual restore is acceptable for pre-release because it is documented, covered by `TestRunnerStateBackupManualRestoreProcedure`, dogfooded against an isolated XDG data home from the primary checkout, and reinforced by `state backup verify` next-action guidance. A guarded `state restore` command can wait until repeated use proves the manual flow too clumsy. + +Close the restore-confidence gap before polishing lower-risk CLI surfaces. Start with a documented manual restore procedure unless command-level restore proves necessary. + +Exit criteria: + +- A user can verify a backup, preserve the current global DB, restore the verified backup into the XDG data-home path, and run doctor/status checks without guessing. +- The procedure is backed by tests where practical and by dogfood output from the primary checkout. +- Restore guidance is visible from backup/doctor docs or output when state is unhealthy. + +### Gate 3: Normalize Repair, Backend, And Human UX + +Only after the core control plane and recovery path are proven, run the UX/policy pass across repair plans, backend/Linear diagnostics, and human output. + +Exit criteria: + +- Every repair-plan command is executable in the state mode that suggested it. +- Backend diagnostics clearly separate invalid local data, warning-only drift, and future Linear sync work. +- Human output consistently names scope, database path, project name, project ID, project path, mutation status, and next action when relevant. +- The completion audit maps each reliability-contract bullet to current tests, docs, or dogfood output. + +## Remaining Work Tracks + +### Track 1: Prove The Matrix + +Add a focused regression harness or table-driven CLI test that runs the critical command matrix against temporary state. It should assert JSON parseability, command names, contract version, scope fields, exit codes, and no repo mutation for read-only commands. + +Progress: + +- 2026-06-14: `TestRunnerStateControlPlaneJSONFailureMatrix` covers sampled JSON failure contracts across `state`, `state repair`, `state backup verify`, `state export`, `project`, and `migrate` control-plane commands, including contract version, command name, error text, silent exit code, and no state database creation for pre-open/read-only failures. +- 2026-06-14: `TestRunnerStateControlPlaneJSONSuccessMatrix` covers JSON success and no-mutation contracts for initialized read-only surfaces (`state status`, `state doctor`, `state export all`, `project show`, `project list`), migration dry-runs (`state migrate markdown --dry-run`, `state migrate storage-home --dry-run`), and `state backup verify` without live state access. +- 2026-06-14: `TestRunnerStateControlPlaneMutationAndRepairSafeguards` covers project rename/move dry-run and apply safeguards plus repair dry-runs for relationship origins and legacy project databases, including durable project ID preservation, single current path after moves, dry-run table stability, and no archive writes during legacy repair previews. + +Go/no-go: the matrix can be re-run with one command and failures identify the exact command contract that regressed. + +### Track 2: Restore Confidence + +Backup verification is strong, but restore is not yet a first-class story. Decide between: + +- documented manual restore: copy verified backup to the global DB path after backing up the current DB, then run `state doctor`; +- or a guarded `loaf state restore <backup> --dry-run|--apply`. + +Recommendation: start with documented manual restore plus tests around backup verification and doctor compatibility. Add a command only when the manual procedure proves too clumsy. + +Progress: + +- 2026-06-14: README and generated CLI reference guidance document the manual restore flow: verify the backup, preserve the current global DB, copy the verified backup into the XDG data-home DB path, then run `state doctor` and `state status`. `TestRunnerStateBackupManualRestoreProcedure` backs the flow by verifying a backup, preserving a changed live DB, copying the backup into the global path, and proving doctor/status report the restored project identity. +- 2026-06-14: Dogfooded the documented restore flow from the primary checkout with isolated `XDG_DATA_HOME`/`XDG_STATE_HOME`: `state backup verify` returned `verified: true`, `integrity_check: ok`, and `foreign_key_check: ok`; the live DB was preserved as `.before-restore`; copying the backup into the global DB path restored the baseline project identity; and both `state doctor --json` and `state status --json` returned `sqlite-ready`. +- 2026-06-14: Human `state backup verify` output now includes the safe restore next action: preserve the current database, copy the verified backup to `loaf state path`, then run `state doctor` and `state status`. +- 2026-06-14: Dogfooded `state backup verify --json` from the primary checkout after removing the isolated live DB. Verification remained read-only and now returns `restore_database_path`, `restore_preserve_path`, and `restore_validation_commands` for the current checkout, while human output prints the concrete restore target and preserve path. `TestRunnerStateBackupVerifyReportsGlobalProjects`, `TestRunnerStateBackupManualRestoreProcedure`, and the control-plane success matrix cover the contract without requiring a live DB. + +Go/no-go: a user can recover from a bad global DB using a verified backup without guessing which files to copy or which checks to run. + +### Track 3: Repair UX + +Audit every `RepairAction` and human repair-plan line. Repair commands should be executable in the current state mode, dry-run first unless explicitly safe, and clear about whether they inspect, preview, apply, or require external sync. + +Progress: + +- 2026-06-14: `TestRunnerStateDoctorRepairPlanCommandsExecuteInDiagnosticMode` now turns doctor repair actions into executable CLI checks. It covers missing DB initialization, storage-home dry-run, legacy DB archive preview, schema drift inspection, SQLite invariant inspection, project path invariant listing, relationship-origin repair preview, invalid backend mapping inspection, backend drift export, Linear task mapping export, and local Markdown import preview. Invalid-state diagnostic commands are allowed to return the expected JSON exit code 1, but parser/refusal failures are caught. +- 2026-06-14: Repair actions now expose a `category` and `requires_external_sync` policy in JSON, while human `state doctor` output labels the action category. Backend mapping diagnostics are split between local backend-mapping audit work and Linear/external sync reconciliation, with `TestRepairPlanClassifiesBackendAndExternalSyncActions` proving that invalid local mappings do not masquerade as external sync and Linear-unmapped tasks are marked as external sync work. + +Go/no-go: no repair plan points to a command that immediately fails for the same diagnostic state. Current command-bearing repair actions are covered; future repair actions need to be added to the executability matrix when introduced. + +### Track 4: Backend/Linear Policy + +Write and enforce a small policy for backend mappings: + +- local DB invariants that make state invalid; +- warning diagnostics that indicate sync drift; +- external sync gaps that are future Linear work; +- fields that must never store secrets. + +Progress: + +- 2026-06-14: Backend-related diagnostics now expose `category`, `policy`, and `requires_external_sync` where relevant. Invalid backend mapping rows use `backend-mapping` / `invalid-local-data`, warning-only drift uses `backend-mapping` / `warning-drift`, and Linear task mapping gaps use `external-sync` / `external-sync-gap` with `requires_external_sync: true`. Human `state doctor` output renders these labels inline, and `TestRunnerStateDoctorLabelsBackendDiagnosticPolicy` verifies both human output and JSON fields. +- 2026-06-14: Dogfooded invalid backend mapping rows and Linear-unmapped local tasks from the primary checkout with isolated XDG homes. Human output already separated invalid local data from external sync work, but JSON diagnostics still forced agents to parse prose for affected rows. Backend and Linear diagnostics now include structured `details` payloads for fields such as `mapping_id`, `entity_kind`, `entity_id`, `external_id`, `row_count`, and `unmapped_task_count`; `TestInspectReportsInvalidBackendMappingMissingEntity`, backend warning tests, Linear warning tests, and `TestRunnerStateDoctorLabelsBackendDiagnosticPolicy` cover the public contract. + +Go/no-go: doctor diagnostics and repair/export guidance make it obvious whether the next action is local repair, export/audit, or backend sync. + +### Track 5: Human Output Pass + +Run the human form of the command matrix and normalize output shape. Prefer terse blocks with command name, scope, project, path, status, and next action. Avoid feature explanations and avoid presenting dangerous operations as casual fixes. + +Progress: + +- 2026-06-14: `loaf project rename` and `loaf project move` human output now use the same high-risk mutation shape: command header, global database scope, database path, durable project ID, friendly name, current path, from/to values, and `applied: true|false`. Dry-run output includes a next action; apply output does not. `TestRunnerProjectRenameDryRunDoesNotWrite`, `TestRunnerProjectMoveDryRunDoesNotWrite`, and `TestRunnerProjectRenameAndMoveHumanApplyOutput` cover the new shape. +- 2026-06-14: `loaf state migrate markdown` and `loaf state migrate storage-home` human output now use the same migration shape: command header, global database scope, project import/migration scope, database path, project context, `applied: true|false`, and dry-run next actions. `TestRunnerStateMigrateMarkdownHumanDryRun`, `TestRunnerStateMigrateMarkdownApplyHuman`, `TestRunnerStateMigrateMarkdownResumeHuman`, `TestRunnerMigrateMarkdownUsesNativeAlias`, `TestRunnerStateMigrateStorageHomeCopiesLegacyDatabase`, and `TestRunnerMigrateStorageHomeUsesNativeAlias` cover the shape. +- 2026-06-14: Dogfooded the human-output matrix with isolated XDG homes and found `loaf project show|identity` and `loaf project list` still used older identity labels. They now share the normalized project identity shape: command header, global database scope, database path, durable project ID, friendly name, and project path. `TestRunnerProjectShowRenameAndMoveUseStableIdentity` covers the shape and alias command header. +- 2026-06-14: Dogfooded `loaf state path` and backup output with isolated XDG homes. `loaf state path` intentionally keeps raw-path default output for shell substitution and manual restore workflows, and now adds `--verbose` for human-oriented command, global scope, project root, and database path context. `TestRunnerDispatchesStatePathNatively`, `TestRunnerStateControlPlaneJSONFailureMatrix`, `TestRunnerStateHelpIsNative`, `TestRunnerAgentHelpIsNative`, and `TestGenerateCLIReferenceIncludesCurrentCommands` cover the behavior and docs surfaces. +- 2026-06-14: Continued backup dogfood across creation, verify, help, and failure output. Backup creation already reported global scope, database, backup path, checksum, verification, and project identity; it now also gives a concrete `loaf state backup verify <backup>` next action, and help/reference text names the global data-home backups directory. `TestRunnerStateBackupHumanOutput`, `TestRunnerNestedStateBackedHelpDoesNotParseAsOption`, `TestRunnerAgentHelpIsNative`, and `TestGenerateCLIReferenceIncludesCurrentCommands` cover the output and docs surfaces. +- 2026-06-14: Dogfooded `loaf state init`, `loaf state status`, and `loaf state doctor` in missing and healthy modes. Initialized state output now uses the same durable identity labels as project/migration surfaces: `project` for the stable ID, `project name` for the friendly name, and `project path` for the checkout path. `TestRunnerStateInitStatusAndDoctor`, `TestRunnerStateInitHumanOutputPrintsRepositoryExternalDatabaseWithoutSecrets`, `TestRunnerStateDoctorFixInitializesMissingDatabase`, and `TestRunnerStateDoctorRepairPlanCommandsExecuteInDiagnosticMode` cover the normalized output and repair flow. +- 2026-06-14: Dogfooded `loaf state export all`, markdown export surfaces, and report-generation aliases. JSON export already carried global database/project identity context; markdown exports now render a `Project Context` block so exported artifacts are self-identifying. External exports (`triage`, `release-readiness`) include scope, durable project ID, and friendly name without local project/database paths, while internal exports (`spec`, `session`) include local project and database paths. `TestExportTriageMarkdownReturnsExternalSafeSummary`, `TestExportReleaseReadinessMarkdownReturnsExternalSafeSummary`, `TestExportSpecMarkdownRendersSpecSnapshot`, `TestExportSessionMarkdownRendersSessionSummary`, `TestRunnerStateExportTriageMarkdown`, `TestRunnerStateExportReleaseReadinessMarkdown`, `TestRunnerStateExportSpecMarkdown`, `TestRunnerStateExportSessionMarkdown`, and report/export equality tests cover the shape. +- 2026-06-14: Sampled export/report failure modes and found `loaf report generate` advertised `--format markdown` but rejected all `--format` flags, and had no structured success contract for agents. `report generate` now accepts `--format markdown`, rejects other formats with machine-readable errors when `--json` is requested, and returns the existing `MarkdownExport` wrapper for `--json` success. `TestRunnerReportGenerateTriageAndReleaseReadinessMatchStateExports`, `TestRunnerReportGenerateJSONContracts`, `TestRunnerReportGenerateHelpNamesMarkdownFormat`, `TestRunnerAgentHelpIsNative`, and `TestRunnerGenerateCLIReferenceWritesSkillNatively` cover the parser, help, agent-help, and generated reference surfaces. +- 2026-06-14: Finished the narrow `state export` failure-mode pass by sampling missing state, invalid state, unsupported kinds, unsupported formats, and `--json` misuse across export kinds. The pass found an order-dependent parser error where `state export all --json --format markdown` fell through to a generic unsupported-format message while the reverse flag order reported the intended conflict. `TestRunnerStateExportJSONErrorsAreMachineReadable`, `TestRunnerStateExportRejectsMissingInvalidUnsupportedState`, and `TestRunnerStateControlPlaneJSONFailureMatrix` now cover the relevant JSON error contracts and flag-order conflict. + +Go/no-go: a user can run the state/project/migration commands without reading docs and understand whether anything changed. + +### Track 6: Completion Audit + +Only after Tracks 1-5, run a requirement-by-requirement completion audit against: + +- this reliability contract; +- SPEC-040 acceptance items; +- native cutover test map guardrails; +- current command output from the primary checkout and temporary fixtures. + +Go/no-go: every requirement has current evidence, not just a historical changelog entry. + +Progress: + +- 2026-06-14: Started the completion audit in `docs/reports/2026-06-14-boring-reliable-completion-audit.md`, mapping the reliability contract to current tests, docs, and dogfood evidence. The first weak proof point was the backend mapping sensitive-value boundary: schema tests proved Loaf does not define dedicated sensitive storage columns, but doctor did not reject sensitive-looking values inside external identity fields. `backend-mapping-sensitive-value` now classifies those rows as invalid local backend-mapping data, and `TestInspectReportsSensitiveBackendMappingValues` covers the repair policy. +- 2026-06-14: Continued human-output dogfood from the primary checkout with isolated XDG homes. Missing and initialized state surfaces were readable, but a natural `loaf project move <from> <to> --dry-run` invocation failed as an unknown option because absolute paths were not accepted positionally. `loaf project move` now accepts positional absolute paths in addition to `--from/--to`, with the same preview/apply safeguards and JSON contracts. `TestRunnerProjectMoveAcceptsPositionalPaths` covers the new shape. +- 2026-06-14: Sampled human failure output for project commands against missing SQLite state. `project show`, `project list`, `project rename --dry-run`, and `project move --dry-run` all protected state correctly, but returned a terse missing-database message without the global database path or an inspect-first next action. Missing-state project errors now name the global database path and point to `loaf state status` or `loaf state init`, with `TestRunnerProjectMissingDatabaseHumanErrorsIncludeContext` covering the shared failure path. +- 2026-06-14: Sampled invalid schema state by drifting a migration checksum in an isolated primary-checkout database. `state doctor` correctly reported invalid state, but project commands still read identity data because their read-only opener only checked max schema version. Project commands now validate migration checksums before reading identity state and reject drift with the global database path plus `loaf state doctor` guidance. `TestRunnerProjectCommandsRejectSchemaChecksumDrift` covers human and JSON failures. +- 2026-06-14: Sampled project path invariant drift by making `projects.current_path` disagree with the current `project_paths` row. `state doctor` correctly marked the DB invalid, but `project show` displayed the stale path while `project list` showed the path-history row. Project-specific commands now reject invalid path invariants before showing or mutating a single identity, while `project list --json` remains available for doctor-recommended inspection. `TestRunnerProjectCommandsRejectPathInvariantMismatch` covers human and JSON failures plus the inspection path. +- 2026-06-14: Dogfooded Markdown import apply/resume from the primary checkout with isolated XDG homes. Dry-run did not create the global database, apply and repeated resume kept one spec/task/idea/relationship row each, and `.agents` source file hashes were unchanged. The pass found that apply and resume JSON payloads were indistinguishable once separated from argv context; `MarkdownMigrationResult.action` now reports `apply` or `resume`, human output prints the same action, and `TestRunnerStateMigrateMarkdownApplyJSON`, `TestRunnerStateMigrateMarkdownApplyHuman`, `TestRunnerStateMigrateMarkdownResumeJSON`, and `TestRunnerStateMigrateMarkdownResumeHuman` cover the contract and source preservation. +- 2026-06-14: Dogfooded backend/Linear repair follow-up exports from the primary checkout with isolated XDG homes. `state doctor --json` correctly reported backend mapping drift, ambiguous Linear mappings, and unmapped active local tasks, but the recommended `state export all --format json` snapshot dropped the diagnostic and repair-plan context that explained why the export existed. `ExportSnapshot` now carries `diagnostics` and `repair_plan`, the manifest reports their counts, and `TestRunnerStateDoctorRepairPlanCommandsExecuteInDiagnosticMode` verifies backend drift and Linear reconciliation exports preserve that context. +- 2026-06-14: Dogfooded stale compatibility export and local Markdown import warnings from the primary checkout with isolated XDG homes. The warnings were preserved in doctor and export JSON, but they had no category, policy, or structured details, unlike backend/Linear diagnostics. `local-markdown-not-imported` now reports `markdown-import/import-pending` with importable artifact counts and preview/apply commands, while `stale-compatibility-export` reports `compatibility-export/stale-export` with export/source identifiers and timestamps. `TestInspectWarnsWhenGlobalDatabaseHasNotImportedCurrentMarkdown`, `TestInspectWarnsWhenInitializedProjectHasUnimportedMarkdown`, `TestInspectWarnsOnStaleCompatibilityExports`, `TestRunnerStateExportAllCarriesWarningDiagnosticDetails`, and `TestRunnerReportListWarnsWhenGlobalDatabaseHasUnimportedMarkdown` cover the contract. +- 2026-06-14: Dogfooded backup/export/import JSON output from the primary checkout with isolated XDG homes. Backup, backup verify, export all, and Markdown apply already carried global/project context, but `state migrate markdown --dry-run --json` only returned artifact counts. Dry-run JSON now uses `MarkdownMigrationPreviewResult` to report global database scope, target database path, project import scope, project name/path, and `applied: false` while still leaving the SQLite database missing. `TestRunnerStateMigrateMarkdownJSONDryRunDoesNotCreateDatabase` and the control-plane success matrix cover the contract. +- 2026-06-14: Dogfooded human and JSON failures for backup/export/import command surfaces from the primary checkout with isolated XDG homes. JSON failures stayed concise and machine-readable, but human missing-state errors from `state backup` and Markdown `state export ...` commands were too terse to show the global DB target. Human missing-state output now includes global database scope, target database path, and safe next actions to inspect state or import local Markdown. `TestRunnerStateBackupRejectsMissingAndInvalidState` and `TestRunnerStateExportRejectsMissingInvalidUnsupportedState` cover the shape. +- 2026-06-14: Re-dogfooded release-readiness/triage/session report-generation aliases from the primary checkout with isolated XDG homes. Markdown output and alias equality were stable, but `loaf report generate ... --json` success wrappers only exposed Markdown metadata and content, unlike their machine-readable failure payloads. `MarkdownExport` now carries contract version, report command, global database scope, project export scope, and durable project identity. External report JSON omits local project/database paths, while internal session JSON retains them for agent routing. `TestRunnerReportGenerateJSONContracts`, `TestRunnerReportGenerateSessionAndSessionReportMatchStateExport`, and `TestRunnerReportGenerateRejectsMissingInvalidUnsupportedState` cover the contract. +- 2026-06-14: Dogfooded migration and repair JSON success envelopes from the primary checkout with isolated XDG homes. Markdown migration and repair envelopes already carried contract/scope/project context where available, but `state migrate storage-home --dry-run --json` did not report project identity after the global data-home database had already been initialized for the current project. Storage-home preview now performs a read-only status inspection when the destination DB exists and records the verified durable project ID, friendly name, and current path. `TestRunnerStateMigrateStorageHomeCopiesLegacyDatabase` covers the after-apply dry-run shape. +- 2026-06-14: Audited command aliases and generated help/reference output against the tightened JSON contracts. Runtime alias output was consistent, but `--agent-help` advertised top-level `migrate markdown|storage-home` without equivalent `state migrate ...` leaves, and report/migration JSON descriptions underspecified contract and project context fields. Agent help now exposes state migration leaf commands, migration/report JSON descriptions name contract/scope/project context, generated CLI reference output matches, and `TestRunnerAgentHelpIsNative`, `TestRunnerReportGenerateHelpNamesMarkdownFormat`, and `TestRunnerGenerateCLIReferenceWritesSkillNatively` guard the surface. +- 2026-06-14: Audited generated `dist/` and plugin CLI-reference artifacts plus install-facing runtime metadata for stale state-command descriptions. Top-level migration aliases still used vague generated-reference wording, and critical state control-plane agent-help/reference entries still used raw/details JSON descriptions. Agent help and generated CLI reference output now name the actual contract fields for `state path|init|status|doctor`, repair previews/applies, backups, backup verification restore guidance, top-level migration aliases, global database scope, and project identity. `TestRunnerAgentHelpIsNative` and `TestRunnerGenerateCLIReferenceWritesSkillNatively` guard the descriptions, while `npm run build` refreshed `content/skills/cli-reference`, all `dist/*/skills/cli-reference` copies, and `plugins/loaf/skills/cli-reference`. +- 2026-06-14: Continued the agent-help/raw-JSON audit across SQLite-backed session, task, spec, and report surfaces. The pass found a behavioral contract bug where `loaf session report --json` was advertised but rejected. `session report --json` now returns the local session `MarkdownExport` wrapper with `command: session report`, and session/task/spec/report agent help, command help, and generated CLI reference output name the relevant contract fields: global database scope, project identity, diagnostics, journal entries, relationships, events, compatibility counts, status transitions, and Markdown export content. `TestRunnerReportGenerateSessionAndSessionReportMatchStateExport`, `TestRunnerAgentHelpIsNative`, and `TestRunnerGenerateCLIReferenceWritesSkillNatively` guard the behavior and descriptions. +- 2026-06-14: Continued the agent-help/raw-JSON audit across SQLite-backed entity families: `brainstorm`, `idea`, `spark`, `tag`, `bundle`, and `link`. Their payload structs already exposed global/project context plus relationships, events, tag mutations, bundle membership, and source/target relationship IDs, but help surfaces still used vague JSON wording. Agent help, command help, and generated CLI reference output now name those fields directly; `TestRunnerAgentHelpIsNative`, `TestRunnerGenerateCLIReferenceWritesSkillNatively`, and rebuilt `bin/loaf --agent-help` dogfood guard the descriptions. +- 2026-06-14: Finished the remaining `--agent-help` raw-JSON pass across `kb`, `check`, `housekeeping`, and `trace`. These surfaces now name their concrete JSON content across agent help, command help, and generated CLI reference output: knowledge totals, coverage/staleness metadata, frontmatter diagnostics, QMD import status, hook pass/block results, cleanup sections/signals, traced entities, sources, relationships, and SQLite-backed project identity. `TestRunnerAgentHelpIsNative`, `TestRunnerGenerateCLIReferenceWritesSkillNatively`, and rebuilt `bin/loaf --agent-help` dogfood guard the descriptions. +- 2026-06-14: Ran the final terminal-help JSON wording sweep for critical state/project control-plane commands. Human `--help` for `state path|init|status|doctor|backup|backup verify`, `project list|show|rename|move`, guarded state repairs, and state/top-level migrations now names the same contract fields already present in agent help and generated CLI reference output. `TestRunnerStateAndProjectJSONHelpNamesContracts`, `TestRunnerGenerateCLIReferenceWritesSkillNatively`, and rebuilt `bin/loaf ... --help` dogfood guard the descriptions. +- 2026-06-14: Completed the final requirement-by-requirement audit in `docs/reports/2026-06-14-boring-reliable-completion-audit.md`. The audit ties the reliability contract to `go test ./...`, `npm run typecheck`, focused CLI/state test inventories, isolated XDG dogfood, SPEC-040 checked decisions/test conditions, native cutover guardrails, README restore guidance, and generated help/reference scans. + +## Next Best Commit + +No immediate implementation checkpoint is identified by this audit. Keep the reliability contract as the regression gate for future schema, state/project/migration CLI, JSON, backup/export/restore, and backend/Linear changes. diff --git a/docs/schema/0003_project_identity_and_relationship_origin.sql b/docs/schema/0003_project_identity_and_relationship_origin.sql new file mode 100644 index 00000000..075fec51 --- /dev/null +++ b/docs/schema/0003_project_identity_and_relationship_origin.sql @@ -0,0 +1,24 @@ +ALTER TABLE projects ADD COLUMN friendly_name TEXT; +ALTER TABLE projects ADD COLUMN current_path TEXT; +ALTER TABLE projects ADD COLUMN last_seen_at TEXT; + +CREATE TABLE IF NOT EXISTS project_paths ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + path TEXT NOT NULL, + is_current INTEGER NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (path) +); + +CREATE INDEX IF NOT EXISTS idx_project_paths_project_current ON project_paths (project_id, is_current); + +ALTER TABLE relationships ADD COLUMN origin TEXT; +ALTER TABLE relationships ADD COLUMN source_id TEXT; +ALTER TABLE relationships ADD COLUMN source_field TEXT; + +CREATE INDEX IF NOT EXISTS idx_relationships_origin ON relationships (project_id, origin); diff --git a/docs/schema/0004_project_path_current_uniqueness.sql b/docs/schema/0004_project_path_current_uniqueness.sql new file mode 100644 index 00000000..c3ce338e --- /dev/null +++ b/docs/schema/0004_project_path_current_uniqueness.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_paths_one_current ON project_paths (project_id) WHERE is_current = 1; diff --git a/docs/schema/operational-state.dbml b/docs/schema/operational-state.dbml index f2bfde5d..7409972a 100644 --- a/docs/schema/operational-state.dbml +++ b/docs/schema/operational-state.dbml @@ -1,5 +1,5 @@ // Loaf operational state schema. -// Source of truth: internal/state/migrations/0001_initial.sql. +// Source of truth: internal/state/migrations/*.sql. // This mirror is guarded by TestSchemaDocumentationMirrorsExecutableMigration. Table schema_migrations { @@ -14,6 +14,24 @@ Table projects { identity_hash text [not null, unique] created_at text [not null] updated_at text [not null] + friendly_name text + current_path text + last_seen_at text +} + +Table project_paths { + id text [pk, not null] + project_id text [not null, ref: > projects.id] + path text [not null, unique] + is_current integer [not null] + first_seen_at text [not null] + last_seen_at text [not null] + created_at text [not null] + updated_at text [not null] + indexes { + (project_id, is_current) + (project_id) [unique, note: 'where is_current = 1'] + } } Table aliases { @@ -180,9 +198,13 @@ Table relationships { reason text created_at text [not null] updated_at text [not null] + origin text + source_id text + source_field text indexes { (project_id, from_entity_kind, from_entity_id) (project_id, to_entity_kind, to_entity_id) + (project_id, origin) } } diff --git a/docs/schema/operational-state.mmd b/docs/schema/operational-state.mmd index 99ae7f03..a680568b 100644 --- a/docs/schema/operational-state.mmd +++ b/docs/schema/operational-state.mmd @@ -4,6 +4,19 @@ erDiagram text identity_hash UK text created_at text updated_at + text friendly_name + text current_path + text last_seen_at + } + project_paths { + text id PK + text project_id FK + text path UK + integer is_current + text first_seen_at + text last_seen_at + text created_at + text updated_at } schema_migrations { integer version PK @@ -146,6 +159,9 @@ erDiagram text reason text created_at text updated_at + text origin + text source_id + text source_field } tags { text id PK @@ -242,6 +258,7 @@ erDiagram projects ||--o{ session_state_snapshots : scopes projects ||--o{ reports : scopes projects ||--o{ journal_entries : scopes + projects ||--o{ project_paths : locates projects ||--o{ events : scopes projects ||--o{ relationships : scopes projects ||--o{ tags : scopes diff --git a/internal/cli/agent_help.go b/internal/cli/agent_help.go index d319c715..e025fa6a 100644 --- a/internal/cli/agent_help.go +++ b/internal/cli/agent_help.go @@ -1,6 +1,11 @@ package cli -import "io" +import ( + "io" + "strings" + + "github.com/levifig/loaf/internal/state" +) type agentHelpOption struct { Flags string `json:"flags"` @@ -41,7 +46,7 @@ func agentHelpCommands() []agentHelpCommand { Name: "build", Description: "Build Loaf content targets", Options: []agentHelpOption{ - {Flags: "--target <target>", Description: "Build only one target"}, + {Flags: "-t, --target <name>", Description: "Build a specific target only"}, }, }, { @@ -55,9 +60,10 @@ func agentHelpCommands() []agentHelpCommand { Name: "install", Description: "Install Loaf into detected or selected agent tools", Options: []agentHelpOption{ - {Flags: "--to <targets>", Description: "Comma-separated target tools or all"}, + {Flags: "--to <target>", Description: "Target to install to, or all"}, {Flags: "--upgrade", Description: "Upgrade already-installed targets"}, - {Flags: "--yes", Description: "Skip confirmation prompts"}, + {Flags: "-y, --yes", Description: "Assume yes to safe project-file symlink migrations"}, + {Flags: "--no-yes", Description: "Force prompt-style declines in non-interactive mode"}, }, }, { @@ -68,37 +74,59 @@ func agentHelpCommands() []agentHelpCommand { Name: "state", Description: "Manage native SQLite state", Subcommands: []agentHelpSubcommand{ - {Name: "path", Description: "Print the native SQLite database path"}, - {Name: "init", Description: "Initialize native SQLite state"}, - {Name: "status", Description: "Show native state status"}, - {Name: "doctor", Description: "Diagnose native state health"}, + {Name: "path", Description: "Print the native SQLite database path", Options: []agentHelpOption{{Flags: "--json", Description: "Output contract version, database path, scope, and project root as JSON"}, {Flags: "--verbose", Description: "Output command, scope, project root, and database path"}}}, + {Name: "init", Description: "Initialize native SQLite state", Options: []agentHelpOption{{Flags: "--json", Description: "Output initialized status, global database scope, and project identity as JSON"}}}, + {Name: "status", Description: "Show native state status", Options: []agentHelpOption{{Flags: "--json", Description: "Output readiness mode, diagnostics, global database scope, and project identity as JSON"}}}, + {Name: "doctor", Description: "Diagnose native state health", Options: []agentHelpOption{{Flags: "--fix", Description: "Apply safe repairs"}, {Flags: "--dry-run", Description: "Preview repairs without writing"}, {Flags: "--json", Description: "Output diagnostics, repair plan, global database scope, and project identity as JSON"}}}, + {Name: "repair", Description: "Repair guarded SQLite data drift"}, + {Name: "repair legacy-project-database", Description: "Archive migrated per-project SQLite leftovers", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview archive paths without writing"}, {Flags: "--apply", Description: "Move legacy SQLite files into the archive directory"}, {Flags: "--json", Description: "Output archive plan/result, global database scope, and project identity as JSON"}}}, + {Name: "repair relationship-origin", Description: "Backfill missing relationship provenance", Options: []agentHelpOption{{Flags: "--origin <imported|manual>", Description: "Provenance value to set"}, {Flags: "--dry-run", Description: "Preview affected rows without writing"}, {Flags: "--apply", Description: "Apply the backfill"}, {Flags: "--json", Description: "Output repair plan/result, global database scope, and project identity as JSON"}}}, {Name: "migrate", Description: "Run state migrations"}, - {Name: "backup", Description: "Create a SQLite database backup"}, - {Name: "export", Description: "Export state data"}, + {Name: "migrate markdown", Description: "Import markdown artifacts into native SQLite state", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview import work without creating SQLite state"}, {Flags: "--apply", Description: "Initialize SQLite and apply the import"}, {Flags: "--resume", Description: "Resume an interrupted import"}, {Flags: "--json", Description: "Output migration contract, scope, project context, and counts as JSON"}}}, + {Name: "migrate storage-home", Description: "Copy legacy XDG_STATE_HOME SQLite state into the global XDG_DATA_HOME database", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview migration work without copying"}, {Flags: "--apply", Description: "Copy or merge eligible legacy state without deleting the source"}, {Flags: "--json", Description: "Output migration contract, global database paths, action, and project identity when available"}}}, + {Name: "backup", Description: "Create a SQLite database backup under the global data-home backups directory", Options: []agentHelpOption{{Flags: "--json", Description: "Output backup verification, checksum, schema version, project count, and current project identity as JSON"}}}, + {Name: "backup verify", Description: "Verify an existing SQLite database backup", Options: []agentHelpOption{{Flags: "--json", Description: "Output backup verification, restore guidance, schema version, and captured project identities as JSON"}}}, + {Name: "export", Description: "Export state data", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format for the selected export kind"}}}, + {Name: "export all", Description: "Export a complete project-scoped SQLite snapshot", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format: json"}, {Flags: "--json", Description: "Alias for --format json"}}}, + {Name: "export triage", Description: "Export a triage summary from SQLite state", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + {Name: "export session", Description: "Export one session from SQLite state", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + {Name: "export spec", Description: "Export one spec from SQLite state", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + {Name: "export release-readiness", Description: "Export a release-readiness report from SQLite state", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + }, + }, + { + Name: "project", + Description: "Manage durable project identity", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "List registered projects in the global SQLite database", Options: []agentHelpOption{{Flags: "--json", Description: "Output database path, project IDs, friendly names, and current paths as JSON"}}}, + {Name: "show", Description: "Show the current project identity", Options: []agentHelpOption{{Flags: "--json", Description: "Output project ID, friendly name, current path, and database path as JSON"}}}, + {Name: "identity", Description: "Alias for project show", Options: []agentHelpOption{{Flags: "--json", Description: "Output project ID, friendly name, current path, and database path as JSON"}}}, + {Name: "rename", Description: "Rename the friendly project name", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Validate and preview without writing"}, {Flags: "--json", Description: "Output project ID, friendly name, current path, and database path as JSON"}}}, + {Name: "move", Description: "Record a checkout path move", Options: []agentHelpOption{{Flags: "<from> [to]", Description: "Previous and optional new absolute project paths"}, {Flags: "--from <path>", Description: "Previous absolute project path"}, {Flags: "--to <path>", Description: "New absolute project path; defaults to the current project root"}, {Flags: "--dry-run", Description: "Validate and preview without writing"}, {Flags: "--json", Description: "Output project ID, friendly name, current path, and database path as JSON"}}}, }, }, { Name: "migrate", Description: "Run migration workflows", Subcommands: []agentHelpSubcommand{ - {Name: "markdown", Description: "Import markdown artifacts into native SQLite state"}, - {Name: "storage-home", Description: "Move durable SQLite state to XDG_DATA_HOME"}, - {Name: "worktree-storage", Description: "Move linked-worktree .agents content to the main checkout"}, + {Name: "markdown", Description: "Import markdown artifacts into native SQLite state", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview import work without creating SQLite state"}, {Flags: "--apply", Description: "Initialize SQLite and apply the import"}, {Flags: "--resume", Description: "Resume an interrupted import"}, {Flags: "--json", Description: "Output migration contract, scope, project context, and counts as JSON"}}}, + {Name: "storage-home", Description: "Copy legacy XDG_STATE_HOME SQLite state into the global XDG_DATA_HOME database", Options: []agentHelpOption{{Flags: "--dry-run", Description: "Preview migration work without copying"}, {Flags: "--apply", Description: "Copy or merge eligible legacy state without deleting the source"}, {Flags: "--json", Description: "Output migration contract, global database paths, action, and project identity when available"}}}, + {Name: "worktree-storage", Description: "Move linked-worktree .agents content to the main checkout", Options: []agentHelpOption{{Flags: "--apply", Description: "Perform the migration; dry-run is the default"}, {Flags: "--force-from-worktree", Description: "On conflict, keep the worktree-local copy"}, {Flags: "--force-from-main", Description: "On conflict, keep the main-worktree copy"}}}, }, }, { Name: "session", Description: "Manage sessions", Subcommands: []agentHelpSubcommand{ - {Name: "start", Description: "Start or resume a session"}, - {Name: "end", Description: "End the current session"}, - {Name: "archive", Description: "Archive completed sessions"}, - {Name: "list", Description: "List sessions"}, - {Name: "show", Description: "Display one session"}, - {Name: "log", Description: "Append a journal entry"}, - {Name: "report", Description: "Generate a session report"}, - {Name: "enrich", Description: "Summarize compatibility enrichment status"}, - {Name: "housekeeping", Description: "Summarize session housekeeping status"}, + {Name: "start", Description: "Start or resume a session", Options: []agentHelpOption{{Flags: "--resume", Description: "Resume if possible"}, {Flags: "--session-id <id>", Description: "Harness session ID"}, {Flags: "--force", Description: "Ignore hook agent adoption guard"}, {Flags: "--json", Description: "Output action, session, journal IDs, global database scope, and project identity as JSON"}}}, + {Name: "end", Description: "End the current session", Options: []agentHelpOption{{Flags: "--if-active", Description: "No-op when no active session exists"}, {Flags: "--wrap", Description: "Mark as wrapped"}, {Flags: "--from-hook", Description: "Read hook input"}, {Flags: "--session-id <id>", Description: "Harness session ID"}, {Flags: "--json", Description: "Output action/noop, session, journal IDs, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive completed sessions", Options: []agentHelpOption{{Flags: "--branch <branch>", Description: "Branch to archive"}, {Flags: "--session-id <id>", Description: "Harness session ID"}, {Flags: "--json", Description: "Output archive result, affected sessions, global database scope, and project identity as JSON"}}}, + {Name: "list", Description: "List sessions", Options: []agentHelpOption{{Flags: "--all", Description: "Include archived sessions"}, {Flags: "--json", Description: "Output sessions, diagnostics, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Display one session", Options: []agentHelpOption{{Flags: "--json", Description: "Output session details, journal entries, relationships, global database scope, and project identity as JSON"}}}, + {Name: "log", Description: "Append a journal entry", Options: []agentHelpOption{{Flags: "--from-hook", Description: "Read hook input"}, {Flags: "--session-id <id>", Description: "Harness session ID"}, {Flags: "--json", Description: "Output journal entry, linked session, global database scope, and project identity as JSON"}}}, + {Name: "report", Description: "Generate a session report", Options: []agentHelpOption{{Flags: "--json", Description: "Output export contract, command, project context, and markdown content as JSON"}}}, + {Name: "enrich", Description: "Summarize compatibility enrichment status", Options: []agentHelpOption{{Flags: "--json", Description: "Output compatibility mode, action, reason, and counts as JSON"}}}, + {Name: "housekeeping", Description: "Summarize session housekeeping status", Options: []agentHelpOption{{Flags: "--json", Description: "Output compatibility mode, action, reason, and counts as JSON"}}}, {Name: "state", Description: "Manage session current-state metadata"}, {Name: "context", Description: "Render session context for compaction or resumption"}, }, @@ -107,46 +135,46 @@ func agentHelpCommands() []agentHelpCommand { Name: "task", Description: "Manage project tasks", Subcommands: []agentHelpSubcommand{ - {Name: "list", Description: "Show task board grouped by status", Options: []agentHelpOption{{Flags: "--json", Description: "Output raw JSON"}, {Flags: "--active", Description: "Hide completed tasks"}, {Flags: "--status <status>", Description: "Filter by task status"}}}, - {Name: "show", Description: "Display a single task's details", Options: []agentHelpOption{{Flags: "--json", Description: "Output raw JSON"}}}, + {Name: "list", Description: "Show task board grouped by status", Options: []agentHelpOption{{Flags: "--json", Description: "Output tasks, diagnostics, global database scope, and project identity as JSON"}, {Flags: "--active", Description: "Hide completed tasks"}, {Flags: "--status <status>", Description: "Filter by task status: " + strings.Join(state.TaskListStatuses(), ", ")}}}, + {Name: "show", Description: "Display a single task's details", Options: []agentHelpOption{{Flags: "--json", Description: "Output task details, relationships, global database scope, and project identity as JSON"}}}, {Name: "status", Description: "Show task summary counts"}, - {Name: "create", Description: "Create a new task", Options: []agentHelpOption{{Flags: "--title <title>", Description: "Task title"}, {Flags: "--spec <id>", Description: "Associated spec ID"}, {Flags: "--priority <level>", Description: "Priority level"}, {Flags: "--depends-on <ids>", Description: "Comma-separated dependency task IDs"}}}, - {Name: "update", Description: "Update a task's metadata", Options: []agentHelpOption{{Flags: "--status <status>", Description: "New task status"}, {Flags: "--priority <level>", Description: "New task priority"}, {Flags: "--spec <id>", Description: "Set or clear associated spec"}, {Flags: "--depends-on <ids>", Description: "Replace dependencies"}, {Flags: "--session <file>", Description: "Set or clear session reference"}}}, - {Name: "archive", Description: "Archive completed tasks", Options: []agentHelpOption{{Flags: "--spec <id>", Description: "Archive done tasks for a spec"}}}, - {Name: "refresh", Description: "Rebuild the Markdown task index from task/spec files"}, - {Name: "sync", Description: "Sync the Markdown task index and task files", Options: []agentHelpOption{{Flags: "--import", Description: "Import orphan markdown files"}, {Flags: "--push", Description: "Push index metadata into markdown frontmatter"}}}, + {Name: "create", Description: "Create a new task", Options: []agentHelpOption{{Flags: "--title <title>", Description: "Task title"}, {Flags: "--spec <id>", Description: "Associated spec ID"}, {Flags: "--priority <level>", Description: "Task priority: " + strings.Join(state.TaskPriorities(), ", ")}, {Flags: "--depends-on <ids>", Description: "Comma-separated dependency task IDs"}, {Flags: "--json", Description: "Output created task, event, global database scope, and project identity as JSON"}}}, + {Name: "update", Description: "Update a task's metadata", Options: []agentHelpOption{{Flags: "--status <status>", Description: "New task status: " + strings.Join(state.TaskStatuses(), ", ")}, {Flags: "--priority <level>", Description: "New task priority: " + strings.Join(state.TaskPriorities(), ", ")}, {Flags: "--spec <id>", Description: "Set or clear associated spec"}, {Flags: "--depends-on <ids>", Description: "Replace dependencies"}, {Flags: "--session <file>", Description: "Set or clear session reference"}, {Flags: "--json", Description: "Output updated task, event, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive completed tasks", Options: []agentHelpOption{{Flags: "--spec <id>", Description: "Archive done tasks for a spec"}, {Flags: "--json", Description: "Output archive result, archived tasks, global database scope, and project identity as JSON"}}}, + {Name: "refresh", Description: "Rebuild the Markdown task index from task/spec files", Options: []agentHelpOption{{Flags: "--json", Description: "Output compatibility mode, action, reason, and counts as JSON"}}}, + {Name: "sync", Description: "Sync the Markdown task index and task files", Options: []agentHelpOption{{Flags: "--import", Description: "Import orphan markdown files"}, {Flags: "--push", Description: "Push index metadata into markdown frontmatter"}, {Flags: "--json", Description: "Output compatibility mode, action, reason, and counts as JSON"}}}, }, }, { Name: "spec", Description: "Manage project specs", Subcommands: []agentHelpSubcommand{ - {Name: "list", Description: "Show specs with status and task counts", Options: []agentHelpOption{{Flags: "--json", Description: "Output raw JSON"}}}, - {Name: "show", Description: "Show spec details", Options: []agentHelpOption{{Flags: "--json", Description: "Output raw JSON"}}}, - {Name: "archive", Description: "Archive a completed spec", Options: []agentHelpOption{{Flags: "--json", Description: "Output raw JSON"}}}, + {Name: "list", Description: "Show specs with status and task counts", Options: []agentHelpOption{{Flags: "--json", Description: "Output specs, diagnostics, task counts, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show spec details", Options: []agentHelpOption{{Flags: "--json", Description: "Output spec details, task counts, relationships, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive a completed spec", Options: []agentHelpOption{{Flags: "--json", Description: "Output archive result, archived specs, global database scope, and project identity as JSON"}}}, }, }, { Name: "report", Description: "Manage durable reports", Subcommands: []agentHelpSubcommand{ - {Name: "list", Description: "List reports", Options: []agentHelpOption{{Flags: "--type <type>", Description: "Filter by report type"}, {Flags: "--status <status>", Description: "Filter by status"}, {Flags: "--json", Description: "Output raw JSON"}}}, - {Name: "generate", Description: "Generate a report from state", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format"}}}, - {Name: "create", Description: "Create a report draft", Options: []agentHelpOption{{Flags: "--type <type>", Description: "Report type"}, {Flags: "--source <source>", Description: "Report source"}, {Flags: "--json", Description: "Output raw JSON"}}}, - {Name: "finalize", Description: "Mark a report draft as final", Options: []agentHelpOption{{Flags: "--json", Description: "Output raw JSON"}}}, - {Name: "archive", Description: "Archive a finalized report", Options: []agentHelpOption{{Flags: "--json", Description: "Output raw JSON"}}}, + {Name: "list", Description: "List reports", Options: []agentHelpOption{{Flags: "--type <type>", Description: "Filter by report type"}, {Flags: "--status <status>", Description: "Filter by status; Loaf lifecycle statuses: draft, final, archived"}, {Flags: "--json", Description: "Output reports, diagnostics, global database scope, and project identity as JSON"}}}, + {Name: "generate", Description: "Generate a report from state", Options: []agentHelpOption{{Flags: "--format <format>", Description: "Output format: markdown"}, {Flags: "--json", Description: "Output contract, command, project context, and markdown content as JSON"}}}, + {Name: "create", Description: "Create a report draft", Options: []agentHelpOption{{Flags: "--type <type>", Description: "Report type"}, {Flags: "--source <source>", Description: "Report source"}, {Flags: "--json", Description: "Output created report, event, global database scope, and project identity as JSON"}}}, + {Name: "finalize", Description: "Mark a report draft as final", Options: []agentHelpOption{{Flags: "--json", Description: "Output report status transition, event, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive a finalized report", Options: []agentHelpOption{{Flags: "--json", Description: "Output report status transition, event, global database scope, and project identity as JSON"}}}, }, }, { Name: "kb", Description: "Knowledge base management", Subcommands: []agentHelpSubcommand{ - {Name: "status", Description: "Show knowledge base status"}, - {Name: "validate", Description: "Validate knowledge file frontmatter"}, - {Name: "check", Description: "Check knowledge staleness"}, - {Name: "review", Description: "Mark knowledge files reviewed"}, - {Name: "init", Description: "Initialize knowledge directories"}, - {Name: "import", Description: "Register external knowledge imports"}, + {Name: "status", Description: "Show knowledge base status", Options: []agentHelpOption{{Flags: "--json", Description: "Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON"}}}, + {Name: "validate", Description: "Validate knowledge file frontmatter", Options: []agentHelpOption{{Flags: "--json", Description: "Output per-file frontmatter errors and warnings as JSON"}}}, + {Name: "check", Description: "Check knowledge staleness", Options: []agentHelpOption{{Flags: "--file <path>", Description: "Reverse lookup: find knowledge files covering this path"}, {Flags: "--json", Description: "Output per-file staleness, coverage, commit, and review metadata as JSON"}}}, + {Name: "review", Description: "Mark knowledge files reviewed", Options: []agentHelpOption{{Flags: "--json", Description: "Output updated knowledge frontmatter as JSON"}}}, + {Name: "init", Description: "Initialize knowledge directories", Options: []agentHelpOption{{Flags: "--json", Description: "Output directory actions, config status, and QMD collections as JSON"}}}, + {Name: "import", Description: "Register external knowledge imports", Options: []agentHelpOption{{Flags: "--path <path>", Description: "Path to the external project's knowledge directory"}, {Flags: "--json", Description: "Output QMD import collection status or import error as JSON"}}}, {Name: "glossary", Description: "Domain glossary mutation and lookup"}, }, }, @@ -155,7 +183,7 @@ func agentHelpCommands() []agentHelpCommand { Description: "Run hook checks", Options: []agentHelpOption{ {Flags: "--hook <id>", Description: "Run one registered hook"}, - {Flags: "--json", Description: "Output raw JSON"}, + {Flags: "--json", Description: "Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON"}, }, }, {Name: "doctor", Description: "Diagnose project alignment", Options: []agentHelpOption{{Flags: "--fix", Description: "Apply safe fixes"}, {Flags: "--verbose", Description: "Show details"}}}, @@ -177,13 +205,71 @@ func agentHelpCommands() []agentHelpCommand { }, }, {Name: "version", Description: "Show version and content counts"}, - {Name: "housekeeping", Description: "Scan agent artifacts and summarize housekeeping recommendations"}, - {Name: "trace", Description: "Trace relationships for an entity"}, - {Name: "brainstorm", Description: "Manage brainstorm artifacts"}, - {Name: "idea", Description: "Manage ideas"}, - {Name: "spark", Description: "Manage sparks"}, - {Name: "tag", Description: "Manage tags"}, - {Name: "bundle", Description: "Manage bundles"}, - {Name: "link", Description: "Manage entity relationships"}, + {Name: "housekeeping", Description: "Scan agent artifacts and summarize housekeeping recommendations", Options: []agentHelpOption{{Flags: "--json", Description: "Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON"}, {Flags: "--dry-run", Description: "Show recommendations without applying actions"}, {Flags: "--sessions", Description: "Only review sessions"}, {Flags: "--specs", Description: "Only review specs"}, {Flags: "--drafts", Description: "Only review shaping drafts"}, {Flags: "--plans", Description: "Accept legacy plans filter for compatibility"}, {Flags: "--handoffs", Description: "Accept legacy handoffs filter for compatibility"}}}, + {Name: "trace", Description: "Trace relationships for an entity", Options: []agentHelpOption{{Flags: "--json", Description: "Output traced entity, sources, relationships, global database scope, and project identity as JSON"}}}, + { + Name: "brainstorm", + Description: "Manage brainstorm artifacts", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "List brainstorms from SQLite state", Options: []agentHelpOption{{Flags: "--all", Description: "Include archived brainstorms"}, {Flags: "--status <status>", Description: "Filter by status"}, {Flags: "--json", Description: "Output brainstorms, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show one brainstorm from SQLite state", Options: []agentHelpOption{{Flags: "--json", Description: "Output brainstorm details, relationships, global database scope, and project identity as JSON"}}}, + {Name: "promote", Description: "Record brainstorm-to-idea promotion", Options: []agentHelpOption{{Flags: "--to-idea <idea>", Description: "Target idea"}, {Flags: "--json", Description: "Output promotion relationship, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive one or more brainstorms", Options: []agentHelpOption{{Flags: "--reason <text>", Description: "Archive reason"}, {Flags: "--json", Description: "Output archive result, archived brainstorms, global database scope, and project identity as JSON"}}}, + }, + }, + { + Name: "idea", + Description: "Manage ideas", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "List ideas from SQLite state", Options: []agentHelpOption{{Flags: "--all", Description: "Include resolved and archived ideas"}, {Flags: "--status <status>", Description: "Filter by status"}, {Flags: "--json", Description: "Output ideas, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show one idea from SQLite state", Options: []agentHelpOption{{Flags: "--json", Description: "Output idea details, relationships, global database scope, and project identity as JSON"}}}, + {Name: "capture", Description: "Capture an idea in SQLite state", Options: []agentHelpOption{{Flags: "--title <title>", Description: "Idea title"}, {Flags: "--json", Description: "Output created idea, event, global database scope, and project identity as JSON"}}}, + {Name: "promote", Description: "Record idea-to-spec promotion", Options: []agentHelpOption{{Flags: "--to-spec <spec>", Description: "Target spec"}, {Flags: "--json", Description: "Output promotion relationship, global database scope, and project identity as JSON"}}}, + {Name: "resolve", Description: "Resolve an idea by linking it to another entity", Options: []agentHelpOption{{Flags: "--by <entity>", Description: "Resolving entity"}, {Flags: "--json", Description: "Output resolution relationship, event, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive one or more ideas", Options: []agentHelpOption{{Flags: "--reason <text>", Description: "Archive reason"}, {Flags: "--json", Description: "Output archive result, archived ideas, global database scope, and project identity as JSON"}}}, + }, + }, + { + Name: "spark", + Description: "Manage sparks", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "List sparks from SQLite state", Options: []agentHelpOption{{Flags: "--all", Description: "Include resolved sparks"}, {Flags: "--status <status>", Description: "Filter by status"}, {Flags: "--json", Description: "Output sparks, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show one spark from SQLite state", Options: []agentHelpOption{{Flags: "--json", Description: "Output spark details, relationships, global database scope, and project identity as JSON"}}}, + {Name: "capture", Description: "Capture a spark in SQLite state", Options: []agentHelpOption{{Flags: "--scope <scope>", Description: "Spark scope"}, {Flags: "--text <text>", Description: "Spark text"}, {Flags: "--json", Description: "Output created spark, event, global database scope, and project identity as JSON"}}}, + {Name: "resolve", Description: "Resolve a spark", Options: []agentHelpOption{{Flags: "--reason <text>", Description: "Resolution reason"}, {Flags: "--json", Description: "Output resolution relationship, event, global database scope, and project identity as JSON"}}}, + {Name: "promote", Description: "Record spark-to-idea promotion", Options: []agentHelpOption{{Flags: "--to-idea <idea>", Description: "Target idea"}, {Flags: "--json", Description: "Output promotion relationship, global database scope, and project identity as JSON"}}}, + }, + }, + { + Name: "tag", + Description: "Manage tags", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "List tags from SQLite state", Options: []agentHelpOption{{Flags: "--json", Description: "Output tags, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show entities with a tag", Options: []agentHelpOption{{Flags: "--json", Description: "Output tagged entities, global database scope, and project identity as JSON"}}}, + {Name: "add", Description: "Add a tag to an entity", Options: []agentHelpOption{{Flags: "--json", Description: "Output tag mutation, entity, global database scope, and project identity as JSON"}}}, + {Name: "remove", Description: "Remove a tag from an entity", Options: []agentHelpOption{{Flags: "--json", Description: "Output tag mutation, entity, global database scope, and project identity as JSON"}}}, + }, + }, + { + Name: "bundle", + Description: "Manage bundles", + Subcommands: []agentHelpSubcommand{ + {Name: "list", Description: "List bundles from SQLite state", Options: []agentHelpOption{{Flags: "--json", Description: "Output bundles, global database scope, and project identity as JSON"}}}, + {Name: "create", Description: "Create a bundle", Options: []agentHelpOption{{Flags: "--title <title>", Description: "Bundle title"}, {Flags: "--tags <tags>", Description: "Comma-separated tag query"}, {Flags: "--json", Description: "Output created bundle, tags, global database scope, and project identity as JSON"}}}, + {Name: "update", Description: "Update a bundle", Options: []agentHelpOption{{Flags: "--title <title>", Description: "Bundle title"}, {Flags: "--tags <tags>", Description: "Comma-separated tag query"}, {Flags: "--json", Description: "Output updated bundle, tags, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show one bundle", Options: []agentHelpOption{{Flags: "--json", Description: "Output bundle details, members, global database scope, and project identity as JSON"}}}, + {Name: "add", Description: "Add an entity to a bundle", Options: []agentHelpOption{{Flags: "--json", Description: "Output bundle membership result, global database scope, and project identity as JSON"}}}, + {Name: "remove", Description: "Remove an entity from a bundle", Options: []agentHelpOption{{Flags: "--json", Description: "Output bundle membership result, global database scope, and project identity as JSON"}}}, + }, + }, + { + Name: "link", + Description: "Manage entity relationships", + Subcommands: []agentHelpSubcommand{ + {Name: "create", Description: "Create an explicit relationship", Options: []agentHelpOption{{Flags: "--from <entity>", Description: "Source entity"}, {Flags: "--to <entity>", Description: "Target entity"}, {Flags: "--type <type>", Description: "Relationship type"}, {Flags: "--reason <text>", Description: "Relationship reason"}, {Flags: "--json", Description: "Output relationship ID, source/target, global database scope, and project identity as JSON"}}}, + {Name: "list", Description: "List relationships for one entity", Options: []agentHelpOption{{Flags: "--json", Description: "Output relationships, global database scope, and project identity as JSON"}}}, + {Name: "remove", Description: "Remove an explicit relationship", Options: []agentHelpOption{{Flags: "--from <entity>", Description: "Source entity"}, {Flags: "--to <entity>", Description: "Target entity"}, {Flags: "--type <type>", Description: "Relationship type"}, {Flags: "--json", Description: "Output removed relationship ID, global database scope, and project identity as JSON"}}}, + }, + }, } } diff --git a/internal/cli/check.go b/internal/cli/check.go index 85e89b30..2420ad91 100644 --- a/internal/cli/check.go +++ b/internal/cli/check.go @@ -84,6 +84,10 @@ var validCheckHooks = map[string]bool{ } func (r Runner) runCheck(args []string, out io.Writer, runtimeRoot string) error { + if isHelpArg(args) { + writeCheckHelp(out) + return nil + } options, err := parseCheckArgs(args) if err != nil { return err @@ -123,6 +127,10 @@ func (r Runner) runCheck(args []string, out io.Writer, runtimeRoot string) error return nil } +func writeCheckHelp(out io.Writer) { + writeUsageHelp(out, "loaf check --hook <id> [--json]", "Run one registered hook check.", "--hook Hook id: check-secrets, validate-commit, security-audit, workflow-pre-pr, validate-push", "--json Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON") +} + func parseCheckArgs(args []string) (checkOptions, error) { var options checkOptions for i := 0; i < len(args); i++ { diff --git a/internal/cli/check_test.go b/internal/cli/check_test.go index c216db7a..bbb30481 100644 --- a/internal/cli/check_test.go +++ b/internal/cli/check_test.go @@ -12,6 +12,23 @@ import ( "testing" ) +func TestRunnerCheckHelp(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: t.TempDir(), + }.Run([]string{"check", "--help"}) + if err != nil { + t.Fatalf("check --help error = %v", err) + } + output := stdout.String() + for _, want := range []string{"Usage: loaf check --hook <id> [--json]", "--hook", "--json", "validate-commit"} { + if !strings.Contains(output, want) { + t.Fatalf("stdout = %q, want %q", output, want) + } + } +} + func TestRunnerCheckSecretsPassesNatively(t *testing.T) { repo := initCLIGitRepo(t) var stdout bytes.Buffer diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 57043718..b15934bb 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -3,8 +3,10 @@ package cli import ( "context" "crypto/sha256" + "database/sql" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -36,18 +38,56 @@ type housekeepingOptions struct { } type compatibilityCommandSummary struct { - Version int `json:"version"` - Command string `json:"command"` - Mode string `json:"mode"` - Action string `json:"action"` - Reason string `json:"reason"` - Counts map[string]int `json:"counts,omitempty"` + ContractVersion int `json:"contract_version"` + Version int `json:"version"` + Command string `json:"command"` + Mode string `json:"mode"` + Action string `json:"action"` + Reason string `json:"reason"` + Counts map[string]int `json:"counts,omitempty"` } type compatibilityCommandOptions struct { jsonOutput bool } +type projectRenameOptions struct { + name string + dryRun bool + jsonOutput bool +} + +type projectMoveOptions struct { + fromPath string + toPath string + dryRun bool + jsonOutput bool +} + +type backupVerifyOptions struct { + path string + jsonOutput bool +} + +type commandErrorJSON struct { + ContractVersion int `json:"contract_version"` + Command string `json:"command"` + Error string `json:"error"` + BackupPath string `json:"backup_path,omitempty"` +} + +type statePathResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + ProjectRoot string `json:"project_root"` + DatabasePath string `json:"database_path"` +} + +type statePathOptions struct { + jsonOutput bool + verboseOutput bool +} + // Run dispatches a loaf command. func (r Runner) Run(args []string) error { out := r.Stdout @@ -85,65 +125,103 @@ func (r Runner) Run(args []string) error { return nil } + var dispatchErr error switch args[0] { case "--help", "-h", "help": writeRootHelp(out) return nil case "--agent-help": - return writeAgentHelpJSON(out) + dispatchErr = writeAgentHelpJSON(out) case "--version", "-v": - return r.runVersion(out, runtime.RootPath()) + dispatchErr = r.runVersion(out, runtime.RootPath()) case "build": - return r.runBuild(args[1:], out, runtime.RootPath()) + dispatchErr = r.runBuild(args[1:], out, runtime.RootPath()) case "init": - return r.runInit(args[1:], out, runtime.RootPath()) + dispatchErr = r.runInit(args[1:], out, runtime.RootPath()) case "install": - return r.runInstall(args[1:], out, runtime.RootPath()) + dispatchErr = r.runInstall(args[1:], out, runtime.RootPath()) case "migrate": - return r.runMigrate(args[1:], out, runtime) + dispatchErr = r.runMigrate(args[1:], out, runtime) case "release": - return r.runRelease(args[1:], out, runtime.RootPath()) + dispatchErr = r.runRelease(args[1:], out, runtime.RootPath()) case "setup": - return r.runSetup(args[1:], out, runtime.RootPath()) + dispatchErr = r.runSetup(args[1:], out, runtime.RootPath()) case "state": - return r.runState(args[1:], out, runtime) + dispatchErr = r.runState(args[1:], out, runtime) + case "project": + dispatchErr = r.runProject(args[1:], out, runtime) case "trace": - return r.runTrace(args[1:], out, runtime) + dispatchErr = r.runTrace(args[1:], out, runtime) case "brainstorm": - return r.runBrainstorm(args[1:], out, runtime) + dispatchErr = r.runBrainstorm(args[1:], out, runtime) case "idea": - return r.runIdea(args[1:], out, runtime) + dispatchErr = r.runIdea(args[1:], out, runtime) case "spark": - return r.runSpark(args[1:], out, runtime) + dispatchErr = r.runSpark(args[1:], out, runtime) case "tag": - return r.runTag(args[1:], out, runtime) + dispatchErr = r.runTag(args[1:], out, runtime) case "bundle": - return r.runBundle(args[1:], out, runtime) + dispatchErr = r.runBundle(args[1:], out, runtime) case "check": - return r.runCheck(args[1:], out, runtime.RootPath()) + dispatchErr = r.runCheck(args[1:], out, runtime.RootPath()) case "doctor": - return r.runDoctor(args[1:], out, runtime.RootPath()) + dispatchErr = r.runDoctor(args[1:], out, runtime.RootPath()) case "link": - return r.runLink(args[1:], out, runtime) + dispatchErr = r.runLink(args[1:], out, runtime) case "report": - return r.runReport(args[1:], out, runtime) + dispatchErr = r.runReport(args[1:], out, runtime) case "spec": - return r.runSpec(args[1:], out, runtime) + dispatchErr = r.runSpec(args[1:], out, runtime) case "session": - return r.runSession(args[1:], out, runtime) + dispatchErr = r.runSession(args[1:], out, runtime) case "task": - return r.runTask(args[1:], out, runtime) + dispatchErr = r.runTask(args[1:], out, runtime) case "housekeeping": - return r.runHousekeeping(args[1:], out, runtime) + dispatchErr = r.runHousekeeping(args[1:], out, runtime) case "kb": - return r.runKb(args[1:], out, runtime.RootPath()) + dispatchErr = r.runKb(args[1:], out, runtime.RootPath()) case "version": - return r.runVersion(out, runtime.RootPath()) + dispatchErr = r.runVersion(out, runtime.RootPath()) default: fmt.Fprintf(errOut, "error: unknown command '%s'\n\n", args[0]) writeRootHelp(errOut) return ExitError{Code: 1} } + return writeJSONCommandErrorFallback(out, args, dispatchErr) +} + +func writeJSONCommandErrorFallback(out io.Writer, args []string, err error) error { + if err == nil { + return nil + } + var silent interface { + ExitCode() int + Silent() bool + } + if errors.As(err, &silent) && silent.Silent() { + return err + } + if !hasFlag(args, "--json") { + return err + } + return writeJSONCommandError(out, jsonErrorCommand(args), err) +} + +func jsonErrorCommand(args []string) string { + if len(args) == 0 { + return "loaf" + } + parts := []string{args[0]} + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "-") || arg == "help" { + break + } + parts = append(parts, arg) + if len(parts) == 3 { + break + } + } + return strings.Join(parts, " ") } func writeRootHelp(out io.Writer) { @@ -157,6 +235,7 @@ func writeRootHelp(out io.Writer) { fmt.Fprintln(out, " install Install Loaf into agent tools") fmt.Fprintln(out, " setup Initialize, build, and install") fmt.Fprintln(out, " state Manage native SQLite state") + fmt.Fprintln(out, " project Manage project identity") fmt.Fprintln(out, " migrate Run migration workflows") fmt.Fprintln(out, " session Manage sessions") fmt.Fprintln(out, " task Manage tasks") @@ -220,7 +299,7 @@ func writeHousekeepingHelp(out io.Writer) { fmt.Fprintln(out, "Scan agent artifacts and summarize housekeeping recommendations.") fmt.Fprintln(out) fmt.Fprintln(out, "Options:") - fmt.Fprintln(out, " --json Output JSON") + fmt.Fprintln(out, " --json Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON") fmt.Fprintln(out, " --dry-run Show recommendations without applying actions") fmt.Fprintln(out, " --sessions Only review sessions") fmt.Fprintln(out, " --specs Only review specs") @@ -254,13 +333,61 @@ func unknownSubcommandError(command string, subcommand string) error { return fmt.Errorf("unknown loaf %s subcommand %q", command, subcommand) } +func isHelpArg(args []string) bool { + return len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") +} + +func writeNestedHelp(out io.Writer, args []string, writers map[string]func(io.Writer)) bool { + if len(args) != 2 || !isHelpArg(args[1:]) { + return false + } + writeHelp, ok := writers[args[0]] + if !ok { + return false + } + writeHelp(out) + return true +} + +func writeUsageHelp(out io.Writer, usage string, summary string, options ...string) { + fmt.Fprintf(out, "Usage: %s\n", usage) + fmt.Fprintln(out) + fmt.Fprintln(out, summary) + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + for _, option := range options { + fmt.Fprintf(out, " %s\n", option) + } + fmt.Fprintln(out, " -h, --help Show help") +} + +type subcommandHelpItem struct { + Name string + Summary string +} + +func writeCommandGroupHelp(out io.Writer, usage string, summary string, items []subcommandHelpItem) { + fmt.Fprintf(out, "Usage: %s\n", usage) + fmt.Fprintln(out) + fmt.Fprintln(out, summary) + fmt.Fprintln(out) + fmt.Fprintln(out, "Subcommands:") + for _, item := range items { + fmt.Fprintf(out, " %-10s%s\n", item.Name, item.Summary) + } + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " -h, --help Show help") +} + func writeHousekeepingSummary(out io.Writer, result state.HousekeepingSummary, options housekeepingOptions) { if options.dryRun { fmt.Fprint(out, "\n loaf housekeeping (SQLite state, dry run)\n\n") } else { fmt.Fprint(out, "\n loaf housekeeping (SQLite state)\n\n") } - fmt.Fprintf(out, " database: %s\n\n", result.DatabasePath) + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + fmt.Fprintln(out) for _, name := range sortedHousekeepingSections(result) { section := result.Sections[name] fmt.Fprintf(out, " %-16s%d total", housekeepingSectionLabel(name), section.Total) @@ -331,9 +458,14 @@ func filterHousekeepingSummary(result state.HousekeepingSummary, sections map[st return result } filtered := state.HousekeepingSummary{ - Version: result.Version, - DatabasePath: result.DatabasePath, - Sections: map[string]state.HousekeepingSection{}, + ContractVersion: result.ContractVersion, + DatabaseScope: result.DatabaseScope, + DatabasePath: result.DatabasePath, + ProjectID: result.ProjectID, + ProjectName: result.ProjectName, + ProjectCurrentPath: result.ProjectCurrentPath, + Version: result.Version, + Sections: map[string]state.HousekeepingSection{}, } for section := range sections { if value, ok := result.Sections[section]; ok { @@ -464,8 +596,17 @@ func housekeepingSignalsFromSections(sections map[string]state.HousekeepingSecti } func (r Runner) runBrainstorm(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { - return missingSubcommandError("brainstorm") + if len(args) == 0 || isHelpArg(args) { + writeBrainstormHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeBrainstormListHelp, + "show": writeBrainstormShowHelp, + "promote": writeBrainstormPromoteHelp, + "archive": writeBrainstormArchiveHelp, + }) { + return nil } switch args[0] { case "list": @@ -481,6 +622,31 @@ func (r Runner) runBrainstorm(args []string, out io.Writer, runtime state.Runtim } } +func writeBrainstormHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf brainstorm <subcommand> [options]", "Manage brainstorms in native SQLite state.", []subcommandHelpItem{ + {Name: "list", Summary: "List brainstorms"}, + {Name: "show", Summary: "Show one brainstorm"}, + {Name: "promote", Summary: "Promote a brainstorm to an idea"}, + {Name: "archive", Summary: "Archive brainstorms"}, + }) +} + +func writeBrainstormListHelp(out io.Writer) { + writeUsageHelp(out, "loaf brainstorm list [--all|--status <status>] [--json]", "List brainstorms from SQLite state.", "--all Include archived brainstorms", "--status Filter by status", "--json Output brainstorms, global database scope, and project identity as JSON") +} + +func writeBrainstormShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf brainstorm show <brainstorm> [--json]", "Show one brainstorm from SQLite state.", "--json Output brainstorm details, relationships, global database scope, and project identity as JSON") +} + +func writeBrainstormPromoteHelp(out io.Writer) { + writeUsageHelp(out, "loaf brainstorm promote <brainstorm> --to-idea <idea> [--json]", "Record brainstorm-to-idea promotion.", "--to-idea Target idea", "--json Output promotion relationship, global database scope, and project identity as JSON") +} + +func writeBrainstormArchiveHelp(out io.Writer) { + writeUsageHelp(out, "loaf brainstorm archive <brainstorm...> [--reason <text>] [--json]", "Archive one or more brainstorms.", "--reason Archive reason", "--json Output archive result, archived brainstorms, global database scope, and project identity as JSON") +} + func (r Runner) runBrainstormList(args []string, out io.Writer, runtime state.Runtime) error { options, err := parseBrainstormListArgs(args) if err != nil { @@ -571,6 +737,7 @@ func (r Runner) runBrainstormPromote(args []string, out io.Writer, runtime state return writeJSON(out, result) } fmt.Fprintf(out, "promoted brainstorm %s to idea %s\n", firstNonEmpty(result.Brainstorm.Alias, result.Brainstorm.ID), firstNonEmpty(result.Idea.Alias, result.Idea.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "relationship: %s\n", result.Relationship) return nil } @@ -607,12 +774,19 @@ func (r Runner) runBrainstormArchive(args []string, out io.Writer, runtime state } func writeBrainstormList(out io.Writer, brainstorms state.BrainstormList, filters state.BrainstormListOptions) { + fmt.Fprint(out, "\n loaf brainstorm list\n\n") + writeProjectMutationContext(out, " ", brainstorms.DatabaseScope, brainstorms.DatabasePath, brainstorms.ProjectID, brainstorms.ProjectName, brainstorms.ProjectCurrentPath) if len(brainstorms.Brainstorms) == 0 { - fmt.Fprint(out, "\n No brainstorms found.\n\n") + if brainstorms.DatabaseScope != "" || brainstorms.DatabasePath != "" || brainstorms.ProjectID != "" || brainstorms.ProjectName != "" || brainstorms.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } + fmt.Fprint(out, " No brainstorms found.\n\n") return } - fmt.Fprint(out, "\n loaf brainstorm list\n\n") + if brainstorms.DatabaseScope != "" || brainstorms.DatabasePath != "" || brainstorms.ProjectID != "" || brainstorms.ProjectName != "" || brainstorms.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } for _, alias := range sortedBrainstorms(brainstorms) { brainstorm := brainstorms.Brainstorms[alias] fmt.Fprintf(out, " %-32s%s", alias, brainstorm.Title) @@ -632,6 +806,7 @@ func writeBrainstormShow(out io.Writer, result state.BrainstormShow) { fmt.Fprintf(out, "brainstorm %s\n", firstNonEmpty(brainstorm.Alias, brainstorm.ID)) fmt.Fprintf(out, "title: %s\n", brainstorm.Title) fmt.Fprintf(out, "status: %s\n", brainstorm.Status) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) for _, source := range brainstorm.Sources { fmt.Fprintf(out, "source: %s\n", source.Path) if source.Hash != "" { @@ -661,6 +836,10 @@ func writeBrainstormShow(out io.Writer, result state.BrainstormShow) { func writeBrainstormArchive(out io.Writer, result state.BrainstormArchiveResult) { fmt.Fprint(out, "\n loaf brainstorm archive\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" || result.DatabasePath != "" || result.ProjectID != "" || result.ProjectName != "" || result.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } for _, item := range result.Archived { brainstorm := item.Ref title := "" @@ -692,13 +871,21 @@ func writeBrainstormArchive(out io.Writer, result state.BrainstormArchiveResult) } func (r Runner) runState(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { - fmt.Fprintln(out, runtime.Name()) + if len(args) == 0 || isHelpArg(args) { + writeStateHelp(out) return nil } switch args[0] { case "path": + if isHelpArg(args[1:]) { + writeStatePathHelp(out) + return nil + } + options, err := parseStatePathArgs(args[1:]) + if err != nil { + return err + } projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { return err @@ -707,59 +894,701 @@ func (r Runner) runState(args []string, out io.Writer, runtime state.Runtime) er if err != nil { return err } + if options.jsonOutput { + return writeJSON(out, statePathResult{ + ContractVersion: state.StateJSONContractVersion, + DatabaseScope: "global", + ProjectRoot: projectRoot.Path(), + DatabasePath: path, + }) + } + if options.verboseOutput { + fmt.Fprintln(out, "loaf state path") + fmt.Fprintln(out, "scope: global database") + fmt.Fprintf(out, "project root: %s\n", projectRoot.Path()) + fmt.Fprintf(out, "database: %s\n", path) + return nil + } fmt.Fprintln(out, path) return nil case "init": + if isHelpArg(args[1:]) { + writeStateInitHelp(out) + return nil + } return r.runStateInit(args[1:], out, runtime) case "status": + if isHelpArg(args[1:]) { + writeStateStatusHelp(out) + return nil + } return r.runStateStatus(args[1:], out, runtime) case "doctor": + if isHelpArg(args[1:]) { + writeStateDoctorHelp(out) + return nil + } return r.runStateDoctor(args[1:], out, runtime) + case "repair": + if len(args) == 1 || isHelpArg(args[1:]) { + writeStateRepairHelp(out) + return nil + } + return r.runStateRepair(args[1:], out, runtime) case "migrate": + if len(args) == 1 || isHelpArg(args[1:]) { + writeStateMigrateHelp(out) + return nil + } return r.runStateMigrate(args[1:], out, runtime) case "backup": + if isHelpArg(args[1:]) { + writeStateBackupHelp(out) + return nil + } + if writeNestedHelp(out, args[1:], map[string]func(io.Writer){ + "verify": writeStateBackupVerifyHelp, + }) { + return nil + } return r.runStateBackup(args[1:], out, runtime) case "export": + if len(args) == 1 || isHelpArg(args[1:]) { + writeStateExportHelp(out) + return nil + } return r.runStateExport(args[1:], out, runtime) default: return fmt.Errorf("state subcommand %q is not implemented yet", args[0]) } } -func (r Runner) runStateInit(args []string, out io.Writer, runtime state.Runtime) error { +func writeStateHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state <command> [options]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Manage native SQLite state.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Commands:") + fmt.Fprintln(out, " path Print the resolved SQLite database path") + fmt.Fprintln(out, " status Show SQLite readiness and markdown compatibility status") + fmt.Fprintln(out, " init Initialize native SQLite state") + fmt.Fprintln(out, " doctor Diagnose SQLite state health") + fmt.Fprintln(out, " repair Repair guarded SQLite data drift") + fmt.Fprintln(out, " migrate Run state migrations") + fmt.Fprintln(out, " backup Create a SQLite database backup") + fmt.Fprintln(out, " backup verify Verify an existing SQLite backup") + fmt.Fprintln(out, " export Export SQLite state") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStatePathHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state path [--json|--verbose]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Print the resolved native SQLite database path.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --json Output contract version, command, project root, database scope, and database path as JSON") + fmt.Fprintln(out, " --verbose Output command, scope, project root, and database path") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateInitHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state init [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Initialize the native SQLite state database.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --json Output readiness mode, global database scope, database path, and project identity as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateStatusHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state status [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Show SQLite readiness and markdown-only compatibility status.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --json Output readiness mode, diagnostics, global database scope, and project identity as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateDoctorHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state doctor [--fix] [--dry-run] [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Diagnose SQLite state health.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --fix Initialize missing SQLite state when safe") + fmt.Fprintln(out, " --dry-run Show repair plan without applying fixes") + fmt.Fprintln(out, " --json Output diagnostics, repair plan, global database scope, and project identity as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateRepairHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state repair <target> [options]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Repair guarded SQLite data drift.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Targets:") + fmt.Fprintln(out, " legacy-project-database Archive migrated per-project SQLite leftovers") + fmt.Fprintln(out, " relationship-origin Backfill missing relationship provenance") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateMigrateHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state migrate <source> [options]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Run state migrations.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Sources:") + fmt.Fprintln(out, " markdown Import .agents Markdown artifacts into SQLite") + fmt.Fprintln(out, " storage-home Copy legacy XDG_STATE_HOME state into XDG_DATA_HOME") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateBackupHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state backup [verify <backup>] [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Create or verify SQLite database backups.") + fmt.Fprintln(out, "Backups are written under the global data-home backups directory next to the SQLite database.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Subcommands:") + fmt.Fprintln(out, " verify <backup> Verify an existing SQLite backup") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --json Output backup verification, checksum, schema version, project count, and current project identity as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateBackupVerifyHelp(out io.Writer) { + writeUsageHelp(out, "loaf state backup verify <backup> [--json]", "Verify an existing SQLite database backup without reading or mutating live state.", "--json Output backup verification, restore guidance, schema version, and captured project identities as JSON") +} + +func writeStateExportHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf state export <kind> --format <format>") + fmt.Fprintln(out) + fmt.Fprintln(out, "Export SQLite state.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Kinds:") + fmt.Fprintln(out, " all") + fmt.Fprintln(out, " release-readiness") + fmt.Fprintln(out, " spec <spec>") + fmt.Fprintln(out, " session <session>") + fmt.Fprintln(out, " triage") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --format Output format") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeStateExportAllHelp(out io.Writer) { + writeUsageHelp(out, "loaf state export all --format json [--json]", "Export a complete project-scoped SQLite snapshot.", "--format Output format", "--json Alias for --format json") +} + +func writeStateExportReleaseReadinessHelp(out io.Writer) { + writeUsageHelp(out, "loaf state export release-readiness --format markdown", "Export a release-readiness report from SQLite state.", "--format Output format") +} + +func writeStateExportSpecHelp(out io.Writer) { + writeUsageHelp(out, "loaf state export spec <spec> --format markdown", "Export one spec from SQLite state.", "--format Output format") +} + +func writeStateExportSessionHelp(out io.Writer) { + writeUsageHelp(out, "loaf state export session <session> --format markdown", "Export one session from SQLite state.", "--format Output format") +} + +func writeStateExportTriageHelp(out io.Writer) { + writeUsageHelp(out, "loaf state export triage --format markdown", "Export a triage summary from SQLite state.", "--format Output format") +} + +func (r Runner) runProject(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 || isHelpArg(args) { + writeProjectHelp(out) + return nil + } + + switch args[0] { + case "list": + if isHelpArg(args[1:]) { + writeProjectListHelp(out) + return nil + } + return r.runProjectList(args[1:], out, runtime) + case "show", "identity": + if isHelpArg(args[1:]) { + writeProjectShowHelp(out) + return nil + } + return r.runProjectShow(args[1:], out, runtime, "loaf project "+args[0]) + case "rename": + if isHelpArg(args[1:]) { + writeProjectRenameHelp(out) + return nil + } + return r.runProjectRename(args[1:], out, runtime) + case "move": + if isHelpArg(args[1:]) { + writeProjectMoveHelp(out) + return nil + } + return r.runProjectMove(args[1:], out, runtime) + default: + return fmt.Errorf("project subcommand %q is not implemented yet", args[0]) + } +} + +func writeProjectHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf project <command> [options]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Manage durable project identity in the global SQLite database.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Commands:") + fmt.Fprintln(out, " list List registered projects") + fmt.Fprintln(out, " show Show the current project identity") + fmt.Fprintln(out, " identity Alias for show") + fmt.Fprintln(out, " rename Rename the friendly project name") + fmt.Fprintln(out, " move Record a project path move") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeProjectListHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf project list [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "List registered projects in the global SQLite database.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --json Output database path, project IDs, friendly names, and current paths as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeProjectShowHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf project show|identity [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Show the current durable project identity.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --json Output project ID, friendly name, current path, and database path as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeProjectRenameHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf project rename <name> [--dry-run] [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Rename the friendly project name without changing its ID.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --dry-run Validate and preview without writing") + fmt.Fprintln(out, " --json Output project ID, friendly name, current path, database path, and applied status as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func writeProjectMoveHelp(out io.Writer) { + fmt.Fprintln(out, "Usage: loaf project move <from> [to] [--dry-run] [--json]") + fmt.Fprintln(out, " loaf project move --from <path> [--to <path>] [--dry-run] [--json]") + fmt.Fprintln(out) + fmt.Fprintln(out, "Record a checkout move without changing the project ID. The target path defaults to the current project root.") + fmt.Fprintln(out) + fmt.Fprintln(out, "Options:") + fmt.Fprintln(out, " --from Previous absolute project path") + fmt.Fprintln(out, " --to New absolute project path") + fmt.Fprintln(out, " --dry-run Validate and preview without writing") + fmt.Fprintln(out, " --json Output project ID, friendly name, current path, database path, and applied status as JSON") + fmt.Fprintln(out, " -h, --help Show help") +} + +func (r Runner) runProjectList(args []string, out io.Writer, runtime state.Runtime) error { jsonOutput, err := parseJSONOnly(args) if err != nil { return err } - status, err := r.initializeState(runtime) + _, store, err := r.openProjectStoreReadOnly(runtime) + if err != nil { + return err + } + defer store.Close() + projects, err := store.ListProjects(context.Background()) if err != nil { return err } if jsonOutput { - return writeJSON(out, status) + return writeJSON(out, projects) } - fmt.Fprintln(out, "loaf state init") - fmt.Fprintf(out, "project root: %s\n", status.ProjectRoot) - fmt.Fprintf(out, "database: %s\n", status.DatabasePath) - fmt.Fprintf(out, "mode: %s\n", status.Mode) - fmt.Fprintf(out, "schema version: %d\n", status.SchemaVersion) + writeProjectList(out, projects) return nil } -func (r Runner) runStateStatus(args []string, out io.Writer, runtime state.Runtime) error { +func (r Runner) runProjectShow(args []string, out io.Writer, runtime state.Runtime, displayCommand string) error { jsonOutput, err := parseJSONOnly(args) if err != nil { return err } - status, err := r.inspectState(runtime) + projectRoot, store, err := r.openProjectStoreReadOnly(runtime) if err != nil { return err } - if jsonOutput { - return writeJSON(out, status) + if err := validateProjectPathInvariantsForCommand(store); err != nil { + store.Close() + return err } - fmt.Fprintln(out, "loaf state status") - fmt.Fprintf(out, "project root: %s\n", status.ProjectRoot) + defer store.Close() + identity, err := store.LookupProjectIdentityForRoot(context.Background(), projectRoot) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("project identity is not registered for %s; run `loaf state init` to register this checkout or `loaf project move --from <old-path>` after moving a registered checkout", projectRoot.Path()) + } + return err + } + if jsonOutput { + return writeJSON(out, identity) + } + writeProjectIdentity(out, displayCommand, identity) + return nil +} + +func (r Runner) runProjectRename(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") + options, err := parseProjectRenameArgs(args) + if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "project rename", err) + } + return err + } + projectRoot, store, err := r.openProjectStoreReadOnly(runtime) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "project rename", err) + } + return err + } + if err := validateProjectPathInvariantsForCommand(store); err != nil { + store.Close() + if options.jsonOutput { + return writeJSONCommandError(out, "project rename", err) + } + return err + } + defer func() { + if store != nil { + store.Close() + } + }() + if options.dryRun { + result, err := store.PreviewRenameProject(context.Background(), projectRoot, options.name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + err = fmt.Errorf("project identity is not registered for %s; run `loaf state init` to register this checkout or `loaf project move --from <old-path>` after moving a registered checkout", projectRoot.Path()) + } + if options.jsonOutput { + return writeJSONCommandError(out, "project rename", err) + } + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeProjectRenameHuman(out, result, false) + return nil + } + preview, err := store.PreviewRenameProject(context.Background(), projectRoot, options.name) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + err = fmt.Errorf("project identity is not registered for %s; run `loaf state init` to register this checkout or `loaf project move --from <old-path>` after moving a registered checkout", projectRoot.Path()) + } + if options.jsonOutput { + return writeJSONCommandError(out, "project rename", err) + } + return err + } + store.Close() + store = nil + projectRoot, store, err = r.openProjectStore(runtime) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "project rename", err) + } + return err + } + identity, err := store.RenameProject(context.Background(), projectRoot, options.name) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "project rename", err) + } + return err + } + if options.jsonOutput { + return writeJSON(out, identity) + } + writeProjectRenameHuman(out, state.ProjectRenameResult{ + ContractVersion: state.StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + Project: identity, + FromName: preview.FromName, + ToName: identity.FriendlyName, + Action: "renamed", + }, true) + return nil +} + +func (r Runner) runProjectMove(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") + options, err := parseProjectMoveArgs(args, runtime.RootPath()) + if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "project move", err) + } + return err + } + projectRoot, store, err := r.openProjectStoreReadOnly(runtime) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "project move", err) + } + return err + } + if err := validateProjectPathInvariantsForCommand(store); err != nil { + store.Close() + if options.jsonOutput { + return writeJSONCommandError(out, "project move", err) + } + return err + } + defer func() { + if store != nil { + store.Close() + } + }() + var result state.ProjectMoveResult + if options.dryRun { + result, err = store.PreviewMoveProject(context.Background(), projectRoot, options.fromPath, options.toPath) + } else { + if _, err = store.PreviewMoveProject(context.Background(), projectRoot, options.fromPath, options.toPath); err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "project move", err) + } + return err + } + store.Close() + store = nil + projectRoot, store, err = r.openProjectStore(runtime) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "project move", err) + } + return err + } + result, err = store.MoveProject(context.Background(), projectRoot, options.fromPath, options.toPath) + } + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "project move", err) + } + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + writeProjectMoveHuman(out, result, !options.dryRun) + return nil +} + +func (r Runner) openProjectIdentityStore(runtime state.Runtime) (project.Root, *state.Store, error) { + projectRoot, store, err := r.openProjectStore(runtime) + if err != nil { + return project.Root{}, nil, err + } + if err := store.UpsertProject(context.Background(), projectRoot); err != nil { + store.Close() + return project.Root{}, nil, err + } + return projectRoot, store, nil +} + +func (r Runner) openProjectStoreReadOnly(runtime state.Runtime) (project.Root, *state.Store, error) { + projectRoot, err := project.ResolveRoot(runtime.RootPath()) + if err != nil { + return project.Root{}, nil, err + } + resolver := state.PathResolver{StateHome: r.StateHome} + databasePath, err := resolver.DatabasePath(projectRoot) + if err != nil { + return project.Root{}, nil, err + } + if info, err := os.Stat(databasePath); err != nil { + if os.IsNotExist(err) { + return project.Root{}, nil, fmt.Errorf("project state database does not exist at %s (scope: global database); run `loaf state status` to inspect it or `loaf state init` to create it", databasePath) + } + return project.Root{}, nil, fmt.Errorf("stat state database: %w", err) + } else if info.IsDir() { + return project.Root{}, nil, fmt.Errorf("state database path is a directory: %s", databasePath) + } + store, err := state.OpenStoreReadOnly(databasePath) + if err != nil { + return project.Root{}, nil, err + } + _, err = store.ValidateCurrentSchema(context.Background()) + if err != nil { + store.Close() + return project.Root{}, nil, fmt.Errorf("project state database is invalid at %s (scope: global database): %w; run `loaf state doctor`", databasePath, err) + } + return projectRoot, store, nil +} + +func validateProjectPathInvariantsForCommand(store *state.Store) error { + if err := store.ValidateProjectPathInvariants(context.Background()); err != nil { + return fmt.Errorf("project state path invariants are invalid at %s (scope: global database): %w; run `loaf state doctor`", store.DatabasePath(), err) + } + return nil +} + +func (r Runner) openProjectStore(runtime state.Runtime) (project.Root, *state.Store, error) { + projectRoot, err := project.ResolveRoot(runtime.RootPath()) + if err != nil { + return project.Root{}, nil, err + } + resolver := state.PathResolver{StateHome: r.StateHome} + databasePath, err := resolver.DatabasePath(projectRoot) + if err != nil { + return project.Root{}, nil, err + } + if err := os.MkdirAll(filepath.Dir(databasePath), 0o700); err != nil { + return project.Root{}, nil, fmt.Errorf("create state database directory: %w", err) + } + store, err := state.OpenStore(databasePath) + if err != nil { + return project.Root{}, nil, err + } + if err := store.ApplyMigrations(context.Background()); err != nil { + store.Close() + return project.Root{}, nil, err + } + return projectRoot, store, nil +} + +func writeProjectIdentity(out io.Writer, command string, identity state.ProjectIdentity) { + fmt.Fprintln(out, command) + writeProjectMutationHuman(out, identity.DatabaseScope, identity) +} + +func writeProjectRenameHuman(out io.Writer, result state.ProjectRenameResult, applied bool) { + command := "loaf project rename" + if !applied { + command += " --dry-run" + } + fmt.Fprintln(out, command) + writeProjectMutationHuman(out, result.DatabaseScope, result.Project) + fmt.Fprintf(out, "from name: %s\n", result.FromName) + fmt.Fprintf(out, "to name: %s\n", result.ToName) + fmt.Fprintf(out, "applied: %t\n", applied) + if !applied { + fmt.Fprintln(out, "next: rerun without --dry-run to apply the friendly name change") + } +} + +func writeProjectMoveHuman(out io.Writer, result state.ProjectMoveResult, applied bool) { + command := "loaf project move" + if !applied { + command += " --dry-run" + } + fmt.Fprintln(out, command) + writeProjectMutationHuman(out, result.DatabaseScope, result.Project) + fmt.Fprintf(out, "from path: %s\n", result.FromPath) + fmt.Fprintf(out, "to path: %s\n", result.ToPath) + fmt.Fprintf(out, "applied: %t\n", applied) + if !applied { + fmt.Fprintln(out, "next: rerun without --dry-run to record the path move") + } +} + +func writeProjectMutationHuman(out io.Writer, databaseScope string, identity state.ProjectIdentity) { + fmt.Fprintf(out, "scope: %s database\n", databaseScope) + fmt.Fprintf(out, "database: %s\n", identity.DatabasePath) + fmt.Fprintf(out, "project: %s\n", identity.ID) + fmt.Fprintf(out, "project name: %s\n", firstNonEmpty(identity.FriendlyName, "(unnamed)")) + fmt.Fprintf(out, "project path: %s\n", firstNonEmpty(identity.CurrentPath, "(none)")) +} + +func writeProjectList(out io.Writer, result state.ProjectList) { + fmt.Fprintln(out, "loaf project list") + fmt.Fprintf(out, "scope: %s database\n", result.DatabaseScope) + fmt.Fprintf(out, "database: %s\n\n", result.DatabasePath) + if len(result.Projects) == 0 { + fmt.Fprintln(out, "No projects registered.") + return + } + for _, project := range result.Projects { + fmt.Fprintf(out, "project: %s\n", project.ID) + fmt.Fprintf(out, "project name: %s\n", firstNonEmpty(project.FriendlyName, "(unnamed)")) + fmt.Fprintf(out, "project path: %s\n", firstNonEmpty(project.CurrentPath, "(none)")) + if project.LastSeenAt != "" { + fmt.Fprintf(out, "last seen: %s\n", project.LastSeenAt) + } + } +} + +func (r Runner) runStateInit(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") + jsonOutput, err := parseJSONOnly(args) + if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state init", err) + } + return err + } + status, err := r.initializeState(runtime) + if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "state init", err) + } + return err + } + if jsonOutput { + return writeJSON(out, status) + } + fmt.Fprintln(out, "loaf state init") + fmt.Fprintf(out, "project root: %s\n", status.ProjectRoot) + writeStateProjectIdentity(out, status) + fmt.Fprintf(out, "scope: %s database\n", status.DatabaseScope) + fmt.Fprintf(out, "database: %s\n", status.DatabasePath) + fmt.Fprintf(out, "mode: %s\n", status.Mode) + fmt.Fprintf(out, "schema version: %d\n", status.SchemaVersion) + return nil +} + +func (r Runner) runStateStatus(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") + jsonOutput, err := parseJSONOnly(args) + if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state status", err) + } + return err + } + status, err := r.inspectState(runtime) + if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "state status", err) + } + return err + } + if jsonOutput { + return writeJSON(out, status) + } + fmt.Fprintln(out, "loaf state status") + fmt.Fprintf(out, "project root: %s\n", status.ProjectRoot) + writeStateProjectIdentity(out, status) + if status.ProjectID == "" && status.LegacyProjectKey != "" { + fmt.Fprintf(out, "legacy project key: %s\n", status.LegacyProjectKey) + } + fmt.Fprintf(out, "scope: %s database\n", status.DatabaseScope) fmt.Fprintf(out, "database: %s\n", status.DatabasePath) fmt.Fprintf(out, "database exists: %t\n", status.DatabaseExists) fmt.Fprintf(out, "mode: %s\n", status.Mode) @@ -768,35 +1597,94 @@ func (r Runner) runStateStatus(args []string, out io.Writer, runtime state.Runti } func (r Runner) runStateDoctor(args []string, out io.Writer, runtime state.Runtime) error { - jsonOutput, fix, err := parseDoctorArgs(args) + jsonRequested := hasFlag(args, "--json") + jsonOutput, fix, dryRun, err := parseDoctorArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state doctor", err) + } return err } status, err := r.inspectState(runtime) if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "state doctor", err) + } return err } + if dryRun || len(status.Diagnostics) > 0 { + status.RepairPlan = state.RepairPlanForStatus(status) + } if fix && status.Mode == state.ModeMarkdownOnly { - status, err = r.initializeState(runtime) - if err != nil { - return err + if !dryRun { + status, err = r.initializeState(runtime) + if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "state doctor", err) + } + return err + } + status.Diagnostics = append([]state.Diagnostic{{ + Severity: "info", + Code: "database-initialized", + Message: "SQLite state database initialized", + }}, status.Diagnostics...) + status.RepairPlan = []state.RepairAction{{ + Code: "initialize-database", + DiagnosticCode: "database-missing", + Category: state.RepairCategoryLocalDatabase, + Description: "Initialized the global SQLite database for this project.", + Command: "loaf state doctor --fix", + Path: status.DatabasePath, + Safe: true, + Applied: true, + }} } - status.Diagnostics = append([]state.Diagnostic{{ - Severity: "info", - Code: "database-initialized", - Message: "SQLite state database initialized", - }}, status.Diagnostics...) } if jsonOutput { - return writeJSON(out, status) + if err := writeJSON(out, status); err != nil { + return err + } + if status.Mode == state.ModeInvalid { + return ExitError{Code: 1} + } + return nil } fmt.Fprintln(out, "loaf state doctor") fmt.Fprintf(out, "project root: %s\n", status.ProjectRoot) + writeStateProjectIdentity(out, status) + fmt.Fprintf(out, "scope: %s database\n", status.DatabaseScope) fmt.Fprintf(out, "database: %s\n", status.DatabasePath) fmt.Fprintf(out, "mode: %s\n", status.Mode) + fmt.Fprintf(out, "schema version: %d\n", status.SchemaVersion) for _, diagnostic := range status.Diagnostics { - fmt.Fprintf(out, "%s: %s\n", diagnostic.Severity, diagnostic.Message) + fmt.Fprintf(out, "%s\n", formatStateDiagnosticLine("", diagnostic)) + } + if len(status.RepairPlan) > 0 { + fmt.Fprintln(out, "repair plan:") + for _, action := range status.RepairPlan { + stateLabel := "manual" + if action.Applied { + stateLabel = "applied" + } else if action.Safe { + stateLabel = "safe" + } + if action.Category != "" { + fmt.Fprintf(out, "- %s [%s/%s]: %s\n", action.Code, stateLabel, action.Category, action.Description) + } else { + fmt.Fprintf(out, "- %s [%s]: %s\n", action.Code, stateLabel, action.Description) + } + if action.RequiresExternalSync { + fmt.Fprintln(out, " external sync: required") + } + if action.Command != "" { + fmt.Fprintf(out, " command: %s\n", action.Command) + } + if action.Path != "" { + fmt.Fprintf(out, " path: %s\n", action.Path) + } + } } if status.Mode == state.ModeInvalid { return fmt.Errorf("state doctor found errors") @@ -804,76 +1692,384 @@ func (r Runner) runStateDoctor(args []string, out io.Writer, runtime state.Runti return nil } +func writeStateProjectIdentity(out io.Writer, status state.Status) { + if status.ProjectID != "" { + fmt.Fprintf(out, "project: %s\n", status.ProjectID) + } + if status.ProjectName != "" { + fmt.Fprintf(out, "project name: %s\n", status.ProjectName) + } + if status.ProjectCurrentPath != "" { + fmt.Fprintf(out, "project path: %s\n", status.ProjectCurrentPath) + } +} + +func (r Runner) withStateMissingContext(err error, root project.Root) error { + if err == nil || !strings.Contains(err.Error(), "SQLite state database is not initialized") { + return err + } + databasePath, pathErr := (state.PathResolver{StateHome: r.StateHome}).DatabasePath(root) + if pathErr != nil { + return err + } + return fmt.Errorf("%v\nscope: global database\ndatabase: %s\nnext: run `loaf state status` to inspect state, or `loaf state migrate markdown --apply` to import local .agents Markdown", err, databasePath) +} + +func writeStateDiagnostics(out io.Writer, indent string, diagnostics []state.Diagnostic) { + for _, diagnostic := range diagnostics { + fmt.Fprintf(out, "%s\n", formatStateDiagnosticLine(indent, diagnostic)) + } +} + +func formatStateDiagnosticLine(indent string, diagnostic state.Diagnostic) string { + label := diagnostic.Severity + switch { + case diagnostic.Category != "" && diagnostic.Policy != "": + label = fmt.Sprintf("%s [%s/%s]", label, diagnostic.Category, diagnostic.Policy) + case diagnostic.Category != "": + label = fmt.Sprintf("%s [%s]", label, diagnostic.Category) + case diagnostic.Policy != "": + label = fmt.Sprintf("%s [%s]", label, diagnostic.Policy) + } + if diagnostic.RequiresExternalSync { + label += " [external-sync-required]" + } + return fmt.Sprintf("%s%s: %s", indent, label, diagnostic.Message) +} + +func stateListWarnings(diagnostics []state.Diagnostic) []state.Diagnostic { + warnings := []state.Diagnostic{} + for _, diagnostic := range diagnostics { + if diagnostic.Severity == "warn" || diagnostic.Severity == "error" { + warnings = append(warnings, diagnostic) + } + } + return warnings +} + +func (r Runner) runStateRepair(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) == 0 { + return fmt.Errorf("state repair requires a target") + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "legacy-project-database": writeStateRepairLegacyProjectDatabaseHelp, + "relationship-origin": writeStateRepairRelationshipOriginHelp, + }) { + return nil + } + switch args[0] { + case "legacy-project-database": + return r.runStateRepairLegacyProjectDatabase(args[1:], out, runtime) + case "relationship-origin": + return r.runStateRepairRelationshipOrigin(args[1:], out, runtime) + default: + return fmt.Errorf("state repair target %q is not implemented yet", args[0]) + } +} + +func writeStateRepairLegacyProjectDatabaseHelp(out io.Writer) { + writeUsageHelp(out, "loaf state repair legacy-project-database [--dry-run|--apply] [--json]", "Archive migrated legacy per-project SQLite files without deleting them.", "--dry-run Preview archive paths without writing", "--apply Move legacy SQLite files into the archive directory", "--json Output archive plan/result, global database scope, and project identity as JSON") +} + +func writeStateRepairRelationshipOriginHelp(out io.Writer) { + writeUsageHelp(out, "loaf state repair relationship-origin --origin <imported|manual> [--dry-run|--apply] [--json]", "Backfill missing relationship provenance for the current project.", "--origin Provenance value to set: imported or manual", "--dry-run Preview affected rows without writing", "--apply Apply the backfill", "--json Output repair plan/result, global database scope, and project identity as JSON") +} + +func (r Runner) runStateRepairLegacyProjectDatabase(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") + options, err := parseLegacyProjectDatabaseRepairArgs(args) + if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state repair legacy-project-database", err) + } + return err + } + projectRoot, err := project.ResolveRoot(runtime.RootPath()) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "state repair legacy-project-database", err) + } + return err + } + result, err := state.ArchiveLegacyProjectDatabase(projectRoot, state.PathResolver{StateHome: r.StateHome}, options.apply) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "state repair legacy-project-database", err) + } + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + + fmt.Fprintf(out, "loaf state repair legacy-project-database %s\n", repairModeFlag(options.apply)) + fmt.Fprintf(out, "scope: %s database\n", result.DatabaseScope) + fmt.Fprintf(out, "project: %s\n", result.ProjectID) + if result.ProjectName != "" { + fmt.Fprintf(out, "project name: %s\n", result.ProjectName) + } + if result.ProjectCurrentPath != "" { + fmt.Fprintf(out, "project path: %s\n", result.ProjectCurrentPath) + } + fmt.Fprintf(out, "database: %s\n", result.DatabasePath) + fmt.Fprintf(out, "legacy database: %s\n", result.LegacyDatabasePath) + fmt.Fprintf(out, "action: %s\n", result.Action) + if result.ArchivePath != "" { + fmt.Fprintf(out, "archive: %s\n", result.ArchivePath) + } + fmt.Fprintf(out, "matched files: %d\n", len(result.MatchedPaths)) + fmt.Fprintf(out, "archived files: %d\n", len(result.ArchivedPaths)) + fmt.Fprintf(out, "applied: %t\n", result.Applied) + for _, warning := range result.Warnings { + fmt.Fprintf(out, "warn: %s\n", warning) + } + if !result.Applied && len(result.MatchedPaths) > 0 { + fmt.Fprintln(out, "next: rerun with --apply after verifying the global database") + } + return nil +} + +func (r Runner) runStateRepairRelationshipOrigin(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") + options, err := parseRelationshipOriginRepairArgs(args) + if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state repair relationship-origin", err) + } + return err + } + projectRoot, err := project.ResolveRoot(runtime.RootPath()) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "state repair relationship-origin", err) + } + return err + } + result, err := state.RepairMissingRelationshipOrigins(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, state.RelationshipOriginRepairOptions{ + Origin: options.origin, + Apply: options.apply, + }) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "state repair relationship-origin", err) + } + return err + } + if options.jsonOutput { + return writeJSON(out, result) + } + + fmt.Fprintf(out, "loaf state repair relationship-origin %s\n", repairModeFlag(options.apply)) + fmt.Fprintf(out, "scope: %s database\n", result.DatabaseScope) + fmt.Fprintf(out, "database: %s\n", result.DatabasePath) + if result.BackupPath != "" { + fmt.Fprintf(out, "backup: %s\n", result.BackupPath) + } + fmt.Fprintf(out, "project: %s\n", result.ProjectID) + if result.ProjectName != "" { + fmt.Fprintf(out, "project name: %s\n", result.ProjectName) + } + if result.ProjectCurrentPath != "" { + fmt.Fprintf(out, "project path: %s\n", result.ProjectCurrentPath) + } + fmt.Fprintf(out, "origin: %s\n", result.Origin) + fmt.Fprintf(out, "matched: %d\n", result.Matched) + fmt.Fprintf(out, "updated: %d\n", result.Updated) + fmt.Fprintf(out, "applied: %t\n", result.Applied) + if !result.Applied && result.Matched > 0 { + fmt.Fprintln(out, "next: rerun with --apply after reviewing the selected origin") + } + return nil +} + +func repairModeFlag(apply bool) string { + if apply { + return "--apply" + } + return "--dry-run" +} + func (r Runner) runStateBackup(args []string, out io.Writer, runtime state.Runtime) error { + if len(args) > 0 && args[0] == "verify" { + return r.runStateBackupVerify(args[1:], out, runtime) + } + jsonRequested := hasFlag(args, "--json") jsonOutput, err := parseJSONOnly(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state backup", err) + } return err } projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "state backup", err) + } return err } result, err := state.Backup(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) if err != nil { - return err + if jsonOutput { + return writeJSONCommandError(out, "state backup", err) + } + return r.withStateMissingContext(err, projectRoot) } if jsonOutput { return writeJSON(out, result) } fmt.Fprintln(out, "loaf state backup") + fmt.Fprintf(out, "scope: %s database\n", result.DatabaseScope) fmt.Fprintf(out, "database: %s\n", result.DatabasePath) fmt.Fprintf(out, "backup: %s\n", result.BackupPath) fmt.Fprintf(out, "bytes: %d\n", result.Bytes) + fmt.Fprintf(out, "sha256: %s\n", result.SHA256) + fmt.Fprintf(out, "verified: %t\n", result.Verified) + fmt.Fprintf(out, "schema version: %d\n", result.SchemaVersion) + fmt.Fprintf(out, "projects: %d\n", result.ProjectCount) + fmt.Fprintf(out, "project: %s\n", result.ProjectID) + fmt.Fprintf(out, "project name: %s\n", result.ProjectName) + fmt.Fprintf(out, "project path: %s\n", result.ProjectCurrentPath) + fmt.Fprintf(out, "integrity: %s\n", result.IntegrityCheck) + fmt.Fprintf(out, "foreign keys: %s\n", result.ForeignKeyCheck) fmt.Fprintf(out, "created at: %s\n", result.CreatedAt) + fmt.Fprintf(out, "next: verify this backup later with `loaf state backup verify %s` before restoring it\n", result.BackupPath) return nil } +func (r Runner) runStateBackupVerify(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") + options, err := parseStateBackupVerifyArgs(args) + if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state backup verify", err) + } + return err + } + result, err := state.VerifyBackup(context.Background(), options.path) + if err != nil { + if options.jsonOutput { + return writeStateBackupVerifyJSONError(out, options.path, err) + } + return err + } + r.addBackupRestoreTargets(&result, runtime) + if options.jsonOutput { + return writeJSON(out, result) + } + fmt.Fprintln(out, "loaf state backup verify") + fmt.Fprintf(out, "scope: %s backup\n", result.DatabaseScope) + fmt.Fprintf(out, "backup: %s\n", result.BackupPath) + fmt.Fprintf(out, "bytes: %d\n", result.Bytes) + fmt.Fprintf(out, "sha256: %s\n", result.SHA256) + fmt.Fprintf(out, "verified: %t\n", result.Verified) + fmt.Fprintf(out, "schema version: %d\n", result.SchemaVersion) + fmt.Fprintf(out, "projects: %d\n", result.ProjectCount) + for _, project := range result.Projects { + fmt.Fprintf(out, "project: %s\n", project.ID) + if project.FriendlyName != "" { + fmt.Fprintf(out, "project name: %s\n", project.FriendlyName) + } + if project.CurrentPath != "" { + fmt.Fprintf(out, "project path: %s\n", project.CurrentPath) + } + } + fmt.Fprintf(out, "integrity: %s\n", result.IntegrityCheck) + fmt.Fprintf(out, "foreign keys: %s\n", result.ForeignKeyCheck) + if result.RestoreDatabasePath != "" { + fmt.Fprintf(out, "restore target: %s\n", result.RestoreDatabasePath) + fmt.Fprintf(out, "preserve as: %s\n", result.RestorePreservePath) + fmt.Fprintf(out, "next: if present, preserve current database as %s, copy this verified backup to %s, then run `loaf state doctor` and `loaf state status`\n", result.RestorePreservePath, result.RestoreDatabasePath) + } else { + fmt.Fprintln(out, "next: if present, preserve current database, copy this verified backup to `loaf state path`, then run `loaf state doctor` and `loaf state status`") + } + return nil +} + +func (r Runner) addBackupRestoreTargets(result *state.BackupVerificationResult, runtime state.Runtime) { + projectRoot, err := project.ResolveRoot(runtime.RootPath()) + if err != nil { + return + } + databasePath, err := (state.PathResolver{StateHome: r.StateHome}).DatabasePath(projectRoot) + if err != nil { + return + } + result.RestoreDatabasePath = databasePath + result.RestorePreservePath = databasePath + ".before-restore" + result.RestoreValidationCommands = []string{"loaf state doctor", "loaf state status"} +} + func (r Runner) runStateExport(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := stateExportJSONRequested(args) + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "all": writeStateExportAllHelp, + "release-readiness": writeStateExportReleaseReadinessHelp, + "spec": writeStateExportSpecHelp, + "session": writeStateExportSessionHelp, + "triage": writeStateExportTriageHelp, + }) { + return nil + } options, err := parseStateExportArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "state export", err) + } return err } + jsonOutput := options.format == state.ExportFormatJSON projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "state export", err) + } return err } switch { case options.kind == state.ExportKindAll && options.format == state.ExportFormatJSON: result, err := state.ExportAllJSON(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "state export", err) + } return err } return writeJSON(out, result) case options.kind == state.ExportKindSpec && options.format == state.ExportFormatMarkdown: result, err := state.ExportSpecMarkdown(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.ref) if err != nil { - return err + return r.withStateMissingContext(err, projectRoot) } fmt.Fprint(out, result.Content) return nil case options.kind == state.ExportKindReleaseReadiness && options.format == state.ExportFormatMarkdown: result, err := state.ExportReleaseReadinessMarkdown(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) if err != nil { - return err + return r.withStateMissingContext(err, projectRoot) } fmt.Fprint(out, result.Content) return nil case options.kind == state.ExportKindSession && options.format == state.ExportFormatMarkdown: result, err := state.ExportSessionMarkdown(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.ref) if err != nil { - return err + return r.withStateMissingContext(err, projectRoot) } fmt.Fprint(out, result.Content) return nil case options.kind == state.ExportKindTriage && options.format == state.ExportFormatMarkdown: result, err := state.ExportTriageMarkdown(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) if err != nil { - return err + return r.withStateMissingContext(err, projectRoot) } fmt.Fprint(out, result.Content) return nil default: - return fmt.Errorf("state export %s --format %s is not implemented yet", options.kind, options.format) + err := fmt.Errorf("state export %s --format %s is not implemented yet", options.kind, options.format) + if jsonOutput { + return writeJSONCommandError(out, "state export", err) + } + return err } } @@ -881,6 +2077,12 @@ func (r Runner) runStateMigrate(args []string, out io.Writer, runtime state.Runt if len(args) == 0 { return fmt.Errorf("state migrate requires a source") } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "markdown": writeStateMigrateMarkdownHelp, + "storage-home": writeStateMigrateStorageHomeHelp, + }) { + return nil + } switch args[0] { case "markdown": return r.runStateMigrateMarkdown(args[1:], out, runtime) @@ -891,10 +2093,32 @@ func (r Runner) runStateMigrate(args []string, out io.Writer, runtime state.Runt } } +func writeStateMigrateMarkdownHelp(out io.Writer) { + writeUsageHelp(out, "loaf state migrate markdown [--dry-run|--apply|--resume] [--json]", "Import .agents Markdown artifacts into SQLite without mutating Markdown.", "--dry-run Preview import work", "--apply Apply the import", "--resume Resume an interrupted import", "--json Output migration contract, scope, project context, and counts as JSON") +} + +func writeStateMigrateStorageHomeHelp(out io.Writer) { + writeUsageHelp(out, "loaf state migrate storage-home [--dry-run|--apply] [--json]", "Copy legacy per-project state into the global XDG data-home SQLite database.", "--dry-run Preview migration work", "--apply Apply the migration", "--json Output migration contract, global database paths, action, and project identity when available") +} + +func writeMigrateMarkdownHelp(out io.Writer) { + writeUsageHelp(out, "loaf migrate markdown [--dry-run|--apply|--resume] [--json]", "Import .agents Markdown artifacts into SQLite without mutating Markdown.", "--dry-run Preview import work", "--apply Apply the import", "--resume Resume an interrupted import", "--json Output migration contract, scope, project context, and counts as JSON") +} + +func writeMigrateStorageHomeHelp(out io.Writer) { + writeUsageHelp(out, "loaf migrate storage-home [--dry-run|--apply] [--json]", "Copy legacy per-project state into the global XDG data-home SQLite database.", "--dry-run Preview migration work", "--apply Apply the migration", "--json Output migration contract, global database paths, action, and project identity when available") +} + func (r Runner) runMigrate(args []string, out io.Writer, runtime state.Runtime) error { if len(args) == 0 { return fmt.Errorf("migrate requires a source") } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "markdown": writeMigrateMarkdownHelp, + "storage-home": writeMigrateStorageHomeHelp, + }) { + return nil + } switch args[0] { case "markdown": return r.runMarkdownMigration(args[1:], out, runtime, "loaf migrate markdown") @@ -912,12 +2136,20 @@ func (r Runner) runStateMigrateStorageHome(args []string, out io.Writer, runtime } func (r Runner) runStorageHomeMigration(args []string, out io.Writer, runtime state.Runtime, displayCommand string) error { - options, err := parseStorageHomeMigrationArgs(args) + command := strings.TrimPrefix(displayCommand, "loaf ") + jsonRequested := hasFlag(args, "--json") + options, err := parseStorageHomeMigrationArgs(args, command) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, command, err) + } return err } projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, command, err) + } return err } resolver := state.PathResolver{StateHome: r.StateHome} @@ -928,17 +2160,15 @@ func (r Runner) runStorageHomeMigration(args []string, out io.Writer, runtime st plan, err = state.PreviewStorageHomeMigration(projectRoot, resolver) } if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, command, err) + } return err } if options.jsonOutput { return writeJSON(out, plan) } - if options.apply { - fmt.Fprintf(out, "%s --apply\n", displayCommand) - } else { - fmt.Fprintf(out, "%s --dry-run\n", displayCommand) - } - writeStorageHomeMigrationPlan(out, plan) + writeStorageHomeMigrationHuman(out, displayCommand, plan) return nil } @@ -947,45 +2177,91 @@ func (r Runner) runStateMigrateMarkdown(args []string, out io.Writer, runtime st } func (r Runner) runMarkdownMigration(args []string, out io.Writer, runtime state.Runtime, displayCommand string) error { - options, err := parseMarkdownMigrationArgs(args) + command := strings.TrimPrefix(displayCommand, "loaf ") + jsonRequested := hasFlag(args, "--json") + options, err := parseMarkdownMigrationArgs(args, command) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, command, err) + } return err } projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, command, err) + } return err } if options.apply || options.resume { result, err := state.ApplyMarkdownMigration(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, command, err) + } return err } + if options.resume { + result.Action = state.MarkdownMigrationActionResume + } if options.jsonOutput { return writeJSON(out, result) } if options.resume { - fmt.Fprintf(out, "%s --resume\n", displayCommand) + writeMarkdownMigrationResultHuman(out, displayCommand+" --resume", result) } else { - fmt.Fprintf(out, "%s --apply\n", displayCommand) + writeMarkdownMigrationResultHuman(out, displayCommand+" --apply", result) } - fmt.Fprintf(out, "database: %s\n", result.DatabasePath) - writeMarkdownMigrationPlan(out, result.MarkdownMigrationPlan) return nil } plan, err := state.PreviewMarkdownMigration(projectRoot) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, command, err) + } return err } if options.jsonOutput { - return writeJSON(out, plan) + databasePath, err := (state.PathResolver{StateHome: r.StateHome}).DatabasePath(projectRoot) + if err != nil { + return writeJSONCommandError(out, command, err) + } + return writeJSON(out, state.NewMarkdownMigrationPreviewResult(plan, projectRoot, databasePath)) } - fmt.Fprintf(out, "%s --dry-run\n", displayCommand) - writeMarkdownMigrationPlan(out, plan) + databasePath, err := (state.PathResolver{StateHome: r.StateHome}).DatabasePath(projectRoot) + if err != nil { + return err + } + writeMarkdownMigrationPreviewHuman(out, displayCommand+" --dry-run", projectRoot, databasePath, plan) return nil } +func writeMarkdownMigrationPreviewHuman(out io.Writer, command string, root project.Root, databasePath string, plan state.MarkdownMigrationPlan) { + fmt.Fprintln(out, command) + fmt.Fprintln(out, "scope: global database, project import") + fmt.Fprintf(out, "database: %s\n", databasePath) + fmt.Fprintln(out, "project: (not initialized)") + fmt.Fprintf(out, "project name: %s\n", filepath.Base(root.Path())) + fmt.Fprintf(out, "project path: %s\n", root.Path()) + fmt.Fprintln(out, "applied: false") + writeMarkdownMigrationPlan(out, plan) + fmt.Fprintln(out, "next: rerun with --apply to import Markdown into the global database") +} + +func writeMarkdownMigrationResultHuman(out io.Writer, command string, result state.MarkdownMigrationResult) { + fmt.Fprintln(out, command) + fmt.Fprintf(out, "scope: %s database, %s import\n", result.DatabaseScope, result.ImportScope) + fmt.Fprintf(out, "database: %s\n", result.DatabasePath) + fmt.Fprintf(out, "project: %s\n", result.ProjectID) + fmt.Fprintf(out, "project name: %s\n", result.ProjectName) + fmt.Fprintf(out, "project path: %s\n", result.ProjectCurrentPath) + fmt.Fprintf(out, "action: %s\n", result.Action) + fmt.Fprintf(out, "applied: %t\n", result.Applied) + writeMarkdownMigrationPlan(out, result.MarkdownMigrationPlan) +} + func writeMarkdownMigrationPlan(out io.Writer, plan state.MarkdownMigrationPlan) { fmt.Fprintf(out, "agents path: %s\n", plan.AgentsPath) fmt.Fprintf(out, "specs: %d\n", plan.Specs) @@ -1006,7 +2282,42 @@ func writeMarkdownMigrationPlan(out io.Writer, plan state.MarkdownMigrationPlan) } } +func writeStorageHomeMigrationHuman(out io.Writer, displayCommand string, plan state.StorageHomeMigrationPlan) { + if plan.Applied { + fmt.Fprintf(out, "%s --apply\n", displayCommand) + } else { + fmt.Fprintf(out, "%s --dry-run\n", displayCommand) + } + writeStorageHomeMigrationPlan(out, plan) + if !plan.Applied { + switch plan.Action { + case state.StorageHomeActionCopy, state.StorageHomeActionMerge: + fmt.Fprintln(out, "next: rerun with --apply to copy eligible legacy state into the global database") + case state.StorageHomeActionAlreadyMigrated: + fmt.Fprintln(out, "next: no storage-home migration is needed; run `loaf state status` to inspect current state") + case state.StorageHomeActionNoLegacyState: + fmt.Fprintln(out, "next: no legacy state was found; run `loaf state init` or `loaf state migrate markdown --apply` if this project still needs SQLite state") + } + } +} + func writeStorageHomeMigrationPlan(out io.Writer, plan state.StorageHomeMigrationPlan) { + fmt.Fprintf(out, "scope: %s database, %s migration\n", plan.DatabaseScope, plan.MigrationScope) + if plan.ProjectID != "" { + fmt.Fprintf(out, "project: %s\n", plan.ProjectID) + } else { + fmt.Fprintln(out, "project: (not initialized)") + } + if plan.ProjectName != "" { + fmt.Fprintf(out, "project name: %s\n", plan.ProjectName) + } else if plan.ProjectRoot != "" { + fmt.Fprintf(out, "project name: %s\n", filepath.Base(plan.ProjectRoot)) + } + if plan.ProjectCurrentPath != "" { + fmt.Fprintf(out, "project path: %s\n", plan.ProjectCurrentPath) + } else if plan.ProjectRoot != "" { + fmt.Fprintf(out, "project path: %s\n", plan.ProjectRoot) + } fmt.Fprintf(out, "database: %s\n", plan.DatabasePath) fmt.Fprintf(out, "legacy database: %s\n", plan.LegacyDatabasePath) fmt.Fprintf(out, "database exists: %t\n", plan.DatabaseExists) @@ -1035,16 +2346,30 @@ func (r Runner) initializeState(runtime state.Runtime) (state.Status, error) { } func (r Runner) runTrace(args []string, out io.Writer, runtime state.Runtime) error { + if isHelpArg(args) { + writeTraceHelp(out) + return nil + } + jsonRequested := hasFlag(args, "--json") ref, jsonOutput, err := parseTraceArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "trace", err) + } return err } projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "trace", err) + } return err } result, err := state.Trace(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, ref) if err != nil { + if jsonOutput { + return writeJSONCommandError(out, "trace", err) + } return err } if jsonOutput { @@ -1052,6 +2377,7 @@ func (r Runner) runTrace(args []string, out io.Writer, runtime state.Runtime) er } fmt.Fprintf(out, "%s %s\n", result.Entity.Kind, firstNonEmpty(result.Entity.Alias, result.Entity.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if result.Entity.Title != "" { fmt.Fprintf(out, "title: %s\n", result.Entity.Title) } @@ -1080,13 +2406,25 @@ func (r Runner) runTrace(args []string, out io.Writer, runtime state.Runtime) er return nil } +func writeTraceHelp(out io.Writer) { + writeUsageHelp(out, "loaf trace <entity> [--json]", "Trace relationships for one entity.", "--json Output traced entity, sources, relationships, global database scope, and project identity as JSON") +} + func (r Runner) runTask(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { + if len(args) == 0 || isHelpArg(args) { writeTaskHelp(out) return nil } - if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") { - writeTaskHelp(out) + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "create": writeTaskCreateHelp, + "list": writeTaskListHelp, + "show": writeTaskShowHelp, + "status": writeTaskStatusHelp, + "update": writeTaskUpdateHelp, + "archive": writeTaskArchiveHelp, + "refresh": writeTaskRefreshHelp, + "sync": writeTaskSyncHelp, + }) { return nil } switch args[0] { @@ -1130,6 +2468,38 @@ func writeTaskHelp(out io.Writer) { fmt.Fprintln(out, " -h, --help Show help") } +func writeTaskCreateHelp(out io.Writer) { + writeUsageHelp(out, "loaf task create --title <title> [options]", "Create a task.", "--title Task title", "--spec Associated spec", "--priority Task priority: "+validTaskPriorityText(), "--depends-on Comma-separated task refs", "--json Output created task, event, global database scope, and project identity as JSON") +} + +func writeTaskListHelp(out io.Writer) { + writeUsageHelp(out, "loaf task list [--active|--status <status>] [--json]", "List tasks.", "--active Hide completed tasks", "--status Filter by status: "+validTaskListStatusText(), "--json Output tasks, diagnostics, global database scope, and project identity as JSON") +} + +func writeTaskShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf task show <task> [--json]", "Show one task.", "--json Output task details, relationships, global database scope, and project identity as JSON") +} + +func writeTaskStatusHelp(out io.Writer) { + writeUsageHelp(out, "loaf task status", "Summarize task and spec statuses.") +} + +func writeTaskUpdateHelp(out io.Writer) { + writeUsageHelp(out, "loaf task update <task> [options]", "Update task metadata.", "--status New task status: "+validTaskStatusText(), "--priority New task priority: "+validTaskPriorityText(), "--spec Associated spec", "--depends-on Comma-separated task refs or none", "--session Session ref or none", "--json Output updated task, event, global database scope, and project identity as JSON") +} + +func writeTaskArchiveHelp(out io.Writer) { + writeUsageHelp(out, "loaf task archive (<task...>|--spec <spec>) [--json]", "Archive done tasks.", "--spec Archive done tasks for one spec", "--json Output archive result, archived tasks, global database scope, and project identity as JSON") +} + +func writeTaskRefreshHelp(out io.Writer) { + writeUsageHelp(out, "loaf task refresh [--json]", "Summarize task refresh compatibility.", "--json Output compatibility mode, action, reason, and counts as JSON") +} + +func writeTaskSyncHelp(out io.Writer) { + writeUsageHelp(out, "loaf task sync [--import|--push] [--json]", "Summarize task sync compatibility.", "--import Import orphan Markdown tasks", "--push Push index metadata to Markdown", "--json Output compatibility mode, action, reason, and counts as JSON") +} + func (r Runner) runTaskRefresh(args []string, out io.Writer, runtime state.Runtime) error { projectRoot, mode, err := r.taskStateMode(runtime) if err != nil { @@ -1162,11 +2532,12 @@ func (r Runner) runTaskRefresh(args []string, out io.Writer, runtime state.Runti return err } summary := compatibilityCommandSummary{ - Version: 1, - Command: "task refresh", - Mode: "sqlite", - Action: "read", - Reason: "SQLite state is canonical; Markdown TASKS.json refresh is not run.", + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: "task refresh", + Mode: "sqlite", + Action: "read", + Reason: "SQLite state is canonical; Markdown TASKS.json refresh is not run.", Counts: map[string]int{ "tasks": len(tasks.Tasks), "specs": len(specs.Specs), @@ -1211,11 +2582,12 @@ func (r Runner) runTaskSync(args []string, out io.Writer, runtime state.Runtime) return err } summary := compatibilityCommandSummary{ - Version: 1, - Command: "task sync", - Mode: "sqlite", - Action: "skipped", - Reason: "SQLite state is canonical; Markdown task sync is a compatibility repair path and is not run in SQLite mode.", + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: "task sync", + Mode: "sqlite", + Action: "skipped", + Reason: "SQLite state is canonical; Markdown task sync is a compatibility repair path and is not run in SQLite mode.", Counts: map[string]int{ "tasks": len(tasks.Tasks), "specs": len(specs.Specs), @@ -1229,18 +2601,28 @@ func (r Runner) runTaskSync(args []string, out io.Writer, runtime state.Runtime) } func (r Runner) runTaskCreate(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") projectRoot, mode, err := r.taskStateMode(runtime) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "task create", err) + } return err } switch mode { case state.ModeMarkdownOnly: options, err := parseTaskCreateArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "task create", err) + } return err } result, err := markdownTaskCreate(projectRoot.Path(), options.create) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task create", err) + } return err } if options.jsonOutput { @@ -1249,15 +2631,25 @@ func (r Runner) runTaskCreate(args []string, out io.Writer, runtime state.Runtim writeTaskCreate(out, result) return nil case state.ModeInvalid: - return fmt.Errorf("state database is invalid; run `loaf state doctor`") + err := fmt.Errorf("state database is invalid; run `loaf state doctor`") + if jsonRequested { + return writeJSONCommandError(out, "task create", err) + } + return err } options, err := parseTaskCreateArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "task create", err) + } return err } result, err := state.CreateTask(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.create) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task create", err) + } return err } if options.jsonOutput { @@ -1303,18 +2695,35 @@ func (r Runner) runTaskShow(args []string, out io.Writer, runtime state.Runtime) } func (r Runner) runTaskList(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") options, err := parseTaskListArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "task list", err) + } return err } - projectRoot, mode, err := r.taskStateMode(runtime) + projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task list", err) + } return err } - switch mode { + status, err := state.Inspect(projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task list", err) + } + return err + } + switch status.Mode { case state.ModeMarkdownOnly: tasks, err := markdownTaskList(projectRoot.Path(), options.filters) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task list", err) + } return err } if options.jsonOutput { @@ -1323,13 +2732,21 @@ func (r Runner) runTaskList(args []string, out io.Writer, runtime state.Runtime) writeTaskList(out, tasks, options.filters) return nil case state.ModeInvalid: - return fmt.Errorf("state database is invalid; run `loaf state doctor`") + err := fmt.Errorf("state database is invalid; run `loaf state doctor`") + if options.jsonOutput { + return writeJSONCommandError(out, "task list", err) + } + return err } tasks, err := state.ListTasks(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.filters) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task list", err) + } return err } + tasks.Diagnostics = stateListWarnings(status.Diagnostics) if options.jsonOutput { return writeJSON(out, tasks) } @@ -1341,11 +2758,15 @@ func (r Runner) runTaskStatus(args []string, out io.Writer, runtime state.Runtim if len(args) > 0 { return fmt.Errorf("task status accepts no arguments") } - projectRoot, mode, err := r.taskStateMode(runtime) + projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { return err } - switch mode { + status, err := state.Inspect(projectRoot, state.PathResolver{StateHome: r.StateHome}) + if err != nil { + return err + } + switch status.Mode { case state.ModeMarkdownOnly: tasks, err := markdownTaskList(projectRoot.Path(), state.TaskListOptions{}) if err != nil { @@ -1364,6 +2785,7 @@ func (r Runner) runTaskStatus(args []string, out io.Writer, runtime state.Runtim if err != nil { return err } + tasks.Diagnostics = stateListWarnings(status.Diagnostics) specs, err := state.ListSpecs(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}) if err != nil { return err @@ -1373,18 +2795,28 @@ func (r Runner) runTaskStatus(args []string, out io.Writer, runtime state.Runtim } func (r Runner) runTaskUpdate(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") projectRoot, mode, err := r.taskStateMode(runtime) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "task update", err) + } return err } switch mode { case state.ModeMarkdownOnly: options, err := parseTaskUpdateArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "task update", err) + } return err } result, err := markdownTaskUpdate(projectRoot.Path(), options.update) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task update", err) + } return err } if options.jsonOutput { @@ -1393,15 +2825,25 @@ func (r Runner) runTaskUpdate(args []string, out io.Writer, runtime state.Runtim writeTaskUpdate(out, result) return nil case state.ModeInvalid: - return fmt.Errorf("state database is invalid; run `loaf state doctor`") + err := fmt.Errorf("state database is invalid; run `loaf state doctor`") + if jsonRequested { + return writeJSONCommandError(out, "task update", err) + } + return err } options, err := parseTaskUpdateArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "task update", err) + } return err } result, err := state.UpdateTask(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.update) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "task update", err) + } return err } if options.jsonOutput { @@ -1452,11 +2894,12 @@ func (r Runner) runTaskArchive(args []string, out io.Writer, runtime state.Runti func writeTaskCreate(out io.Writer, result state.TaskCreateResult) { fmt.Fprintf(out, "created task %s: %s\n", firstNonEmpty(result.Task.Alias, result.Task.ID), result.Task.Title) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "status: %s\n", result.Task.Status) if result.Priority != "" { fmt.Fprintf(out, "priority: %s\n", result.Priority) } - if result.Spec.Alias != "" { + if result.Spec != nil && result.Spec.Alias != "" { fmt.Fprintf(out, "spec: %s\n", result.Spec.Alias) } if len(result.Depends) > 0 { @@ -1473,6 +2916,7 @@ func writeTaskCreate(out io.Writer, result state.TaskCreateResult) { func writeTaskUpdate(out io.Writer, result state.TaskStatusUpdateResult) { fmt.Fprintf(out, "updated task %s\n", firstNonEmpty(result.Task.Alias, result.Task.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if result.Previous != result.Status { fmt.Fprintf(out, "status: %s -> %s\n", result.Previous, result.Status) } else if result.Status != "" { @@ -1501,6 +2945,7 @@ func writeTaskUpdate(out io.Writer, result state.TaskStatusUpdateResult) { func writeTaskArchive(out io.Writer, result state.TaskArchiveResult) { fmt.Fprint(out, "\n loaf task archive\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if result.Spec != nil && len(result.Archived) == 0 && len(result.Skipped) == 0 { fmt.Fprintf(out, " No completed tasks found for %s\n\n", firstNonEmpty(result.Spec.Alias, result.Spec.ID)) return @@ -1535,9 +2980,29 @@ func writeTaskArchive(out io.Writer, result state.TaskArchiveResult) { fmt.Fprintln(out) } +func writeProjectMutationContext(out io.Writer, prefix string, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + if databaseScope == "" { + return + } + fmt.Fprintf(out, "%sscope: %s database\n", prefix, databaseScope) + if databasePath != "" { + fmt.Fprintf(out, "%sdatabase: %s\n", prefix, databasePath) + } + if projectID != "" { + fmt.Fprintf(out, "%sproject: %s\n", prefix, projectID) + } + if projectName != "" { + fmt.Fprintf(out, "%sproject name: %s\n", prefix, projectName) + } + if projectCurrentPath != "" { + fmt.Fprintf(out, "%sproject path: %s\n", prefix, projectCurrentPath) + } +} + func writeTaskShow(out io.Writer, result state.TaskShow) { task := result.Task fmt.Fprintf(out, "task %s\n", firstNonEmpty(task.Alias, task.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "title: %s\n", task.Title) fmt.Fprintf(out, "status: %s\n", task.Status) if task.Priority != "" { @@ -1581,12 +3046,18 @@ func (r Runner) taskStateMode(runtime state.Runtime) (project.Root, string, erro } func writeTaskList(out io.Writer, tasks state.TaskList, filters state.TaskListOptions) { + fmt.Fprint(out, "\n loaf task list\n\n") + writeProjectMutationContext(out, " ", tasks.DatabaseScope, tasks.DatabasePath, tasks.ProjectID, tasks.ProjectName, tasks.ProjectCurrentPath) + writeStateDiagnostics(out, " ", tasks.Diagnostics) + if taskListHasContext(tasks) { + fmt.Fprintln(out) + } + if len(tasks.Tasks) == 0 { - fmt.Fprint(out, "\n No tasks found.\n\n") + fmt.Fprint(out, " No tasks found.\n\n") return } - fmt.Fprint(out, "\n loaf task list\n\n") specs := map[string]bool{} for _, status := range taskStatusDisplayOrder(filters) { group := sortedTasksByStatus(tasks, status) @@ -1613,6 +3084,15 @@ func writeTaskList(out io.Writer, tasks state.TaskList, filters state.TaskListOp fmt.Fprintf(out, " Total: %d tasks across %d specs\n\n", total, len(specs)) } +func taskListHasContext(tasks state.TaskList) bool { + return tasks.DatabaseScope != "" || + tasks.DatabasePath != "" || + tasks.ProjectID != "" || + tasks.ProjectName != "" || + tasks.ProjectCurrentPath != "" || + len(tasks.Diagnostics) > 0 +} + func markdownTaskList(rootPath string, options state.TaskListOptions) (state.TaskList, error) { files, err := filepath.Glob(filepath.Join(rootPath, ".agents", "tasks", "*.md")) if err != nil { @@ -1698,7 +3178,7 @@ func markdownTaskCreate(rootPath string, options state.TaskCreateOptions) (state priority = "P2" } if !state.ValidTaskPriority(priority) { - return state.TaskCreateResult{}, fmt.Errorf("invalid priority %q", priority) + return state.TaskCreateResult{}, fmt.Errorf("invalid priority %q (valid: %s)", priority, validTaskPriorityText()) } agentsDir := filepath.Join(rootPath, ".agents") tasksDir := filepath.Join(agentsDir, "tasks") @@ -1817,12 +3297,13 @@ func markdownTaskCreate(rootPath string, options state.TaskCreateOptions) (state } result := state.TaskCreateResult{ - Task: state.TraceEntity{Kind: "task", ID: taskID, Alias: taskID, Title: title, Status: "todo"}, - Priority: priority, - Depends: dependencies, + ContractVersion: state.StateJSONContractVersion, + Task: state.TraceEntity{Kind: "task", ID: taskID, Alias: taskID, Title: title, Status: "todo"}, + Priority: priority, + Depends: dependencies, } if specRef != "" { - result.Spec = specEntity + result.Spec = &specEntity } return result, nil } @@ -1835,7 +3316,7 @@ func markdownTaskUpdate(rootPath string, options state.TaskUpdateOptions) (state return state.TaskStatusUpdateResult{}, fmt.Errorf("invalid status %q", options.Status) } if options.SetPriority && !state.ValidTaskPriority(options.Priority) { - return state.TaskStatusUpdateResult{}, fmt.Errorf("invalid priority %q", options.Priority) + return state.TaskStatusUpdateResult{}, fmt.Errorf("invalid priority %q (valid: %s)", options.Priority, validTaskPriorityText()) } indexPath := filepath.Join(rootPath, ".agents", "TASKS.json") index, err := loadMarkdownTaskIndexObject(indexPath) @@ -1955,12 +3436,13 @@ func markdownTaskUpdate(rootPath string, options state.TaskUpdateOptions) (state } result := state.TaskStatusUpdateResult{ - Task: state.TraceEntity{Kind: "task", ID: options.Ref, Alias: options.Ref, Title: jsonObjectString(entry, "title"), Status: finalStatus}, - Previous: previousStatus, - Status: finalStatus, - Priority: finalPriority, - Spec: specEntity, - Session: sessionEntity, + ContractVersion: state.StateJSONContractVersion, + Task: state.TraceEntity{Kind: "task", ID: options.Ref, Alias: options.Ref, Title: jsonObjectString(entry, "title"), Status: finalStatus}, + Previous: previousStatus, + Status: finalStatus, + Priority: finalPriority, + Spec: specEntity, + Session: sessionEntity, } if options.SetDependsOn { result.Depends = dependencies @@ -2035,12 +3517,13 @@ func markdownTaskRefresh(rootPath string) (compatibilityCommandSummary, error) { return compatibilityCommandSummary{}, err } return compatibilityCommandSummary{ - Version: 1, - Command: "task refresh", - Mode: "markdown", - Action: "rebuild", - Reason: "Rebuilt TASKS.json from task/spec markdown files.", - Counts: counts, + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: "task refresh", + Mode: "markdown", + Action: "rebuild", + Reason: "Rebuilt TASKS.json from task/spec markdown files.", + Counts: counts, }, nil } @@ -2106,12 +3589,13 @@ func markdownTaskSync(rootPath string, args []string) (compatibilityCommandSumma return compatibilityCommandSummary{}, err } return compatibilityCommandSummary{ - Version: 1, - Command: "task sync", - Mode: "markdown", - Action: "push", - Reason: "Pushed TASKS.json metadata into task/spec markdown frontmatter.", - Counts: counts, + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: "task sync", + Mode: "markdown", + Action: "push", + Reason: "Pushed TASKS.json metadata into task/spec markdown frontmatter.", + Counts: counts, }, nil case hasFlag(args, "--import"): return importMarkdownTaskIndexOrphans(rootPath) @@ -2383,12 +3867,13 @@ func importMarkdownTaskIndexOrphans(rootPath string) (compatibilityCommandSummar } } return compatibilityCommandSummary{ - Version: 1, - Command: "task sync", - Mode: "markdown", - Action: "import", - Reason: "Imported orphan task/spec markdown files into TASKS.json.", - Counts: markdownTaskIndexCounts(index, importedTasks, importedSpecs), + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: "task sync", + Mode: "markdown", + Action: "import", + Reason: "Imported orphan task/spec markdown files into TASKS.json.", + Counts: markdownTaskIndexCounts(index, importedTasks, importedSpecs), }, nil } @@ -2902,6 +4387,11 @@ func writeTaskStatus(out io.Writer, tasks state.TaskList, specs state.SpecList) specCounts := countSpecStatuses(specs) fmt.Fprint(out, "\n loaf task status\n\n") + writeProjectMutationContext(out, " ", tasks.DatabaseScope, tasks.DatabasePath, tasks.ProjectID, tasks.ProjectName, tasks.ProjectCurrentPath) + writeStateDiagnostics(out, " ", tasks.Diagnostics) + if taskListHasContext(tasks) { + fmt.Fprintln(out) + } fmt.Fprintf(out, " Tasks: %s (%d total)\n", formatStatusCounts(taskCounts, []string{"in_progress", "blocked", "todo", "review", "done"}), len(tasks.Tasks)) fmt.Fprintf(out, " Specs: %s (%d total)\n\n", formatStatusCounts(specCounts, []string{"implementing", "approved", "drafting", "complete"}), len(specs.Specs)) } @@ -2931,8 +4421,19 @@ func formatStatusCounts(counts map[string]int, order []string) string { } func (r Runner) runIdea(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { - return missingSubcommandError("idea") + if len(args) == 0 || isHelpArg(args) { + writeIdeaHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeIdeaListHelp, + "show": writeIdeaShowHelp, + "capture": writeIdeaCaptureHelp, + "promote": writeIdeaPromoteHelp, + "resolve": writeIdeaResolveHelp, + "archive": writeIdeaArchiveHelp, + }) { + return nil } switch args[0] { case "list": @@ -2952,6 +4453,41 @@ func (r Runner) runIdea(args []string, out io.Writer, runtime state.Runtime) err } } +func writeIdeaHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf idea <subcommand> [options]", "Manage ideas in native SQLite state.", []subcommandHelpItem{ + {Name: "list", Summary: "List ideas"}, + {Name: "show", Summary: "Show one idea"}, + {Name: "capture", Summary: "Capture an idea"}, + {Name: "promote", Summary: "Promote an idea to a spec"}, + {Name: "resolve", Summary: "Resolve an idea by another entity"}, + {Name: "archive", Summary: "Archive ideas"}, + }) +} + +func writeIdeaListHelp(out io.Writer) { + writeUsageHelp(out, "loaf idea list [--all|--status <status>] [--json]", "List ideas from SQLite state.", "--all Include resolved and archived ideas", "--status Filter by status", "--json Output ideas, global database scope, and project identity as JSON") +} + +func writeIdeaShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf idea show <idea> [--json]", "Show one idea from SQLite state.", "--json Output idea details, relationships, global database scope, and project identity as JSON") +} + +func writeIdeaCaptureHelp(out io.Writer) { + writeUsageHelp(out, "loaf idea capture --title <title> [--json]", "Capture an idea in SQLite state.", "--title Idea title", "--json Output created idea, event, global database scope, and project identity as JSON") +} + +func writeIdeaPromoteHelp(out io.Writer) { + writeUsageHelp(out, "loaf idea promote <idea> --to-spec <spec> [--json]", "Record idea-to-spec promotion.", "--to-spec Target spec", "--json Output promotion relationship, global database scope, and project identity as JSON") +} + +func writeIdeaResolveHelp(out io.Writer) { + writeUsageHelp(out, "loaf idea resolve <idea> --by <entity> [--json]", "Resolve an idea by linking it to another entity.", "--by Resolving entity", "--json Output resolution relationship, event, global database scope, and project identity as JSON") +} + +func writeIdeaArchiveHelp(out io.Writer) { + writeUsageHelp(out, "loaf idea archive <idea...> [--reason <text>] [--json]", "Archive one or more ideas.", "--reason Archive reason", "--json Output archive result, archived ideas, global database scope, and project identity as JSON") +} + func (r Runner) runIdeaList(args []string, out io.Writer, runtime state.Runtime) error { options, err := parseIdeaListArgs(args) if err != nil { @@ -3015,33 +4551,55 @@ func (r Runner) runIdeaShow(args []string, out io.Writer, runtime state.Runtime) } func (r Runner) runIdeaCapture(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "idea capture", err) + } return err } status, err := state.Inspect(projectRoot, state.PathResolver{StateHome: r.StateHome}) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "idea capture", err) + } return err } switch status.Mode { case state.ModeMarkdownOnly: - return sqliteStateRequiredError("idea capture") + err := sqliteStateRequiredError("idea capture") + if jsonRequested { + return writeJSONCommandError(out, "idea capture", err) + } + return err case state.ModeInvalid: - return fmt.Errorf("state database is invalid; run `loaf state doctor`") + err := fmt.Errorf("state database is invalid; run `loaf state doctor`") + if jsonRequested { + return writeJSONCommandError(out, "idea capture", err) + } + return err } options, err := parseIdeaCaptureArgs(args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "idea capture", err) + } return err } result, err := state.CaptureIdea(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, options.capture) if err != nil { + if options.jsonOutput { + return writeJSONCommandError(out, "idea capture", err) + } return err } if options.jsonOutput { return writeJSON(out, result) } fmt.Fprintf(out, "captured idea %s\n", firstNonEmpty(result.Idea.Alias, result.Idea.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "title: %s\n", result.Idea.Title) if result.EventID != "" { fmt.Fprintf(out, "event: %s\n", result.EventID) @@ -3077,6 +4635,7 @@ func (r Runner) runIdeaPromote(args []string, out io.Writer, runtime state.Runti return writeJSON(out, result) } fmt.Fprintf(out, "promoted idea %s to spec %s\n", firstNonEmpty(result.Idea.Alias, result.Idea.ID), firstNonEmpty(result.Spec.Alias, result.Spec.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "relationship: %s\n", result.Relationship) return nil } @@ -3109,6 +4668,7 @@ func (r Runner) runIdeaResolve(args []string, out io.Writer, runtime state.Runti return writeJSON(out, result) } fmt.Fprintf(out, "resolved idea %s by %s\n", firstNonEmpty(result.Idea.Alias, result.Idea.ID), firstNonEmpty(result.ResolvedBy.Alias, result.ResolvedBy.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3144,12 +4704,19 @@ func (r Runner) runIdeaArchive(args []string, out io.Writer, runtime state.Runti } func writeIdeaList(out io.Writer, ideas state.IdeaList, filters state.IdeaListOptions) { + fmt.Fprint(out, "\n loaf idea list\n\n") + writeProjectMutationContext(out, " ", ideas.DatabaseScope, ideas.DatabasePath, ideas.ProjectID, ideas.ProjectName, ideas.ProjectCurrentPath) if len(ideas.Ideas) == 0 { - fmt.Fprint(out, "\n No ideas found.\n\n") + if ideas.DatabaseScope != "" || ideas.DatabasePath != "" || ideas.ProjectID != "" || ideas.ProjectName != "" || ideas.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } + fmt.Fprint(out, " No ideas found.\n\n") return } - fmt.Fprint(out, "\n loaf idea list\n\n") + if ideas.DatabaseScope != "" || ideas.DatabasePath != "" || ideas.ProjectID != "" || ideas.ProjectName != "" || ideas.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } for _, alias := range sortedIdeas(ideas) { idea := ideas.Ideas[alias] fmt.Fprintf(out, " %-24s%s", alias, idea.Title) @@ -3169,6 +4736,7 @@ func writeIdeaShow(out io.Writer, result state.IdeaShow) { fmt.Fprintf(out, "idea %s\n", firstNonEmpty(idea.Alias, idea.ID)) fmt.Fprintf(out, "title: %s\n", idea.Title) fmt.Fprintf(out, "status: %s\n", idea.Status) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) for _, source := range idea.Sources { fmt.Fprintf(out, "source: %s\n", source.Path) if source.Hash != "" { @@ -3198,6 +4766,10 @@ func writeIdeaShow(out io.Writer, result state.IdeaShow) { func writeIdeaArchive(out io.Writer, result state.IdeaArchiveResult) { fmt.Fprint(out, "\n loaf idea archive\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" || result.DatabasePath != "" || result.ProjectID != "" || result.ProjectName != "" || result.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } for _, item := range result.Archived { idea := item.Ref title := "" @@ -3229,8 +4801,18 @@ func writeIdeaArchive(out io.Writer, result state.IdeaArchiveResult) { } func (r Runner) runSpark(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { - return missingSubcommandError("spark") + if len(args) == 0 || isHelpArg(args) { + writeSparkHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeSparkListHelp, + "show": writeSparkShowHelp, + "capture": writeSparkCaptureHelp, + "resolve": writeSparkResolveHelp, + "promote": writeSparkPromoteHelp, + }) { + return nil } switch args[0] { case "list": @@ -3248,6 +4830,36 @@ func (r Runner) runSpark(args []string, out io.Writer, runtime state.Runtime) er } } +func writeSparkHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf spark <subcommand> [options]", "Manage sparks in native SQLite state.", []subcommandHelpItem{ + {Name: "list", Summary: "List sparks"}, + {Name: "show", Summary: "Show one spark"}, + {Name: "capture", Summary: "Capture a spark"}, + {Name: "resolve", Summary: "Resolve a spark"}, + {Name: "promote", Summary: "Promote a spark to an idea"}, + }) +} + +func writeSparkListHelp(out io.Writer) { + writeUsageHelp(out, "loaf spark list [--all|--status <status>] [--json]", "List sparks from SQLite state.", "--all Include resolved sparks", "--status Filter by status", "--json Output sparks, global database scope, and project identity as JSON") +} + +func writeSparkShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf spark show <spark> [--json]", "Show one spark from SQLite state.", "--json Output spark details, relationships, global database scope, and project identity as JSON") +} + +func writeSparkCaptureHelp(out io.Writer) { + writeUsageHelp(out, "loaf spark capture --scope <scope> --text <text> [--json]", "Capture a spark in SQLite state.", "--scope Spark scope", "--text Spark text", "--json Output created spark, event, global database scope, and project identity as JSON") +} + +func writeSparkResolveHelp(out io.Writer) { + writeUsageHelp(out, "loaf spark resolve <spark> [--reason <text>] [--json]", "Resolve a spark.", "--reason Resolution reason", "--json Output resolution relationship, event, global database scope, and project identity as JSON") +} + +func writeSparkPromoteHelp(out io.Writer) { + writeUsageHelp(out, "loaf spark promote <spark> --to-idea <idea> [--json]", "Record spark-to-idea promotion.", "--to-idea Target idea", "--json Output promotion relationship, global database scope, and project identity as JSON") +} + func (r Runner) runSparkList(args []string, out io.Writer, runtime state.Runtime) error { options, err := parseSparkListArgs(args) if err != nil { @@ -3338,6 +4950,7 @@ func (r Runner) runSparkCapture(args []string, out io.Writer, runtime state.Runt return writeJSON(out, result) } fmt.Fprintf(out, "captured spark %s\n", firstNonEmpty(result.Spark.Alias, result.Spark.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if result.Scope != "" { fmt.Fprintf(out, "scope: %s\n", result.Scope) } @@ -3376,6 +4989,7 @@ func (r Runner) runSparkResolve(args []string, out io.Writer, runtime state.Runt return writeJSON(out, result) } fmt.Fprintf(out, "resolved spark %s by %s\n", firstNonEmpty(result.Spark.Alias, result.Spark.ID), firstNonEmpty(result.ResolvedBy.Alias, result.ResolvedBy.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3407,17 +5021,25 @@ func (r Runner) runSparkPromote(args []string, out io.Writer, runtime state.Runt return writeJSON(out, result) } fmt.Fprintf(out, "promoted spark %s to idea %s\n", firstNonEmpty(result.Spark.Alias, result.Spark.ID), firstNonEmpty(result.Idea.Alias, result.Idea.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "relationship: %s\n", result.Relationship) return nil } func writeSparkList(out io.Writer, sparks state.SparkList, filters state.SparkListOptions) { + fmt.Fprint(out, "\n loaf spark list\n\n") + writeProjectMutationContext(out, " ", sparks.DatabaseScope, sparks.DatabasePath, sparks.ProjectID, sparks.ProjectName, sparks.ProjectCurrentPath) if len(sparks.Sparks) == 0 { - fmt.Fprint(out, "\n No sparks found.\n\n") + if sparks.DatabaseScope != "" || sparks.DatabasePath != "" || sparks.ProjectID != "" || sparks.ProjectName != "" || sparks.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } + fmt.Fprint(out, " No sparks found.\n\n") return } - fmt.Fprint(out, "\n loaf spark list\n\n") + if sparks.DatabaseScope != "" || sparks.DatabasePath != "" || sparks.ProjectID != "" || sparks.ProjectName != "" || sparks.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } for _, alias := range sortedSparks(sparks) { spark := sparks.Sparks[alias] fmt.Fprintf(out, " %-24s%s", alias, spark.Text) @@ -3438,6 +5060,7 @@ func writeSparkList(out io.Writer, sparks state.SparkList, filters state.SparkLi func writeSparkShow(out io.Writer, result state.SparkShow) { spark := result.Spark fmt.Fprintf(out, "spark %s\n", firstNonEmpty(spark.Alias, spark.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if spark.Scope != "" { fmt.Fprintf(out, "scope: %s\n", spark.Scope) } @@ -3465,8 +5088,17 @@ func writeSparkShow(out io.Writer, result state.SparkShow) { } func (r Runner) runTag(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { - return missingSubcommandError("tag") + if len(args) == 0 || isHelpArg(args) { + writeTagHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeTagListHelp, + "show": writeTagShowHelp, + "add": writeTagAddHelp, + "remove": writeTagRemoveHelp, + }) { + return nil } switch args[0] { case "list": @@ -3482,6 +5114,31 @@ func (r Runner) runTag(args []string, out io.Writer, runtime state.Runtime) erro } } +func writeTagHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf tag <subcommand> [options]", "Manage tags in native SQLite state.", []subcommandHelpItem{ + {Name: "list", Summary: "List tags"}, + {Name: "show", Summary: "Show tagged entities"}, + {Name: "add", Summary: "Add a tag to an entity"}, + {Name: "remove", Summary: "Remove a tag from an entity"}, + }) +} + +func writeTagListHelp(out io.Writer) { + writeUsageHelp(out, "loaf tag list [--json]", "List tags from SQLite state.", "--json Output tags, global database scope, and project identity as JSON") +} + +func writeTagShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf tag show <tag> [--json]", "Show entities with a tag.", "--json Output tagged entities, global database scope, and project identity as JSON") +} + +func writeTagAddHelp(out io.Writer) { + writeUsageHelp(out, "loaf tag add <entity> <tag> [--json]", "Add a tag to an entity.", "--json Output tag mutation, entity, global database scope, and project identity as JSON") +} + +func writeTagRemoveHelp(out io.Writer) { + writeUsageHelp(out, "loaf tag remove <entity> <tag> [--json]", "Remove a tag from an entity.", "--json Output tag mutation, entity, global database scope, and project identity as JSON") +} + func (r Runner) runTagList(args []string, out io.Writer, runtime state.Runtime) error { jsonOutput, err := parseJSONOnly(args) if err != nil { @@ -3557,6 +5214,7 @@ func (r Runner) runTagAdd(args []string, out io.Writer, runtime state.Runtime) e return writeJSON(out, result) } fmt.Fprintf(out, "tagged %s %s with %s\n", result.Entity.Kind, firstNonEmpty(result.Entity.Alias, result.Entity.ID), result.Name) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3583,6 +5241,7 @@ func (r Runner) runTagRemove(args []string, out io.Writer, runtime state.Runtime return writeJSON(out, result) } fmt.Fprintf(out, "removed tag %s from %s %s\n", result.Name, result.Entity.Kind, firstNonEmpty(result.Entity.Alias, result.Entity.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3600,10 +5259,19 @@ func (r Runner) tagStateMode(runtime state.Runtime) (project.Root, string, error func writeTagList(out io.Writer, tags state.TagList) { if len(tags.Tags) == 0 { - fmt.Fprint(out, "\n No tags found.\n\n") + fmt.Fprint(out, "\n loaf tag list\n\n") + writeProjectMutationContext(out, " ", tags.DatabaseScope, tags.DatabasePath, tags.ProjectID, tags.ProjectName, tags.ProjectCurrentPath) + if tags.DatabaseScope != "" { + fmt.Fprintln(out) + } + fmt.Fprint(out, " No tags found.\n\n") return } fmt.Fprint(out, "\n loaf tag list\n\n") + writeProjectMutationContext(out, " ", tags.DatabaseScope, tags.DatabasePath, tags.ProjectID, tags.ProjectName, tags.ProjectCurrentPath) + if tags.DatabaseScope != "" { + fmt.Fprintln(out) + } for _, name := range sortedTags(tags) { fmt.Fprintf(out, " %-24s%d\n", name, tags.Tags[name].Count) } @@ -3612,6 +5280,10 @@ func writeTagList(out io.Writer, tags state.TagList) { func writeTagShow(out io.Writer, result state.TagShowResult) { fmt.Fprintf(out, "\n tag %s\n\n", result.Name) + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" { + fmt.Fprintln(out) + } if len(result.Members) == 0 { fmt.Fprint(out, " No tagged rows found.\n\n") return @@ -3628,8 +5300,19 @@ func writeTagShow(out io.Writer, result state.TagShowResult) { } func (r Runner) runBundle(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { - return missingSubcommandError("bundle") + if len(args) == 0 || isHelpArg(args) { + writeBundleHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeBundleListHelp, + "create": writeBundleCreateHelp, + "update": writeBundleUpdateHelp, + "show": writeBundleShowHelp, + "add": writeBundleAddHelp, + "remove": writeBundleRemoveHelp, + }) { + return nil } switch args[0] { case "list": @@ -3649,6 +5332,41 @@ func (r Runner) runBundle(args []string, out io.Writer, runtime state.Runtime) e } } +func writeBundleHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf bundle <subcommand> [options]", "Manage bundles in native SQLite state.", []subcommandHelpItem{ + {Name: "list", Summary: "List bundles"}, + {Name: "create", Summary: "Create a bundle"}, + {Name: "update", Summary: "Update a bundle"}, + {Name: "show", Summary: "Show a bundle"}, + {Name: "add", Summary: "Add an entity to a bundle"}, + {Name: "remove", Summary: "Remove an entity from a bundle"}, + }) +} + +func writeBundleListHelp(out io.Writer) { + writeUsageHelp(out, "loaf bundle list [--json]", "List bundles from SQLite state.", "--json Output bundles, global database scope, and project identity as JSON") +} + +func writeBundleCreateHelp(out io.Writer) { + writeUsageHelp(out, "loaf bundle create <slug> [--title <title>] [--tags <tags>] [--json]", "Create a bundle.", "--title Bundle title", "--tags Comma-separated tag query", "--json Output created bundle, tags, global database scope, and project identity as JSON") +} + +func writeBundleUpdateHelp(out io.Writer) { + writeUsageHelp(out, "loaf bundle update <slug> [--title <title>] [--tags <tags>] [--json]", "Update a bundle.", "--title Bundle title", "--tags Comma-separated tag query", "--json Output updated bundle, tags, global database scope, and project identity as JSON") +} + +func writeBundleShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf bundle show <bundle> [--json]", "Show one bundle.", "--json Output bundle details, members, global database scope, and project identity as JSON") +} + +func writeBundleAddHelp(out io.Writer) { + writeUsageHelp(out, "loaf bundle add <bundle> <entity> [--json]", "Add an entity to a bundle.", "--json Output bundle membership result, global database scope, and project identity as JSON") +} + +func writeBundleRemoveHelp(out io.Writer) { + writeUsageHelp(out, "loaf bundle remove <bundle> <entity> [--json]", "Remove an entity from a bundle.", "--json Output bundle membership result, global database scope, and project identity as JSON") +} + func (r Runner) runBundleList(args []string, out io.Writer, runtime state.Runtime) error { jsonOutput, err := parseJSONOnly(args) if err != nil { @@ -3702,6 +5420,7 @@ func (r Runner) runBundleCreate(args []string, out io.Writer, runtime state.Runt return writeJSON(out, result) } fmt.Fprintf(out, "created bundle %s\n", result.Slug) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3734,6 +5453,7 @@ func (r Runner) runBundleUpdate(args []string, out io.Writer, runtime state.Runt return writeJSON(out, result) } fmt.Fprintf(out, "updated bundle %s\n", result.Slug) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if result.Title != "" { fmt.Fprintf(out, "title: %s\n", result.Title) } @@ -3794,6 +5514,7 @@ func (r Runner) runBundleAdd(args []string, out io.Writer, runtime state.Runtime return writeJSON(out, result) } fmt.Fprintf(out, "added %s %s to bundle %s\n", result.Entity.Kind, firstNonEmpty(result.Entity.Alias, result.Entity.ID), result.Slug) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3820,6 +5541,7 @@ func (r Runner) runBundleRemove(args []string, out io.Writer, runtime state.Runt return writeJSON(out, result) } fmt.Fprintf(out, "removed %s %s from bundle %s\n", result.Entity.Kind, firstNonEmpty(result.Entity.Alias, result.Entity.ID), result.Slug) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3837,10 +5559,19 @@ func (r Runner) bundleStateMode(runtime state.Runtime) (project.Root, string, er func writeBundleList(out io.Writer, result state.BundleList) { if len(result.Bundles) == 0 { - fmt.Fprint(out, "\n No bundles found.\n\n") + fmt.Fprint(out, "\n loaf bundle list\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" { + fmt.Fprintln(out) + } + fmt.Fprint(out, " No bundles found.\n\n") return } fmt.Fprint(out, "\n loaf bundle list\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" { + fmt.Fprintln(out) + } for _, slug := range sortedBundleSlugs(result) { bundle := result.Bundles[slug] fmt.Fprintf(out, " %-24s%s", slug, bundle.Title) @@ -3861,6 +5592,10 @@ func writeBundleShow(out io.Writer, result state.BundleShowResult) { fmt.Fprintf(out, " tags: %s\n", strings.Join(result.TagQuery, ", ")) } fmt.Fprintln(out) + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" { + fmt.Fprintln(out) + } if len(result.Members) == 0 { fmt.Fprint(out, " No bundled rows found.\n\n") return @@ -3917,8 +5652,16 @@ func housekeepingSectionLabel(name string) string { } func (r Runner) runLink(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { - return missingSubcommandError("link") + if len(args) == 0 || isHelpArg(args) { + writeLinkHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "create": writeLinkCreateHelp, + "list": writeLinkListHelp, + "remove": writeLinkRemoveHelp, + }) { + return nil } switch args[0] { case "create": @@ -3932,19 +5675,52 @@ func (r Runner) runLink(args []string, out io.Writer, runtime state.Runtime) err } } +func writeLinkHelp(out io.Writer) { + writeCommandGroupHelp(out, "loaf link <subcommand> [options]", "Manage explicit relationships in native SQLite state.", []subcommandHelpItem{ + {Name: "create", Summary: "Create a relationship"}, + {Name: "list", Summary: "List relationships for an entity"}, + {Name: "remove", Summary: "Remove a relationship"}, + }) +} + +func writeLinkCreateHelp(out io.Writer) { + writeUsageHelp(out, "loaf link create --from <entity> --to <entity> [--type <type>] [--reason <text>] [--json]", "Create an explicit relationship.", "--from Source entity", "--to Target entity", "--type Relationship type", "--reason Relationship reason", "--json Output relationship ID, source/target, global database scope, and project identity as JSON") +} + +func writeLinkListHelp(out io.Writer) { + writeUsageHelp(out, "loaf link list <entity> [--json]", "List relationships for one entity.", "--json Output relationships, global database scope, and project identity as JSON") +} + +func writeLinkRemoveHelp(out io.Writer) { + writeUsageHelp(out, "loaf link remove --from <entity> --to <entity> [--type <type>] [--json]", "Remove an explicit relationship.", "--from Source entity", "--to Target entity", "--type Relationship type", "--json Output removed relationship ID, global database scope, and project identity as JSON") +} + func (r Runner) runLinkCreate(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") options, err := parseLinkMutationArgs("link create", args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "link create", err) + } return err } projectRoot, mode, err := r.linkStateMode(runtime) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "link create", err) + } return err } switch mode { case state.ModeMarkdownOnly: + if jsonRequested { + return writeJSONCommandError(out, "link create", sqliteStateRequiredError("link create")) + } return sqliteStateRequiredError("link create") case state.ModeInvalid: + if jsonRequested { + return writeJSONCommandError(out, "link create", fmt.Errorf("state database is invalid; run `loaf state doctor`")) + } return fmt.Errorf("state database is invalid; run `loaf state doctor`") } result, err := state.CreateLink(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, state.LinkMutationOptions{ @@ -3954,12 +5730,16 @@ func (r Runner) runLinkCreate(args []string, out io.Writer, runtime state.Runtim Reason: options.reason, }) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "link create", err) + } return err } if options.jsonOutput { return writeJSON(out, result) } fmt.Fprintf(out, "linked %s %s %s %s %s\n", result.From.Kind, firstNonEmpty(result.From.Alias, result.From.ID), result.Type, result.To.Kind, firstNonEmpty(result.To.Alias, result.To.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -3990,18 +5770,31 @@ func (r Runner) runLinkList(args []string, out io.Writer, runtime state.Runtime) } func (r Runner) runLinkRemove(args []string, out io.Writer, runtime state.Runtime) error { + jsonRequested := hasFlag(args, "--json") options, err := parseLinkMutationArgs("link remove", args) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "link remove", err) + } return err } projectRoot, mode, err := r.linkStateMode(runtime) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "link remove", err) + } return err } switch mode { case state.ModeMarkdownOnly: + if jsonRequested { + return writeJSONCommandError(out, "link remove", sqliteStateRequiredError("link remove")) + } return sqliteStateRequiredError("link remove") case state.ModeInvalid: + if jsonRequested { + return writeJSONCommandError(out, "link remove", fmt.Errorf("state database is invalid; run `loaf state doctor`")) + } return fmt.Errorf("state database is invalid; run `loaf state doctor`") } result, err := state.RemoveLink(context.Background(), projectRoot, state.PathResolver{StateHome: r.StateHome}, state.LinkMutationOptions{ @@ -4011,12 +5804,16 @@ func (r Runner) runLinkRemove(args []string, out io.Writer, runtime state.Runtim Reason: options.reason, }) if err != nil { + if jsonRequested { + return writeJSONCommandError(out, "link remove", err) + } return err } if options.jsonOutput { return writeJSON(out, result) } fmt.Fprintf(out, "removed link %s %s %s %s %s\n", result.From.Kind, firstNonEmpty(result.From.Alias, result.From.ID), result.Type, result.To.Kind, firstNonEmpty(result.To.Alias, result.To.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -4034,6 +5831,10 @@ func (r Runner) linkStateMode(runtime state.Runtime) (project.Root, string, erro func writeLinkList(out io.Writer, result state.LinkListResult) { fmt.Fprintf(out, "\n links for %s %s\n\n", result.Entity.Kind, firstNonEmpty(result.Entity.Alias, result.Entity.ID)) + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" { + fmt.Fprintln(out) + } if len(result.Relationships) == 0 { fmt.Fprint(out, " No links found.\n\n") return @@ -4050,12 +5851,15 @@ func writeLinkList(out io.Writer, result state.LinkListResult) { } func (r Runner) runSpec(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { + if len(args) == 0 || isHelpArg(args) { writeSpecHelp(out) return nil } - if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") { - writeSpecHelp(out) + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeSpecListHelp, + "show": writeSpecShowHelp, + "archive": writeSpecArchiveHelp, + }) { return nil } switch args[0] { @@ -4084,6 +5888,18 @@ func writeSpecHelp(out io.Writer) { fmt.Fprintln(out, " -h, --help Show help") } +func writeSpecListHelp(out io.Writer) { + writeUsageHelp(out, "loaf spec list [--json]", "List specs.", "--json Output specs, diagnostics, task counts, global database scope, and project identity as JSON") +} + +func writeSpecShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf spec show <spec> [--json]", "Show one spec.", "--json Output spec details, task counts, relationships, global database scope, and project identity as JSON") +} + +func writeSpecArchiveHelp(out io.Writer) { + writeUsageHelp(out, "loaf spec archive <spec...> [--json]", "Archive completed specs.", "--json Output archive result, archived specs, global database scope, and project identity as JSON") +} + func (r Runner) runSpecList(args []string, out io.Writer, runtime state.Runtime) error { jsonOutput, err := parseJSONOnly(args) if err != nil { @@ -4116,6 +5932,7 @@ func (r Runner) runSpecList(args []string, out io.Writer, runtime state.Runtime) if err != nil { return err } + specs.Diagnostics = stateListWarnings(status.Diagnostics) if jsonOutput { return writeJSON(out, specs) } @@ -4206,12 +6023,18 @@ func (r Runner) runSpecArchive(args []string, out io.Writer, runtime state.Runti } func writeSpecList(out io.Writer, specs state.SpecList) { + fmt.Fprint(out, "\n loaf spec list\n\n") + writeProjectMutationContext(out, " ", specs.DatabaseScope, specs.DatabasePath, specs.ProjectID, specs.ProjectName, specs.ProjectCurrentPath) + writeStateDiagnostics(out, " ", specs.Diagnostics) + if specListHasContext(specs) { + fmt.Fprintln(out) + } + if len(specs.Specs) == 0 { - fmt.Fprint(out, "\n No specs found.\n\n") + fmt.Fprint(out, " No specs found.\n\n") return } - fmt.Fprint(out, "\n loaf spec list\n\n") for _, status := range specStatusDisplayOrder(specs) { group := sortedSpecsByStatus(specs, status) if len(group) == 0 { @@ -4229,6 +6052,15 @@ func writeSpecList(out io.Writer, specs state.SpecList) { fmt.Fprintf(out, " Total: %d specs\n\n", len(specs.Specs)) } +func specListHasContext(specs state.SpecList) bool { + return specs.DatabaseScope != "" || + specs.DatabasePath != "" || + specs.ProjectID != "" || + specs.ProjectName != "" || + specs.ProjectCurrentPath != "" || + len(specs.Diagnostics) > 0 +} + func markdownSpecList(rootPath string) (state.SpecList, error) { agentsDir := filepath.Join(rootPath, ".agents") files, err := filepath.Glob(filepath.Join(agentsDir, "specs", "*.md")) @@ -4396,7 +6228,7 @@ func markdownSpecArchive(rootPath string, refs []string) (state.SpecArchiveResul return state.SpecArchiveResult{}, fmt.Errorf("TASKS.json specs must be an object") } - result := state.SpecArchiveResult{Archived: []state.SpecArchiveItem{}, Skipped: []state.SpecArchiveItem{}} + result := state.SpecArchiveResult{ContractVersion: state.StateJSONContractVersion, Archived: []state.SpecArchiveItem{}, Skipped: []state.SpecArchiveItem{}} changed := false for _, ref := range refs { entryValue, ok := specs[ref] @@ -4493,7 +6325,7 @@ func markdownTaskArchive(rootPath string, options state.TaskArchiveOptions) (sta return state.TaskArchiveResult{}, fmt.Errorf("TASKS.json tasks must be an object") } - result := state.TaskArchiveResult{Archived: []state.TaskArchiveItem{}, Skipped: []state.TaskArchiveItem{}} + result := state.TaskArchiveResult{ContractVersion: state.StateJSONContractVersion, Archived: []state.TaskArchiveItem{}, Skipped: []state.TaskArchiveItem{}} refs := append([]string{}, options.Refs...) if options.Spec != "" { specs, ok := index["specs"].(map[string]any) @@ -4730,6 +6562,7 @@ func loadMarkdownSpecIndex(rootPath string) map[string]markdownSpecIndexEntry { func writeSpecShow(out io.Writer, result state.SpecShow) { spec := result.Spec fmt.Fprintf(out, "spec %s\n", firstNonEmpty(spec.Alias, spec.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "title: %s\n", spec.Title) fmt.Fprintf(out, "status: %s\n", spec.Status) fmt.Fprintf(out, "tasks: %d todo / %d in_progress / %d done\n", spec.Tasks.Todo, spec.Tasks.InProgress, spec.Tasks.Done) @@ -4760,6 +6593,7 @@ func writeSpecShow(out io.Writer, result state.SpecShow) { func writeSpecArchive(out io.Writer, result state.SpecArchiveResult) { fmt.Fprint(out, "\n loaf spec archive\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) for _, item := range result.Archived { spec := item.Ref title := "" @@ -4791,12 +6625,21 @@ func writeSpecArchive(out io.Writer, result state.SpecArchiveResult) { } func (r Runner) runSession(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { + if len(args) == 0 || isHelpArg(args) { writeSessionHelp(out) return nil } - if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") { - writeSessionHelp(out) + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "start": writeSessionStartHelp, + "end": writeSessionEndHelp, + "archive": writeSessionArchiveHelp, + "list": writeSessionListHelp, + "show": writeSessionShowHelp, + "log": writeSessionLogHelp, + "enrich": writeSessionEnrichHelp, + "housekeeping": writeSessionHousekeepingHelp, + "report": writeSessionReportHelp, + }) { return nil } switch args[0] { @@ -4849,6 +6692,42 @@ func writeSessionHelp(out io.Writer) { fmt.Fprintln(out, " -h, --help Show help") } +func writeSessionStartHelp(out io.Writer) { + writeUsageHelp(out, "loaf session start [--resume] [--session-id <id>] [--force] [--json]", "Start or resume session state.", "--resume Resume if possible", "--session-id Harness session ID", "--force Ignore hook agent adoption guard", "--json Output action, session, journal IDs, global database scope, and project identity as JSON") +} + +func writeSessionEndHelp(out io.Writer) { + writeUsageHelp(out, "loaf session end [--if-active] [--wrap] [--from-hook] [--session-id <id>] [--json]", "End, wrap, or clear a session.", "--if-active No-op when no active session exists", "--wrap Mark as wrapped", "--from-hook Read hook input", "--session-id Harness session ID", "--json Output action/noop, session, journal IDs, global database scope, and project identity as JSON") +} + +func writeSessionArchiveHelp(out io.Writer) { + writeUsageHelp(out, "loaf session archive [--branch <branch>] [--session-id <id>] [--json]", "Archive a stopped or targeted session.", "--branch Branch to archive", "--session-id Harness session ID", "--json Output archive result, affected sessions, global database scope, and project identity as JSON") +} + +func writeSessionListHelp(out io.Writer) { + writeUsageHelp(out, "loaf session list [--all] [--json]", "List sessions.", "--all Include archived sessions", "--json Output sessions, diagnostics, global database scope, and project identity as JSON") +} + +func writeSessionShowHelp(out io.Writer) { + writeUsageHelp(out, "loaf session show <session> [--json]", "Show one session.", "--json Output session details, journal entries, relationships, global database scope, and project identity as JSON") +} + +func writeSessionLogHelp(out io.Writer) { + writeUsageHelp(out, "loaf session log <entry> [--from-hook] [--session-id <id>] [--json]", "Append a session journal entry.", "--from-hook Read hook input", "--session-id Harness session ID", "--json Output journal entry, linked session, global database scope, and project identity as JSON") +} + +func writeSessionEnrichHelp(out io.Writer) { + writeUsageHelp(out, "loaf session enrich [--json]", "Summarize markdown enrichment compatibility.", "--json Output compatibility mode, action, reason, and counts as JSON") +} + +func writeSessionHousekeepingHelp(out io.Writer) { + writeUsageHelp(out, "loaf session housekeeping [--json]", "Summarize markdown housekeeping compatibility.", "--json Output compatibility mode, action, reason, and counts as JSON") +} + +func writeSessionReportHelp(out io.Writer) { + writeUsageHelp(out, "loaf session report <session> [--json]", "Export a session report.", "--json Output export contract, command, project context, and markdown content as JSON") +} + func (r Runner) runSessionStart(args []string, out io.Writer, runtime state.Runtime) error { options, err := parseSessionStartArgs(args) if err != nil { @@ -5161,11 +7040,12 @@ func (r Runner) runSessionEnrich(args []string, out io.Writer, runtime state.Run return err } summary := compatibilityCommandSummary{ - Version: 1, - Command: "session enrich", - Mode: "sqlite", - Action: "skipped", - Reason: "SQLite journal state is written through `loaf session log`; Markdown JSONL enrichment is a compatibility path and is not run in SQLite mode.", + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: "session enrich", + Mode: "sqlite", + Action: "skipped", + Reason: "SQLite journal state is written through `loaf session log`; Markdown JSONL enrichment is a compatibility path and is not run in SQLite mode.", Counts: map[string]int{ "sessions": len(sessions.Sessions), }, @@ -5205,11 +7085,12 @@ func (r Runner) runSessionHousekeeping(args []string, out io.Writer, runtime sta return err } summary := compatibilityCommandSummary{ - Version: 1, - Command: "session housekeeping", - Mode: "sqlite", - Action: "skipped", - Reason: "SQLite session lifecycle is maintained by native `loaf session start/end/archive/log`; markdown session housekeeping is a compatibility cleanup path and is not run in SQLite mode.", + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: "session housekeeping", + Mode: "sqlite", + Action: "skipped", + Reason: "SQLite session lifecycle is maintained by native `loaf session start/end/archive/log`; markdown session housekeeping is a compatibility cleanup path and is not run in SQLite mode.", Counts: map[string]int{ "sessions": len(sessions.Sessions), }, @@ -5783,12 +7664,13 @@ func markdownCompatibilitySummary(rootPath string, command string, mode string, counts[status]++ } return compatibilityCommandSummary{ - Version: 1, - Command: command, - Mode: mode, - Action: action, - Reason: reason, - Counts: counts, + ContractVersion: state.StateJSONContractVersion, + Version: 1, + Command: command, + Mode: mode, + Action: action, + Reason: reason, + Counts: counts, }, nil } @@ -6442,6 +8324,7 @@ func (r Runner) runSessionList(args []string, out io.Writer, runtime state.Runti if err != nil { return err } + sessions.Diagnostics = stateListWarnings(status.Diagnostics) if options.jsonOutput { return writeJSON(out, sessions) } @@ -6520,6 +8403,7 @@ func (r Runner) runSessionLog(args []string, out io.Writer, runtime state.Runtim return writeJSON(out, result) } fmt.Fprintf(out, "logged journal entry: %s\n", result.ID) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) return nil } @@ -6844,9 +8728,6 @@ func (r Runner) runSessionReport(args []string, out io.Writer, runtime state.Run if err != nil { return err } - if jsonOutput { - return fmt.Errorf("session report does not support --json") - } projectRoot, err := project.ResolveRoot(runtime.RootPath()) if err != nil { return err @@ -6855,6 +8736,10 @@ func (r Runner) runSessionReport(args []string, out io.Writer, runtime state.Run if err != nil { return err } + if jsonOutput { + result.Command = "session report" + return writeJSON(out, result) + } fmt.Fprint(out, result.Content) return nil } @@ -6994,6 +8879,10 @@ func nestedStringMapValue(values map[string]any, keys ...string) string { func writeSessionStart(out io.Writer, branch string, result state.SessionStartResult) { fmt.Fprint(out, "\n loaf session start\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" || result.DatabasePath != "" || result.ProjectID != "" || result.ProjectName != "" || result.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } fmt.Fprintf(out, " Branch: %s\n", branch) fmt.Fprintf(out, " Action: %s\n", result.Action) fmt.Fprintf(out, " Session: %s\n", firstNonEmpty(result.Session.Alias, result.Session.ID)) @@ -7013,6 +8902,10 @@ func writeSessionStart(out io.Writer, branch string, result state.SessionStartRe func writeSessionEnd(out io.Writer, result state.SessionEndResult) { fmt.Fprint(out, "\n loaf session end\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" || result.DatabasePath != "" || result.ProjectID != "" || result.ProjectName != "" || result.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } fmt.Fprintf(out, " Action: %s\n", result.Action) if result.NoopReason != "" { fmt.Fprintf(out, " Reason: %s\n", result.NoopReason) @@ -7029,6 +8922,10 @@ func writeSessionEnd(out io.Writer, result state.SessionEndResult) { func writeSessionArchive(out io.Writer, result state.SessionArchiveResult) { fmt.Fprint(out, "\n loaf session archive\n\n") + writeProjectMutationContext(out, " ", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) + if result.DatabaseScope != "" || result.DatabasePath != "" || result.ProjectID != "" || result.ProjectName != "" || result.ProjectCurrentPath != "" { + fmt.Fprintln(out) + } fmt.Fprintf(out, " Action: %s\n", result.Action) fmt.Fprintf(out, " Session: %s\n", firstNonEmpty(result.Session.Alias, result.Session.ID)) fmt.Fprintf(out, " Status: %s\n", result.Session.Status) @@ -7043,6 +8940,11 @@ func writeSessionList(out io.Writer, sessions state.SessionList, filters state.S archived := sortedSessionsByArchivedState(sessions, true) fmt.Fprint(out, "\n loaf session list\n\n") + writeProjectMutationContext(out, " ", sessions.DatabaseScope, sessions.DatabasePath, sessions.ProjectID, sessions.ProjectName, sessions.ProjectCurrentPath) + writeStateDiagnostics(out, " ", sessions.Diagnostics) + if sessionListHasContext(sessions) { + fmt.Fprintln(out) + } if len(active) == 0 { fmt.Fprint(out, " No active sessions found.\n") } else { @@ -7093,6 +8995,7 @@ func writeSessionList(out io.Writer, sessions state.SessionList, filters state.S func writeSessionShow(out io.Writer, result state.SessionShow) { session := result.Session fmt.Fprintf(out, "session %s\n", firstNonEmpty(session.Alias, session.ID)) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if session.Branch != "" { fmt.Fprintf(out, "branch: %s\n", session.Branch) } @@ -7136,12 +9039,17 @@ func writeSessionShow(out io.Writer, result state.SessionShow) { } func (r Runner) runReport(args []string, out io.Writer, runtime state.Runtime) error { - if len(args) == 0 { + if len(args) == 0 || isHelpArg(args) { writeReportHelp(out) return nil } - if len(args) == 1 && (args[0] == "--help" || args[0] == "-h" || args[0] == "help") { - writeReportHelp(out) + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "list": writeReportListHelp, + "generate": writeReportGenerateHelp, + "create": writeReportCreateHelp, + "finalize": writeReportFinalizeHelp, + "archive": writeReportArchiveHelp, + }) { return nil } switch args[0] { @@ -7160,6 +9068,15 @@ func (r Runner) runReport(args []string, out io.Writer, runtime state.Runtime) e } } +func sessionListHasContext(sessions state.SessionList) bool { + return sessions.DatabaseScope != "" || + sessions.DatabasePath != "" || + sessions.ProjectID != "" || + sessions.ProjectName != "" || + sessions.ProjectCurrentPath != "" || + len(sessions.Diagnostics) > 0 +} + func writeReportHelp(out io.Writer) { fmt.Fprintln(out, "Usage: loaf report <subcommand> [options]") fmt.Fprintln(out) @@ -7176,6 +9093,26 @@ func writeReportHelp(out io.Writer) { fmt.Fprintln(out, " -h, --help Show help") } +func writeReportListHelp(out io.Writer) { + writeUsageHelp(out, "loaf report list [--type <type>|--status <status>] [--json]", "List reports.", "--type Filter by report type", "--status Filter by status; Loaf lifecycle statuses: draft, final, archived", "--json Output reports, diagnostics, global database scope, and project identity as JSON") +} + +func writeReportGenerateHelp(out io.Writer) { + writeUsageHelp(out, "loaf report generate <kind> [ref] [--format markdown] [--json]", "Generate a read-only markdown report.", "--format Output format: markdown", "--json Output contract, command, project context, and markdown content as JSON") +} + +func writeReportCreateHelp(out io.Writer) { + writeUsageHelp(out, "loaf report create <slug> [--type <type>] [--source <source>] [--json]", "Create a report.", "--type Report type", "--source Report source", "--json Output created report, event, global database scope, and project identity as JSON") +} + +func writeReportFinalizeHelp(out io.Writer) { + writeUsageHelp(out, "loaf report finalize <report> [--json]", "Finalize a report.", "--json Output report status transition, event, global database scope, and project identity as JSON") +} + +func writeReportArchiveHelp(out io.Writer) { + writeUsageHelp(out, "loaf report archive <report> [--json]", "Archive a report.", "--json Output report status transition, event, global database scope, and project identity as JSON") +} + func (r Runner) runReportList(args []string, out io.Writer, runtime state.Runtime) error { options, err := parseReportListArgs(args) if err != nil { @@ -7208,6 +9145,7 @@ func (r Runner) runReportList(args []string, out io.Writer, runtime state.Runtim if err != nil { return err } + reports.Diagnostics = stateListWarnings(status.Diagnostics) if options.jsonOutput { return writeJSON(out, reports) } @@ -7237,7 +9175,14 @@ func (r Runner) runReportGenerate(args []string, out io.Writer, runtime state.Ru return fmt.Errorf("report generate kind %q is not implemented yet", options.kind) } if err != nil { - return err + if options.jsonOutput { + return err + } + return r.withStateMissingContext(err, projectRoot) + } + if options.jsonOutput { + result.Command = "report generate " + options.kind + return writeJSON(out, result) } fmt.Fprint(out, result.Content) return nil @@ -7366,6 +9311,7 @@ func (r Runner) reportStateMode(runtime state.Runtime) (project.Root, string, er func writeReportCreate(out io.Writer, result state.ReportCreateResult) { fmt.Fprintf(out, "created report %s: %s\n", firstNonEmpty(result.Report.Alias, result.Report.ID), result.Report.Title) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "status: %s\n", result.Report.Status) fmt.Fprintf(out, "type: %s\n", result.Kind) fmt.Fprintf(out, "source: %s\n", result.Source) @@ -7376,6 +9322,7 @@ func writeReportCreate(out io.Writer, result state.ReportCreateResult) { func writeReportStatus(out io.Writer, action string, result state.ReportStatusResult) { fmt.Fprintf(out, "%s report %s: %s\n", action, firstNonEmpty(result.Report.Alias, result.Report.ID), result.Report.Title) + writeProjectMutationContext(out, "", result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) fmt.Fprintf(out, "previous: %s\n", result.Previous) fmt.Fprintf(out, "status: %s\n", result.Status) if result.EventID != "" { @@ -7384,12 +9331,21 @@ func writeReportStatus(out io.Writer, action string, result state.ReportStatusRe } func writeReportList(out io.Writer, reports state.ReportList) { + fmt.Fprint(out, "\n loaf report list\n\n") + writeProjectMutationContext(out, " ", reports.DatabaseScope, reports.DatabasePath, reports.ProjectID, reports.ProjectName, reports.ProjectCurrentPath) + writeStateDiagnostics(out, " ", reports.Diagnostics) + if len(reports.Reports) == 0 { - fmt.Fprint(out, "\n No reports found.\n\n") + if reportListHasContext(reports) { + fmt.Fprintln(out) + } + fmt.Fprint(out, " No reports found.\n\n") return } - fmt.Fprint(out, "\n loaf report list\n\n") + if reportListHasContext(reports) { + fmt.Fprintln(out) + } for _, status := range reportStatusDisplayOrder(reports) { group := sortedReportsByStatus(reports, status) if len(group) == 0 { @@ -7408,6 +9364,15 @@ func writeReportList(out io.Writer, reports state.ReportList) { fmt.Fprintf(out, " %d report(s) total\n\n", len(reports.Reports)) } +func reportListHasContext(reports state.ReportList) bool { + return reports.DatabaseScope != "" || + reports.DatabasePath != "" || + reports.ProjectID != "" || + reports.ProjectName != "" || + reports.ProjectCurrentPath != "" || + len(reports.Diagnostics) > 0 +} + func markdownReportList(rootPath string, options state.ReportListOptions) (state.ReportList, error) { agentsDir := filepath.Join(rootPath, ".agents") files, err := filepath.Glob(filepath.Join(agentsDir, "reports", "*.md")) @@ -7835,6 +9800,46 @@ func parseJSONOnly(args []string) (bool, error) { return jsonOutput, nil } +func parseStatePathArgs(args []string) (statePathOptions, error) { + var options statePathOptions + for _, arg := range args { + switch arg { + case "--json": + options.jsonOutput = true + case "--verbose": + options.verboseOutput = true + default: + return statePathOptions{}, fmt.Errorf("unknown option %q", arg) + } + } + if options.jsonOutput && options.verboseOutput { + return statePathOptions{}, fmt.Errorf("state path cannot combine --json and --verbose") + } + return options, nil +} + +func parseStateBackupVerifyArgs(args []string) (backupVerifyOptions, error) { + var options backupVerifyOptions + for _, arg := range args { + switch arg { + case "--json": + options.jsonOutput = true + default: + if strings.HasPrefix(arg, "-") { + return backupVerifyOptions{}, fmt.Errorf("unknown option %q", arg) + } + if options.path != "" { + return backupVerifyOptions{}, fmt.Errorf("state backup verify accepts exactly one backup path") + } + options.path = arg + } + } + if options.path == "" { + return backupVerifyOptions{}, fmt.Errorf("state backup verify requires a backup path") + } + return options, nil +} + func parseCompatibilityCommandArgs(command string, args []string, allowedFlags map[string]bool) (compatibilityCommandOptions, error) { var options compatibilityCommandOptions positional := 0 @@ -7912,6 +9917,7 @@ func parseStateExportArgs(args []string) (stateExportOptions, error) { return stateExportOptions{}, fmt.Errorf("state export requires a kind") } options := stateExportOptions{kind: args[0]} + jsonAliasRequested := false var positional []string for i := 1; i < len(args); i++ { arg := args[i] @@ -7921,9 +9927,25 @@ func parseStateExportArgs(args []string) (stateExportOptions, error) { if err != nil { return stateExportOptions{}, err } + if jsonAliasRequested && value != state.ExportFormatJSON { + return stateExportOptions{}, fmt.Errorf("state export all cannot combine --json with --format %s", value) + } options.format = value case strings.HasPrefix(arg, "--format="): - options.format = strings.TrimPrefix(arg, "--format=") + value := strings.TrimPrefix(arg, "--format=") + if jsonAliasRequested && value != state.ExportFormatJSON { + return stateExportOptions{}, fmt.Errorf("state export all cannot combine --json with --format %s", value) + } + options.format = value + case arg == "--json": + jsonAliasRequested = true + if options.kind != state.ExportKindAll { + return stateExportOptions{}, fmt.Errorf("state export %s uses --format markdown; --json is only supported for state export all", options.kind) + } + if options.format != "" && options.format != state.ExportFormatJSON { + return stateExportOptions{}, fmt.Errorf("state export all cannot combine --json with --format %s", options.format) + } + options.format = state.ExportFormatJSON default: if strings.HasPrefix(arg, "-") { return stateExportOptions{}, fmt.Errorf("unknown option %q", arg) @@ -7971,20 +9993,43 @@ func parseStateExportArgs(args []string) (stateExportOptions, error) { } } -func parseDoctorArgs(args []string) (bool, bool, error) { +func stateExportJSONRequested(args []string) bool { + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--format": + if i+1 < len(args) && args[i+1] == state.ExportFormatJSON { + return true + } + i++ + case strings.HasPrefix(arg, "--format="): + if strings.TrimPrefix(arg, "--format=") == state.ExportFormatJSON { + return true + } + case arg == "--json": + return true + } + } + return false +} + +func parseDoctorArgs(args []string) (bool, bool, bool, error) { jsonOutput := false fix := false + dryRun := false for _, arg := range args { switch arg { case "--json": jsonOutput = true case "--fix": fix = true + case "--dry-run": + dryRun = true default: - return false, false, fmt.Errorf("unknown option %q", arg) + return false, false, false, fmt.Errorf("unknown option %q", arg) } } - return jsonOutput, fix, nil + return jsonOutput, fix, dryRun, nil } func parseTraceArgs(args []string) (string, bool, error) { @@ -8171,8 +10216,10 @@ type reportCreateOptions struct { } type reportGenerateOptions struct { - kind string - ref string + kind string + ref string + format string + jsonOutput bool } func parseTaskListArgs(args []string) (taskListOptions, error) { @@ -8189,7 +10236,7 @@ func parseTaskListArgs(args []string) (taskListOptions, error) { return taskListOptions{}, err } if !state.ValidTaskListStatus(value) { - return taskListOptions{}, fmt.Errorf("invalid status %q", value) + return taskListOptions{}, fmt.Errorf("invalid status %q (valid: %s)", value, validTaskListStatusText()) } options.filters.Status = value default: @@ -8223,7 +10270,7 @@ func parseTaskCreateArgs(args []string) (taskCreateOptions, error) { return taskCreateOptions{}, err } if !state.ValidTaskPriority(value) { - return taskCreateOptions{}, fmt.Errorf("invalid priority %q", value) + return taskCreateOptions{}, fmt.Errorf("invalid priority %q (valid: %s)", value, validTaskPriorityText()) } options.create.Priority = value case "--depends-on": @@ -8255,7 +10302,7 @@ func parseTaskUpdateArgs(args []string) (taskUpdateOptions, error) { return taskUpdateOptions{}, err } if !state.ValidTaskStatus(value) { - return taskUpdateOptions{}, fmt.Errorf("invalid status %q", value) + return taskUpdateOptions{}, fmt.Errorf("invalid status %q (valid: %s)", value, validTaskStatusText()) } options.update.Status = value options.update.SetStatus = true @@ -8265,7 +10312,7 @@ func parseTaskUpdateArgs(args []string) (taskUpdateOptions, error) { return taskUpdateOptions{}, err } if !state.ValidTaskPriority(value) { - return taskUpdateOptions{}, fmt.Errorf("invalid priority %q", value) + return taskUpdateOptions{}, fmt.Errorf("invalid priority %q (valid: %s)", value, validTaskPriorityText()) } options.update.Priority = value options.update.SetPriority = true @@ -8858,6 +10905,18 @@ func parseLinkMutationArgs(command string, args []string) (linkMutationOptions, return linkMutationOptions{}, err } options.reason = value + case "--from": + value, err := consumeFlagValue(args, &i, "--from") + if err != nil { + return linkMutationOptions{}, err + } + options.from = value + case "--to": + value, err := consumeFlagValue(args, &i, "--to") + if err != nil { + return linkMutationOptions{}, err + } + options.to = value default: if strings.HasPrefix(args[i], "-") { return linkMutationOptions{}, fmt.Errorf("unknown option %q", args[i]) @@ -8865,14 +10924,22 @@ func parseLinkMutationArgs(command string, args []string) (linkMutationOptions, positional = append(positional, args[i]) } } - if len(positional) != 2 { + if len(positional) > 0 { + if options.from != "" || options.to != "" { + return linkMutationOptions{}, fmt.Errorf("%s cannot mix positional entities with --from or --to", command) + } + if len(positional) != 2 { + return linkMutationOptions{}, fmt.Errorf("%s requires a source entity and target entity", command) + } + options.from = positional[0] + options.to = positional[1] + } + if options.from == "" || options.to == "" { return linkMutationOptions{}, fmt.Errorf("%s requires a source entity and target entity", command) } if options.relationshipType == "" { return linkMutationOptions{}, fmt.Errorf("%s requires --type", command) } - options.from = positional[0] - options.to = positional[1] return options, nil } @@ -9052,24 +11119,43 @@ func parseReportCreateArgs(args []string) (reportCreateOptions, error) { } func parseReportGenerateArgs(args []string) (reportGenerateOptions, error) { - if len(args) == 0 { - return reportGenerateOptions{}, fmt.Errorf("report generate requires a kind") - } - options := reportGenerateOptions{kind: args[0]} - positional := args[1:] - for _, arg := range positional { - if strings.HasPrefix(arg, "-") { + options := reportGenerateOptions{format: state.ExportFormatMarkdown} + positional := []string{} + for i := 0; i < len(args); i++ { + arg := args[i] + switch { + case arg == "--json": + options.jsonOutput = true + case arg == "--format": + value, err := consumeFlagValue(args, &i, "--format") + if err != nil { + return reportGenerateOptions{}, err + } + options.format = value + case strings.HasPrefix(arg, "--format="): + options.format = strings.TrimPrefix(arg, "--format=") + case strings.HasPrefix(arg, "-"): return reportGenerateOptions{}, fmt.Errorf("unknown option %q", arg) + default: + positional = append(positional, arg) } } + if len(positional) == 0 { + return reportGenerateOptions{}, fmt.Errorf("report generate requires a kind") + } + if options.format != state.ExportFormatMarkdown { + return reportGenerateOptions{}, fmt.Errorf("report generate supports only --format markdown") + } + options.kind = positional[0] + refs := positional[1:] switch options.kind { case state.ExportKindSession: - if len(positional) != 1 { + if len(refs) != 1 { return reportGenerateOptions{}, fmt.Errorf("report generate session requires exactly one session") } - options.ref = positional[0] + options.ref = refs[0] case state.ExportKindTriage, state.ExportKindReleaseReadiness: - if len(positional) != 0 { + if len(refs) != 0 { return reportGenerateOptions{}, fmt.Errorf("report generate %s does not accept positional arguments", options.kind) } default: @@ -9082,13 +11168,25 @@ func taskStatusDisplayOrder(filters state.TaskListOptions) []string { if filters.Status != "" { return []string{filters.Status} } - statuses := []string{"in_progress", "blocked", "todo", "review", "done", "archived"} + statuses := state.TaskListStatuses() if !filters.Active { return statuses } return statuses[:len(statuses)-2] } +func validTaskStatusText() string { + return strings.Join(state.TaskStatuses(), ", ") +} + +func validTaskListStatusText() string { + return strings.Join(state.TaskListStatuses(), ", ") +} + +func validTaskPriorityText() string { + return strings.Join(state.TaskPriorities(), ", ") +} + func sortedTasksByStatus(tasks state.TaskList, status string) []string { var ids []string for id, task := range tasks.Tasks { @@ -9384,7 +11482,20 @@ type storageHomeMigrationOptions struct { dryRun bool } -func parseMarkdownMigrationArgs(args []string) (markdownMigrationOptions, error) { +type relationshipOriginRepairOptions struct { + jsonOutput bool + apply bool + dryRun bool + origin string +} + +type legacyProjectDatabaseRepairOptions struct { + jsonOutput bool + apply bool + dryRun bool +} + +func parseMarkdownMigrationArgs(args []string, command string) (markdownMigrationOptions, error) { var options markdownMigrationOptions for _, arg := range args { switch arg { @@ -9401,18 +11512,18 @@ func parseMarkdownMigrationArgs(args []string) (markdownMigrationOptions, error) } } if options.apply && options.dryRun { - return markdownMigrationOptions{}, fmt.Errorf("state migrate markdown cannot combine --apply and --dry-run") + return markdownMigrationOptions{}, fmt.Errorf("%s cannot combine --apply and --dry-run", command) } if options.resume && options.dryRun { - return markdownMigrationOptions{}, fmt.Errorf("state migrate markdown cannot combine --resume and --dry-run") + return markdownMigrationOptions{}, fmt.Errorf("%s cannot combine --resume and --dry-run", command) } if options.resume && options.apply { - return markdownMigrationOptions{}, fmt.Errorf("state migrate markdown cannot combine --resume and --apply") + return markdownMigrationOptions{}, fmt.Errorf("%s cannot combine --resume and --apply", command) } return options, nil } -func parseStorageHomeMigrationArgs(args []string) (storageHomeMigrationOptions, error) { +func parseStorageHomeMigrationArgs(args []string, command string) (storageHomeMigrationOptions, error) { var options storageHomeMigrationOptions for _, arg := range args { switch arg { @@ -9427,8 +11538,143 @@ func parseStorageHomeMigrationArgs(args []string) (storageHomeMigrationOptions, } } if options.apply && options.dryRun { - return storageHomeMigrationOptions{}, fmt.Errorf("state migrate storage-home cannot combine --apply and --dry-run") + return storageHomeMigrationOptions{}, fmt.Errorf("%s cannot combine --apply and --dry-run", command) + } + return options, nil +} + +func parseLegacyProjectDatabaseRepairArgs(args []string) (legacyProjectDatabaseRepairOptions, error) { + var options legacyProjectDatabaseRepairOptions + for _, arg := range args { + switch arg { + case "--dry-run": + options.dryRun = true + case "--json": + options.jsonOutput = true + case "--apply": + options.apply = true + default: + return legacyProjectDatabaseRepairOptions{}, fmt.Errorf("unknown option %q", arg) + } + } + if options.apply && options.dryRun { + return legacyProjectDatabaseRepairOptions{}, fmt.Errorf("state repair legacy-project-database cannot combine --apply and --dry-run") + } + return options, nil +} + +func parseRelationshipOriginRepairArgs(args []string) (relationshipOriginRepairOptions, error) { + var options relationshipOriginRepairOptions + for i := 0; i < len(args); i++ { + arg := args[i] + switch arg { + case "--dry-run": + options.dryRun = true + case "--json": + options.jsonOutput = true + case "--apply": + options.apply = true + case "--origin": + value, err := consumeFlagValue(args, &i, arg) + if err != nil { + return relationshipOriginRepairOptions{}, err + } + options.origin = value + default: + return relationshipOriginRepairOptions{}, fmt.Errorf("unknown option %q", arg) + } + } + if options.apply && options.dryRun { + return relationshipOriginRepairOptions{}, fmt.Errorf("state repair relationship-origin cannot combine --apply and --dry-run") + } + if options.origin == "" { + return relationshipOriginRepairOptions{}, fmt.Errorf("state repair relationship-origin requires --origin imported|manual") + } + if options.origin != "imported" && options.origin != "manual" { + return relationshipOriginRepairOptions{}, fmt.Errorf("relationship origin must be imported or manual") + } + return options, nil +} + +func parseProjectRenameArgs(args []string) (projectRenameOptions, error) { + options := projectRenameOptions{} + values := []string{} + for _, arg := range args { + switch arg { + case "--json": + options.jsonOutput = true + case "--dry-run": + options.dryRun = true + default: + values = append(values, arg) + } } + if len(values) == 0 { + return projectRenameOptions{}, fmt.Errorf("project rename requires a name") + } + if len(values) > 1 { + return projectRenameOptions{}, fmt.Errorf("project rename accepts exactly one name") + } + options.name = values[0] + return options, nil +} + +func parseProjectMoveArgs(args []string, currentPath string) (projectMoveOptions, error) { + options := projectMoveOptions{toPath: currentPath} + positionals := []string{} + fromFlagSet := false + toFlagSet := false + for i := 0; i < len(args); i++ { + arg := args[i] + switch arg { + case "--json": + options.jsonOutput = true + case "--dry-run": + options.dryRun = true + case "--from": + i++ + if i >= len(args) { + return projectMoveOptions{}, fmt.Errorf("--from requires a value") + } + options.fromPath = args[i] + fromFlagSet = true + case "--to": + i++ + if i >= len(args) { + return projectMoveOptions{}, fmt.Errorf("--to requires a value") + } + options.toPath = args[i] + toFlagSet = true + default: + if strings.HasPrefix(arg, "-") { + return projectMoveOptions{}, fmt.Errorf("unknown option %q", arg) + } + positionals = append(positionals, arg) + } + } + if len(positionals) > 0 { + if fromFlagSet || toFlagSet { + return projectMoveOptions{}, fmt.Errorf("project move cannot mix positional paths with --from or --to") + } + if len(positionals) > 2 { + return projectMoveOptions{}, fmt.Errorf("project move accepts at most <from> and [to] paths") + } + options.fromPath = positionals[0] + if len(positionals) == 2 { + options.toPath = positionals[1] + } + } + if options.fromPath == "" { + return projectMoveOptions{}, fmt.Errorf("project move requires <from> or --from") + } + if options.toPath == "" { + return projectMoveOptions{}, fmt.Errorf("project move requires [to], --to, or a current project root") + } + if !filepath.IsAbs(options.fromPath) || !filepath.IsAbs(options.toPath) { + return projectMoveOptions{}, fmt.Errorf("project move requires absolute from and to paths") + } + options.fromPath = filepath.Clean(options.fromPath) + options.toPath = filepath.Clean(options.toPath) return options, nil } @@ -9438,6 +11684,29 @@ func writeJSON(out io.Writer, value any) error { return encoder.Encode(value) } +func writeJSONCommandError(out io.Writer, command string, err error) error { + if writeErr := writeJSON(out, commandErrorJSON{ + ContractVersion: state.StateJSONContractVersion, + Command: command, + Error: err.Error(), + }); writeErr != nil { + return writeErr + } + return ExitError{Code: 1} +} + +func writeStateBackupVerifyJSONError(out io.Writer, backupPath string, err error) error { + if writeErr := writeJSON(out, commandErrorJSON{ + ContractVersion: state.StateJSONContractVersion, + Command: "state backup verify", + Error: err.Error(), + BackupPath: backupPath, + }); writeErr != nil { + return writeErr + } + return ExitError{Code: 1} +} + func firstNonEmpty(values ...string) string { for _, value := range values { if value != "" { diff --git a/internal/cli/cli_reference.go b/internal/cli/cli_reference.go index 85f20cbe..bdba29e4 100644 --- a/internal/cli/cli_reference.go +++ b/internal/cli/cli_reference.go @@ -88,30 +88,77 @@ func cliReferenceCommands() []cliReferenceCommand { Name: "state", Description: "Manage native SQLite state", Subcommands: []cliReferenceSubcommand{ - {Name: "path", Description: "Print the resolved SQLite database path"}, + {Name: "path", Description: "Print the resolved SQLite database path", Options: []cliReferenceOption{ + {Flags: "--json", Description: "Output contract version, database path, scope, and project root as JSON"}, + {Flags: "--verbose", Description: "Output command, scope, project root, and database path"}, + }}, {Name: "status", Description: "Show SQLite readiness and markdown-only compatibility status", Options: []cliReferenceOption{ - {Flags: "--json", Description: "Output status as JSON"}, + {Flags: "--json", Description: "Output readiness mode, diagnostics, global database scope, and project identity as JSON"}, }}, {Name: "init", Description: "Initialize an empty SQLite state database", Options: []cliReferenceOption{ - {Flags: "--json", Description: "Output initialized status as JSON"}, + {Flags: "--json", Description: "Output initialized status, global database scope, and project identity as JSON"}, }}, {Name: "doctor", Description: "Diagnose SQLite state health", Options: []cliReferenceOption{ {Flags: "--fix", Description: "Initialize missing SQLite state when safe"}, - {Flags: "--json", Description: "Output diagnostics as JSON"}, + {Flags: "--dry-run", Description: "Show the repair plan without applying fixes"}, + {Flags: "--json", Description: "Output diagnostics, repair plan, global database scope, and project identity as JSON"}, + }}, + {Name: "repair legacy-project-database", Description: "Archive migrated per-project SQLite leftovers", Options: []cliReferenceOption{ + {Flags: "--dry-run", Description: "Preview archive paths without writing"}, + {Flags: "--apply", Description: "Move legacy SQLite files into the archive directory"}, + {Flags: "--json", Description: "Output archive plan/result, global database scope, and project identity as JSON"}, + }}, + {Name: "repair relationship-origin", Description: "Preview or apply guarded relationship provenance backfills", Options: []cliReferenceOption{ + {Flags: "--origin <imported|manual>", Description: "Provenance value to backfill"}, + {Flags: "--dry-run", Description: "Preview affected rows without writing"}, + {Flags: "--apply", Description: "Backfill missing origins after creating a SQLite backup"}, + {Flags: "--json", Description: "Output repair plan/result, global database scope, and project identity as JSON"}, }}, {Name: "migrate markdown", Description: "Import existing .agents Markdown artifacts into SQLite", Options: []cliReferenceOption{ {Flags: "--dry-run", Description: "Preview import counts without creating a database"}, {Flags: "--apply", Description: "Initialize SQLite and import Markdown artifacts"}, {Flags: "--resume", Description: "Resume the Markdown import after an interrupted attempt"}, - {Flags: "--json", Description: "Output migration details as JSON"}, + {Flags: "--json", Description: "Output migration contract, scope, project context, and counts as JSON"}, }}, {Name: "migrate storage-home", Description: "Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME", Options: []cliReferenceOption{ {Flags: "--dry-run", Description: "Preview the storage-home migration"}, {Flags: "--apply", Description: "Copy the legacy database without deleting it"}, - {Flags: "--json", Description: "Output migration details as JSON"}, + {Flags: "--json", Description: "Output migration contract, global database paths, action, and project identity when available"}, + }}, + {Name: "backup", Description: "Create a SQLite database backup under the global data-home backups directory", Options: []cliReferenceOption{{Flags: "--json", Description: "Output backup verification, checksum, schema version, project count, and current project identity as JSON"}}}, + {Name: "backup verify", Description: "Verify an existing SQLite database backup", Options: []cliReferenceOption{{Flags: "--json", Description: "Output backup verification, restore guidance, schema version, and captured project identities as JSON"}}}, + {Name: "export", Description: "Export SQLite state for review or migration", Options: []cliReferenceOption{{Flags: "--format <format>", Description: "Output format for the selected export kind"}}}, + {Name: "export all", Description: "Export a complete project-scoped SQLite snapshot", Options: []cliReferenceOption{{Flags: "--format <format>", Description: "Output format: json"}, {Flags: "--json", Description: "Alias for --format json"}}}, + {Name: "export triage", Description: "Export a triage summary from SQLite state", Options: []cliReferenceOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + {Name: "export session", Description: "Export one session from SQLite state", Options: []cliReferenceOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + {Name: "export spec", Description: "Export one spec from SQLite state", Options: []cliReferenceOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + {Name: "export release-readiness", Description: "Export a release-readiness report from SQLite state", Options: []cliReferenceOption{{Flags: "--format <format>", Description: "Output format: markdown"}}}, + }, + }, + { + Name: "project", + Description: "Manage durable project identity", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "List registered projects in the global SQLite database", Options: []cliReferenceOption{ + {Flags: "--json", Description: "Output database path, project IDs, friendly names, and current paths as JSON"}, + }}, + {Name: "show", Description: "Show the current project identity", Options: []cliReferenceOption{ + {Flags: "--json", Description: "Output project ID, friendly name, current path, and database path as JSON"}, + }}, + {Name: "identity", Description: "Alias for project show", Options: []cliReferenceOption{ + {Flags: "--json", Description: "Output project ID, friendly name, current path, and database path as JSON"}, + }}, + {Name: "rename", Description: "Rename the friendly project name", Options: []cliReferenceOption{ + {Flags: "--dry-run", Description: "Validate and preview without writing"}, + {Flags: "--json", Description: "Output project ID, friendly name, current path, database path, and applied status as JSON"}, + }}, + {Name: "move", Description: "Record a checkout path move", Options: []cliReferenceOption{ + {Flags: "<from> [to]", Description: "Previous and optional new absolute project paths"}, + {Flags: "--from <path>", Description: "Previous absolute project path"}, + {Flags: "--to <path>", Description: "New absolute project path; defaults to the current project root"}, + {Flags: "--dry-run", Description: "Validate and preview without writing"}, + {Flags: "--json", Description: "Output project ID, friendly name, current path, database path, and applied status as JSON"}, }}, - {Name: "backup", Description: "Create a SQLite database backup", Options: []cliReferenceOption{{Flags: "--json", Description: "Output backup details as JSON"}}}, - {Name: "export", Description: "Export SQLite state for review or migration"}, }, }, { @@ -122,14 +169,18 @@ func cliReferenceCommands() []cliReferenceCommand { {Flags: "--dry-run", Description: "Preview import counts without creating a database"}, {Flags: "--apply", Description: "Initialize SQLite and import Markdown artifacts"}, {Flags: "--resume", Description: "Resume the Markdown import after an interrupted attempt"}, - {Flags: "--json", Description: "Output migration details as JSON"}, + {Flags: "--json", Description: "Output migration contract, scope, project context, and counts as JSON"}, }}, {Name: "storage-home", Description: "Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME", Options: []cliReferenceOption{ {Flags: "--dry-run", Description: "Preview the storage-home migration"}, {Flags: "--apply", Description: "Copy the legacy database without deleting it"}, - {Flags: "--json", Description: "Output migration details as JSON"}, + {Flags: "--json", Description: "Output migration contract, global database paths, action, and project identity when available"}, + }}, + {Name: "worktree-storage", Description: "Move linked-worktree .agents state to the main worktree", Options: []cliReferenceOption{ + {Flags: "--apply", Description: "Perform the migration; dry-run is the default"}, + {Flags: "--force-from-worktree", Description: "On conflict, keep the worktree-local copy"}, + {Flags: "--force-from-main", Description: "On conflict, keep the main-worktree copy"}, }}, - {Name: "worktree-storage", Description: "Move linked-worktree .agents state to the main worktree"}, }, }, { @@ -137,34 +188,40 @@ func cliReferenceCommands() []cliReferenceCommand { Description: "Manage project tasks", Subcommands: []cliReferenceSubcommand{ {Name: "list", Description: "Show task board grouped by status", Options: []cliReferenceOption{ - {Flags: "--json", Description: "Output raw JSON"}, + {Flags: "--json", Description: "Output tasks, diagnostics, global database scope, and project identity as JSON"}, {Flags: "--active", Description: "Hide completed tasks"}, - {Flags: "--status <status>", Description: "Only show tasks with status: in_progress, blocked, todo, review, done"}, + {Flags: "--status <status>", Description: "Only show tasks with status: " + validTaskListStatusText()}, }}, {Name: "show", Description: "Display a single task's details", Options: []cliReferenceOption{ - {Flags: "--json", Description: "Output task entry as JSON"}, + {Flags: "--json", Description: "Output task details, relationships, global database scope, and project identity as JSON"}, }}, {Name: "status", Description: "Show task summary counts"}, {Name: "create", Description: "Create a new task", Options: []cliReferenceOption{ {Flags: "--title <title>", Description: "Task title"}, {Flags: "--spec <id>", Description: "Associated spec ID (e.g., SPEC-010)"}, - {Flags: "--priority <level>", Description: "Priority level (P0/P1/P2/P3)"}, + {Flags: "--priority <level>", Description: "Priority level: " + validTaskPriorityText()}, {Flags: "--depends-on <ids>", Description: "Comma-separated task IDs"}, + {Flags: "--json", Description: "Output created task, event, global database scope, and project identity as JSON"}, }}, {Name: "update", Description: "Update a task's metadata", Options: []cliReferenceOption{ - {Flags: "--status <status>", Description: "New status: todo, in_progress, blocked, review, done"}, - {Flags: "--priority <level>", Description: "New priority: P0, P1, P2, P3"}, + {Flags: "--status <status>", Description: "New status: " + validTaskStatusText()}, + {Flags: "--priority <level>", Description: "New priority: " + validTaskPriorityText()}, {Flags: "--depends-on <ids>", Description: "Replace depends_on (comma-separated task IDs)"}, {Flags: "--session <file>", Description: `Set or clear session reference (use "none" to clear)`}, {Flags: "--spec <id>", Description: "Set or change associated spec"}, + {Flags: "--json", Description: "Output updated task, event, global database scope, and project identity as JSON"}, }}, {Name: "archive", Description: "Archive completed tasks through the task lifecycle", Options: []cliReferenceOption{ {Flags: "--spec <id>", Description: "Archive all done tasks for a spec"}, + {Flags: "--json", Description: "Output archive result, archived tasks, global database scope, and project identity as JSON"}, + }}, + {Name: "refresh", Description: "Compatibility: rebuild the Markdown task index from task/spec files", Options: []cliReferenceOption{ + {Flags: "--json", Description: "Output compatibility summary as JSON"}, }}, - {Name: "refresh", Description: "Compatibility: rebuild the Markdown task index from task/spec files"}, {Name: "sync", Description: "Compatibility: sync the Markdown task index and task files", Options: []cliReferenceOption{ {Flags: "--import", Description: "Import orphan .md files not in the index"}, {Flags: "--push", Description: "Push compatibility index metadata into .md frontmatter"}, + {Flags: "--json", Description: "Output compatibility summary as JSON"}, }}, }, }, @@ -172,9 +229,9 @@ func cliReferenceCommands() []cliReferenceCommand { Name: "spec", Description: "Manage project specs", Subcommands: []cliReferenceSubcommand{ - {Name: "list", Description: "Show specs with status and task counts", Options: []cliReferenceOption{{Flags: "--json", Description: "Output raw JSON"}}}, - {Name: "show", Description: "Show spec details", Options: []cliReferenceOption{{Flags: "--json", Description: "Output raw JSON"}}}, - {Name: "archive", Description: "Archive a completed spec", Options: []cliReferenceOption{{Flags: "--json", Description: "Output raw JSON"}}}, + {Name: "list", Description: "Show specs with status and task counts", Options: []cliReferenceOption{{Flags: "--json", Description: "Output specs, diagnostics, task counts, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show spec details", Options: []cliReferenceOption{{Flags: "--json", Description: "Output spec details, task counts, relationships, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive a completed spec", Options: []cliReferenceOption{{Flags: "--json", Description: "Output archive result, archived specs, global database scope, and project identity as JSON"}}}, }, }, { @@ -183,17 +240,20 @@ func cliReferenceCommands() []cliReferenceCommand { Subcommands: []cliReferenceSubcommand{ {Name: "list", Description: "List reports", Options: []cliReferenceOption{ {Flags: "--type <type>", Description: "Filter by report type"}, - {Flags: "--status <status>", Description: "Filter by status"}, - {Flags: "--json", Description: "Output as JSON"}, + {Flags: "--status <status>", Description: "Filter by status; Loaf lifecycle statuses: draft, final, archived"}, + {Flags: "--json", Description: "Output reports, diagnostics, global database scope, and project identity as JSON"}, + }}, + {Name: "generate", Description: "Generate a report from state", Options: []cliReferenceOption{ + {Flags: "--format <format>", Description: "Output format: markdown"}, + {Flags: "--json", Description: "Output contract, command, project context, and markdown content as JSON"}, }}, - {Name: "generate", Description: "Generate a report from state", Options: []cliReferenceOption{{Flags: "--format <format>", Description: "Output format"}}}, {Name: "create", Description: "Create a report draft", Options: []cliReferenceOption{ {Flags: "--type <type>", Description: "Report type"}, {Flags: "--source <source>", Description: "Report source"}, - {Flags: "--json", Description: "Output as JSON"}, + {Flags: "--json", Description: "Output created report, event, global database scope, and project identity as JSON"}, }}, - {Name: "finalize", Description: "Mark a report draft as final", Options: []cliReferenceOption{{Flags: "--json", Description: "Output as JSON"}}}, - {Name: "archive", Description: "Archive a finalized report", Options: []cliReferenceOption{{Flags: "--json", Description: "Output as JSON"}}}, + {Name: "finalize", Description: "Mark a report draft as final", Options: []cliReferenceOption{{Flags: "--json", Description: "Output report status transition, event, global database scope, and project identity as JSON"}}}, + {Name: "archive", Description: "Archive a finalized report", Options: []cliReferenceOption{{Flags: "--json", Description: "Output report status transition, event, global database scope, and project identity as JSON"}}}, }, }, { @@ -201,17 +261,17 @@ func cliReferenceCommands() []cliReferenceCommand { Description: "Knowledge base management", Subcommands: []cliReferenceSubcommand{ {Name: "glossary", Description: "Domain glossary mutation and lookup"}, - {Name: "validate", Description: "Validate knowledge file frontmatter", Options: []cliReferenceOption{{Flags: "--json", Description: "Output results as JSON"}}}, - {Name: "status", Description: "Show knowledge base overview", Options: []cliReferenceOption{{Flags: "--json", Description: "Output status as JSON"}}}, + {Name: "validate", Description: "Validate knowledge file frontmatter", Options: []cliReferenceOption{{Flags: "--json", Description: "Output per-file frontmatter errors and warnings as JSON"}}}, + {Name: "status", Description: "Show knowledge base overview", Options: []cliReferenceOption{{Flags: "--json", Description: "Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON"}}}, {Name: "check", Description: "Check knowledge file staleness against git history", Options: []cliReferenceOption{ {Flags: "--file <path>", Description: "Reverse lookup: find knowledge files covering this path"}, - {Flags: "--json", Description: "Output results as JSON"}, + {Flags: "--json", Description: "Output per-file staleness, coverage, commit, and review metadata as JSON"}, }}, - {Name: "review", Description: "Mark a knowledge file as reviewed today", Options: []cliReferenceOption{{Flags: "--json", Description: "Output updated frontmatter as JSON"}}}, - {Name: "init", Description: "Initialize knowledge base directories and QMD collections", Options: []cliReferenceOption{{Flags: "--json", Description: "Output results as JSON"}}}, + {Name: "review", Description: "Mark a knowledge file as reviewed today", Options: []cliReferenceOption{{Flags: "--json", Description: "Output updated knowledge frontmatter as JSON"}}}, + {Name: "init", Description: "Initialize knowledge base directories and QMD collections", Options: []cliReferenceOption{{Flags: "--json", Description: "Output directory actions, config status, and QMD collections as JSON"}}}, {Name: "import", Description: "Import external project knowledge via QMD collection", Options: []cliReferenceOption{ {Flags: "--path <path>", Description: "Path to the external project's knowledge directory"}, - {Flags: "--json", Description: "Output results as JSON"}, + {Flags: "--json", Description: "Output QMD import collection status or import error as JSON"}, }}, }, }, @@ -228,7 +288,7 @@ func cliReferenceCommands() []cliReferenceCommand { Description: "Scan project artifacts and recommend housekeeping actions", Options: []cliReferenceOption{ {Flags: "--dry-run", Description: "Show recommendations without prompting for actions"}, - {Flags: "--json", Description: "Output as JSON"}, + {Flags: "--json", Description: "Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON"}, {Flags: "--sessions", Description: "Only review sessions"}, {Flags: "--specs", Description: "Only review specs"}, {Flags: "--plans", Description: "Only review plans"}, @@ -236,12 +296,142 @@ func cliReferenceCommands() []cliReferenceCommand { {Flags: "--handoffs", Description: "Only review handoffs"}, }, }, + { + Name: "trace", + Description: "Trace relationships for one state entity", + Options: []cliReferenceOption{ + {Flags: "--json", Description: "Output traced entity, sources, relationships, global database scope, and project identity as JSON"}, + }, + }, + { + Name: "brainstorm", + Description: "Manage brainstorms in native SQLite state", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "List brainstorms from SQLite state", Options: []cliReferenceOption{ + {Flags: "--all", Description: "Include archived brainstorms"}, + {Flags: "--status <status>", Description: "Filter by status"}, + {Flags: "--json", Description: "Output brainstorms, global database scope, and project identity as JSON"}, + }}, + {Name: "show", Description: "Show one brainstorm from SQLite state", Options: []cliReferenceOption{{Flags: "--json", Description: "Output brainstorm details, relationships, global database scope, and project identity as JSON"}}}, + {Name: "promote", Description: "Record brainstorm-to-idea promotion", Options: []cliReferenceOption{ + {Flags: "--to-idea <idea>", Description: "Target idea"}, + {Flags: "--json", Description: "Output promotion relationship, global database scope, and project identity as JSON"}, + }}, + {Name: "archive", Description: "Archive one or more brainstorms", Options: []cliReferenceOption{ + {Flags: "--reason <text>", Description: "Archive reason"}, + {Flags: "--json", Description: "Output archive result, archived brainstorms, global database scope, and project identity as JSON"}, + }}, + }, + }, + { + Name: "idea", + Description: "Manage ideas in native SQLite state", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "List ideas from SQLite state", Options: []cliReferenceOption{ + {Flags: "--all", Description: "Include resolved and archived ideas"}, + {Flags: "--status <status>", Description: "Filter by status"}, + {Flags: "--json", Description: "Output ideas, global database scope, and project identity as JSON"}, + }}, + {Name: "show", Description: "Show one idea from SQLite state", Options: []cliReferenceOption{{Flags: "--json", Description: "Output idea details, relationships, global database scope, and project identity as JSON"}}}, + {Name: "capture", Description: "Capture an idea in SQLite state", Options: []cliReferenceOption{ + {Flags: "--title <title>", Description: "Idea title"}, + {Flags: "--json", Description: "Output created idea, event, global database scope, and project identity as JSON"}, + }}, + {Name: "promote", Description: "Record idea-to-spec promotion", Options: []cliReferenceOption{ + {Flags: "--to-spec <spec>", Description: "Target spec"}, + {Flags: "--json", Description: "Output promotion relationship, global database scope, and project identity as JSON"}, + }}, + {Name: "resolve", Description: "Resolve an idea by linking it to another entity", Options: []cliReferenceOption{ + {Flags: "--by <entity>", Description: "Resolving entity"}, + {Flags: "--json", Description: "Output resolution relationship, event, global database scope, and project identity as JSON"}, + }}, + {Name: "archive", Description: "Archive one or more ideas", Options: []cliReferenceOption{ + {Flags: "--reason <text>", Description: "Archive reason"}, + {Flags: "--json", Description: "Output archive result, archived ideas, global database scope, and project identity as JSON"}, + }}, + }, + }, + { + Name: "spark", + Description: "Manage sparks in native SQLite state", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "List sparks from SQLite state", Options: []cliReferenceOption{ + {Flags: "--all", Description: "Include resolved sparks"}, + {Flags: "--status <status>", Description: "Filter by status"}, + {Flags: "--json", Description: "Output sparks, global database scope, and project identity as JSON"}, + }}, + {Name: "show", Description: "Show one spark from SQLite state", Options: []cliReferenceOption{{Flags: "--json", Description: "Output spark details, relationships, global database scope, and project identity as JSON"}}}, + {Name: "capture", Description: "Capture a spark in SQLite state", Options: []cliReferenceOption{ + {Flags: "--scope <scope>", Description: "Spark scope"}, + {Flags: "--text <text>", Description: "Spark text"}, + {Flags: "--json", Description: "Output created spark, event, global database scope, and project identity as JSON"}, + }}, + {Name: "resolve", Description: "Resolve a spark", Options: []cliReferenceOption{ + {Flags: "--reason <text>", Description: "Resolution reason"}, + {Flags: "--json", Description: "Output resolution relationship, event, global database scope, and project identity as JSON"}, + }}, + {Name: "promote", Description: "Record spark-to-idea promotion", Options: []cliReferenceOption{ + {Flags: "--to-idea <idea>", Description: "Target idea"}, + {Flags: "--json", Description: "Output promotion relationship, global database scope, and project identity as JSON"}, + }}, + }, + }, + { + Name: "tag", + Description: "Manage tags in native SQLite state", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "List tags from SQLite state", Options: []cliReferenceOption{{Flags: "--json", Description: "Output tags, global database scope, and project identity as JSON"}}}, + {Name: "show", Description: "Show entities with a tag", Options: []cliReferenceOption{{Flags: "--json", Description: "Output tagged entities, global database scope, and project identity as JSON"}}}, + {Name: "add", Description: "Add a tag to an entity", Options: []cliReferenceOption{{Flags: "--json", Description: "Output tag mutation, entity, global database scope, and project identity as JSON"}}}, + {Name: "remove", Description: "Remove a tag from an entity", Options: []cliReferenceOption{{Flags: "--json", Description: "Output tag mutation, entity, global database scope, and project identity as JSON"}}}, + }, + }, + { + Name: "bundle", + Description: "Manage bundles in native SQLite state", + Subcommands: []cliReferenceSubcommand{ + {Name: "list", Description: "List bundles from SQLite state", Options: []cliReferenceOption{{Flags: "--json", Description: "Output bundles, global database scope, and project identity as JSON"}}}, + {Name: "create", Description: "Create a bundle", Options: []cliReferenceOption{ + {Flags: "--title <title>", Description: "Bundle title"}, + {Flags: "--tags <tags>", Description: "Comma-separated tag query"}, + {Flags: "--json", Description: "Output created bundle, tags, global database scope, and project identity as JSON"}, + }}, + {Name: "update", Description: "Update a bundle", Options: []cliReferenceOption{ + {Flags: "--title <title>", Description: "Bundle title"}, + {Flags: "--tags <tags>", Description: "Comma-separated tag query"}, + {Flags: "--json", Description: "Output updated bundle, tags, global database scope, and project identity as JSON"}, + }}, + {Name: "show", Description: "Show one bundle", Options: []cliReferenceOption{{Flags: "--json", Description: "Output bundle details, members, global database scope, and project identity as JSON"}}}, + {Name: "add", Description: "Add an entity to a bundle", Options: []cliReferenceOption{{Flags: "--json", Description: "Output bundle membership result, global database scope, and project identity as JSON"}}}, + {Name: "remove", Description: "Remove an entity from a bundle", Options: []cliReferenceOption{{Flags: "--json", Description: "Output bundle membership result, global database scope, and project identity as JSON"}}}, + }, + }, + { + Name: "link", + Description: "Manage explicit relationships in native SQLite state", + Subcommands: []cliReferenceSubcommand{ + {Name: "create", Description: "Create an explicit relationship", Options: []cliReferenceOption{ + {Flags: "--from <entity>", Description: "Source entity"}, + {Flags: "--to <entity>", Description: "Target entity"}, + {Flags: "--type <type>", Description: "Relationship type"}, + {Flags: "--reason <text>", Description: "Relationship reason"}, + {Flags: "--json", Description: "Output relationship ID, source/target, global database scope, and project identity as JSON"}, + }}, + {Name: "list", Description: "List relationships for one entity", Options: []cliReferenceOption{{Flags: "--json", Description: "Output relationships, global database scope, and project identity as JSON"}}}, + {Name: "remove", Description: "Remove an explicit relationship", Options: []cliReferenceOption{ + {Flags: "--from <entity>", Description: "Source entity"}, + {Flags: "--to <entity>", Description: "Target entity"}, + {Flags: "--type <type>", Description: "Relationship type"}, + {Flags: "--json", Description: "Output removed relationship ID, global database scope, and project identity as JSON"}, + }}, + }, + }, { Name: "check", Description: "Run enforcement hook checks", Options: []cliReferenceOption{ {Flags: "--hook <id>", Description: "Registered hook ID to run"}, - {Flags: "--json", Description: "Output JSON format"}, + {Flags: "--json", Description: "Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON"}, }, }, } @@ -378,6 +568,14 @@ func generateCLIReferenceCommandSection(cmd cliReferenceCommand) string { } } + if len(cmd.Options) > 0 { + parts = append(parts, "**Options:**", "") + for _, opt := range cmd.Options { + parts = append(parts, fmt.Sprintf("- `%s` - %s", opt.Flags, opt.Description)) + } + parts = append(parts, "") + } + parts = append(parts, "**Usage:**", "```bash") if examples := cliReferenceCommandUsageExamples(cmd.Name); len(examples) > 0 { parts = append(parts, examples...) @@ -451,7 +649,11 @@ func cliReferenceCommandGuidance(commandName string) string { case "report": return "In SQLite-backed projects, report lifecycle state is stored in SQLite. Use\ngenerated report commands for review output; create authored Markdown reports\nonly when a durable prose artifact is explicitly needed." case "state": - return "Existing TypeScript-era projects can keep running supported commands in\nmarkdown-only compatibility mode until SQLite is initialized. Use\n`loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite\nwithout rewriting the source Markdown files." + return "Existing TypeScript-era projects can keep running supported commands in\nmarkdown-only compatibility mode until SQLite is initialized. Use\n`loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite\nwithout rewriting the source Markdown files." + + "\n\nManual restore from a backup is explicit until a guarded restore command exists:\nverify the backup with `loaf state backup verify <backup>`, preserve the current\n`$(loaf state path)` file, copy the verified backup to that path, then run\n`loaf state doctor` and `loaf state status`." + + "\nFor agents, `loaf state backup verify <backup> --json` also returns\n`restore_database_path`, `restore_preserve_path`, and\n`restore_validation_commands` for the current checkout." + case "project": + return "Project IDs are stable SQLite identities, not path or name hashes. Use\n`loaf project rename --dry-run` for display-name previews and\n`loaf project move --dry-run` before recording checkout path moves." case "migrate": return "`loaf migrate markdown` is the upgrade path for existing `.agents/`\nprojects with no SQLite database. Start with `--dry-run`, then use `--apply`\nwhen the artifact counts and skipped files look right." default: @@ -466,8 +668,21 @@ func cliReferenceCommandUsageExamples(commandName string) []string { "loaf state status", "loaf state migrate markdown --dry-run", "loaf state migrate markdown --apply", + "loaf state backup", + "loaf state backup verify /path/to/backup.sqlite", "loaf state status", } + case "project": + return []string{ + "loaf project show", + "loaf project identity --json", + "loaf project rename \"Loaf\" --dry-run", + "loaf project rename \"Loaf\"", + "loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run", + "loaf project move --from /old/path/to/loaf --dry-run", + "loaf project move --from /old/path/to/loaf", + "loaf project show --json", + } case "migrate": return []string{ "loaf migrate markdown --dry-run", diff --git a/internal/cli/cli_reference_test.go b/internal/cli/cli_reference_test.go index d8312676..e7fd381f 100644 --- a/internal/cli/cli_reference_test.go +++ b/internal/cli/cli_reference_test.go @@ -28,10 +28,83 @@ func TestRunnerGenerateCLIReferenceWritesSkillNatively(t *testing.T) { content := string(data) for _, want := range []string{ "**Note:** This file is auto-generated from native CLI reference metadata.", + "`-t, --target <name>` - Build a specific target only", + "`loaf state repair legacy-project-database`", + "`--dry-run` - Preview archive paths without writing", + "`loaf state repair relationship-origin`", + "`--origin <imported|manual>` - Provenance value to backfill", + "`loaf state backup verify`", + "`--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON", + "`loaf state path`", + "`--verbose` - Output command, scope, project root, and database path", + "`loaf state backup` | Create a SQLite database backup under the global data-home backups directory", + "`loaf state export`", + "`--format <format>` - Output format for the selected export kind", + "`loaf state export all`", + "`--format <format>` - Output format: json", + "`loaf state export release-readiness`", + "`--format <format>` - Output format: markdown", + "`loaf migrate worktree-storage`", + "`--apply` - Perform the migration; dry-run is the default", + "`--force-from-worktree` - On conflict, keep the worktree-local copy", + "`--force-from-main` - On conflict, keep the main-worktree copy", + "## Project Management", + "`loaf project list`", + "`--json` - Output database path, project IDs, friendly names, and current paths as JSON", + "`loaf project show`", + "`--json` - Output project ID, friendly name, current path, and database path as JSON", + "`loaf project rename`", + "`--dry-run` - Validate and preview without writing", + "`--json` - Output project ID, friendly name, current path, database path, and applied status as JSON", + "`loaf project move`", "## Task Management", "`loaf task create`", + "`--json` - Output created task, event, global database scope, and project identity as JSON", + "`--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived", + "`--priority <level>` - Priority level: P0, P1, P2, P3", + "`--status <status>` - New status: in_progress, blocked, todo, review, done", + "`--priority <level>` - New priority: P0, P1, P2, P3", + "`--json` - Output updated task, event, global database scope, and project identity as JSON", + "`--json` - Output archive result, archived tasks, global database scope, and project identity as JSON", + "`--json` - Output compatibility summary as JSON", + "## Trace Management", + "`loaf trace`", + "`--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON", + "`loaf brainstorm list`", + "`--json` - Output brainstorms, global database scope, and project identity as JSON", + "## Idea Management", + "`loaf idea capture`", + "`--title <title>` - Idea title", + "`loaf idea resolve`", + "`--by <entity>` - Resolving entity", + "`--json` - Output resolution relationship, event, global database scope, and project identity as JSON", + "## Spark Management", + "`loaf spark capture`", + "`--scope <scope>` - Spark scope", + "`--text <text>` - Spark text", + "## Bundle Management", + "`loaf bundle create`", + "`--tags <tags>` - Comma-separated tag query", + "`loaf bundle update`", + "`loaf bundle show`", + "`--json` - Output bundle details, members, global database scope, and project identity as JSON", + "## Link Management", + "`loaf link create`", + "`--from <entity>` - Source entity", + "`--to <entity>` - Target entity", + "`--type <type>` - Relationship type", + "`--json` - Output relationship ID, source/target, global database scope, and project identity as JSON", "## Kb Management", + "`loaf kb status`", + "`--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON", "`loaf kb check`", + "`--json` - Output per-file staleness, coverage, commit, and review metadata as JSON", + "`loaf kb init`", + "`--json` - Output directory actions, config status, and QMD collections as JSON", + "`loaf housekeeping`", + "`--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON", + "`loaf check`", + "`--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON", "## Command Substitution Reference", } { if !strings.Contains(content, want) { @@ -41,4 +114,34 @@ func TestRunnerGenerateCLIReferenceWritesSkillNatively(t *testing.T) { if !strings.Contains(stdout.String(), outputPath) { t.Fatalf("stdout = %q, want generated path %q", stdout.String(), outputPath) } + if !strings.Contains(content, "- `loaf report generate`:\n - `--format <format>` - Output format: markdown") { + t.Fatalf("generated CLI reference missing report generate markdown format guidance\n%s", content) + } + if !strings.Contains(content, " - `--json` - Output contract, command, project context, and markdown content as JSON") { + t.Fatalf("generated CLI reference missing report generate JSON guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf state migrate markdown`:\n - `--dry-run` - Preview import counts without creating a database\n - `--apply` - Initialize SQLite and import Markdown artifacts\n - `--resume` - Resume the Markdown import after an interrupted attempt\n - `--json` - Output migration contract, scope, project context, and counts as JSON") { + t.Fatalf("generated CLI reference missing state migrate markdown JSON contract guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf state migrate storage-home`:\n - `--dry-run` - Preview the storage-home migration\n - `--apply` - Copy the legacy database without deleting it\n - `--json` - Output migration contract, global database paths, action, and project identity when available") { + t.Fatalf("generated CLI reference missing state migrate storage-home JSON contract guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf migrate markdown`:\n - `--dry-run` - Preview import counts without creating a database\n - `--apply` - Initialize SQLite and import Markdown artifacts\n - `--resume` - Resume the Markdown import after an interrupted attempt\n - `--json` - Output migration contract, scope, project context, and counts as JSON") { + t.Fatalf("generated CLI reference missing top-level migrate markdown JSON contract guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf migrate storage-home`:\n - `--dry-run` - Preview the storage-home migration\n - `--apply` - Copy the legacy database without deleting it\n - `--json` - Output migration contract, global database paths, action, and project identity when available") { + t.Fatalf("generated CLI reference missing top-level migrate storage-home JSON contract guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf report list`:\n - `--type <type>` - Filter by report type\n - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived") { + t.Fatalf("generated CLI reference missing report list status guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf task list`:\n - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON") { + t.Fatalf("generated CLI reference missing task list JSON contract guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf spec list`:\n - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON") { + t.Fatalf("generated CLI reference missing spec list JSON contract guidance\n%s", content) + } + if !strings.Contains(content, "- `loaf report list`:\n - `--type <type>` - Filter by report type\n - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived\n - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON") { + t.Fatalf("generated CLI reference missing report list JSON contract guidance\n%s", content) + } } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index ad7b495b..dd772440 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -2,7 +2,9 @@ package cli import ( "bytes" + "crypto/sha256" "database/sql" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -44,9 +46,60 @@ func TestRunnerDispatchesStatePathNatively(t *testing.T) { if got != want { t.Fatalf("stdout = %q, want %q", got, want) } - if !strings.HasPrefix(got, filepath.Join(stateHome, "loaf", "projects")+string(filepath.Separator)) { + if got != filepath.Join(stateHome, "loaf", "loaf.sqlite") { t.Fatalf("stdout = %q, want state path under %q", got, stateHome) } + + var jsonOut bytes.Buffer + err = Runner{ + Stdout: &jsonOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "path", "--json"}) + if err != nil { + t.Fatalf("Run(--json) error = %v", err) + } + var result struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + ProjectRoot string `json:"project_root"` + DatabasePath string `json:"database_path"` + } + if err := json.Unmarshal(jsonOut.Bytes(), &result); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", jsonOut.String(), err) + } + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" || result.ProjectRoot != root.Path() || result.DatabasePath != want { + t.Fatalf("state path JSON = %#v, want global scope, root %q, database %q", result, root.Path(), want) + } + if _, err := os.Stat(want); !os.IsNotExist(err) { + t.Fatalf("state path --json database stat = %v, want command not to create database", err) + } + + var verboseOut bytes.Buffer + err = Runner{ + Stdout: &verboseOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "path", "--verbose"}) + if err != nil { + t.Fatalf("Run(--verbose) error = %v", err) + } + for _, wantLine := range []string{ + "loaf state path", + "scope: global database", + "project root: " + root.Path(), + "database: " + want, + } { + if !strings.Contains(verboseOut.String(), wantLine) { + t.Fatalf("state path --verbose stdout = %q, want %q", verboseOut.String(), wantLine) + } + } + if _, err := os.Stat(want); !os.IsNotExist(err) { + t.Fatalf("state path --verbose database stat = %v, want command not to create database", err) + } } func TestRunnerHousekeepingUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -74,6 +127,7 @@ status: complete t.Fatalf("housekeeping --json error = %v", err) } summary := decodeHousekeepingSummary(t, jsonOut.Bytes()) + assertCLIProjectContext(t, workingDir, summary.ContractVersion, summary.DatabaseScope, summary.DatabasePath, summary.ProjectID, summary.ProjectName, summary.ProjectCurrentPath) if summary.Sections["specs"].ByStatus["complete"] != 1 || summary.Sections["tasks"].ByStatus["done"] != 1 { t.Fatalf("summary = %#v, want SQLite spec/task lifecycle counts", summary) } @@ -87,7 +141,7 @@ status: complete if err != nil { t.Fatalf("housekeeping --dry-run error = %v", err) } - for _, want := range []string{"loaf housekeeping (SQLite state, dry run)", "database:", "specs", "tasks", "cleanup candidate"} { + for _, want := range []string{"loaf housekeeping (SQLite state, dry run)", "scope: global database", "database:", "project:", "project name:", "project path:", "specs", "tasks", "cleanup candidate"} { if !strings.Contains(humanOut.String(), want) { t.Fatalf("stdout = %q, want %q", humanOut.String(), want) } @@ -136,6 +190,9 @@ status: absorbed if summary.DatabasePath != filepath.Join(workingDir, ".agents") { t.Fatalf("database path = %q, want markdown artifacts path", summary.DatabasePath) } + if summary.ContractVersion != 0 || summary.DatabaseScope != "" || summary.ProjectID != "" || summary.ProjectName != "" || summary.ProjectCurrentPath != "" { + t.Fatalf("markdown housekeeping context = %#v, want empty", summary) + } if summary.Sections["specs"].ByStatus["complete"] != 1 || summary.Sections["tasks"].ByStatus["done"] != 1 || summary.Sections["sessions"].ByStatus["active"] != 1 || summary.Sections["sessions"].ByStatus["archived"] != 1 || summary.Sections["shaping_drafts"].ByStatus["absorbed"] != 1 { t.Fatalf("summary = %#v, want markdown artifact lifecycle counts", summary) } @@ -157,6 +214,9 @@ status: absorbed t.Fatalf("stdout = %q, want %q", humanOut.String(), want) } } + if strings.Contains(humanOut.String(), "scope: global database") || strings.Contains(humanOut.String(), "project path:") { + t.Fatalf("stdout = %q, want markdown fallback without database context", humanOut.String()) + } if strings.Contains(humanOut.String(), "specs") { t.Fatalf("stdout = %q, want --sessions filter to hide specs", humanOut.String()) } @@ -174,10 +234,7 @@ func TestRunnerStateMigrateStorageHomeCopiesLegacyDatabase(t *testing.T) { if err != nil { t.Fatalf("ResolveRoot() error = %v", err) } - legacyStatus, err := state.Initialize(t.Context(), root, state.PathResolver{StateHome: stateHome}) - if err != nil { - t.Fatalf("Initialize(legacy) error = %v", err) - } + legacyPath := initializeCLILegacyStateDatabase(t, root) var dryRun bytes.Buffer err = Runner{ @@ -191,6 +248,12 @@ func TestRunnerStateMigrateStorageHomeCopiesLegacyDatabase(t *testing.T) { if err := json.Unmarshal(dryRun.Bytes(), &preview); err != nil { t.Fatalf("Unmarshal(preview) error = %v\n%s", err, dryRun.String()) } + if preview.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("preview ContractVersion = %d, want %d", preview.ContractVersion, state.StateJSONContractVersion) + } + if preview.DatabaseScope != "global" || preview.MigrationScope != "project" { + t.Fatalf("preview scopes = %q/%q, want global/project", preview.DatabaseScope, preview.MigrationScope) + } if preview.Action != state.StorageHomeActionCopy || preview.Applied { t.Fatalf("preview = %#v, want copy dry-run", preview) } @@ -203,12 +266,15 @@ func TestRunnerStateMigrateStorageHomeCopiesLegacyDatabase(t *testing.T) { if err != nil { t.Fatalf("state migrate storage-home --apply error = %v", err) } - for _, want := range []string{"loaf state migrate storage-home --apply", "action: already-migrated", "applied: true"} { + for _, want := range []string{"loaf state migrate storage-home --apply", "scope: global database, project migration", "project:", "project name:", "project path:", "action: already-migrated", "applied: true"} { if !strings.Contains(applyOut.String(), want) { t.Fatalf("stdout = %q, want %q", applyOut.String(), want) } } - if _, err := os.Stat(legacyStatus.DatabasePath); err != nil { + if strings.Contains(applyOut.String(), "next:") { + t.Fatalf("stdout = %q, did not want dry-run next action after apply", applyOut.String()) + } + if _, err := os.Stat(legacyPath); err != nil { t.Fatalf("legacy database stat error = %v, want legacy preserved", err) } @@ -224,9 +290,29 @@ func TestRunnerStateMigrateStorageHomeCopiesLegacyDatabase(t *testing.T) { if status.Mode != state.ModeSQLiteReady { t.Fatalf("Mode = %q, want %q", status.Mode, state.ModeSQLiteReady) } + if status.ProjectID == "" { + t.Fatal("ProjectID is empty after storage-home migration") + } if !strings.HasPrefix(status.DatabasePath, dataHome+string(filepath.Separator)) { t.Fatalf("DatabasePath = %q, want under data home %q", status.DatabasePath, dataHome) } + + var migratedPreviewOut bytes.Buffer + err = Runner{ + Stdout: &migratedPreviewOut, + WorkingDir: workingDir, + }.Run([]string{"state", "migrate", "storage-home", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state migrate storage-home --dry-run --json after apply error = %v", err) + } + var migratedPreview state.StorageHomeMigrationPlan + if err := json.Unmarshal(migratedPreviewOut.Bytes(), &migratedPreview); err != nil { + t.Fatalf("Unmarshal(migratedPreview) error = %v\n%s", err, migratedPreviewOut.String()) + } + if migratedPreview.Action != state.StorageHomeActionAlreadyMigrated || migratedPreview.Applied { + t.Fatalf("migrated preview = %#v, want already-migrated dry-run", migratedPreview) + } + assertCLIProjectContext(t, workingDir, migratedPreview.ContractVersion, migratedPreview.DatabaseScope, migratedPreview.DatabasePath, migratedPreview.ProjectID, migratedPreview.ProjectName, migratedPreview.ProjectCurrentPath) } func TestRunnerMigrateStorageHomeUsesNativeAlias(t *testing.T) { @@ -240,9 +326,7 @@ func TestRunnerMigrateStorageHomeUsesNativeAlias(t *testing.T) { if err != nil { t.Fatalf("ResolveRoot() error = %v", err) } - if _, err := state.Initialize(t.Context(), root, state.PathResolver{StateHome: stateHome}); err != nil { - t.Fatalf("Initialize(legacy) error = %v", err) - } + initializeCLILegacyStateDatabase(t, root) var stdout bytes.Buffer err = Runner{ @@ -252,11 +336,50 @@ func TestRunnerMigrateStorageHomeUsesNativeAlias(t *testing.T) { if err != nil { t.Fatalf("migrate storage-home error = %v", err) } - for _, want := range []string{"loaf migrate storage-home --dry-run", "action: copy"} { + for _, want := range []string{ + "loaf migrate storage-home --dry-run", + "scope: global database, project migration", + "project: (not initialized)", + "project name:", + "project path:", + "action: copy", + "applied: false", + "next: rerun with --apply to copy eligible legacy state into the global database", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } + } +} + +func TestRunnerMigrateStorageHomeNoLegacyHumanDryRun(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + }.Run([]string{"state", "migrate", "storage-home", "--dry-run"}) + if err != nil { + t.Fatalf("state migrate storage-home --dry-run error = %v", err) + } + for _, want := range []string{ + "loaf state migrate storage-home --dry-run", + "action: no-legacy-state", + "applied: false", + "next: no legacy state was found; run `loaf state init` or `loaf state migrate markdown --apply` if this project still needs SQLite state", + } { if !strings.Contains(stdout.String(), want) { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } } + if strings.Contains(stdout.String(), "rerun with --apply") { + t.Fatalf("stdout = %q, did not want apply guidance when no legacy source exists", stdout.String()) + } } func TestRunnerMigrateMarkdownUsesNativeAlias(t *testing.T) { @@ -271,15 +394,27 @@ func TestRunnerMigrateMarkdownUsesNativeAlias(t *testing.T) { "# Demo task", }, "\n")) + stateHome := t.TempDir() var stdout bytes.Buffer err := Runner{ Stdout: &stdout, WorkingDir: repo, + StateHome: stateHome, }.Run([]string{"migrate", "markdown"}) if err != nil { t.Fatalf("migrate markdown error = %v", err) } - for _, want := range []string{"loaf migrate markdown --dry-run", "tasks: 1"} { + for _, want := range []string{ + "loaf migrate markdown --dry-run", + "scope: global database, project import", + "database:", + "project: (not initialized)", + "project name:", + "project path:", + "applied: false", + "tasks: 1", + "next: rerun with --apply to import Markdown into the global database", + } { if !strings.Contains(stdout.String(), want) { t.Fatalf("stdout = %q, want %q", stdout.String(), want) } @@ -288,7 +423,7 @@ func TestRunnerMigrateMarkdownUsesNativeAlias(t *testing.T) { if err != nil { t.Fatalf("ResolveRoot() error = %v", err) } - databasePath, err := state.PathResolver{}.DatabasePath(root) + databasePath, err := state.PathResolver{StateHome: stateHome}.DatabasePath(root) if err != nil { t.Fatalf("DatabasePath() error = %v", err) } @@ -666,14 +801,23 @@ func TestRunnerHousekeepingReportsInvalidSQLiteState(t *testing.T) { func assertSQLiteRequired(t *testing.T, args ...string) { t.Helper() + var stdout bytes.Buffer err := Runner{ - Stdout: &bytes.Buffer{}, + Stdout: &stdout, WorkingDir: realpath(t, t.TempDir()), StateHome: t.TempDir(), }.Run(args) if err == nil { t.Fatalf("Run(%v) error = nil, want SQLite state required error", args) } + if hasFlag(args, "--json") { + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if !strings.Contains(output.Error, "requires initialized SQLite state") { + t.Fatalf("Run(%v) JSON error = %#v, want SQLite state required error", args, output) + } + return + } if !strings.Contains(err.Error(), "requires initialized SQLite state") { t.Fatalf("Run(%v) error = %v, want SQLite state required error", args, err) } @@ -1899,6 +2043,32 @@ last_entry: 2026-06-10T10:05:00Z } } +func initializeCLILegacyStateDatabase(t *testing.T, root project.Root) string { + t.Helper() + resolver := state.PathResolver{} + legacyPath, err := resolver.LegacyDatabasePath(root) + if err != nil { + t.Fatalf("LegacyDatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil { + t.Fatalf("create legacy database dir error = %v", err) + } + store, err := state.OpenStore(legacyPath) + if err != nil { + t.Fatalf("OpenStore(legacy) error = %v", err) + } + if err := store.ApplyMigrations(t.Context()); err != nil { + t.Fatalf("ApplyMigrations(legacy) error = %v", err) + } + if err := store.UpsertProject(t.Context(), root); err != nil { + t.Fatalf("UpsertProject(legacy) error = %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close(legacy) error = %v", err) + } + return legacyPath +} + func TestRunnerSessionContextForResumptionWarnsWithoutMarkdownSession(t *testing.T) { requireCLIGit(t) workingDir := initCLIGitRepo(t) @@ -1940,8 +2110,8 @@ func TestRunnerLinkedWorktreesShareSQLiteState(t *testing.T) { if mainPath != linkedPath { t.Fatalf("linked state path = %q, want main path %q", linkedPath, mainPath) } - if !strings.HasPrefix(mainPath, filepath.Join(stateHome, "loaf", "projects")+string(filepath.Separator)) { - t.Fatalf("state path = %q, want under state home %q", mainPath, stateHome) + if mainPath != filepath.Join(stateHome, "loaf", "loaf.sqlite") { + t.Fatalf("state path = %q, want global database under state home %q", mainPath, stateHome) } if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: main, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { @@ -1982,219 +2152,2496 @@ func TestRunnerLinkedWorktreesShareSQLiteState(t *testing.T) { } } -func TestRunnerStateInitStatusAndDoctor(t *testing.T) { +func TestRunnerProjectShowRenameAndMoveUseStableIdentity(t *testing.T) { workingDir := realpath(t, t.TempDir()) + movedDir := realpath(t, t.TempDir()) stateHome := t.TempDir() - var statusBefore bytes.Buffer - err := Runner{ - Stdout: &statusBefore, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"state", "status", "--json"}) - if err != nil { - t.Fatalf("state status before init error = %v", err) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) } - before := decodeStateStatus(t, statusBefore.Bytes()) - if before.Mode != state.ModeMarkdownOnly { - t.Fatalf("before.Mode = %q, want %q", before.Mode, state.ModeMarkdownOnly) + + var showOut bytes.Buffer + if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show --json error = %v", err) } - if before.DatabaseExists { - t.Fatal("before.DatabaseExists = true, want false") + var shown state.ProjectIdentity + if err := json.Unmarshal(showOut.Bytes(), &shown); err != nil { + t.Fatalf("json.Unmarshal(show) error = %v\n%s", err, showOut.String()) } - - var initOut bytes.Buffer - err = Runner{ - Stdout: &initOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"state", "init", "--json"}) - if err != nil { - t.Fatalf("state init error = %v", err) + if shown.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("shown.ContractVersion = %d, want %d", shown.ContractVersion, state.StateJSONContractVersion) } - initialized := decodeStateStatus(t, initOut.Bytes()) - if initialized.Mode != state.ModeSQLiteReady { - t.Fatalf("initialized.Mode = %q, want %q", initialized.Mode, state.ModeSQLiteReady) + if shown.DatabaseScope != "global" { + t.Fatalf("shown.DatabaseScope = %q, want global", shown.DatabaseScope) } - if initialized.SchemaVersion != state.CurrentSchemaVersion() { - t.Fatalf("initialized.SchemaVersion = %d, want %d", initialized.SchemaVersion, state.CurrentSchemaVersion()) + if shown.ID == "" || shown.CurrentPath != workingDir || shown.FriendlyName != filepath.Base(workingDir) { + t.Fatalf("shown project = %#v, want generated identity for %s", shown, workingDir) } - if _, err := os.Stat(initialized.DatabasePath); err != nil { - t.Fatalf("state init did not create database: %v", err) + var identityOut bytes.Buffer + if err := (Runner{Stdout: &identityOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "identity", "--json"}); err != nil { + t.Fatalf("project identity --json error = %v", err) } - - var doctorOut bytes.Buffer - err = Runner{ - Stdout: &doctorOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"state", "doctor"}) - if err != nil { - t.Fatalf("state doctor error = %v", err) + var aliasShown state.ProjectIdentity + if err := json.Unmarshal(identityOut.Bytes(), &aliasShown); err != nil { + t.Fatalf("json.Unmarshal(identity alias) error = %v\n%s", err, identityOut.String()) } - if !strings.Contains(doctorOut.String(), "mode: "+state.ModeSQLiteReady) { - t.Fatalf("doctor output = %q, want sqlite-ready mode", doctorOut.String()) + if aliasShown != shown { + t.Fatalf("project identity alias = %#v, want project show result %#v", aliasShown, shown) } -} - -func TestRunnerStateInitHumanOutputPrintsRepositoryExternalDatabaseWithoutSecrets(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"state", "init"}) - if err != nil { - t.Fatalf("state init error = %v", err) + var humanShowOut bytes.Buffer + if err := (Runner{Stdout: &humanShowOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show"}); err != nil { + t.Fatalf("project show error = %v", err) } - - output := stdout.String() - databasePath := "" - for _, line := range strings.Split(output, "\n") { - if strings.HasPrefix(line, "database: ") { - databasePath = strings.TrimSpace(strings.TrimPrefix(line, "database: ")) - break + for _, want := range []string{ + "loaf project show", + "scope: global database", + "database:", + "project: " + shown.ID, + "project name: " + filepath.Base(workingDir), + "project path: " + workingDir, + } { + if !strings.Contains(humanShowOut.String(), want) { + t.Fatalf("project show output = %q, want %q", humanShowOut.String(), want) } } - if databasePath == "" { - t.Fatalf("output = %q, want database path line", output) + if strings.Contains(humanShowOut.String(), "Project:") || strings.Contains(humanShowOut.String(), "db:") { + t.Fatalf("project show output = %q, want normalized identity labels", humanShowOut.String()) } - if !filepath.IsAbs(databasePath) { - t.Fatalf("database path = %q, want absolute path", databasePath) + var humanIdentityOut bytes.Buffer + if err := (Runner{Stdout: &humanIdentityOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "identity"}); err != nil { + t.Fatalf("project identity error = %v", err) } - if !strings.HasPrefix(databasePath, filepath.Join(stateHome, "loaf", "projects")+string(filepath.Separator)) { - t.Fatalf("database path = %q, want under state home %q", databasePath, stateHome) + if !strings.Contains(humanIdentityOut.String(), "loaf project identity") || !strings.Contains(humanIdentityOut.String(), "project: "+shown.ID) { + t.Fatalf("project identity output = %q, want alias command header and project ID", humanIdentityOut.String()) } - if strings.HasPrefix(databasePath, workingDir+string(filepath.Separator)) { - t.Fatalf("database path = %q, want outside working dir %q", databasePath, workingDir) + + var renameOut bytes.Buffer + if err := (Runner{Stdout: &renameOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "Friendly Loaf", "--json"}); err != nil { + t.Fatalf("project rename --json error = %v", err) } - if _, err := os.Stat(databasePath); err != nil { - t.Fatalf("database was not created at printed path: %v", err) + var renamed state.ProjectIdentity + if err := json.Unmarshal(renameOut.Bytes(), &renamed); err != nil { + t.Fatalf("json.Unmarshal(rename) error = %v\n%s", err, renameOut.String()) } - if _, err := os.Stat(filepath.Join(workingDir, ".agents")); !os.IsNotExist(err) { - t.Fatalf("state init created repository .agents directory; err = %v", err) + if renamed.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("renamed.ContractVersion = %d, want %d", renamed.ContractVersion, state.StateJSONContractVersion) } - lowerOutput := strings.ToLower(output) - for _, forbidden := range []string{"token", "password", "secret", "api_key", "api key", "credential"} { - if strings.Contains(lowerOutput, forbidden) { - t.Fatalf("state init output contains forbidden secret-storage term %q:\n%s", forbidden, output) - } + if renamed.DatabaseScope != "global" { + t.Fatalf("renamed.DatabaseScope = %q, want global", renamed.DatabaseScope) + } + if renamed.ID != shown.ID || renamed.FriendlyName != "Friendly Loaf" { + t.Fatalf("renamed project = %#v, want same ID %q and friendly name", renamed, shown.ID) } -} - -func TestRunnerStateDoctorFixInitializesMissingDatabase(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"state", "doctor", "--fix", "--json"}) - if err != nil { - t.Fatalf("state doctor --fix error = %v", err) + var moveOut bytes.Buffer + if err := (Runner{Stdout: &moveOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", workingDir, "--json"}); err != nil { + t.Fatalf("project move --json error = %v", err) } - status := decodeStateStatus(t, stdout.Bytes()) - if status.Mode != state.ModeSQLiteReady { - t.Fatalf("Mode = %q, want %q", status.Mode, state.ModeSQLiteReady) + var moved state.ProjectMoveResult + if err := json.Unmarshal(moveOut.Bytes(), &moved); err != nil { + t.Fatalf("json.Unmarshal(move) error = %v\n%s", err, moveOut.String()) } - if _, err := os.Stat(status.DatabasePath); err != nil { - t.Fatalf("doctor --fix did not create database: %v", err) + if moved.ContractVersion != state.StateJSONContractVersion || moved.Project.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("moved contract versions = %d/%d, want %d", moved.ContractVersion, moved.Project.ContractVersion, state.StateJSONContractVersion) } - if !hasDiagnostic(status.Diagnostics, "database-initialized") { - t.Fatalf("diagnostics = %#v, want database-initialized", status.Diagnostics) + if moved.DatabaseScope != "global" || moved.Project.DatabaseScope != "global" { + t.Fatalf("moved scopes = %q/%q, want global/global", moved.DatabaseScope, moved.Project.DatabaseScope) + } + if moved.Project.ID != shown.ID || moved.Project.CurrentPath != movedDir { + t.Fatalf("moved project = %#v, want same ID %q at %s", moved.Project, shown.ID, movedDir) } -} -func TestRunnerStateDoctorReportsSchemaMismatch(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) + var movedShowOut bytes.Buffer + if err := (Runner{Stdout: &movedShowOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show after move --json error = %v", err) } - root, err := project.ResolveRoot(workingDir) - if err != nil { - t.Fatalf("ResolveRoot() error = %v", err) + var movedShown state.ProjectIdentity + if err := json.Unmarshal(movedShowOut.Bytes(), &movedShown); err != nil { + t.Fatalf("json.Unmarshal(moved show) error = %v\n%s", err, movedShowOut.String()) } - databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) - if err != nil { - t.Fatalf("DatabasePath() error = %v", err) + if movedShown.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("movedShown.ContractVersion = %d, want %d", movedShown.ContractVersion, state.StateJSONContractVersion) } - db, err := sql.Open("sqlite3", databasePath) - if err != nil { - t.Fatalf("sql.Open() error = %v", err) + if movedShown.DatabaseScope != "global" { + t.Fatalf("movedShown.DatabaseScope = %q, want global", movedShown.DatabaseScope) } - defer db.Close() - if _, err := db.Exec(`INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (99, 'future_schema', 'future', '2026-05-28T10:00:00Z')`); err != nil { - t.Fatalf("insert future schema migration error = %v", err) + if movedShown.ID != shown.ID || movedShown.FriendlyName != "Friendly Loaf" { + t.Fatalf("moved show = %#v, want same renamed project", movedShown) } - var stdout bytes.Buffer - err = Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"state", "doctor"}) - if err == nil { - t.Fatal("state doctor schema mismatch error = nil, want error") + var listOut bytes.Buffer + if err := (Runner{Stdout: &listOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "list", "--json"}); err != nil { + t.Fatalf("project list --json error = %v", err) } - if !strings.Contains(stdout.String(), fmt.Sprintf("schema version 99 does not match expected version %d", state.CurrentSchemaVersion())) { - t.Fatalf("stdout = %q, want schema mismatch diagnostic", stdout.String()) + var listed state.ProjectList + if err := json.Unmarshal(listOut.Bytes(), &listed); err != nil { + t.Fatalf("json.Unmarshal(list) error = %v\n%s", err, listOut.String()) } -} - -func TestRunnerStateBackupCreatesSQLiteCopy(t *testing.T) { - workingDir := realpath(t, t.TempDir()) - stateHome := t.TempDir() - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { - t.Fatalf("state init error = %v", err) + if listed.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("listed.ContractVersion = %d, want %d", listed.ContractVersion, state.StateJSONContractVersion) } - - var stdout bytes.Buffer - err := Runner{ - Stdout: &stdout, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"state", "backup", "--json"}) - if err != nil { - t.Fatalf("state backup --json error = %v", err) + if listed.DatabaseScope != "global" { + t.Fatalf("listed.DatabaseScope = %q, want global", listed.DatabaseScope) } - - result := decodeStateBackupResult(t, stdout.Bytes()) - if result.DatabasePath == "" { - t.Fatal("DatabasePath is empty") + if len(listed.Projects) != 1 { + t.Fatalf("listed projects = %#v, want one stable project", listed.Projects) } - if result.BackupPath == "" { - t.Fatal("BackupPath is empty") + if listed.Projects[0].ContractVersion != state.StateJSONContractVersion { + t.Fatalf("listed project ContractVersion = %d, want %d", listed.Projects[0].ContractVersion, state.StateJSONContractVersion) } - if result.Bytes <= 0 { - t.Fatalf("Bytes = %d, want > 0", result.Bytes) + if listed.Projects[0].DatabaseScope != "global" { + t.Fatalf("listed project DatabaseScope = %q, want global", listed.Projects[0].DatabaseScope) } - if result.CreatedAt == "" { - t.Fatal("CreatedAt is empty") + if listed.Projects[0].ID != shown.ID || listed.Projects[0].FriendlyName != "Friendly Loaf" || listed.Projects[0].CurrentPath != movedDir { + t.Fatalf("listed project = %#v, want renamed moved project", listed.Projects[0]) } - if strings.HasPrefix(result.BackupPath, workingDir+string(filepath.Separator)) { - t.Fatalf("BackupPath = %q, want outside working dir %q", result.BackupPath, workingDir) + + var humanListOut bytes.Buffer + if err := (Runner{Stdout: &humanListOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "list"}); err != nil { + t.Fatalf("project list error = %v", err) } - if _, err := os.Stat(result.BackupPath); err != nil { - t.Fatalf("backup file missing: %v", err) + for _, want := range []string{ + "loaf project list", + "scope: global database", + "database:", + "project: " + shown.ID, + "project name: Friendly Loaf", + "project path: " + movedDir, + "last seen:", + } { + if !strings.Contains(humanListOut.String(), want) { + t.Fatalf("project list output = %q, want %q", humanListOut.String(), want) + } } - store, err := state.OpenStore(result.BackupPath) - if err != nil { - t.Fatalf("OpenStore(backup) error = %v", err) + if strings.Contains(humanListOut.String(), " id:") || strings.Contains(humanListOut.String(), " path:") || strings.Contains(humanListOut.String(), " seen:") { + t.Fatalf("project list output = %q, want normalized identity labels", humanListOut.String()) } - defer store.Close() - version, err := store.SchemaVersion(t.Context()) + + db, err := sql.Open("sqlite3", filepath.Join(stateHome, "loaf", "loaf.sqlite")) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if got := sqliteCount(t, db, `SELECT COUNT(*) FROM projects`); got != 1 { + t.Fatalf("projects = %d, want one stable project row", got) + } +} + +func TestRunnerProjectReadsDoNotCreateMissingDatabase(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + for _, args := range [][]string{ + {"project", "show", "--json"}, + {"project", "list", "--json"}, + } { + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run(args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want missing database error", args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if !strings.Contains(output.Error, "state database does not exist") { + t.Fatalf("Run(%v) JSON error = %#v, want missing database message", args, output) + } + } + if _, err := os.Stat(filepath.Join(stateHome, "loaf", "loaf.sqlite")); !os.IsNotExist(err) { + t.Fatalf("state database stat error = %v, want project reads not to create database", err) + } +} + +func TestRunnerProjectMissingDatabaseHumanErrorsIncludeContext(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + databasePath := filepath.Join(stateHome, "loaf", "loaf.sqlite") + + tests := []struct { + name string + args []string + }{ + {name: "show", args: []string{"project", "show"}}, + {name: "list", args: []string{"project", "list"}}, + {name: "rename dry-run", args: []string{"project", "rename", "Human Dogfood", "--dry-run"}}, + {name: "move dry-run", args: []string{"project", "move", workingDir, realpath(t, t.TempDir()), "--dry-run"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run(tt.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want missing database error", tt.args) + } + for _, want := range []string{ + "project state database does not exist", + "scope: global database", + databasePath, + "loaf state status", + "loaf state init", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Run(%v) error = %q, want %q", tt.args, err.Error(), want) + } + } + }) + } + if _, err := os.Stat(databasePath); !os.IsNotExist(err) { + t.Fatalf("state database stat error = %v, want project human failures not to create database", err) + } +} + +func TestRunnerProjectCommandsRejectSchemaChecksumDrift(t *testing.T) { + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(`UPDATE schema_migrations SET checksum = 'drifted' WHERE version = 1`); err != nil { + t.Fatalf("drift schema checksum error = %v", err) + } + closeCLITestDB(t, db) + + tests := []struct { + name string + args []string + }{ + {name: "show", args: []string{"project", "show"}}, + {name: "list", args: []string{"project", "list"}}, + {name: "rename dry-run", args: []string{"project", "rename", "Human Dogfood", "--dry-run"}}, + {name: "move dry-run", args: []string{"project", "move", workingDir, realpath(t, t.TempDir()), "--dry-run"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run(tt.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want schema checksum drift rejection", tt.args) + } + for _, want := range []string{ + "project state database is invalid", + initialized.DatabasePath, + "scope: global database", + "schema migration 1 checksum does not match Go-owned migration", + "loaf state doctor", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Run(%v) error = %q, want %q", tt.args, err.Error(), want) + } + } + }) + } + + var jsonOut bytes.Buffer + err := (Runner{Stdout: &jsonOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}) + if err == nil { + t.Fatal("project show --json schema checksum drift error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, jsonOut.Bytes()) + if output.Command != "project show" || !strings.Contains(output.Error, "schema migration 1 checksum") || !strings.Contains(output.Error, initialized.DatabasePath) { + t.Fatalf("project show --json error = %#v, want schema checksum drift context", output) + } +} + +func TestRunnerProjectCommandsRejectPathInvariantMismatch(t *testing.T) { + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(`UPDATE projects SET current_path = current_path || '/stale' WHERE id = ?`, initialized.ProjectID); err != nil { + t.Fatalf("drift project current_path error = %v", err) + } + closeCLITestDB(t, db) + + var listOut bytes.Buffer + if err := (Runner{Stdout: &listOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "list", "--json"}); err != nil { + t.Fatalf("project list --json error = %v", err) + } + var listed state.ProjectList + if err := json.Unmarshal(listOut.Bytes(), &listed); err != nil { + t.Fatalf("json.Unmarshal(project list) error = %v\n%s", err, listOut.String()) + } + if len(listed.Projects) != 1 || listed.Projects[0].CurrentPath != workingDir { + t.Fatalf("project list = %#v, want inspectable current path row %s", listed.Projects, workingDir) + } + + tests := []struct { + name string + args []string + }{ + {name: "show", args: []string{"project", "show"}}, + {name: "rename dry-run", args: []string{"project", "rename", "Human Dogfood", "--dry-run"}}, + {name: "move dry-run", args: []string{"project", "move", workingDir, realpath(t, t.TempDir()), "--dry-run"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run(tt.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want project path invariant rejection", tt.args) + } + for _, want := range []string{ + "project state path invariants are invalid", + initialized.DatabasePath, + "scope: global database", + "current_path", + "does not match current project_paths row", + "loaf state doctor", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Run(%v) error = %q, want %q", tt.args, err.Error(), want) + } + } + }) + } + + var jsonOut bytes.Buffer + err := (Runner{Stdout: &jsonOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}) + if err == nil { + t.Fatal("project show --json path invariant error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, jsonOut.Bytes()) + if output.Command != "project show" || !strings.Contains(output.Error, "project state path invariants") || !strings.Contains(output.Error, initialized.DatabasePath) { + t.Fatalf("project show --json error = %#v, want path invariant context", output) + } +} + +func TestRunnerProjectShowDoesNotRegisterUnknownPath(t *testing.T) { + registeredDir := realpath(t, t.TempDir()) + unknownDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: registeredDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: unknownDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}) + if err == nil { + t.Fatal("project show unknown path error = nil, want not registered error") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "project show" || !strings.Contains(output.Error, "project identity is not registered") { + t.Fatalf("JSON error = %#v, want project show not registered error", output) + } + + var listOut bytes.Buffer + if err := (Runner{Stdout: &listOut, WorkingDir: registeredDir, StateHome: stateHome}).Run([]string{"project", "list", "--json"}); err != nil { + t.Fatalf("project list --json error = %v", err) + } + var listed state.ProjectList + if err := json.Unmarshal(listOut.Bytes(), &listed); err != nil { + t.Fatalf("json.Unmarshal(list) error = %v\n%s", err, listOut.String()) + } + if len(listed.Projects) != 1 || listed.Projects[0].CurrentPath != registeredDir { + t.Fatalf("listed projects = %#v, want only registered path %s", listed.Projects, registeredDir) + } +} + +func TestRunnerProjectRenameDoesNotCreateMissingDatabase(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "New Name", "--json"}) + if err == nil { + t.Fatal("project rename missing database error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "project rename" || !strings.Contains(output.Error, "state database does not exist") { + t.Fatalf("project rename JSON error = %#v, want machine-readable missing database rejection", output) + } + if _, err := os.Stat(filepath.Join(stateHome, "loaf", "loaf.sqlite")); !os.IsNotExist(err) { + t.Fatalf("state database stat error = %v, want rejected project rename not to create database", err) + } +} + +func TestRunnerProjectRenameUnknownPathDoesNotRegisterProject(t *testing.T) { + registeredDir := realpath(t, t.TempDir()) + unknownDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: registeredDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: unknownDir, StateHome: stateHome}).Run([]string{"project", "rename", "Unknown", "--json"}) + if err == nil { + t.Fatal("project rename unknown path error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "project rename" || !strings.Contains(output.Error, "project identity is not registered") { + t.Fatalf("JSON error = %#v, want project rename not registered error", output) + } + + var listOut bytes.Buffer + if err := (Runner{Stdout: &listOut, WorkingDir: registeredDir, StateHome: stateHome}).Run([]string{"project", "list", "--json"}); err != nil { + t.Fatalf("project list --json error = %v", err) + } + var listed state.ProjectList + if err := json.Unmarshal(listOut.Bytes(), &listed); err != nil { + t.Fatalf("json.Unmarshal(list) error = %v\n%s", err, listOut.String()) + } + if len(listed.Projects) != 1 || listed.Projects[0].CurrentPath != registeredDir { + t.Fatalf("listed projects = %#v, want only registered path %s", listed.Projects, registeredDir) + } +} + +func TestRunnerProjectRenameDryRunDoesNotWrite(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + var showOut bytes.Buffer + if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show --json error = %v", err) + } + var shown state.ProjectIdentity + if err := json.Unmarshal(showOut.Bytes(), &shown); err != nil { + t.Fatalf("json.Unmarshal(show) error = %v\n%s", err, showOut.String()) + } + + var dryRunOut bytes.Buffer + if err := (Runner{Stdout: &dryRunOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "Preview Loaf", "--dry-run", "--json"}); err != nil { + t.Fatalf("project rename --dry-run --json error = %v", err) + } + var preview state.ProjectRenameResult + if err := json.Unmarshal(dryRunOut.Bytes(), &preview); err != nil { + t.Fatalf("json.Unmarshal(dry-run rename) error = %v\n%s", err, dryRunOut.String()) + } + if preview.ContractVersion != state.StateJSONContractVersion || preview.Project.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("preview contract versions = %d/%d, want %d", preview.ContractVersion, preview.Project.ContractVersion, state.StateJSONContractVersion) + } + if preview.DatabaseScope != "global" || preview.Project.DatabaseScope != "global" { + t.Fatalf("preview scopes = %q/%q, want global/global", preview.DatabaseScope, preview.Project.DatabaseScope) + } + if preview.Action != "dry-run" || preview.Project.ID != shown.ID || preview.FromName != shown.FriendlyName || preview.ToName != "Preview Loaf" { + t.Fatalf("preview = %#v, want dry-run rename from %q to Preview Loaf", preview, shown.FriendlyName) + } + if preview.Project.FriendlyName != "Preview Loaf" { + t.Fatalf("preview project friendly name = %q, want Preview Loaf", preview.Project.FriendlyName) + } + + var afterOut bytes.Buffer + if err := (Runner{Stdout: &afterOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show after dry-run --json error = %v", err) + } + var after state.ProjectIdentity + if err := json.Unmarshal(afterOut.Bytes(), &after); err != nil { + t.Fatalf("json.Unmarshal(after dry-run show) error = %v\n%s", err, afterOut.String()) + } + if after.ID != shown.ID || after.FriendlyName != shown.FriendlyName { + t.Fatalf("after dry-run = %#v, want unchanged friendly name %q", after, shown.FriendlyName) + } + + var humanOut bytes.Buffer + if err := (Runner{Stdout: &humanOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "Preview Loaf", "--dry-run"}); err != nil { + t.Fatalf("project rename --dry-run error = %v", err) + } + for _, want := range []string{ + "loaf project rename --dry-run", + "scope: global database", + "database:", + "project: " + shown.ID, + "project name: Preview Loaf", + "project path: " + workingDir, + "from name: " + shown.FriendlyName, + "to name: Preview Loaf", + "applied: false", + "next: rerun without --dry-run", + } { + if !strings.Contains(humanOut.String(), want) { + t.Fatalf("human dry-run output = %q, want %q", humanOut.String(), want) + } + } +} + +func TestRunnerProjectMoveDryRunDoesNotWrite(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + movedDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + var showOut bytes.Buffer + if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show --json error = %v", err) + } + var shown state.ProjectIdentity + if err := json.Unmarshal(showOut.Bytes(), &shown); err != nil { + t.Fatalf("json.Unmarshal(show) error = %v\n%s", err, showOut.String()) + } + + var dryRunOut bytes.Buffer + if err := (Runner{Stdout: &dryRunOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", workingDir, "--dry-run", "--json"}); err != nil { + t.Fatalf("project move --dry-run --json error = %v", err) + } + var preview state.ProjectMoveResult + if err := json.Unmarshal(dryRunOut.Bytes(), &preview); err != nil { + t.Fatalf("json.Unmarshal(dry-run move) error = %v\n%s", err, dryRunOut.String()) + } + if preview.ContractVersion != state.StateJSONContractVersion || preview.Project.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("preview contract versions = %d/%d, want %d", preview.ContractVersion, preview.Project.ContractVersion, state.StateJSONContractVersion) + } + if preview.DatabaseScope != "global" || preview.Project.DatabaseScope != "global" { + t.Fatalf("preview scopes = %q/%q, want global/global", preview.DatabaseScope, preview.Project.DatabaseScope) + } + if preview.Action != "dry-run" || preview.Project.ID != shown.ID || preview.Project.CurrentPath != movedDir { + t.Fatalf("preview = %#v, want dry-run with same ID %q and target path %s", preview, shown.ID, movedDir) + } + + var afterOut bytes.Buffer + if err := (Runner{Stdout: &afterOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show after dry-run --json error = %v", err) + } + var after state.ProjectIdentity + if err := json.Unmarshal(afterOut.Bytes(), &after); err != nil { + t.Fatalf("json.Unmarshal(after dry-run show) error = %v\n%s", err, afterOut.String()) + } + if after.ID != shown.ID || after.CurrentPath != workingDir { + t.Fatalf("after dry-run = %#v, want unchanged current path %s", after, workingDir) + } + + var humanOut bytes.Buffer + if err := (Runner{Stdout: &humanOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", workingDir, "--dry-run"}); err != nil { + t.Fatalf("project move --dry-run error = %v", err) + } + for _, want := range []string{ + "loaf project move --dry-run", + "scope: global database", + "database:", + "project: " + shown.ID, + "project name: " + shown.FriendlyName, + "project path: " + movedDir, + "from path: " + workingDir, + "to path: " + movedDir, + "applied: false", + "next: rerun without --dry-run", + } { + if !strings.Contains(humanOut.String(), want) { + t.Fatalf("human dry-run output = %q, want %q", humanOut.String(), want) + } + } +} + +func TestRunnerProjectMoveAcceptsPositionalPaths(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + movedDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + var showOut bytes.Buffer + if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show --json error = %v", err) + } + var shown state.ProjectIdentity + if err := json.Unmarshal(showOut.Bytes(), &shown); err != nil { + t.Fatalf("json.Unmarshal(show) error = %v\n%s", err, showOut.String()) + } + + var dryRunOut bytes.Buffer + if err := (Runner{Stdout: &dryRunOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", workingDir, movedDir, "--dry-run", "--json"}); err != nil { + t.Fatalf("project move positional --dry-run --json error = %v", err) + } + var preview state.ProjectMoveResult + if err := json.Unmarshal(dryRunOut.Bytes(), &preview); err != nil { + t.Fatalf("json.Unmarshal(positional dry-run move) error = %v\n%s", err, dryRunOut.String()) + } + if preview.Action != "dry-run" || preview.FromPath != workingDir || preview.ToPath != movedDir || preview.Project.ID != shown.ID { + t.Fatalf("preview = %#v, want positional dry-run from %s to %s with project ID %s", preview, workingDir, movedDir, shown.ID) + } + + var humanOut bytes.Buffer + if err := (Runner{Stdout: &humanOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", workingDir, movedDir, "--dry-run"}); err != nil { + t.Fatalf("project move positional --dry-run error = %v", err) + } + for _, want := range []string{ + "loaf project move --dry-run", + "from path: " + workingDir, + "to path: " + movedDir, + "applied: false", + } { + if !strings.Contains(humanOut.String(), want) { + t.Fatalf("human positional dry-run output = %q, want %q", humanOut.String(), want) + } + } +} + +func TestRunnerProjectRenameAndMoveHumanApplyOutput(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + movedDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + identity := projectIdentityForCLI(t, workingDir, stateHome) + var renameOut bytes.Buffer + if err := (Runner{Stdout: &renameOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "Friendly Loaf"}); err != nil { + t.Fatalf("project rename human error = %v", err) + } + for _, want := range []string{ + "loaf project rename", + "scope: global database", + "database:", + "project: " + identity.ID, + "project name: Friendly Loaf", + "project path: " + workingDir, + "from name: " + identity.FriendlyName, + "to name: Friendly Loaf", + "applied: true", + } { + if !strings.Contains(renameOut.String(), want) { + t.Fatalf("rename output = %q, want %q", renameOut.String(), want) + } + } + + var moveOut bytes.Buffer + if err := (Runner{Stdout: &moveOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", workingDir}); err != nil { + t.Fatalf("project move human error = %v", err) + } + for _, want := range []string{ + "loaf project move", + "scope: global database", + "database:", + "project: " + identity.ID, + "project name: Friendly Loaf", + "project path: " + movedDir, + "from path: " + workingDir, + "to path: " + movedDir, + "applied: true", + } { + if !strings.Contains(moveOut.String(), want) { + t.Fatalf("move output = %q, want %q", moveOut.String(), want) + } + } + if strings.Contains(renameOut.String(), "next:") || strings.Contains(moveOut.String(), "next:") { + t.Fatalf("apply outputs should not include dry-run next action:\nrename=%q\nmove=%q", renameOut.String(), moveOut.String()) + } +} + +func TestRunnerProjectDryRunsDoNotCreateMissingDatabase(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + for _, args := range [][]string{ + {"project", "rename", "Preview Loaf", "--dry-run", "--json"}, + {"project", "move", "--from", workingDir, "--dry-run", "--json"}, + } { + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run(args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want missing database error", args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if !strings.Contains(output.Error, "state database does not exist") { + t.Fatalf("Run(%v) JSON error = %#v, want missing database message", args, output) + } + } + if _, err := os.Stat(filepath.Join(stateHome, "loaf", "loaf.sqlite")); !os.IsNotExist(err) { + t.Fatalf("state database stat error = %v, want project dry-runs not to create database", err) + } +} + +func TestRunnerProjectMoveDoesNotCreateMissingDatabase(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", filepath.Join(t.TempDir(), "missing"), "--json"}) + if err == nil { + t.Fatal("project move unknown --from error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "project move" || !strings.Contains(output.Error, "state database does not exist") { + t.Fatalf("project move JSON error = %#v, want machine-readable missing database rejection", output) + } + if _, err := os.Stat(filepath.Join(stateHome, "loaf", "loaf.sqlite")); !os.IsNotExist(err) { + t.Fatalf("state database stat error = %v, want rejected project move not to create database", err) + } +} + +func TestRunnerProjectMoveUnknownFromDoesNotCreateProject(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", filepath.Join(t.TempDir(), "missing"), "--json"}) + if err == nil { + t.Fatal("project move unknown --from error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "project move" || !strings.Contains(output.Error, "not registered") { + t.Fatalf("project move JSON error = %#v, want machine-readable unknown path rejection", output) + } + db, openErr := sql.Open("sqlite3", filepath.Join(stateHome, "loaf", "loaf.sqlite")) + if openErr != nil { + t.Fatalf("sql.Open() error = %v", openErr) + } + defer db.Close() + if got := sqliteCount(t, db, `SELECT COUNT(*) FROM projects`); got != 1 { + t.Fatalf("projects = %d, want only initialized project row after rejected move", got) + } +} + +func TestRunnerProjectMoveRejectsMissingTargetPath(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + missingTarget := filepath.Join(t.TempDir(), "missing-target") + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", workingDir, "--to", missingTarget, "--dry-run", "--json"}) + if err == nil { + t.Fatal("project move missing --to error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "project move" || !strings.Contains(output.Error, "target path does not exist") { + t.Fatalf("project move JSON error = %#v, want missing target path rejection", output) + } + + var showOut bytes.Buffer + if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show after rejected move --json error = %v", err) + } + var shown state.ProjectIdentity + if err := json.Unmarshal(showOut.Bytes(), &shown); err != nil { + t.Fatalf("json.Unmarshal(show) error = %v\n%s", err, showOut.String()) + } + if shown.CurrentPath != workingDir { + t.Fatalf("CurrentPath = %q, want unchanged %q", shown.CurrentPath, workingDir) + } +} + +func TestRunnerProjectJSONValidationErrorsAreMachineReadable(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var initOut bytes.Buffer + if err := (Runner{Stdout: &initOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + tests := []struct { + name string + args []string + command string + want string + }{ + { + name: "rename parse error", + args: []string{"project", "rename", "--json"}, + command: "project rename", + want: "requires a name", + }, + { + name: "rename store validation error", + args: []string{"project", "rename", " ", "--dry-run", "--json"}, + command: "project rename", + want: "project name cannot be empty", + }, + { + name: "move parse error", + args: []string{"project", "move", "--from", "relative/path", "--json"}, + command: "project move", + want: "requires absolute", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON validation error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != tc.command || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want command %q and error containing %q", output, tc.command, tc.want) + } + }) + } +} + +func TestRunnerJSONErrorFallbackWrapsUnownedErrors(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + tests := []struct { + name string + args []string + command string + want string + }{ + { + name: "idea promote parse error", + args: []string{"idea", "promote", "--json"}, + command: "idea promote", + want: "requires an idea", + }, + { + name: "idea resolve parse error", + args: []string{"idea", "resolve", "--json"}, + command: "idea resolve", + want: "requires an idea", + }, + { + name: "spark capture parse error", + args: []string{"spark", "capture", "--json"}, + command: "spark capture", + want: "requires --text", + }, + { + name: "unknown nested subcommand", + args: []string{"idea", "nope", "--json"}, + command: "idea nope", + want: "unknown loaf idea subcommand", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON validation error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != tc.command || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want command %q and error containing %q", output, tc.command, tc.want) + } + }) + } +} + +func TestRunnerStateInitStatusAndDoctor(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var statusBefore bytes.Buffer + err := Runner{ + Stdout: &statusBefore, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "status", "--json"}) + if err != nil { + t.Fatalf("state status before init error = %v", err) + } + before := decodeStateStatus(t, statusBefore.Bytes()) + if before.Mode != state.ModeMarkdownOnly { + t.Fatalf("before.Mode = %q, want %q", before.Mode, state.ModeMarkdownOnly) + } + if before.DatabaseScope != "global" { + t.Fatalf("before.DatabaseScope = %q, want global", before.DatabaseScope) + } + if before.DatabaseExists { + t.Fatal("before.DatabaseExists = true, want false") + } + if before.ProjectID != "" { + t.Fatalf("before.ProjectID = %q, want empty before SQLite records durable identity", before.ProjectID) + } + if before.LegacyProjectKey == "" { + t.Fatal("before.LegacyProjectKey is empty") + } + assertJSONFieldAbsent(t, statusBefore.Bytes(), "project_id") + assertJSONFieldPresent(t, statusBefore.Bytes(), "database_scope") + assertJSONFieldPresent(t, statusBefore.Bytes(), "legacy_project_key") + + var initOut bytes.Buffer + err = Runner{ + Stdout: &initOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "init", "--json"}) + if err != nil { + t.Fatalf("state init error = %v", err) + } + initialized := decodeStateStatus(t, initOut.Bytes()) + if initialized.Mode != state.ModeSQLiteReady { + t.Fatalf("initialized.Mode = %q, want %q", initialized.Mode, state.ModeSQLiteReady) + } + if initialized.DatabaseScope != "global" { + t.Fatalf("initialized.DatabaseScope = %q, want global", initialized.DatabaseScope) + } + if initialized.SchemaVersion != state.CurrentSchemaVersion() { + t.Fatalf("initialized.SchemaVersion = %d, want %d", initialized.SchemaVersion, state.CurrentSchemaVersion()) + } + if initialized.ProjectID == "" { + t.Fatal("initialized.ProjectID is empty after SQLite records durable identity") + } + if initialized.ProjectID == initialized.LegacyProjectKey { + t.Fatalf("initialized.ProjectID = legacy key %q, want generated durable identity", initialized.ProjectID) + } + assertJSONFieldPresent(t, initOut.Bytes(), "project_id") + assertJSONFieldPresent(t, initOut.Bytes(), "legacy_project_key") + if _, err := os.Stat(initialized.DatabasePath); err != nil { + t.Fatalf("state init did not create database: %v", err) + } + + var humanStatusOut bytes.Buffer + err = Runner{ + Stdout: &humanStatusOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "status"}) + if err != nil { + t.Fatalf("state status error = %v", err) + } + for _, want := range []string{"loaf state status", "scope: global database", "project: " + initialized.ProjectID, "project name: " + initialized.ProjectName, "project path:", "mode: " + state.ModeSQLiteReady} { + if !strings.Contains(humanStatusOut.String(), want) { + t.Fatalf("state status output = %q, want %q", humanStatusOut.String(), want) + } + } + if strings.Contains(humanStatusOut.String(), "project id:") { + t.Fatalf("state status output = %q, want normalized project identity labels", humanStatusOut.String()) + } + + var doctorOut bytes.Buffer + err = Runner{ + Stdout: &doctorOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor"}) + if err != nil { + t.Fatalf("state doctor error = %v", err) + } + if !strings.Contains(doctorOut.String(), "mode: "+state.ModeSQLiteReady) { + t.Fatalf("doctor output = %q, want sqlite-ready mode", doctorOut.String()) + } + for _, want := range []string{"scope: global database", "project: " + initialized.ProjectID, "project name: " + initialized.ProjectName, "project path:", "schema version:"} { + if !strings.Contains(doctorOut.String(), want) { + t.Fatalf("doctor output = %q, want %q", doctorOut.String(), want) + } + } + if strings.Contains(doctorOut.String(), "project id:") { + t.Fatalf("doctor output = %q, want normalized project identity labels", doctorOut.String()) + } +} + +func TestRunnerStateLifecycleJSONErrorsAreMachineReadable(t *testing.T) { + tests := []struct { + name string + args []string + command string + want string + }{ + { + name: "init unknown option", + args: []string{"state", "init", "--json", "--bogus"}, + command: "state init", + want: "unknown option", + }, + { + name: "status unknown option", + args: []string{"state", "status", "--json", "--bogus"}, + command: "state status", + want: "unknown option", + }, + { + name: "doctor unknown option", + args: []string{"state", "doctor", "--json", "--bogus"}, + command: "state doctor", + want: "unknown option", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: realpath(t, t.TempDir()), + StateHome: t.TempDir(), + }.Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != tc.command || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want command %q and error containing %q", output, tc.command, tc.want) + } + }) + } +} + +func TestRunnerStateHelpIsNative(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + tests := []struct { + name string + args []string + want string + }{ + {name: "state", args: []string{"state", "--help"}, want: "Usage: loaf state <command> [options]"}, + {name: "state path", args: []string{"state", "path", "--help"}, want: "Usage: loaf state path [--json|--verbose]"}, + {name: "state init", args: []string{"state", "init", "--help"}, want: "Usage: loaf state init [--json]"}, + {name: "state doctor", args: []string{"state", "doctor", "--help"}, want: "Usage: loaf state doctor [--fix] [--dry-run] [--json]"}, + {name: "state repair", args: []string{"state", "repair", "--help"}, want: "Usage: loaf state repair <target> [options]"}, + {name: "state repair legacy-project-database", args: []string{"state", "repair", "legacy-project-database", "--help"}, want: "Usage: loaf state repair legacy-project-database [--dry-run|--apply] [--json]"}, + {name: "state repair relationship-origin", args: []string{"state", "repair", "relationship-origin", "--help"}, want: "Usage: loaf state repair relationship-origin --origin <imported|manual> [--dry-run|--apply] [--json]"}, + {name: "state migrate", args: []string{"state", "migrate", "--help"}, want: "Usage: loaf state migrate <source> [options]"}, + {name: "project list", args: []string{"project", "list", "--help"}, want: "Usage: loaf project list [--json]"}, + {name: "project identity", args: []string{"project", "identity", "--help"}, want: "Usage: loaf project show|identity [--json]"}, + {name: "project rename", args: []string{"project", "rename", "--help"}, want: "Usage: loaf project rename <name> [--dry-run] [--json]"}, + {name: "project move", args: []string{"project", "move", "--help"}, want: "Usage: loaf project move <from> [to] [--dry-run] [--json]"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tt.args) + if err != nil { + t.Fatalf("Run(%v) error = %v", tt.args, err) + } + if !strings.Contains(stdout.String(), tt.want) { + t.Fatalf("output = %q, want %q", stdout.String(), tt.want) + } + }) + } +} + +func TestRunnerStateAndProjectJSONHelpNamesContracts(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + tests := []struct { + name string + args []string + wants []string + }{ + {name: "state path", args: []string{"state", "path", "--help"}, wants: []string{"--json", "contract version", "database scope", "database path"}}, + {name: "state init", args: []string{"state", "init", "--help"}, wants: []string{"--json", "readiness mode", "global database scope", "project identity"}}, + {name: "state status", args: []string{"state", "status", "--help"}, wants: []string{"--json", "readiness mode", "diagnostics", "project identity"}}, + {name: "state doctor", args: []string{"state", "doctor", "--help"}, wants: []string{"--json", "diagnostics", "repair plan", "global database scope"}}, + {name: "state backup", args: []string{"state", "backup", "--help"}, wants: []string{"--json", "backup verification", "checksum", "current project identity"}}, + {name: "state backup verify", args: []string{"state", "backup", "verify", "--help"}, wants: []string{"--json", "restore guidance", "schema version", "captured project identities"}}, + {name: "project list", args: []string{"project", "list", "--help"}, wants: []string{"--json", "database path", "friendly names", "current paths"}}, + {name: "project show", args: []string{"project", "show", "--help"}, wants: []string{"--json", "project ID", "friendly name", "current path", "database path"}}, + {name: "project rename", args: []string{"project", "rename", "--help"}, wants: []string{"--json", "friendly name", "database path", "applied status"}}, + {name: "project move", args: []string{"project", "move", "--help"}, wants: []string{"--json", "current path", "database path", "applied status"}}, + {name: "state repair legacy", args: []string{"state", "repair", "legacy-project-database", "--help"}, wants: []string{"--json", "archive plan/result", "global database scope", "project identity"}}, + {name: "state repair relationship", args: []string{"state", "repair", "relationship-origin", "--help"}, wants: []string{"--json", "repair plan/result", "global database scope", "project identity"}}, + {name: "state migrate markdown", args: []string{"state", "migrate", "markdown", "--help"}, wants: []string{"--json", "migration contract", "project context", "counts"}}, + {name: "state migrate storage-home", args: []string{"state", "migrate", "storage-home", "--help"}, wants: []string{"--json", "migration contract", "global database paths", "project identity"}}, + {name: "migrate markdown", args: []string{"migrate", "markdown", "--help"}, wants: []string{"--json", "migration contract", "project context", "counts"}}, + {name: "migrate storage-home", args: []string{"migrate", "storage-home", "--help"}, wants: []string{"--json", "migration contract", "global database paths", "project identity"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tt.args) + if err != nil { + t.Fatalf("Run(%v) error = %v", tt.args, err) + } + output := stdout.String() + if strings.Contains(output, "Output JSON") { + t.Fatalf("output = %q, want specific JSON contract wording", output) + } + for _, want := range tt.wants { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } + }) + } +} + +func TestRunnerTaskStatusHelpNamesValidStatuses(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + tests := []struct { + name string + args []string + want string + }{ + {name: "task list", args: []string{"task", "list", "--help"}, want: "--status Filter by status: in_progress, blocked, todo, review, done, archived"}, + {name: "task update", args: []string{"task", "update", "--help"}, want: "--status New task status: in_progress, blocked, todo, review, done"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tt.args) + if err != nil { + t.Fatalf("Run(%v) error = %v", tt.args, err) + } + if !strings.Contains(stdout.String(), tt.want) { + t.Fatalf("output = %q, want %q", stdout.String(), tt.want) + } + }) + } +} + +func TestRunnerTaskStatusErrorsNameValidStatuses(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "task list", args: []string{"task", "list", "--status", "open"}, want: `invalid status "open" (valid: in_progress, blocked, todo, review, done, archived)`}, + {name: "task update", args: []string{"task", "update", "TASK-001", "--status", "archived"}, want: `invalid status "archived" (valid: in_progress, blocked, todo, review, done)`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Runner{ + Stdout: &bytes.Buffer{}, + WorkingDir: realpath(t, t.TempDir()), + StateHome: t.TempDir(), + }.Run(tt.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want invalid status error", tt.args) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %q, want %q", err.Error(), tt.want) + } + }) + } +} + +func TestRunnerTaskPriorityHelpNamesValidPriorities(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + tests := []struct { + name string + args []string + want string + }{ + {name: "task create", args: []string{"task", "create", "--help"}, want: "--priority Task priority: P0, P1, P2, P3"}, + {name: "task update", args: []string{"task", "update", "--help"}, want: "--priority New task priority: P0, P1, P2, P3"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tt.args) + if err != nil { + t.Fatalf("Run(%v) error = %v", tt.args, err) + } + if !strings.Contains(stdout.String(), tt.want) { + t.Fatalf("output = %q, want %q", stdout.String(), tt.want) + } + }) + } +} + +func TestRunnerTaskPriorityErrorsNameValidPriorities(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "task create", args: []string{"task", "create", "--title", "Bad", "--priority", "P9"}, want: `invalid priority "P9" (valid: P0, P1, P2, P3)`}, + {name: "task update", args: []string{"task", "update", "TASK-001", "--priority", "P9"}, want: `invalid priority "P9" (valid: P0, P1, P2, P3)`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Runner{ + Stdout: &bytes.Buffer{}, + WorkingDir: realpath(t, t.TempDir()), + StateHome: t.TempDir(), + }.Run(tt.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want invalid priority error", tt.args) + } + if !strings.Contains(err.Error(), tt.want) { + t.Fatalf("error = %q, want %q", err.Error(), tt.want) + } + }) + } +} + +func TestRunnerTaskJSONValidationErrorsAreMachineReadable(t *testing.T) { + tests := []struct { + name string + args []string + command string + want string + }{ + { + name: "list invalid status", + args: []string{"task", "list", "--json", "--status", "open"}, + command: "task list", + want: `invalid status "open"`, + }, + { + name: "create invalid priority", + args: []string{"task", "create", "--title", "Bad", "--priority", "P9", "--json"}, + command: "task create", + want: `invalid priority "P9"`, + }, + { + name: "update invalid status", + args: []string{"task", "update", "TASK-001", "--status", "archived", "--json"}, + command: "task update", + want: `invalid status "archived"`, + }, + { + name: "update invalid priority", + args: []string{"task", "update", "TASK-001", "--priority", "P9", "--json"}, + command: "task update", + want: `invalid priority "P9"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: realpath(t, t.TempDir()), + StateHome: t.TempDir(), + }.Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON validation error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != tc.command || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want command %q and error containing %q", output, tc.command, tc.want) + } + }) + } +} + +func TestRunnerStateInitHumanOutputPrintsRepositoryExternalDatabaseWithoutSecrets(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "init"}) + if err != nil { + t.Fatalf("state init error = %v", err) + } + + output := stdout.String() + for _, want := range []string{"scope: global database", "project:", "project name:", "project path:"} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } + if strings.Contains(output, "project id:") { + t.Fatalf("output = %q, want normalized project identity labels", output) + } + databasePath := "" + for _, line := range strings.Split(output, "\n") { + if strings.HasPrefix(line, "database: ") { + databasePath = strings.TrimSpace(strings.TrimPrefix(line, "database: ")) + break + } + } + if databasePath == "" { + t.Fatalf("output = %q, want database path line", output) + } + if !filepath.IsAbs(databasePath) { + t.Fatalf("database path = %q, want absolute path", databasePath) + } + if databasePath != filepath.Join(stateHome, "loaf", "loaf.sqlite") { + t.Fatalf("database path = %q, want under state home %q", databasePath, stateHome) + } + if strings.HasPrefix(databasePath, workingDir+string(filepath.Separator)) { + t.Fatalf("database path = %q, want outside working dir %q", databasePath, workingDir) + } + if _, err := os.Stat(databasePath); err != nil { + t.Fatalf("database was not created at printed path: %v", err) + } + if _, err := os.Stat(filepath.Join(workingDir, ".agents")); !os.IsNotExist(err) { + t.Fatalf("state init created repository .agents directory; err = %v", err) + } + lowerOutput := strings.ToLower(output) + for _, forbidden := range []string{"token", "password", "secret", "api_key", "api key", "credential"} { + if strings.Contains(lowerOutput, forbidden) { + t.Fatalf("state init output contains forbidden secret-storage term %q:\n%s", forbidden, output) + } + } +} + +func TestRunnerStateDoctorFixInitializesMissingDatabase(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor", "--fix", "--json"}) + if err != nil { + t.Fatalf("state doctor --fix error = %v", err) + } + status := decodeStateStatus(t, stdout.Bytes()) + if status.Mode != state.ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q", status.Mode, state.ModeSQLiteReady) + } + if _, err := os.Stat(status.DatabasePath); err != nil { + t.Fatalf("doctor --fix did not create database: %v", err) + } + if !hasDiagnostic(status.Diagnostics, "database-initialized") { + t.Fatalf("diagnostics = %#v, want database-initialized", status.Diagnostics) + } +} + +func TestRunnerStateDoctorDryRunShowsRepairPlanWithoutCreatingDatabase(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + for _, args := range [][]string{ + {"state", "doctor", "--dry-run", "--json"}, + {"state", "doctor", "--fix", "--dry-run", "--json"}, + } { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(args) + if err != nil { + t.Fatalf("%v error = %v", args, err) + } + status := decodeStateStatus(t, stdout.Bytes()) + if status.Mode != state.ModeMarkdownOnly { + t.Fatalf("Mode = %q, want %q", status.Mode, state.ModeMarkdownOnly) + } + action := findStateRepairAction(t, status.RepairPlan, "initialize-database") + if !action.Safe || action.Applied { + t.Fatalf("repair action = %#v, want safe unapplied initialization", action) + } + if action.Path != status.DatabasePath { + t.Fatalf("repair action path = %q, want %q", action.Path, status.DatabasePath) + } + assertNoStateDatabase(t, workingDir, stateHome) + }) + } +} + +func TestRunnerStateDoctorJSONIncludesRepairPlanForDiagnostics(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var missingOut bytes.Buffer + err := Runner{ + Stdout: &missingOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor", "--json"}) + if err != nil { + t.Fatalf("state doctor --json missing database error = %v", err) + } + missing := decodeStateStatus(t, missingOut.Bytes()) + if missing.Mode != state.ModeMarkdownOnly { + t.Fatalf("missing Mode = %q, want %q", missing.Mode, state.ModeMarkdownOnly) + } + action := findStateRepairAction(t, missing.RepairPlan, "initialize-database") + if !action.Safe || action.Applied { + t.Fatalf("missing repair action = %#v, want safe unapplied initialization", action) + } + assertNoStateDatabase(t, workingDir, stateHome) + + var initOut bytes.Buffer + if err := (Runner{Stdout: &initOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + initialized := decodeStateStatus(t, initOut.Bytes()) + db, err := sql.Open("sqlite3", initialized.DatabasePath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-wrong-project', ?, 'linear', 'project', 'project-missing', 'project', 'LIN-PROJ-124', 'https://linear.app/workspace/project/LIN-PROJ-124', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert invalid backend mapping error = %v", err) + } + + var invalidOut bytes.Buffer + err = Runner{ + Stdout: &invalidOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor", "--json"}) + if err == nil { + t.Fatal("state doctor --json invalid database error = nil, want nonzero exit") + } + assertSilentExitCode(t, err, 1) + invalid := decodeStateStatus(t, invalidOut.Bytes()) + if invalid.Mode != state.ModeInvalid { + t.Fatalf("invalid Mode = %q, want %q", invalid.Mode, state.ModeInvalid) + } + action = findStateRepairAction(t, invalid.RepairPlan, "inspect-backend-mappings") + if action.Safe || action.Applied { + t.Fatalf("invalid repair action = %#v, want manual unapplied audit", action) + } + if action.Command != "loaf state doctor --json" { + t.Fatalf("invalid repair action command = %q, want state doctor JSON", action.Command) + } + if action.Category != state.RepairCategoryBackendMapping || action.RequiresExternalSync { + t.Fatalf("invalid repair action = %#v, want local backend mapping inspection", action) + } +} + +func TestRunnerStateDoctorRepairPlanCommandsExecuteInDiagnosticMode(t *testing.T) { + tests := []struct { + name string + actionCode string + setup func(t *testing.T) (string, string) + wantCommand string + wantExitCode int + verifyCommand func(t *testing.T, output []byte) + }{ + { + name: "missing database initializes through doctor fix", + actionCode: "initialize-database", + wantCommand: "loaf state doctor --fix", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + return realpath(t, t.TempDir()), t.TempDir() + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + text := string(output) + if !strings.Contains(text, "loaf state doctor") || !strings.Contains(text, "info: SQLite state database initialized") { + t.Fatalf("doctor --fix output = %q, want initialized SQLite state", text) + } + }, + }, + { + name: "legacy storage-home migration previews while markdown-only", + actionCode: "migrate-storage-home", + wantCommand: "loaf state migrate storage-home --dry-run", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir := realpath(t, t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + initializeCLILegacyStateDatabase(t, root) + return workingDir, "" + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + text := string(output) + if !strings.Contains(text, "loaf state migrate storage-home --dry-run") || !strings.Contains(text, "applied: false") { + t.Fatalf("storage-home dry-run output = %q, want preview output", text) + } + }, + }, + { + name: "legacy project database repair dry-run previews leftover", + actionCode: "review-legacy-project-database", + wantCommand: "loaf state repair legacy-project-database --dry-run --json", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir := realpath(t, t.TempDir()) + t.Setenv("XDG_DATA_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + initializeCLILegacyStateDatabase(t, root) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + return workingDir, "" + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + result := decodeLegacyProjectDatabaseArchiveResult(t, output) + if result.Applied || len(result.MatchedPaths) == 0 { + t.Fatalf("legacy repair dry-run = %#v, want matched unapplied archive plan", result) + } + }, + }, + { + name: "schema drift inspection keeps doctor JSON executable", + actionCode: "inspect-schema-migrations", + wantCommand: "loaf state doctor --json", + wantExitCode: 1, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(`UPDATE schema_migrations SET checksum = 'drifted' WHERE version = 1`); err != nil { + t.Fatalf("drift schema checksum error = %v", err) + } + closeCLITestDB(t, db) + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + status := decodeStateStatus(t, output) + if status.Mode != state.ModeInvalid || !hasDiagnostic(status.Diagnostics, "schema-checksum-mismatch") { + t.Fatalf("doctor output = %#v, want schema checksum mismatch", status) + } + }, + }, + { + name: "SQLite invariant inspection keeps doctor JSON executable", + actionCode: "inspect-state-invariants", + wantCommand: "loaf state doctor --json", + wantExitCode: 1, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { + t.Fatalf("disable foreign keys error = %v", err) + } + if _, err := db.Exec(` +INSERT INTO aliases (id, project_id, entity_kind, entity_id, namespace, alias, created_at, updated_at) +VALUES ('alias-orphaned-project', 'project-missing', 'task', 'task-missing', 'task', 'TASK-MISSING', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`); err != nil { + t.Fatalf("insert orphaned alias fixture error = %v", err) + } + closeCLITestDB(t, db) + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + status := decodeStateStatus(t, output) + if status.Mode != state.ModeInvalid || !hasDiagnostic(status.Diagnostics, "sqlite-foreign-key-violation") { + t.Fatalf("doctor output = %#v, want foreign-key violation", status) + } + }, + }, + { + name: "project path invariant repair can list projects", + actionCode: "repair-project-path-invariants", + wantCommand: "loaf project list --json", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(`UPDATE projects SET current_path = ? WHERE id = ?`, filepath.Join(workingDir, "stale"), initialized.ProjectID); err != nil { + t.Fatalf("drift project current_path error = %v", err) + } + closeCLITestDB(t, db) + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + var projects state.ProjectList + if err := json.Unmarshal(output, &projects); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(output), err) + } + if projects.DatabaseScope != "global" || len(projects.Projects) != 1 { + t.Fatalf("project list = %#v, want global project index", projects) + } + }, + }, + { + name: "relationship provenance repair dry-run executes", + actionCode: "audit-relationship-origin", + wantCommand: "loaf state repair relationship-origin --origin imported --dry-run --json", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(` +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) +VALUES ('relationship-without-origin', ?, 'task', 'task-one', 'spec', 'spec-one', 'implements', 'legacy row', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert relationship without origin error = %v", err) + } + closeCLITestDB(t, db) + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + result := decodeRelationshipOriginRepairResult(t, output) + if result.Applied || result.Matched != 1 || result.Updated != 0 { + t.Fatalf("relationship repair dry-run = %#v, want one matched unapplied row", result) + } + }, + }, + { + name: "invalid backend mappings keep doctor JSON executable", + actionCode: "inspect-backend-mappings", + wantCommand: "loaf state doctor --json", + wantExitCode: 1, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-wrong-project', ?, 'linear', 'project', 'project-missing', 'project', 'LIN-PROJ-124', 'https://linear.app/workspace/project/LIN-PROJ-124', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert invalid backend mapping error = %v", err) + } + closeCLITestDB(t, db) + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + status := decodeStateStatus(t, output) + if status.Mode != state.ModeInvalid || !hasDiagnostic(status.Diagnostics, "backend-mapping-entity-missing") { + t.Fatalf("doctor output = %#v, want invalid backend mapping diagnostic", status) + } + }, + }, + { + name: "backend mapping drift can export audit snapshot", + actionCode: "audit-backend-mappings", + wantCommand: "loaf state export all --format json", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES ('task-linear-typo', ?, NULL, 'Linear typo task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert task fixture error = %v", err) + } + if _, err := db.Exec(` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-linear-typo', ?, 'linear', 'task', 'task-linear-typo', 'issue', 'ENG-126', 'https://linear.app/workspace/issue/ENG-126', 'lnked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert backend mapping fixture error = %v", err) + } + closeCLITestDB(t, db) + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + snapshot := decodeStateExportSnapshot(t, output) + if snapshot.ExportKind != state.ExportKindAll || !snapshot.Manifest.Verified { + t.Fatalf("export snapshot = %#v, want verified all export", snapshot) + } + if !hasDiagnostic(snapshot.Diagnostics, "backend-mapping-sync-status-unknown") { + t.Fatalf("export diagnostics = %#v, want backend mapping drift diagnostic", snapshot.Diagnostics) + } + action := findStateRepairAction(t, snapshot.RepairPlan, "audit-backend-mappings") + if action.Command != "loaf state export all --format json" || action.RequiresExternalSync { + t.Fatalf("export repair action = %#v, want local backend mapping audit action", action) + } + if snapshot.Manifest.DiagnosticCount != len(snapshot.Diagnostics) || snapshot.Manifest.RepairActionCount != len(snapshot.RepairPlan) { + t.Fatalf("export manifest = %#v, want diagnostic and repair counts matching payload", snapshot.Manifest) + } + }, + }, + { + name: "Linear task mapping gaps can export sync snapshot", + actionCode: "reconcile-linear-task-mappings", + wantCommand: "loaf state export all --format json", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + writeCLIAgentsFile(t, workingDir, "loaf.json", `{"integrations":{"linear":{"enabled":true}}}`) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES ('task-active-unmapped', ?, NULL, 'Active unmapped task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert task fixture error = %v", err) + } + closeCLITestDB(t, db) + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + snapshot := decodeStateExportSnapshot(t, output) + if snapshot.ExportKind != state.ExportKindAll || !snapshot.Manifest.Verified { + t.Fatalf("export snapshot = %#v, want verified Linear reconciliation export", snapshot) + } + if !hasDiagnostic(snapshot.Diagnostics, "linear-mode-local-task-unmapped") { + t.Fatalf("export diagnostics = %#v, want Linear unmapped task diagnostic", snapshot.Diagnostics) + } + action := findStateRepairAction(t, snapshot.RepairPlan, "reconcile-linear-task-mappings") + if action.Command != "loaf state export all --format json" || !action.RequiresExternalSync { + t.Fatalf("export repair action = %#v, want external Linear reconciliation action", action) + } + if snapshot.Manifest.DiagnosticCount != len(snapshot.Diagnostics) || snapshot.Manifest.RepairActionCount != len(snapshot.RepairPlan) { + t.Fatalf("export manifest = %#v, want diagnostic and repair counts matching payload", snapshot.Manifest) + } + }, + }, + { + name: "local markdown import preview executes", + actionCode: "migrate-current-project-markdown", + wantCommand: "loaf state migrate markdown --dry-run", + wantExitCode: 0, + setup: func(t *testing.T) (string, string) { + t.Helper() + workingDir, stateHome, _ := initCLIStateForRepairCommand(t) + writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-local.md", "# Local Task\n") + return workingDir, stateHome + }, + verifyCommand: func(t *testing.T, output []byte) { + t.Helper() + text := string(output) + if !strings.Contains(text, "loaf state migrate markdown --dry-run") || !strings.Contains(text, "tasks: 1") { + t.Fatalf("markdown dry-run output = %q, want preview output", text) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + workingDir, stateHome := tc.setup(t) + action := doctorRepairActionForCLI(t, workingDir, stateHome, tc.actionCode) + if action.Command != tc.wantCommand { + t.Fatalf("repair action command = %q, want %q", action.Command, tc.wantCommand) + } + output := runRepairActionCommandForCLI(t, workingDir, stateHome, action, tc.wantExitCode) + if tc.verifyCommand != nil { + tc.verifyCommand(t, output) + } + }) + } +} + +func TestRunnerStateDoctorDryRunJSONUsesStableEmptyRepairPlan(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state doctor --dry-run --json error = %v", err) + } + assertJSONArrayLength(t, stdout.Bytes(), "repair_plan", 0) +} + +func TestRunnerStateDoctorDryRunShowsLegacyLeftoverManualAction(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + legacyPath := initializeCLILegacyStateDatabase(t, root) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + + var stdout bytes.Buffer + err = Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + }.Run([]string{"state", "doctor", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state doctor --dry-run --json error = %v", err) + } + status := decodeStateStatus(t, stdout.Bytes()) + if !hasDiagnostic(status.Diagnostics, "legacy-project-database-leftover") { + t.Fatalf("diagnostics = %#v, want legacy leftover", status.Diagnostics) + } + action := findStateRepairAction(t, status.RepairPlan, "review-legacy-project-database") + if action.Safe || action.Applied { + t.Fatalf("repair action = %#v, want manual unapplied legacy review", action) + } + if action.Command != "loaf state repair legacy-project-database --dry-run --json" { + t.Fatalf("repair action command = %q, want legacy archive dry-run", action.Command) + } + if action.Path != legacyPath { + t.Fatalf("repair action path = %q, want %q", action.Path, legacyPath) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy database was removed during dry-run: %v", err) + } +} + +func TestRunnerStateDoctorDryRunShowsRelationshipOriginAuditAction(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var initOut bytes.Buffer + if err := (Runner{Stdout: &initOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init error = %v", err) + } + initialized := decodeStateStatus(t, initOut.Bytes()) + db, err := sql.Open("sqlite3", initialized.DatabasePath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(` +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) +VALUES ('relationship-without-origin', ?, 'task', 'task-one', 'spec', 'spec-one', 'implements', 'legacy row', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert relationship without origin error = %v", err) + } + + var stdout bytes.Buffer + err = Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state doctor --dry-run --json error = %v", err) + } + status := decodeStateStatus(t, stdout.Bytes()) + if status.Mode != state.ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q for relationship provenance warning", status.Mode, state.ModeSQLiteReady) + } + if !hasDiagnostic(status.Diagnostics, "relationship-origin-missing") { + t.Fatalf("diagnostics = %#v, want relationship-origin-missing", status.Diagnostics) + } + action := findStateRepairAction(t, status.RepairPlan, "audit-relationship-origin") + if action.Safe || action.Applied { + t.Fatalf("repair action = %#v, want manual unapplied relationship audit", action) + } + if action.Command != "loaf state repair relationship-origin --origin imported --dry-run --json" { + t.Fatalf("repair action command = %q, want guarded relationship origin repair command", action.Command) + } +} + +func TestRunnerStateRepairRelationshipOriginDryRunAndApply(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + var initOut bytes.Buffer + if err := (Runner{Stdout: &initOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init error = %v", err) + } + initialized := decodeStateStatus(t, initOut.Bytes()) + db, err := sql.Open("sqlite3", initialized.DatabasePath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(` +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) +VALUES ('relationship-without-origin', ?, 'task', 'task-one', 'spec', 'spec-one', 'implements', 'legacy row', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert relationship without origin error = %v", err) + } + + var humanDryRunOut bytes.Buffer + err = Runner{ + Stdout: &humanDryRunOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "repair", "relationship-origin", "--origin", "imported", "--dry-run"}) + if err != nil { + t.Fatalf("state repair relationship-origin human dry-run error = %v", err) + } + if !strings.Contains(humanDryRunOut.String(), "loaf state repair relationship-origin --dry-run") { + t.Fatalf("human dry-run output = %q, want explicit --dry-run header", humanDryRunOut.String()) + } + for _, want := range []string{"scope: global database", "project:", "project name:", "project path:"} { + if !strings.Contains(humanDryRunOut.String(), want) { + t.Fatalf("human dry-run output = %q, want %q", humanDryRunOut.String(), want) + } + } + if !strings.Contains(humanDryRunOut.String(), "next: rerun with --apply") { + t.Fatalf("human dry-run output = %q, want apply guidance when rows match", humanDryRunOut.String()) + } + + var dryRunOut bytes.Buffer + err = Runner{ + Stdout: &dryRunOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "repair", "relationship-origin", "--origin", "imported", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state repair relationship-origin --dry-run error = %v", err) + } + dryRun := decodeRelationshipOriginRepairResult(t, dryRunOut.Bytes()) + if dryRun.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("dry-run ContractVersion = %d, want %d", dryRun.ContractVersion, state.StateJSONContractVersion) + } + if dryRun.DatabaseScope != "global" { + t.Fatalf("dry-run DatabaseScope = %q, want global", dryRun.DatabaseScope) + } + if dryRun.ProjectID != initialized.ProjectID { + t.Fatalf("dry-run ProjectID = %q, want %q", dryRun.ProjectID, initialized.ProjectID) + } + if dryRun.ProjectName != filepath.Base(workingDir) { + t.Fatalf("dry-run ProjectName = %q, want %q", dryRun.ProjectName, filepath.Base(workingDir)) + } + if dryRun.ProjectCurrentPath != workingDir { + t.Fatalf("dry-run ProjectCurrentPath = %q, want %q", dryRun.ProjectCurrentPath, workingDir) + } + if dryRun.Applied { + t.Fatal("dry-run Applied = true, want false") + } + if dryRun.Matched != 1 || dryRun.Updated != 0 { + t.Fatalf("dry-run result = %#v, want matched 1 updated 0", dryRun) + } + if dryRun.BackupPath != "" { + t.Fatalf("dry-run BackupPath = %q, want empty", dryRun.BackupPath) + } + if got := sqliteCount(t, db, `SELECT COUNT(*) FROM relationships WHERE origin IS NULL OR TRIM(origin) = ''`); got != 1 { + t.Fatalf("relationships without origin after dry-run = %d, want 1", got) + } + + var applyOut bytes.Buffer + err = Runner{ + Stdout: &applyOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "repair", "relationship-origin", "--origin", "imported", "--apply", "--json"}) + if err != nil { + t.Fatalf("state repair relationship-origin --apply error = %v", err) + } + applied := decodeRelationshipOriginRepairResult(t, applyOut.Bytes()) + if applied.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("applied ContractVersion = %d, want %d", applied.ContractVersion, state.StateJSONContractVersion) + } + if applied.DatabaseScope != "global" { + t.Fatalf("applied DatabaseScope = %q, want global", applied.DatabaseScope) + } + if applied.ProjectID != initialized.ProjectID { + t.Fatalf("applied ProjectID = %q, want %q", applied.ProjectID, initialized.ProjectID) + } + if applied.ProjectName != filepath.Base(workingDir) { + t.Fatalf("applied ProjectName = %q, want %q", applied.ProjectName, filepath.Base(workingDir)) + } + if applied.ProjectCurrentPath != workingDir { + t.Fatalf("applied ProjectCurrentPath = %q, want %q", applied.ProjectCurrentPath, workingDir) + } + if !applied.Applied { + t.Fatal("apply Applied = false, want true") + } + if applied.Matched != 1 || applied.Updated != 1 { + t.Fatalf("apply result = %#v, want matched 1 updated 1", applied) + } + if applied.BackupPath == "" { + t.Fatal("apply BackupPath is empty") + } + if _, err := os.Stat(applied.BackupPath); err != nil { + t.Fatalf("apply backup does not exist: %v", err) + } + if got := sqliteCount(t, db, `SELECT COUNT(*) FROM relationships WHERE origin = 'imported'`); got != 1 { + t.Fatalf("relationships with imported origin = %d, want 1", got) + } + + var noopHumanOut bytes.Buffer + err = Runner{ + Stdout: &noopHumanOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "repair", "relationship-origin", "--origin", "imported", "--dry-run"}) + if err != nil { + t.Fatalf("state repair relationship-origin no-op human dry-run error = %v", err) + } + if !strings.Contains(noopHumanOut.String(), "loaf state repair relationship-origin --dry-run") { + t.Fatalf("no-op human output = %q, want explicit --dry-run header", noopHumanOut.String()) + } + if strings.Contains(noopHumanOut.String(), "next: rerun with --apply") { + t.Fatalf("no-op human output = %q, want no apply guidance when no rows match", noopHumanOut.String()) + } +} + +func TestRunnerStateRepairLegacyProjectDatabaseDryRunAndApply(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + legacyPath := initializeCLILegacyStateDatabase(t, root) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + + var humanDryRunOut bytes.Buffer + err = Runner{ + Stdout: &humanDryRunOut, + WorkingDir: workingDir, + }.Run([]string{"state", "repair", "legacy-project-database", "--dry-run"}) + if err != nil { + t.Fatalf("state repair legacy-project-database human dry-run error = %v", err) + } + if !strings.Contains(humanDryRunOut.String(), "loaf state repair legacy-project-database --dry-run") { + t.Fatalf("human dry-run output = %q, want explicit --dry-run header", humanDryRunOut.String()) + } + for _, want := range []string{"scope: global database", "project:", "project name:", "project path:"} { + if !strings.Contains(humanDryRunOut.String(), want) { + t.Fatalf("human dry-run output = %q, want %q", humanDryRunOut.String(), want) + } + } + if !strings.Contains(humanDryRunOut.String(), "next: rerun with --apply") { + t.Fatalf("human dry-run output = %q, want apply guidance when legacy files match", humanDryRunOut.String()) + } + + var dryRunOut bytes.Buffer + err = Runner{ + Stdout: &dryRunOut, + WorkingDir: workingDir, + }.Run([]string{"state", "repair", "legacy-project-database", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state repair legacy-project-database --dry-run error = %v", err) + } + dryRun := decodeLegacyProjectDatabaseArchiveResult(t, dryRunOut.Bytes()) + if dryRun.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("dry-run ContractVersion = %d, want %d", dryRun.ContractVersion, state.StateJSONContractVersion) + } + if dryRun.DatabaseScope != "global" { + t.Fatalf("dry-run DatabaseScope = %q, want global", dryRun.DatabaseScope) + } + if dryRun.ProjectID == "" { + t.Fatal("dry-run ProjectID is empty") + } + if dryRun.ProjectName != filepath.Base(workingDir) { + t.Fatalf("dry-run ProjectName = %q, want %q", dryRun.ProjectName, filepath.Base(workingDir)) + } + if dryRun.ProjectCurrentPath != workingDir { + t.Fatalf("dry-run ProjectCurrentPath = %q, want %q", dryRun.ProjectCurrentPath, workingDir) + } + if dryRun.Applied { + t.Fatal("dry-run Applied = true, want false") + } + if dryRun.Action != state.LegacyProjectDatabaseArchiveAction { + t.Fatalf("dry-run Action = %q, want archive action", dryRun.Action) + } + if len(dryRun.MatchedPaths) != 1 || dryRun.MatchedPaths[0] != legacyPath { + t.Fatalf("dry-run MatchedPaths = %#v, want legacy path %q", dryRun.MatchedPaths, legacyPath) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy database moved during dry-run: %v", err) + } + + var applyOut bytes.Buffer + err = Runner{ + Stdout: &applyOut, + WorkingDir: workingDir, + }.Run([]string{"state", "repair", "legacy-project-database", "--apply", "--json"}) + if err != nil { + t.Fatalf("state repair legacy-project-database --apply error = %v", err) + } + applied := decodeLegacyProjectDatabaseArchiveResult(t, applyOut.Bytes()) + if applied.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("applied ContractVersion = %d, want %d", applied.ContractVersion, state.StateJSONContractVersion) + } + if applied.DatabaseScope != "global" { + t.Fatalf("applied DatabaseScope = %q, want global", applied.DatabaseScope) + } + if applied.ProjectID != dryRun.ProjectID { + t.Fatalf("applied ProjectID = %q, want %q", applied.ProjectID, dryRun.ProjectID) + } + if applied.ProjectName != filepath.Base(workingDir) { + t.Fatalf("applied ProjectName = %q, want %q", applied.ProjectName, filepath.Base(workingDir)) + } + if applied.ProjectCurrentPath != workingDir { + t.Fatalf("applied ProjectCurrentPath = %q, want %q", applied.ProjectCurrentPath, workingDir) + } + if !applied.Applied { + t.Fatal("apply Applied = false, want true") + } + if len(applied.ArchivedPaths) != 1 { + t.Fatalf("ArchivedPaths = %#v, want one archived database", applied.ArchivedPaths) + } + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("legacy database still exists after apply; err = %v", err) + } + if _, err := os.Stat(applied.ArchivedPaths[0]); err != nil { + t.Fatalf("archived legacy database missing: %v", err) + } + + var doctorOut bytes.Buffer + err = Runner{ + Stdout: &doctorOut, + WorkingDir: workingDir, + }.Run([]string{"state", "doctor", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state doctor after legacy archive error = %v", err) + } + status := decodeStateStatus(t, doctorOut.Bytes()) + if hasDiagnostic(status.Diagnostics, "legacy-project-database-leftover") { + t.Fatalf("diagnostics = %#v, want legacy leftover resolved", status.Diagnostics) + } + + var noopOut bytes.Buffer + err = Runner{ + Stdout: &noopOut, + WorkingDir: workingDir, + }.Run([]string{"state", "repair", "legacy-project-database", "--dry-run", "--json"}) + if err != nil { + t.Fatalf("state repair legacy-project-database no-op dry-run error = %v", err) + } + assertJSONArrayLength(t, noopOut.Bytes(), "matched_paths", 0) + assertJSONArrayLength(t, noopOut.Bytes(), "archived_paths", 0) + assertJSONArrayLength(t, noopOut.Bytes(), "warnings", 0) + + var noopHumanOut bytes.Buffer + err = Runner{ + Stdout: &noopHumanOut, + WorkingDir: workingDir, + }.Run([]string{"state", "repair", "legacy-project-database", "--dry-run"}) + if err != nil { + t.Fatalf("state repair legacy-project-database no-op human dry-run error = %v", err) + } + if !strings.Contains(noopHumanOut.String(), "loaf state repair legacy-project-database --dry-run") { + t.Fatalf("no-op human output = %q, want explicit --dry-run header", noopHumanOut.String()) + } + if strings.Contains(noopHumanOut.String(), "next: rerun with --apply") { + t.Fatalf("no-op human output = %q, want no apply guidance when no files match", noopHumanOut.String()) + } +} + +func TestRunnerStateDoctorReportsSchemaMismatch(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + db, err := sql.Open("sqlite3", databasePath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(`INSERT INTO schema_migrations (version, name, checksum, applied_at) VALUES (99, 'future_schema', 'future', '2026-05-28T10:00:00Z')`); err != nil { + t.Fatalf("insert future schema migration error = %v", err) + } + + var stdout bytes.Buffer + err = Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor"}) + if err == nil { + t.Fatal("state doctor schema mismatch error = nil, want error") + } + if !strings.Contains(stdout.String(), fmt.Sprintf("schema version 99 does not match expected version %d", state.CurrentSchemaVersion())) { + t.Fatalf("stdout = %q, want schema mismatch diagnostic", stdout.String()) + } +} + +func TestRunnerStateDoctorJSONExitsNonzeroForInvalidState(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + var initOut bytes.Buffer + if err := (Runner{Stdout: &initOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + initialized := decodeStateStatus(t, initOut.Bytes()) + db, err := sql.Open("sqlite3", initialized.DatabasePath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-wrong-project', ?, 'linear', 'project', 'project-missing', 'project', 'LIN-PROJ-124', 'https://linear.app/workspace/project/LIN-PROJ-124', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert invalid backend mapping error = %v", err) + } + + var stdout bytes.Buffer + err = Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "doctor", "--json"}) + if err == nil { + t.Fatal("state doctor --json invalid-state error = nil, want nonzero exit") + } + assertSilentExitCode(t, err, 1) + status := decodeStateStatus(t, stdout.Bytes()) + if status.Mode != state.ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, state.ModeInvalid) + } + if !hasDiagnostic(status.Diagnostics, "backend-mapping-entity-missing") { + t.Fatalf("diagnostics = %#v, want backend mapping diagnostic", status.Diagnostics) + } + assertJSONFieldAbsent(t, stdout.Bytes(), "error") +} + +func TestRunnerStateDoctorLabelsBackendDiagnosticPolicy(t *testing.T) { + t.Run("invalid local backend mapping", func(t *testing.T) { + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-wrong-project', ?, 'linear', 'project', 'project-missing', 'project', 'LIN-PROJ-124', 'https://linear.app/workspace/project/LIN-PROJ-124', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert invalid backend mapping error = %v", err) + } + closeCLITestDB(t, db) + + var humanOut bytes.Buffer + err := (Runner{Stdout: &humanOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "doctor"}) + if err == nil { + t.Fatal("state doctor invalid backend mapping error = nil, want nonzero exit") + } + for _, want := range []string{ + "error [backend-mapping/invalid-local-data]:", + "fix or remove the local backend mapping row", + "- inspect-backend-mappings [manual/backend-mapping]", + } { + if !strings.Contains(humanOut.String(), want) { + t.Fatalf("human output = %q, want %q", humanOut.String(), want) + } + } + + var jsonOut bytes.Buffer + err = (Runner{Stdout: &jsonOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "doctor", "--json"}) + assertSilentExitCode(t, err, 1) + status := decodeStateStatus(t, jsonOut.Bytes()) + diagnostic := findCLIDiagnostic(t, status.Diagnostics, "backend-mapping-entity-missing") + if diagnostic.Category != state.RepairCategoryBackendMapping || diagnostic.Policy != state.DiagnosticPolicyInvalidLocalData || diagnostic.RequiresExternalSync { + t.Fatalf("diagnostic = %#v, want invalid local backend mapping policy", diagnostic) + } + if diagnostic.Details["mapping_id"] != "backend-mapping-wrong-project" || diagnostic.Details["entity_kind"] != "project" { + t.Fatalf("diagnostic Details = %#v, want structured backend mapping identifiers", diagnostic.Details) + } + }) + + t.Run("Linear external sync gap", func(t *testing.T) { + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + writeCLIAgentsFile(t, workingDir, "loaf.json", `{"integrations":{"linear":{"enabled":true}}}`) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES ('task-active-unmapped', ?, NULL, 'Active unmapped task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert task fixture error = %v", err) + } + closeCLITestDB(t, db) + + var humanOut bytes.Buffer + if err := (Runner{Stdout: &humanOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "doctor"}); err != nil { + t.Fatalf("state doctor Linear warning error = %v", err) + } + for _, want := range []string{ + "warn [external-sync/external-sync-gap] [external-sync-required]:", + "reconcile it through Linear or future backend sync tooling", + "- reconcile-linear-task-mappings [manual/external-sync]", + "external sync: required", + } { + if !strings.Contains(humanOut.String(), want) { + t.Fatalf("human output = %q, want %q", humanOut.String(), want) + } + } + + var jsonOut bytes.Buffer + if err := (Runner{Stdout: &jsonOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "doctor", "--json"}); err != nil { + t.Fatalf("state doctor --json Linear warning error = %v", err) + } + status := decodeStateStatus(t, jsonOut.Bytes()) + diagnostic := findCLIDiagnostic(t, status.Diagnostics, "linear-mode-local-task-unmapped") + if diagnostic.Category != state.RepairCategoryExternalSync || diagnostic.Policy != state.DiagnosticPolicyExternalSyncGap || !diagnostic.RequiresExternalSync { + t.Fatalf("diagnostic = %#v, want Linear external sync policy", diagnostic) + } + if diagnostic.Details["backend"] != "linear" || diagnostic.Details["unmapped_task_count"] != float64(1) { + t.Fatalf("diagnostic Details = %#v, want structured Linear sync identifiers", diagnostic.Details) + } + }) +} + +func TestRunnerStateExportAllCarriesWarningDiagnosticDetails(t *testing.T) { + workingDir, stateHome, initialized := initCLIStateForRepairCommand(t) + writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-local.md", `--- +title: Local Markdown Task +status: todo +--- +# Local Markdown Task +`) + db := openCLITestDB(t, initialized.DatabasePath) + if _, err := db.Exec(` +INSERT INTO specs (id, project_id, title, status, body_source_id, created_at, updated_at) +VALUES ('SPEC-STALE', ?, 'Stale Spec', 'active', NULL, '2026-06-13T10:00:00Z', '2026-06-14T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert stale spec fixture error = %v", err) + } + if _, err := db.Exec(` +INSERT INTO exports (id, project_id, export_kind, format, path, state_version, source_entity_kind, source_entity_id, generated_at, created_at, updated_at) +VALUES ('export-stale-spec', ?, 'spec', 'markdown', '.agents/specs/SPEC-STALE.md', 1, 'spec', 'SPEC-STALE', '2026-06-13T11:00:00Z', '2026-06-13T11:00:00Z', '2026-06-13T11:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert stale export fixture error = %v", err) + } + closeCLITestDB(t, db) + + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "export", "all", "--format", "json"}); err != nil { + t.Fatalf("state export all --format json error = %v", err) + } + snapshot := decodeStateExportSnapshot(t, stdout.Bytes()) + localMarkdown := findCLIDiagnostic(t, snapshot.Diagnostics, "local-markdown-not-imported") + if localMarkdown.Category != state.RepairCategoryMarkdownImport || localMarkdown.Policy != state.DiagnosticPolicyImportPending { + t.Fatalf("local markdown diagnostic = %#v, want markdown import/import-pending", localMarkdown) + } + if localMarkdown.Details["importable_count"] != float64(1) || localMarkdown.Details["tasks"] != float64(1) { + t.Fatalf("local markdown details = %#v, want importable task counts", localMarkdown.Details) + } + if localMarkdown.Details["preview_command"] != "loaf state migrate markdown --dry-run" { + t.Fatalf("local markdown details = %#v, want preview command", localMarkdown.Details) + } + + staleExport := findCLIDiagnostic(t, snapshot.Diagnostics, "stale-compatibility-export") + if staleExport.Category != state.RepairCategoryCompatibilityExport || staleExport.Policy != state.DiagnosticPolicyStaleExport { + t.Fatalf("stale export diagnostic = %#v, want compatibility-export/stale-export", staleExport) + } + if staleExport.Details["export_id"] != "export-stale-spec" || staleExport.Details["source_entity_id"] != "SPEC-STALE" { + t.Fatalf("stale export details = %#v, want export and source identifiers", staleExport.Details) + } + if snapshot.Manifest.DiagnosticCount != len(snapshot.Diagnostics) || snapshot.Manifest.RepairActionCount != len(snapshot.RepairPlan) { + t.Fatalf("export manifest = %#v, want diagnostic and repair counts matching payload", snapshot.Manifest) + } +} + +func TestRunnerStateBackupCreatesSQLiteCopy(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "backup", "--json"}) + if err != nil { + t.Fatalf("state backup --json error = %v", err) + } + + result := decodeStateBackupResult(t, stdout.Bytes()) + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.BackupPath == "" { + t.Fatal("BackupPath is empty") + } + if result.Bytes <= 0 { + t.Fatalf("Bytes = %d, want > 0", result.Bytes) + } + if result.SHA256 == "" { + t.Fatal("SHA256 is empty") + } + if result.CreatedAt == "" { + t.Fatal("CreatedAt is empty") + } + if !result.Verified { + t.Fatal("Verified = false, want true") + } + if result.SchemaVersion != state.CurrentSchemaVersion() { + t.Fatalf("SchemaVersion = %d, want %d", result.SchemaVersion, state.CurrentSchemaVersion()) + } + if result.ProjectCount != 1 { + t.Fatalf("ProjectCount = %d, want 1", result.ProjectCount) + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } + if result.IntegrityCheck != "ok" { + t.Fatalf("IntegrityCheck = %q, want ok", result.IntegrityCheck) + } + if result.ForeignKeyCheck != "ok" { + t.Fatalf("ForeignKeyCheck = %q, want ok", result.ForeignKeyCheck) + } + if strings.HasPrefix(result.BackupPath, workingDir+string(filepath.Separator)) { + t.Fatalf("BackupPath = %q, want outside working dir %q", result.BackupPath, workingDir) + } + if _, err := os.Stat(result.BackupPath); err != nil { + t.Fatalf("backup file missing: %v", err) + } + if result.SHA256 != testFileSHA256(t, result.BackupPath) { + t.Fatalf("SHA256 = %q, want actual backup digest", result.SHA256) + } + assertNoSQLiteSidecars(t, result.BackupPath) + store, err := state.OpenStoreReadOnly(result.BackupPath) + if err != nil { + t.Fatalf("OpenStoreReadOnly(backup) error = %v", err) + } + defer store.Close() + version, err := store.SchemaVersion(t.Context()) if err != nil { t.Fatalf("backup SchemaVersion() error = %v", err) } if version != state.CurrentSchemaVersion() { t.Fatalf("backup schema version = %d, want %d", version, state.CurrentSchemaVersion()) } + assertNoSQLiteSidecars(t, result.BackupPath) } func TestRunnerStateBackupHumanOutput(t *testing.T) { @@ -2215,13 +4662,219 @@ func TestRunnerStateBackupHumanOutput(t *testing.T) { } output := stdout.String() - for _, want := range []string{"loaf state backup", "database:", "backup:", "bytes:", "created at:"} { + for _, want := range []string{"loaf state backup", "scope: global database", "database:", "backup:", "bytes:", "sha256:", "verified: true", "schema version:", "projects: 1", "project:", "project name:", "project path:", "integrity: ok", "foreign keys: ok", "created at:", "next: verify this backup later with `loaf state backup verify "} { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } } } +func TestRunnerStateBackupVerifyReportsGlobalProjects(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + otherDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init first error = %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: otherDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init second error = %v", err) + } + + var backupOut bytes.Buffer + if err := (Runner{Stdout: &backupOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "backup", "--json"}); err != nil { + t.Fatalf("state backup --json error = %v", err) + } + backup := decodeStateBackupResult(t, backupOut.Bytes()) + if err := os.Remove(backup.DatabasePath); err != nil { + t.Fatalf("remove live database error = %v", err) + } + + var jsonOut bytes.Buffer + err := Runner{ + Stdout: &jsonOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "backup", "verify", backup.BackupPath, "--json"}) + if err != nil { + t.Fatalf("state backup verify --json error = %v", err) + } + result := decodeStateBackupVerificationResult(t, jsonOut.Bytes()) + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.BackupPath != backup.BackupPath { + t.Fatalf("BackupPath = %q, want %q", result.BackupPath, backup.BackupPath) + } + if result.SHA256 != backup.SHA256 { + t.Fatalf("SHA256 = %q, want %q", result.SHA256, backup.SHA256) + } + if !result.Verified { + t.Fatal("Verified = false, want true") + } + if result.SchemaVersion != state.CurrentSchemaVersion() { + t.Fatalf("SchemaVersion = %d, want %d", result.SchemaVersion, state.CurrentSchemaVersion()) + } + if result.ProjectCount != 2 || len(result.Projects) != 2 { + t.Fatalf("projects = %d/%d, want two projects", result.ProjectCount, len(result.Projects)) + } + if result.RestoreDatabasePath != backup.DatabasePath { + t.Fatalf("RestoreDatabasePath = %q, want live target %q", result.RestoreDatabasePath, backup.DatabasePath) + } + if result.RestorePreservePath != backup.DatabasePath+".before-restore" { + t.Fatalf("RestorePreservePath = %q, want preserve path for live target", result.RestorePreservePath) + } + if strings.Join(result.RestoreValidationCommands, ",") != "loaf state doctor,loaf state status" { + t.Fatalf("RestoreValidationCommands = %#v, want doctor/status checks", result.RestoreValidationCommands) + } + if _, err := os.Stat(backup.DatabasePath); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("state backup verify recreated live database; stat err = %v", err) + } + for _, project := range result.Projects { + if project.DatabasePath != backup.BackupPath { + t.Fatalf("project DatabasePath = %q, want backup path %q", project.DatabasePath, backup.BackupPath) + } + } + + var humanOut bytes.Buffer + err = Runner{ + Stdout: &humanOut, + WorkingDir: otherDir, + StateHome: stateHome, + }.Run([]string{"state", "backup", "verify", backup.BackupPath}) + if err != nil { + t.Fatalf("state backup verify error = %v", err) + } + for _, want := range []string{"loaf state backup verify", "scope: global backup", "backup:", "bytes:", "sha256:", "verified: true", "schema version:", "projects: 2", "project:", "project name:", "project path:", "integrity: ok", "foreign keys: ok", "restore target:", "preserve as:", "next: if present, preserve current database as"} { + if !strings.Contains(humanOut.String(), want) { + t.Fatalf("output = %q, want %q", humanOut.String(), want) + } + } +} + +func TestRunnerStateBackupManualRestoreProcedure(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + original := projectIdentityForCLI(t, workingDir, stateHome) + + var backupOut bytes.Buffer + if err := (Runner{Stdout: &backupOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "backup", "--json"}); err != nil { + t.Fatalf("state backup --json error = %v", err) + } + backup := decodeStateBackupResult(t, backupOut.Bytes()) + if backup.ProjectID != original.ID || backup.ProjectName != original.FriendlyName { + t.Fatalf("backup project = %s/%s, want original %s/%s", backup.ProjectID, backup.ProjectName, original.ID, original.FriendlyName) + } + + var verifyOut bytes.Buffer + if err := (Runner{Stdout: &verifyOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "backup", "verify", backup.BackupPath, "--json"}); err != nil { + t.Fatalf("state backup verify --json error = %v", err) + } + verified := decodeStateBackupVerificationResult(t, verifyOut.Bytes()) + if !verified.Verified || verified.BackupPath != backup.BackupPath || verified.SHA256 != backup.SHA256 { + t.Fatalf("backup verification = %#v, want verified backup %s", verified, backup.BackupPath) + } + if verified.RestoreDatabasePath != backup.DatabasePath { + t.Fatalf("verified RestoreDatabasePath = %q, want %q", verified.RestoreDatabasePath, backup.DatabasePath) + } + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "Changed After Backup", "--json"}); err != nil { + t.Fatalf("project rename after backup error = %v", err) + } + changed := projectIdentityForCLI(t, workingDir, stateHome) + if changed.ID != original.ID || changed.FriendlyName != "Changed After Backup" { + t.Fatalf("changed project = %#v, want same ID %s with changed name", changed, original.ID) + } + + preservedLivePath := backup.DatabasePath + ".before-restore" + if err := os.Rename(backup.DatabasePath, preservedLivePath); err != nil { + t.Fatalf("preserve live database error = %v", err) + } + copyFileForCLITest(t, backup.BackupPath, backup.DatabasePath, 0o600) + + var preservedVerifyOut bytes.Buffer + if err := (Runner{Stdout: &preservedVerifyOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "backup", "verify", preservedLivePath, "--json"}); err != nil { + t.Fatalf("state backup verify preserved live database error = %v", err) + } + preserved := decodeStateBackupVerificationResult(t, preservedVerifyOut.Bytes()) + if !preserved.Verified || len(preserved.Projects) != 1 || preserved.Projects[0].FriendlyName != "Changed After Backup" { + t.Fatalf("preserved live database verification = %#v, want changed project preserved", preserved) + } + + var doctorOut bytes.Buffer + if err := (Runner{Stdout: &doctorOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "doctor", "--json"}); err != nil { + t.Fatalf("state doctor --json after manual restore error = %v", err) + } + doctor := decodeStateStatus(t, doctorOut.Bytes()) + if doctor.Mode != state.ModeSQLiteReady || doctor.ProjectID != original.ID || doctor.ProjectName != original.FriendlyName { + t.Fatalf("doctor after restore = %#v, want original restored project %s/%s", doctor, original.ID, original.FriendlyName) + } + + var statusOut bytes.Buffer + if err := (Runner{Stdout: &statusOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "status", "--json"}); err != nil { + t.Fatalf("state status --json after manual restore error = %v", err) + } + status := decodeStateStatus(t, statusOut.Bytes()) + if status.Mode != state.ModeSQLiteReady || status.ProjectID != original.ID || status.ProjectName != original.FriendlyName || status.DatabasePath != backup.DatabasePath { + t.Fatalf("status after restore = %#v, want original restored state at %s", status, backup.DatabasePath) + } + if restoredHash := testFileSHA256(t, backup.DatabasePath); restoredHash != backup.SHA256 { + t.Fatalf("restored database sha256 = %q, want backup sha256 %q", restoredHash, backup.SHA256) + } +} + +func TestRunnerStateBackupVerifyJSONErrorsIncludeBackupPath(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + invalidBackup := filepath.Join(t.TempDir(), "not-a-backup.sqlite") + if err := os.WriteFile(invalidBackup, []byte("not sqlite"), 0o600); err != nil { + t.Fatalf("WriteFile(invalid backup) error = %v", err) + } + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: t.TempDir(), + }.Run([]string{"state", "backup", "verify", invalidBackup, "--json"}) + if err == nil { + t.Fatal("state backup verify invalid backup error = nil, want JSON rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "state backup verify" { + t.Fatalf("Command = %q, want state backup verify", output.Command) + } + if output.BackupPath != invalidBackup { + t.Fatalf("BackupPath = %q, want %q", output.BackupPath, invalidBackup) + } + if !strings.Contains(output.Error, "open state backup for verification") { + t.Fatalf("Error = %q, want verification context", output.Error) + } + + var parseOut bytes.Buffer + err = Runner{ + Stdout: &parseOut, + WorkingDir: workingDir, + StateHome: t.TempDir(), + }.Run([]string{"state", "backup", "verify", "--json"}) + if err == nil { + t.Fatal("state backup verify missing path error = nil, want JSON rejection") + } + assertSilentExitCode(t, err, 1) + parseError := decodeCommandError(t, parseOut.Bytes()) + if parseError.Command != "state backup verify" || !strings.Contains(parseError.Error, "requires a backup path") { + t.Fatalf("parse JSON error = %#v, want missing backup path", parseError) + } + if parseError.BackupPath != "" { + t.Fatalf("parse BackupPath = %q, want omitted/empty before path is parsed", parseError.BackupPath) + } +} + func TestRunnerStateBackupRejectsMissingAndInvalidState(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -2236,6 +4889,17 @@ func TestRunnerStateBackupRejectsMissingAndInvalidState(t *testing.T) { if !strings.Contains(err.Error(), "SQLite state database is not initialized") { t.Fatalf("error = %v, want initialization message", err) } + for _, want := range []string{ + "scope: global database", + "database:", + filepath.Join(stateHome, "loaf", "loaf.sqlite"), + "next: run `loaf state status`", + "loaf state migrate markdown --apply", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } + } root, err := project.ResolveRoot(workingDir) if err != nil { @@ -2265,6 +4929,47 @@ func TestRunnerStateBackupRejectsMissingAndInvalidState(t *testing.T) { } } +func TestRunnerStateBackupJSONErrorsAreMachineReadable(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + tests := []struct { + name string + args []string + want string + }{ + { + name: "unknown option", + args: []string{"state", "backup", "--json", "--bogus"}, + want: "unknown option", + }, + { + name: "missing state", + args: []string{"state", "backup", "--json"}, + want: "SQLite state database is not initialized", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "state backup" || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want state backup error containing %q", output, tc.want) + } + }) + } +} + func TestRunnerStateExportAllJSON(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -2292,6 +4997,9 @@ status: implementing } snapshot := decodeStateExportSnapshot(t, stdout.Bytes()) + if snapshot.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", snapshot.ContractVersion, state.StateJSONContractVersion) + } if snapshot.ExportKind != state.ExportKindAll { t.Fatalf("ExportKind = %q, want %q", snapshot.ExportKind, state.ExportKindAll) } @@ -2301,9 +5009,78 @@ status: implementing if snapshot.Audience != state.ExportAudienceLocal { t.Fatalf("Audience = %q, want internal marker", snapshot.Audience) } + if snapshot.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", snapshot.DatabaseScope) + } + if snapshot.ExportScope != "project" { + t.Fatalf("ExportScope = %q, want project", snapshot.ExportScope) + } + + var aliasOut bytes.Buffer + err = Runner{ + Stdout: &aliasOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "export", "all", "--json"}) + if err != nil { + t.Fatalf("state export all --json error = %v", err) + } + aliasSnapshot := decodeStateExportSnapshot(t, aliasOut.Bytes()) + if aliasSnapshot.ExportKind != state.ExportKindAll || aliasSnapshot.Format != state.ExportFormatJSON || aliasSnapshot.ProjectID != snapshot.ProjectID { + t.Fatalf("alias snapshot = %#v, want all/json export for project %q", aliasSnapshot, snapshot.ProjectID) + } + if snapshot.SchemaVersion != state.CurrentSchemaVersion() { t.Fatalf("SchemaVersion = %d, want %d", snapshot.SchemaVersion, state.CurrentSchemaVersion()) } + if !snapshot.Manifest.Verified { + t.Fatal("Manifest.Verified = false, want true") + } + if snapshot.Manifest.ContractVersion != snapshot.ContractVersion { + t.Fatalf("Manifest.ContractVersion = %d, want %d", snapshot.Manifest.ContractVersion, snapshot.ContractVersion) + } + if snapshot.Manifest.DatabaseScope != snapshot.DatabaseScope { + t.Fatalf("Manifest.DatabaseScope = %q, want %q", snapshot.Manifest.DatabaseScope, snapshot.DatabaseScope) + } + if snapshot.Manifest.ExportScope != snapshot.ExportScope { + t.Fatalf("Manifest.ExportScope = %q, want %q", snapshot.Manifest.ExportScope, snapshot.ExportScope) + } + if snapshot.Manifest.SchemaVersion != snapshot.SchemaVersion { + t.Fatalf("Manifest.SchemaVersion = %d, want %d", snapshot.Manifest.SchemaVersion, snapshot.SchemaVersion) + } + if snapshot.Manifest.ProjectID != snapshot.ProjectID { + t.Fatalf("Manifest.ProjectID = %q, want %q", snapshot.Manifest.ProjectID, snapshot.ProjectID) + } + if snapshot.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", snapshot.ProjectName, filepath.Base(workingDir)) + } + if snapshot.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", snapshot.ProjectCurrentPath, workingDir) + } + if snapshot.Manifest.ProjectName != snapshot.ProjectName { + t.Fatalf("Manifest.ProjectName = %q, want %q", snapshot.Manifest.ProjectName, snapshot.ProjectName) + } + if snapshot.Manifest.ProjectCurrentPath != snapshot.ProjectCurrentPath { + t.Fatalf("Manifest.ProjectCurrentPath = %q, want %q", snapshot.Manifest.ProjectCurrentPath, snapshot.ProjectCurrentPath) + } + if snapshot.Manifest.IntegrityCheck != "ok" { + t.Fatalf("Manifest.IntegrityCheck = %q, want ok", snapshot.Manifest.IntegrityCheck) + } + if snapshot.Manifest.ForeignKeyCheck != "ok" { + t.Fatalf("Manifest.ForeignKeyCheck = %q, want ok", snapshot.Manifest.ForeignKeyCheck) + } + if snapshot.Manifest.RowCounts["project_paths"] != 1 || snapshot.Manifest.RowCounts["specs"] != 1 || snapshot.Manifest.RowCounts["tasks"] != 1 { + t.Fatalf("manifest row counts = %#v, want exported project path, spec, and task counts", snapshot.Manifest.RowCounts) + } + if snapshot.Manifest.TotalRows == 0 { + t.Fatal("Manifest.TotalRows = 0, want exported row count") + } + if len(snapshot.Tables["project_paths"]) != 1 { + t.Fatalf("project_paths rows = %#v, want exported project path row", snapshot.Tables["project_paths"]) + } + if snapshot.Tables["project_paths"][0]["path"] != workingDir { + t.Fatalf("project path row = %#v, want path %q", snapshot.Tables["project_paths"][0], workingDir) + } if len(snapshot.Tables["specs"]) != 1 || len(snapshot.Tables["tasks"]) != 1 { t.Fatalf("tables = %#v, want exported spec and task rows", snapshot.Tables) } @@ -2336,12 +5113,23 @@ func TestRunnerStateExportTriageMarkdown(t *testing.T) { } output := stdout.String() - for _, want := range []string{"# Triage Export", "Audience: external", "## Ideas", "## Sparks", "## Brainstorms", "internal reference"} { + for _, want := range []string{ + "# Triage Export", + "Audience: external", + "## Project Context", + "- Scope: global database, project export", + "- Project: `proj_", + "- Project name: " + filepath.Base(workingDir), + "## Ideas", + "## Sparks", + "## Brainstorms", + "internal reference", + } { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } } - for _, banned := range []string{"SPEC-001", "TASK-002", ".agents/", "Track A", "Phase 2"} { + for _, banned := range []string{"SPEC-001", "TASK-002", ".agents/", "Track A", "Phase 2", "Project path:", "Database:"} { if strings.Contains(output, banned) { t.Fatalf("output leaked %q:\n%s", banned, output) } @@ -2396,6 +5184,10 @@ status: final for _, want := range []string{ "# Release Readiness Export", "Audience: external", + "## Project Context", + "- Scope: global database, project export", + "- Project: `proj_", + "- Project name: " + filepath.Base(workingDir), "Release readiness: not ready", "Specs: 1 active, 0 complete, 0 archived", "Tasks: 1 unresolved, 0 done, 0 archived", @@ -2409,7 +5201,7 @@ status: final t.Fatalf("output = %q, want %q", output, want) } } - for _, banned := range []string{"SPEC-001", "TASK-001", ".agents/", "Track A", "Phase 2"} { + for _, banned := range []string{"SPEC-001", "TASK-001", ".agents/", "Track A", "Phase 2", "Project path:", "Database:"} { if strings.Contains(output, banned) { t.Fatalf("output leaked %q:\n%s", banned, output) } @@ -2457,6 +5249,12 @@ Imported spec prose. for _, want := range []string{ "# Spec Export", "Audience: internal", + "## Project Context", + "- Scope: global database, project export", + "- Project: `proj_", + "- Project name: " + filepath.Base(workingDir), + "- Project path: `" + workingDir + "`", + "- Database: `" + filepath.Join(stateHome, "loaf", "loaf.sqlite") + "`", "Spec: `SPEC-001`", "Title: Example Spec", "Status: implementing", @@ -2510,6 +5308,12 @@ claude_session_id: harness-export for _, want := range []string{ "# Session Export", "Audience: internal", + "## Project Context", + "- Scope: global database, project export", + "- Project: `proj_", + "- Project name: " + filepath.Base(workingDir), + "- Project path: `" + workingDir + "`", + "- Database: `" + filepath.Join(stateHome, "loaf", "loaf.sqlite") + "`", "Session: `20260528-session`", "Branch: `feature/session-export`", "Harness session: `harness-export`", @@ -2560,6 +5364,32 @@ status: active if !strings.Contains(reportOut.String(), "# Session Export") { t.Fatalf("report output = %q, want session export markdown", reportOut.String()) } + + var sessionJSONOut bytes.Buffer + if err := (Runner{Stdout: &sessionJSONOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "generate", "session", "20260528-session", "--json"}); err != nil { + t.Fatalf("report generate session --json error = %v", err) + } + var sessionJSON state.MarkdownExport + if err := json.Unmarshal(sessionJSONOut.Bytes(), &sessionJSON); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", sessionJSONOut.String(), err) + } + if sessionJSON.Command != "report generate session" || sessionJSON.ExportKind != state.ExportKindSession || sessionJSON.Audience != state.ExportAudienceLocal { + t.Fatalf("session JSON wrapper = %#v, want session report command and local audience", sessionJSON) + } + assertCLIProjectContext(t, workingDir, sessionJSON.ContractVersion, sessionJSON.DatabaseScope, sessionJSON.DatabasePath, sessionJSON.ProjectID, sessionJSON.ProjectName, sessionJSON.ProjectCurrentPath) + + var sessionReportJSONOut bytes.Buffer + if err := (Runner{Stdout: &sessionReportJSONOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"session", "report", "20260528-session", "--json"}); err != nil { + t.Fatalf("session report --json error = %v", err) + } + var sessionReportJSON state.MarkdownExport + if err := json.Unmarshal(sessionReportJSONOut.Bytes(), &sessionReportJSON); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", sessionReportJSONOut.String(), err) + } + if sessionReportJSON.Command != "session report" || sessionReportJSON.ExportKind != state.ExportKindSession || sessionReportJSON.Content != sessionJSON.Content { + t.Fatalf("session report JSON wrapper = %#v, want session report command and export content parity", sessionReportJSON) + } + assertCLIProjectContext(t, workingDir, sessionReportJSON.ContractVersion, sessionReportJSON.DatabaseScope, sessionReportJSON.DatabasePath, sessionReportJSON.ProjectID, sessionReportJSON.ProjectName, sessionReportJSON.ProjectCurrentPath) } func TestRunnerReportGenerateTriageAndReleaseReadinessMatchStateExports(t *testing.T) { @@ -2580,23 +5410,90 @@ func TestRunnerReportGenerateTriageAndReleaseReadinessMatchStateExports(t *testi if err := (Runner{Stdout: &triageReport, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "generate", "triage"}); err != nil { t.Fatalf("report generate triage error = %v", err) } - if triageReport.String() != triageExport.String() { - t.Fatalf("triage report output differs from state export:\nreport=%s\nexport=%s", triageReport.String(), triageExport.String()) + if triageReport.String() != triageExport.String() { + t.Fatalf("triage report output differs from state export:\nreport=%s\nexport=%s", triageReport.String(), triageExport.String()) + } + var triageReportFormat bytes.Buffer + if err := (Runner{Stdout: &triageReportFormat, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "generate", "triage", "--format", "markdown"}); err != nil { + t.Fatalf("report generate triage --format markdown error = %v", err) + } + if triageReportFormat.String() != triageExport.String() { + t.Fatalf("triage report --format output differs from state export:\nreport=%s\nexport=%s", triageReportFormat.String(), triageExport.String()) + } + + var releaseExport bytes.Buffer + if err := (Runner{Stdout: &releaseExport, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "export", "release-readiness", "--format", "markdown"}); err != nil { + t.Fatalf("state export release-readiness error = %v", err) + } + var releaseReport bytes.Buffer + if err := (Runner{Stdout: &releaseReport, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "generate", "release-readiness"}); err != nil { + t.Fatalf("report generate release-readiness error = %v", err) + } + if releaseReport.String() != releaseExport.String() { + t.Fatalf("release report output differs from state export:\nreport=%s\nexport=%s", releaseReport.String(), releaseExport.String()) + } + if !strings.Contains(releaseReport.String(), "# Release Readiness Export") { + t.Fatalf("release report output = %q, want release readiness markdown", releaseReport.String()) + } +} + +func TestRunnerReportGenerateJSONContracts(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"idea", "capture", "--title", "JSON report follow-up"}); err != nil { + t.Fatalf("idea capture error = %v", err) + } + + var jsonOut bytes.Buffer + if err := (Runner{Stdout: &jsonOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "generate", "triage", "--format", "markdown", "--json"}); err != nil { + t.Fatalf("report generate triage --format markdown --json error = %v", err) + } + var export state.MarkdownExport + if err := json.Unmarshal(jsonOut.Bytes(), &export); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", jsonOut.String(), err) + } + if export.ExportKind != state.ExportKindTriage || export.Format != state.ExportFormatMarkdown || export.Audience != state.ExportAudienceExternal { + t.Fatalf("export wrapper = %#v, want triage markdown external", export) + } + if export.Command != "report generate triage" { + t.Fatalf("export.Command = %q, want report generate triage", export.Command) + } + if export.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("export.ContractVersion = %d, want %d", export.ContractVersion, state.StateJSONContractVersion) + } + if export.DatabaseScope != "global" || export.ExportScope != "project" || export.ProjectID == "" || export.ProjectName != filepath.Base(workingDir) { + t.Fatalf("export context = %#v, want global project identity", export) + } + if export.DatabasePath != "" || export.ProjectCurrentPath != "" { + t.Fatalf("external export context = %#v, want no local paths", export) + } + if !strings.Contains(export.Content, "# Triage Export") || !strings.Contains(export.Content, "## Project Context") { + t.Fatalf("export content = %q, want triage markdown with project context", export.Content) } - var releaseExport bytes.Buffer - if err := (Runner{Stdout: &releaseExport, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "export", "release-readiness", "--format", "markdown"}); err != nil { - t.Fatalf("state export release-readiness error = %v", err) + var formatErrorOut bytes.Buffer + err := (Runner{Stdout: &formatErrorOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "generate", "triage", "--format", "json", "--json"}) + if err == nil { + t.Fatal("report generate triage --format json --json error = nil, want rejection") } - var releaseReport bytes.Buffer - if err := (Runner{Stdout: &releaseReport, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "generate", "release-readiness"}); err != nil { - t.Fatalf("report generate release-readiness error = %v", err) + assertSilentExitCode(t, err, 1) + formatError := decodeCommandError(t, formatErrorOut.Bytes()) + if formatError.Command != "report generate triage" || !strings.Contains(formatError.Error, "supports only --format markdown") { + t.Fatalf("JSON error = %#v, want unsupported-format report generate error", formatError) } - if releaseReport.String() != releaseExport.String() { - t.Fatalf("release report output differs from state export:\nreport=%s\nexport=%s", releaseReport.String(), releaseExport.String()) + + var missingOut bytes.Buffer + err = (Runner{Stdout: &missingOut, WorkingDir: realpath(t, t.TempDir()), StateHome: t.TempDir()}).Run([]string{"report", "generate", "triage", "--json"}) + if err == nil { + t.Fatal("report generate triage --json missing-state error = nil, want rejection") } - if !strings.Contains(releaseReport.String(), "# Release Readiness Export") { - t.Fatalf("release report output = %q, want release readiness markdown", releaseReport.String()) + assertSilentExitCode(t, err, 1) + missingError := decodeCommandError(t, missingOut.Bytes()) + if missingError.Command != "report generate triage" || !strings.Contains(missingError.Error, "SQLite state database is not initialized") { + t.Fatalf("JSON error = %#v, want missing-state report generate error", missingError) } } @@ -2645,6 +5542,17 @@ func TestRunnerReportGenerateRejectsMissingInvalidUnsupportedState(t *testing.T) if !strings.Contains(err.Error(), "SQLite state database is not initialized") { t.Fatalf("error = %v, want initialization message", err) } + for _, want := range []string{ + "scope: global database", + "database:", + filepath.Join(stateHome, "loaf", "loaf.sqlite"), + "next: run `loaf state status`", + "loaf state migrate markdown --apply", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } + } err = Runner{ Stdout: &bytes.Buffer{}, @@ -2689,6 +5597,7 @@ func TestRunnerReportLifecycleUsesSQLiteStateWhenInitialized(t *testing.T) { if created.Report.Alias != "report-release-readiness" || created.Report.Status != "draft" || created.Kind != "audit" || created.Source != "manual" { t.Fatalf("created = %#v, want draft report", created) } + assertCLIReportContext(t, created.ContractVersion, created.DatabaseScope, created.DatabasePath, created.ProjectID, created.ProjectName, created.ProjectCurrentPath, workingDir) var draftListOut bytes.Buffer if err := (Runner{Stdout: &draftListOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "list", "--json"}); err != nil { @@ -2698,6 +5607,7 @@ func TestRunnerReportLifecycleUsesSQLiteStateWhenInitialized(t *testing.T) { if draftReports.Reports["report-release-readiness"].Status != "draft" { t.Fatalf("draft reports = %#v, want draft report", draftReports.Reports) } + assertCLIReportContext(t, draftReports.ContractVersion, draftReports.DatabaseScope, draftReports.DatabasePath, draftReports.ProjectID, draftReports.ProjectName, draftReports.ProjectCurrentPath, workingDir) var finalizeOut bytes.Buffer if err := (Runner{Stdout: &finalizeOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "finalize", "report-release-readiness", "--json"}); err != nil { @@ -2707,6 +5617,7 @@ func TestRunnerReportLifecycleUsesSQLiteStateWhenInitialized(t *testing.T) { if finalized.Previous != "draft" || finalized.Status != "final" { t.Fatalf("finalized = %#v, want final transition", finalized) } + assertCLIReportContext(t, finalized.ContractVersion, finalized.DatabaseScope, finalized.DatabasePath, finalized.ProjectID, finalized.ProjectName, finalized.ProjectCurrentPath, workingDir) var archiveOut bytes.Buffer if err := (Runner{Stdout: &archiveOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "archive", "report-release-readiness", "--json"}); err != nil { @@ -2716,6 +5627,7 @@ func TestRunnerReportLifecycleUsesSQLiteStateWhenInitialized(t *testing.T) { if archived.Previous != "final" || archived.Status != "archived" { t.Fatalf("archived = %#v, want archived transition", archived) } + assertCLIReportContext(t, archived.ContractVersion, archived.DatabaseScope, archived.DatabasePath, archived.ProjectID, archived.ProjectName, archived.ProjectCurrentPath, workingDir) var archivedListOut bytes.Buffer if err := (Runner{Stdout: &archivedListOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"report", "list", "--json", "--status", "archived"}); err != nil { @@ -2725,6 +5637,7 @@ func TestRunnerReportLifecycleUsesSQLiteStateWhenInitialized(t *testing.T) { if archivedReports.Reports["report-release-readiness"].Status != "archived" { t.Fatalf("archived reports = %#v, want archived report", archivedReports.Reports) } + assertCLIReportContext(t, archivedReports.ContractVersion, archivedReports.DatabaseScope, archivedReports.DatabasePath, archivedReports.ProjectID, archivedReports.ProjectName, archivedReports.ProjectCurrentPath, workingDir) afterFiles := repoFileList(t, workingDir) if strings.Join(afterFiles, "\n") != strings.Join(beforeFiles, "\n") { @@ -2749,6 +5662,9 @@ func TestRunnerReportLifecycleUsesMarkdownFilesWhenMarkdownOnly(t *testing.T) { if created.Report.Status != "draft" || created.Kind != "audit" || created.Source != "manual" || !strings.HasSuffix(created.Report.Alias, "-audit-release-readiness") { t.Fatalf("created = %#v, want markdown draft report", created) } + if created.ContractVersion != 0 || created.DatabaseScope != "" || created.DatabasePath != "" || created.ProjectID != "" || created.ProjectName != "" || created.ProjectCurrentPath != "" { + t.Fatalf("created markdown context = %#v, want no SQLite context", created) + } reportFile := filepath.Join(workingDir, ".agents", "reports", created.Report.Alias+".md") reportRaw, err := os.ReadFile(reportFile) if err != nil { @@ -2782,6 +5698,9 @@ func TestRunnerReportLifecycleUsesMarkdownFilesWhenMarkdownOnly(t *testing.T) { if listed.Reports[created.Report.Alias].Status != "draft" || listed.Reports[created.Report.Alias].Kind != "audit" { t.Fatalf("reports = %#v, want created markdown report", listed.Reports) } + if listed.ContractVersion != 0 || listed.DatabaseScope != "" || listed.DatabasePath != "" || listed.ProjectID != "" || listed.ProjectName != "" || listed.ProjectCurrentPath != "" { + t.Fatalf("listed markdown context = %#v, want no SQLite context", listed) + } var finalizeOut bytes.Buffer err = Runner{ @@ -2796,6 +5715,9 @@ func TestRunnerReportLifecycleUsesMarkdownFilesWhenMarkdownOnly(t *testing.T) { if finalized.Previous != "draft" || finalized.Status != "final" || finalized.Report.Alias != created.Report.Alias { t.Fatalf("finalized = %#v, want draft to final", finalized) } + if finalized.ContractVersion != 0 || finalized.DatabaseScope != "" || finalized.DatabasePath != "" || finalized.ProjectID != "" || finalized.ProjectName != "" || finalized.ProjectCurrentPath != "" { + t.Fatalf("finalized markdown context = %#v, want no SQLite context", finalized) + } reportRaw, err = os.ReadFile(reportFile) if err != nil { t.Fatalf("ReadFile(finalized report) error = %v", err) @@ -2821,6 +5743,9 @@ func TestRunnerReportLifecycleUsesMarkdownFilesWhenMarkdownOnly(t *testing.T) { if archived.Previous != "final" || archived.Status != "archived" || archived.Report.Alias != created.Report.Alias { t.Fatalf("archived = %#v, want final to archived", archived) } + if archived.ContractVersion != 0 || archived.DatabaseScope != "" || archived.DatabasePath != "" || archived.ProjectID != "" || archived.ProjectName != "" || archived.ProjectCurrentPath != "" { + t.Fatalf("archived markdown context = %#v, want no SQLite context", archived) + } if _, err := os.Stat(reportFile); !os.IsNotExist(err) { t.Fatalf("active report stat error = %v, want removed", err) } @@ -3094,16 +6019,41 @@ func TestRunnerStateExportTriageMarkdownDoesNotCreateRepoFiles(t *testing.T) { func TestRunnerStateExportRejectsMissingInvalidUnsupportedState(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() + err := Runner{ Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome, + }.Run([]string{"state", "export", "triage", "--format", "markdown"}) + if err == nil { + t.Fatal("state export triage missing-state error = nil, want rejection") + } + for _, want := range []string{ + "SQLite state database is not initialized", + "scope: global database", + "database:", + filepath.Join(stateHome, "loaf", "loaf.sqlite"), + "next: run `loaf state status`", + "loaf state migrate markdown --apply", + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } + } + + var missingOut bytes.Buffer + err = Runner{ + Stdout: &missingOut, + WorkingDir: workingDir, + StateHome: stateHome, }.Run([]string{"state", "export", "all", "--format", "json"}) if err == nil { t.Fatal("state export missing-state error = nil, want rejection") } - if !strings.Contains(err.Error(), "SQLite state database is not initialized") { - t.Fatalf("error = %v, want initialization message", err) + assertSilentExitCode(t, err, 1) + missingOutput := decodeCommandError(t, missingOut.Bytes()) + if missingOutput.Command != "state export" || !strings.Contains(missingOutput.Error, "SQLite state database is not initialized") { + t.Fatalf("JSON error = %#v, want initialization message", missingOutput) } err = Runner{ @@ -3154,6 +6104,21 @@ func TestRunnerStateExportRejectsMissingInvalidUnsupportedState(t *testing.T) { t.Fatalf("error = %v, want unsupported format message", err) } + var jsonMarkdownOut bytes.Buffer + err = Runner{ + Stdout: &jsonMarkdownOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "export", "triage", "--json"}) + if err == nil { + t.Fatal("state export triage --json error = nil, want rejection") + } + assertSilentExitCode(t, err, 1) + jsonMarkdownError := decodeCommandError(t, jsonMarkdownOut.Bytes()) + if jsonMarkdownError.Command != "state export" || !strings.Contains(jsonMarkdownError.Error, "--json is only supported for state export all") { + t.Fatalf("JSON error = %#v, want markdown export json-alias rejection", jsonMarkdownError) + } + root, err := project.ResolveRoot(workingDir) if err != nil { t.Fatalf("ResolveRoot() error = %v", err) @@ -3169,16 +6134,105 @@ func TestRunnerStateExportRejectsMissingInvalidUnsupportedState(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } + var invalidOut bytes.Buffer err = Runner{ - Stdout: &bytes.Buffer{}, + Stdout: &invalidOut, WorkingDir: workingDir, StateHome: stateHome, }.Run([]string{"state", "export", "all", "--format", "json"}) if err == nil { t.Fatal("state export invalid-state error = nil, want rejection") } - if !strings.Contains(err.Error(), "state database is invalid; run `loaf state doctor`") { - t.Fatalf("error = %v, want doctor message", err) + assertSilentExitCode(t, err, 1) + invalidOutput := decodeCommandError(t, invalidOut.Bytes()) + if invalidOutput.Command != "state export" || !strings.Contains(invalidOutput.Error, "state database is invalid; run `loaf state doctor`") { + t.Fatalf("JSON error = %#v, want doctor message", invalidOutput) + } +} + +func TestRunnerStateExportJSONErrorsAreMachineReadable(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing state", + args: []string{"state", "export", "all", "--format", "json"}, + want: "SQLite state database is not initialized", + }, + { + name: "unknown option", + args: []string{"state", "export", "all", "--format=json", "--bogus"}, + want: "unknown option", + }, + { + name: "unsupported json export kind", + args: []string{"state", "export", "spec", "SPEC-001", "--format", "json"}, + want: "state export format \"json\" is not implemented yet", + }, + { + name: "json alias after markdown format", + args: []string{"state", "export", "all", "--format", "markdown", "--json"}, + want: "cannot combine --json with --format markdown", + }, + { + name: "json alias before markdown format", + args: []string{"state", "export", "all", "--json", "--format", "markdown"}, + want: "cannot combine --json with --format markdown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "state export" || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want state export error containing %q", output, tc.want) + } + }) + } + + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + databasePath, err := (state.PathResolver{StateHome: stateHome}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(databasePath), 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile(databasePath, []byte("not sqlite"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + var stdout bytes.Buffer + err = Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "export", "all", "--format", "json"}) + if err == nil { + t.Fatal("state export invalid-state error = nil, want JSON rejection") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "state export" || !strings.Contains(output.Error, "state database is invalid; run `loaf state doctor`") { + t.Fatalf("JSON error = %#v, want invalid database message", output) } } @@ -3330,6 +6384,7 @@ func TestRunnerSessionStartUsesSQLiteStateWhenInitialized(t *testing.T) { if start.Action != state.SessionStartActionCreated || start.Session.Alias == "" || start.HarnessSessionID != "harness-cli-123456" { t.Fatalf("start = %#v, want created harness-backed session", start) } + assertCLISessionContext(t, start.ContractVersion, start.DatabaseScope, start.DatabasePath, start.ProjectID, start.ProjectName, start.ProjectCurrentPath, workingDir) var showOut bytes.Buffer if err := (Runner{Stdout: &showOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"session", "show", start.Session.Alias, "--json"}); err != nil { @@ -3342,6 +6397,7 @@ func TestRunnerSessionStartUsesSQLiteStateWhenInitialized(t *testing.T) { if len(show.Session.JournalEntries) != 1 || show.Session.JournalEntries[0].EntryType != "session" || show.Session.JournalEntries[0].Scope != "start" { t.Fatalf("journal entries = %#v, want linked session(start)", show.Session.JournalEntries) } + assertCLISessionContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) } func TestRunnerSessionStartUsesMarkdownSessionWhenMarkdownOnly(t *testing.T) { @@ -3369,6 +6425,9 @@ func TestRunnerSessionStartUsesMarkdownSessionWhenMarkdownOnly(t *testing.T) { if start.Action != state.SessionStartActionCreated || start.Session.Alias == "" || start.HarnessSessionID != "markdown-start-111111" { t.Fatalf("start = %#v, want created markdown session", start) } + if start.ContractVersion != 0 || start.DatabaseScope != "" || start.DatabasePath != "" || start.ProjectID != "" || start.ProjectName != "" || start.ProjectCurrentPath != "" { + t.Fatalf("markdown start context = %#v, want no SQLite context", start) + } sessionRel := filepath.ToSlash(filepath.Join("sessions", start.Session.Alias+".md")) created := readCLIAgentsFile(t, workingDir, sessionRel) for _, want := range []string{"status: active", "branch: main", "claude_session_id: markdown-start-111111", "session(start): === SESSION STARTED === (session markdown)"} { @@ -3475,6 +6534,7 @@ func TestRunnerSessionEndTargetsHarnessSessionInSQLiteState(t *testing.T) { if ended.Action != state.SessionEndActionStopped || ended.Session.ID != target.Session.ID || len(ended.JournalEntryIDs) != 2 { t.Fatalf("ended = %#v, want stopped target session", ended) } + assertCLISessionContext(t, ended.ContractVersion, ended.DatabaseScope, ended.DatabasePath, ended.ProjectID, ended.ProjectName, ended.ProjectCurrentPath, workingDir) var targetShowOut bytes.Buffer if err := (Runner{Stdout: &targetShowOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"session", "show", target.Session.Alias, "--json"}); err != nil { @@ -3484,6 +6544,7 @@ func TestRunnerSessionEndTargetsHarnessSessionInSQLiteState(t *testing.T) { if targetShow.Session.Status != "stopped" { t.Fatalf("target status = %q, want stopped", targetShow.Session.Status) } + assertCLISessionContext(t, targetShow.ContractVersion, targetShow.DatabaseScope, targetShow.DatabasePath, targetShow.ProjectID, targetShow.ProjectName, targetShow.ProjectCurrentPath, workingDir) var otherShowOut bytes.Buffer if err := (Runner{Stdout: &otherShowOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"session", "show", other.Session.Alias, "--json"}); err != nil { @@ -3505,6 +6566,7 @@ func TestRunnerSessionEndTargetsHarnessSessionInSQLiteState(t *testing.T) { if _, ok := list.Sessions[other.Session.Alias]; !ok { t.Fatalf("active session list missing active session %s", other.Session.Alias) } + assertCLISessionContext(t, list.ContractVersion, list.DatabaseScope, list.DatabasePath, list.ProjectID, list.ProjectName, list.ProjectCurrentPath, workingDir) } func TestRunnerSessionEndIfActiveNoopsInSQLiteState(t *testing.T) { @@ -3532,6 +6594,7 @@ func TestRunnerSessionEndIfActiveNoopsInSQLiteState(t *testing.T) { if ended.Action != state.SessionEndActionNoop || ended.NoopReason == "" { t.Fatalf("ended = %#v, want noop with reason", ended) } + assertCLISessionContext(t, ended.ContractVersion, ended.DatabaseScope, ended.DatabasePath, ended.ProjectID, ended.ProjectName, ended.ProjectCurrentPath, workingDir) } func TestRunnerSessionEndUsesMarkdownSessionWhenMarkdownOnly(t *testing.T) { @@ -3571,6 +6634,9 @@ last_updated: 2026-06-10T10:00:00Z if ended.Action != state.SessionEndActionStopped || ended.Session.Alias != "20260610-active" || ended.Session.Status != "stopped" || len(ended.JournalEntryIDs) != 2 { t.Fatalf("ended = %#v, want stopped markdown session", ended) } + if ended.ContractVersion != 0 || ended.DatabaseScope != "" || ended.DatabasePath != "" || ended.ProjectID != "" || ended.ProjectName != "" || ended.ProjectCurrentPath != "" { + t.Fatalf("markdown end context = %#v, want no SQLite context", ended) + } stopped := readCLIAgentsFile(t, workingDir, "sessions/20260610-active.md") for _, want := range []string{"status: stopped", "session(end): session ended", "session(stop): === SESSION STOPPED ==="} { if !strings.Contains(stopped, want) { @@ -3697,6 +6763,7 @@ func TestRunnerSessionArchiveTargetsHarnessSessionInSQLiteState(t *testing.T) { if archived.Action != state.SessionArchiveActionArchived || archived.Session.ID != target.Session.ID || archived.Session.Status != "archived" { t.Fatalf("archived = %#v, want archived target session", archived) } + assertCLISessionContext(t, archived.ContractVersion, archived.DatabaseScope, archived.DatabasePath, archived.ProjectID, archived.ProjectName, archived.ProjectCurrentPath, workingDir) var listOut bytes.Buffer if err := (Runner{Stdout: &listOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"session", "list", "--json"}); err != nil { @@ -3886,7 +6953,23 @@ func TestRunnerStateMigrateMarkdownJSONDryRunDoesNotCreateDatabase(t *testing.T) t.Fatalf("state migrate markdown --dry-run --json error = %v", err) } - plan := decodeMarkdownMigrationPlan(t, stdout.Bytes()) + preview := decodeMarkdownMigrationPreviewResult(t, stdout.Bytes()) + plan := preview.MarkdownMigrationPlan + if plan.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", plan.ContractVersion, state.StateJSONContractVersion) + } + if preview.DatabaseScope != "global" || preview.ImportScope != "project" { + t.Fatalf("preview = %#v, want global database project import scope", preview) + } + if preview.DatabasePath != databasePath { + t.Fatalf("DatabasePath = %q, want %q", preview.DatabasePath, databasePath) + } + if preview.ProjectName != filepath.Base(workingDir) || preview.ProjectCurrentPath != workingDir { + t.Fatalf("preview = %#v, want project name %q and path %s", preview, filepath.Base(workingDir), workingDir) + } + if preview.Applied { + t.Fatal("Applied = true, want false for dry-run") + } if plan.Specs != 1 || plan.Tasks != 1 || plan.Ideas != 1 || @@ -3925,8 +7008,19 @@ func TestRunnerStateMigrateMarkdownHumanDryRun(t *testing.T) { if !strings.Contains(output, "loaf state migrate markdown --dry-run") { t.Fatalf("output = %q, want dry-run heading", output) } - if !strings.Contains(output, "ideas: 1") { - t.Fatalf("output = %q, want idea count", output) + for _, want := range []string{ + "scope: global database, project import", + "database:", + "project: (not initialized)", + "project name:", + "project path:", + "applied: false", + "ideas: 1", + "next: rerun with --apply to import Markdown into the global database", + } { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } } } @@ -4071,6 +7165,9 @@ status: open if got := sqliteCount(t, db, `SELECT COUNT(*) FROM relationships WHERE relationship_type = ? AND reason = ?`, "resolved_by", "matrix link"); got != 0 { t.Fatalf("matrix resolved_by link count = %d, want 0 after link remove", got) } + if got := sqliteCount(t, db, `SELECT COUNT(*) FROM relationships WHERE origin IS NULL OR origin = ''`); got != 0 { + t.Fatalf("relationships without origin = %d, want 0", got) + } } func TestRunnerStateMigrateMarkdownApplyJSON(t *testing.T) { @@ -4091,12 +7188,33 @@ func TestRunnerStateMigrateMarkdownApplyJSON(t *testing.T) { } result := decodeMarkdownMigrationResult(t, stdout.Bytes()) + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } if !result.Applied { t.Fatal("Applied = false, want true") } + if result.Action != state.MarkdownMigrationActionApply { + t.Fatalf("Action = %q, want %q", result.Action, state.MarkdownMigrationActionApply) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.ImportScope != "project" { + t.Fatalf("ImportScope = %q, want project", result.ImportScope) + } if result.DatabasePath == "" { t.Fatal("DatabasePath is empty") } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } if _, err := os.Stat(result.DatabasePath); err != nil { t.Fatalf("database was not created: %v", err) } @@ -4105,12 +7223,97 @@ func TestRunnerStateMigrateMarkdownApplyJSON(t *testing.T) { } } +func TestRunnerStateMigrateMarkdownApplyHuman(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + writeCLIAgentsFile(t, workingDir, "ideas/20260528-apply-idea.md", "# Apply Idea\n") + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"state", "migrate", "markdown", "--apply"}) + if err != nil { + t.Fatalf("state migrate markdown --apply error = %v", err) + } + + output := stdout.String() + for _, want := range []string{ + "loaf state migrate markdown --apply", + "scope: global database, project import", + "database:", + "project:", + "project name:", + "project path:", + "action: apply", + "applied: true", + "ideas: 1", + } { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } + if strings.Contains(output, "next:") { + t.Fatalf("output = %q, did not want dry-run next action after apply", output) + } +} + +func TestRunnerStateMigrateMarkdownApplyJSONDoesNotRequireTasksJSON(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-markdown-only.md", `--- +id: TASK-001 +title: Markdown Only Task +status: todo +priority: P2 +depends_on: [] +--- + +# Markdown Only Task +`) + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"migrate", "markdown", "--apply", "--json"}) + if err != nil { + t.Fatalf("migrate markdown --apply --json error = %v", err) + } + + result := decodeMarkdownMigrationResult(t, stdout.Bytes()) + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if !result.Applied || result.Tasks != 1 || result.Relationships != 0 { + t.Fatalf("result = %#v, want one markdown-only task with no relationships", result) + } + if _, err := os.Stat(result.DatabasePath); err != nil { + t.Fatalf("database was not created: %v", err) + } +} + func TestRunnerStateMigrateMarkdownResumeJSON(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-resume.md", "# Resume Spec\n") writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-resume.md", "# Resume Task\n") writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{"TASK-001":{"spec":"SPEC-001"}}}`) + sourcePaths := []string{ + filepath.Join(workingDir, ".agents", "specs", "SPEC-001-resume.md"), + filepath.Join(workingDir, ".agents", "tasks", "TASK-001-resume.md"), + filepath.Join(workingDir, ".agents", "TASKS.json"), + } + sourceBytes := map[string][]byte{} + for _, path := range sourcePaths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read source %s: %v", path, err) + } + sourceBytes[path] = content + } var firstStdout bytes.Buffer err := Runner{ @@ -4126,6 +7329,9 @@ func TestRunnerStateMigrateMarkdownResumeJSON(t *testing.T) { if !firstResult.Applied { t.Fatal("Applied = false, want true") } + if firstResult.Action != state.MarkdownMigrationActionResume { + t.Fatalf("first Action = %q, want %q", firstResult.Action, state.MarkdownMigrationActionResume) + } if firstResult.DatabasePath == "" { t.Fatal("DatabasePath is empty") } @@ -4144,12 +7350,24 @@ func TestRunnerStateMigrateMarkdownResumeJSON(t *testing.T) { } secondResult := decodeMarkdownMigrationResult(t, secondStdout.Bytes()) + if secondResult.Action != state.MarkdownMigrationActionResume { + t.Fatalf("second Action = %q, want %q", secondResult.Action, state.MarkdownMigrationActionResume) + } if secondResult.DatabasePath != firstResult.DatabasePath { t.Fatalf("DatabasePath = %q, want %q", secondResult.DatabasePath, firstResult.DatabasePath) } if secondResult.Specs != 1 || secondResult.Tasks != 1 || secondResult.Relationships != 1 { t.Fatalf("second result = %#v, want idempotent imported counts", secondResult) } + for _, path := range sourcePaths { + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read source after resume %s: %v", path, err) + } + if !bytes.Equal(content, sourceBytes[path]) { + t.Fatalf("source %s changed after repeated resume", path) + } + } } func TestRunnerStateMigrateMarkdownResumeHuman(t *testing.T) { @@ -4175,8 +7393,21 @@ func TestRunnerStateMigrateMarkdownResumeHuman(t *testing.T) { if !strings.Contains(output, "database: ") { t.Fatalf("output = %q, want database path", output) } - if !strings.Contains(output, "ideas: 1") { - t.Fatalf("output = %q, want idea count", output) + for _, want := range []string{"scope: global database, project import", "project:", "project name:", "project path:"} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } + for _, want := range []string{"applied: true", "ideas: 1"} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } + if !strings.Contains(output, "action: resume") { + t.Fatalf("output = %q, want resume action", output) + } + if strings.Contains(output, "next:") { + t.Fatalf("output = %q, did not want dry-run next action after resume", output) } } @@ -4201,34 +7432,616 @@ func TestRunnerStateMigrateMarkdownResumeRejectsFlagCombinations(t *testing.T) { wantErr string }{ { - name: "dry-run", - args: []string{"state", "migrate", "markdown", "--resume", "--dry-run"}, - wantErr: "cannot combine --resume and --dry-run", + name: "dry-run", + args: []string{"state", "migrate", "markdown", "--resume", "--dry-run"}, + wantErr: "cannot combine --resume and --dry-run", + }, + { + name: "apply", + args: []string{"state", "migrate", "markdown", "--resume", "--apply"}, + wantErr: "cannot combine --resume and --apply", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Runner{ + Stdout: &bytes.Buffer{}, + WorkingDir: realpath(t, t.TempDir()), + StateHome: t.TempDir(), + }.Run(tt.args) + if err == nil { + t.Fatal("state migrate markdown --resume error = nil, want rejection") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + }) + } +} + +func TestRunnerStateJSONValidationErrorsAreMachineReadable(t *testing.T) { + tests := []struct { + name string + args []string + command string + want string + }{ + { + name: "state markdown conflicting flags", + args: []string{"state", "migrate", "markdown", "--apply", "--dry-run", "--json"}, + command: "state migrate markdown", + want: "cannot combine --apply and --dry-run", + }, + { + name: "top-level markdown conflicting flags", + args: []string{"migrate", "markdown", "--resume", "--apply", "--json"}, + command: "migrate markdown", + want: "migrate markdown cannot combine --resume and --apply", + }, + { + name: "storage-home conflicting flags", + args: []string{"state", "migrate", "storage-home", "--apply", "--dry-run", "--json"}, + command: "state migrate storage-home", + want: "cannot combine --apply and --dry-run", + }, + { + name: "legacy repair conflicting flags", + args: []string{"state", "repair", "legacy-project-database", "--apply", "--dry-run", "--json"}, + command: "state repair legacy-project-database", + want: "cannot combine --apply and --dry-run", + }, + { + name: "relationship repair missing origin", + args: []string{"state", "repair", "relationship-origin", "--dry-run", "--json"}, + command: "state repair relationship-origin", + want: "requires --origin", + }, + { + name: "relationship repair invalid origin", + args: []string{"state", "repair", "relationship-origin", "--origin", "external", "--json"}, + command: "state repair relationship-origin", + want: "must be imported or manual", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := (Runner{ + Stdout: &stdout, + WorkingDir: realpath(t, t.TempDir()), + StateHome: t.TempDir(), + }).Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON validation error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != tc.command || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want command %q and error containing %q", output, tc.command, tc.want) + } + }) + } +} + +func TestRunnerStateControlPlaneJSONFailureMatrix(t *testing.T) { + tests := []struct { + name string + args []string + command string + want string + wantMissingStateDB bool + }{ + { + name: "state path parse failure", + args: []string{"state", "path", "--json", "--bogus"}, + command: "state path", + want: "unknown option", + wantMissingStateDB: true, + }, + { + name: "state path json verbose conflict", + args: []string{"state", "path", "--json", "--verbose"}, + command: "state path", + want: "cannot combine --json and --verbose", + wantMissingStateDB: true, + }, + { + name: "state status parse failure", + args: []string{"state", "status", "--json", "--bogus"}, + command: "state status", + want: "unknown option", + wantMissingStateDB: true, + }, + { + name: "state doctor parse failure", + args: []string{"state", "doctor", "--json", "--bogus"}, + command: "state doctor", + want: "unknown option", + wantMissingStateDB: true, + }, + { + name: "state backup parse failure", + args: []string{"state", "backup", "--json", "--bogus"}, + command: "state backup", + want: "unknown option", + wantMissingStateDB: true, + }, + { + name: "state backup verify missing path", + args: []string{"state", "backup", "verify", "--json"}, + command: "state backup verify", + want: "requires a backup path", + wantMissingStateDB: true, + }, + { + name: "state export missing database", + args: []string{"state", "export", "all", "--json"}, + command: "state export", + want: "SQLite state database is not initialized", + wantMissingStateDB: true, }, { - name: "apply", - args: []string{"state", "migrate", "markdown", "--resume", "--apply"}, - wantErr: "cannot combine --resume and --apply", + name: "state export markdown json misuse", + args: []string{"state", "export", "triage", "--json"}, + command: "state export", + want: "--json is only supported for state export all", + wantMissingStateDB: true, + }, + { + name: "state repair conflicting flags", + args: []string{"state", "repair", "legacy-project-database", "--dry-run", "--apply", "--json"}, + command: "state repair legacy-project-database", + want: "cannot combine --apply and --dry-run", + wantMissingStateDB: true, + }, + { + name: "project show missing database", + args: []string{"project", "show", "--json"}, + command: "project show", + want: "state database does not exist", + wantMissingStateDB: true, + }, + { + name: "project list parse failure", + args: []string{"project", "list", "--json", "--bogus"}, + command: "project list", + want: "unknown option", + wantMissingStateDB: true, + }, + { + name: "project rename parse failure", + args: []string{"project", "rename", "--json"}, + command: "project rename", + want: "requires a name", + wantMissingStateDB: true, + }, + { + name: "project move parse failure", + args: []string{"project", "move", "--from", "relative/path", "--json"}, + command: "project move", + want: "requires absolute", + wantMissingStateDB: true, + }, + { + name: "state migrate markdown conflicting flags", + args: []string{"state", "migrate", "markdown", "--apply", "--dry-run", "--json"}, + command: "state migrate markdown", + want: "cannot combine --apply and --dry-run", + wantMissingStateDB: true, + }, + { + name: "top-level migrate markdown conflicting flags", + args: []string{"migrate", "markdown", "--resume", "--apply", "--json"}, + command: "migrate markdown", + want: "cannot combine --resume and --apply", + wantMissingStateDB: true, + }, + { + name: "state migrate storage-home conflicting flags", + args: []string{"state", "migrate", "storage-home", "--apply", "--dry-run", "--json"}, + command: "state migrate storage-home", + want: "cannot combine --apply and --dry-run", + wantMissingStateDB: true, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := Runner{ - Stdout: &bytes.Buffer{}, - WorkingDir: realpath(t, t.TempDir()), - StateHome: t.TempDir(), - }.Run(tt.args) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + var stdout bytes.Buffer + err := (Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }).Run(tc.args) if err == nil { - t.Fatal("state migrate markdown --resume error = nil, want rejection") + t.Fatalf("Run(%v) error = nil, want JSON failure", tc.args) } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("error = %v, want %q", err, tt.wantErr) + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != tc.command || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want command %q and error containing %q", output, tc.command, tc.want) + } + if tc.wantMissingStateDB { + assertNoStateDatabase(t, workingDir, stateHome) } }) } } +func TestRunnerStateControlPlaneJSONSuccessMatrix(t *testing.T) { + t.Run("initialized read-only commands preserve SQLite rows and repo files", func(t *testing.T) { + tests := []struct { + name string + args []string + verify func(t *testing.T, data []byte, workingDir string) + }{ + { + name: "state status", + args: []string{"state", "status", "--json"}, + verify: func(t *testing.T, data []byte, workingDir string) { + t.Helper() + status := decodeStateStatus(t, data) + assertCLIProjectContext(t, workingDir, status.ContractVersion, status.DatabaseScope, status.DatabasePath, status.ProjectID, status.ProjectName, status.ProjectCurrentPath) + if status.Mode != state.ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q", status.Mode, state.ModeSQLiteReady) + } + }, + }, + { + name: "state doctor", + args: []string{"state", "doctor", "--json"}, + verify: func(t *testing.T, data []byte, workingDir string) { + t.Helper() + status := decodeStateStatus(t, data) + assertCLIProjectContext(t, workingDir, status.ContractVersion, status.DatabaseScope, status.DatabasePath, status.ProjectID, status.ProjectName, status.ProjectCurrentPath) + if status.Mode != state.ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q", status.Mode, state.ModeSQLiteReady) + } + assertJSONArrayLength(t, data, "repair_plan", 0) + }, + }, + { + name: "state export all", + args: []string{"state", "export", "all", "--json"}, + verify: func(t *testing.T, data []byte, workingDir string) { + t.Helper() + snapshot := decodeStateExportSnapshot(t, data) + assertCLIProjectContext(t, workingDir, snapshot.ContractVersion, snapshot.DatabaseScope, snapshot.DatabasePath, snapshot.ProjectID, snapshot.ProjectName, snapshot.ProjectCurrentPath) + if snapshot.ExportKind != state.ExportKindAll || snapshot.Format != state.ExportFormatJSON || snapshot.ExportScope != "project" { + t.Fatalf("snapshot = %#v, want all/json project export", snapshot) + } + if !snapshot.Manifest.Verified { + t.Fatal("Manifest.Verified = false, want true") + } + }, + }, + { + name: "project show", + args: []string{"project", "show", "--json"}, + verify: func(t *testing.T, data []byte, workingDir string) { + t.Helper() + var shown state.ProjectIdentity + if err := json.Unmarshal(data, &shown); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + if shown.ContractVersion != state.StateJSONContractVersion || shown.DatabaseScope != "global" || shown.ID == "" || shown.FriendlyName != filepath.Base(workingDir) || shown.CurrentPath != workingDir { + t.Fatalf("project show = %#v, want stable initialized project identity for %s", shown, workingDir) + } + }, + }, + { + name: "project list", + args: []string{"project", "list", "--json"}, + verify: func(t *testing.T, data []byte, workingDir string) { + t.Helper() + var listed state.ProjectList + if err := json.Unmarshal(data, &listed); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + if listed.ContractVersion != state.StateJSONContractVersion || listed.DatabaseScope != "global" || len(listed.Projects) != 1 { + t.Fatalf("project list = %#v, want one initialized global project", listed) + } + project := listed.Projects[0] + if project.ContractVersion != state.StateJSONContractVersion || project.DatabaseScope != "global" || project.ID == "" || project.FriendlyName != filepath.Base(workingDir) || project.CurrentPath != workingDir { + t.Fatalf("listed project = %#v, want stable initialized project identity for %s", project, workingDir) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + before := exportAllTablesForCLI(t, workingDir, stateHome) + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run(tc.args); err != nil { + t.Fatalf("Run(%v) error = %v\nstdout:\n%s", tc.args, err, stdout.String()) + } + tc.verify(t, stdout.Bytes(), workingDir) + after := exportAllTablesForCLI(t, workingDir, stateHome) + if !reflect.DeepEqual(before, after) { + t.Fatalf("Run(%v) mutated exported tables:\nbefore=%#v\nafter=%#v", tc.args, before, after) + } + assertNoRepositoryAgentsDir(t, workingDir) + }) + } + }) + + t.Run("markdown dry-run returns JSON without creating SQLite state", func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-example.md", "# Spec\n") + writeCLIAgentsFile(t, workingDir, "tasks/TASK-001-example.md", "# Task\n") + + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--dry-run", "--json"}); err != nil { + t.Fatalf("state migrate markdown --dry-run --json error = %v", err) + } + preview := decodeMarkdownMigrationPreviewResult(t, stdout.Bytes()) + plan := preview.MarkdownMigrationPlan + if plan.ContractVersion != state.StateJSONContractVersion || plan.AgentsPath != filepath.Join(workingDir, ".agents") { + t.Fatalf("markdown migration plan = %#v, want contract version and agents path for dry-run", plan) + } + if preview.DatabaseScope != "global" || preview.ImportScope != "project" || preview.DatabasePath == "" || preview.Applied { + t.Fatalf("markdown migration preview = %#v, want non-mutating global project preview", preview) + } + if plan.Specs != 1 || plan.Tasks != 1 { + t.Fatalf("markdown migration plan = %#v, want one spec and one task", plan) + } + assertNoStateDatabase(t, workingDir, stateHome) + }) + + t.Run("storage-home dry-run returns JSON without copying destination", func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + legacyPath := initializeCLILegacyStateDatabase(t, root) + destination, err := state.PathResolver{}.DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir}).Run([]string{"state", "migrate", "storage-home", "--dry-run", "--json"}); err != nil { + t.Fatalf("state migrate storage-home --dry-run --json error = %v", err) + } + var plan state.StorageHomeMigrationPlan + if err := json.Unmarshal(stdout.Bytes(), &plan); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", stdout.String(), err) + } + if plan.ContractVersion != state.StateJSONContractVersion || plan.DatabaseScope != "global" || plan.MigrationScope != "project" || plan.Action != state.StorageHomeActionCopy || plan.Applied { + t.Fatalf("storage-home plan = %#v, want unapplied global/project copy dry-run", plan) + } + if plan.LegacyDatabasePath != legacyPath || plan.DatabasePath != destination { + t.Fatalf("storage-home paths = %q -> %q, want %q -> %q", plan.LegacyDatabasePath, plan.DatabasePath, legacyPath, destination) + } + if _, err := os.Stat(destination); !os.IsNotExist(err) { + t.Fatalf("destination stat error = %v, want storage-home dry-run not to copy %s", err, destination) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy source stat error = %v, want preserved source %s", err, legacyPath) + } + }) + + t.Run("backup verify reads backup without live state", func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + var backupOut bytes.Buffer + if err := (Runner{Stdout: &backupOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "backup", "--json"}); err != nil { + t.Fatalf("state backup --json error = %v", err) + } + backup := decodeStateBackupResult(t, backupOut.Bytes()) + + var verifyOut bytes.Buffer + otherWorkingDir := realpath(t, t.TempDir()) + otherStateHome := t.TempDir() + if err := (Runner{Stdout: &verifyOut, WorkingDir: otherWorkingDir, StateHome: otherStateHome}).Run([]string{"state", "backup", "verify", backup.BackupPath, "--json"}); err != nil { + t.Fatalf("state backup verify --json error = %v", err) + } + verified := decodeStateBackupVerificationResult(t, verifyOut.Bytes()) + if verified.ContractVersion != state.StateJSONContractVersion || !verified.Verified || verified.BackupPath != backup.BackupPath { + t.Fatalf("backup verification = %#v, want verified backup %s for project %s", verified, backup.BackupPath, backup.ProjectID) + } + if len(verified.Projects) != 1 || verified.Projects[0].ID != backup.ProjectID { + t.Fatalf("backup verification projects = %#v, want backed-up project %s", verified.Projects, backup.ProjectID) + } + otherRoot, err := project.ResolveRoot(otherWorkingDir) + if err != nil { + t.Fatalf("ResolveRoot(otherWorkingDir) error = %v", err) + } + otherDatabasePath, err := state.PathResolver{StateHome: otherStateHome}.DatabasePath(otherRoot) + if err != nil { + t.Fatalf("DatabasePath(otherWorkingDir) error = %v", err) + } + if verified.RestoreDatabasePath != otherDatabasePath { + t.Fatalf("RestoreDatabasePath = %q, want verifier target %q", verified.RestoreDatabasePath, otherDatabasePath) + } + assertNoStateDatabase(t, otherWorkingDir, otherStateHome) + }) +} + +func TestRunnerStateControlPlaneMutationAndRepairSafeguards(t *testing.T) { + t.Run("project rename and move keep durable identity boundaries", func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + movedDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + original := projectIdentityForCLI(t, workingDir, stateHome) + beforeRenamePreview := exportAllTablesForCLI(t, workingDir, stateHome) + var renamePreviewOut bytes.Buffer + if err := (Runner{Stdout: &renamePreviewOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "Preview Loaf", "--dry-run", "--json"}); err != nil { + t.Fatalf("project rename --dry-run --json error = %v", err) + } + var renamePreview state.ProjectRenameResult + if err := json.Unmarshal(renamePreviewOut.Bytes(), &renamePreview); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", renamePreviewOut.String(), err) + } + if renamePreview.ContractVersion != state.StateJSONContractVersion || renamePreview.DatabaseScope != "global" || renamePreview.Action != "dry-run" { + t.Fatalf("rename preview = %#v, want global dry-run contract", renamePreview) + } + if renamePreview.Project.ID != original.ID || renamePreview.Project.FriendlyName != "Preview Loaf" || renamePreview.FromName != original.FriendlyName { + t.Fatalf("rename preview = %#v, want same ID %q from %q to Preview Loaf", renamePreview, original.ID, original.FriendlyName) + } + afterRenamePreview := exportAllTablesForCLI(t, workingDir, stateHome) + if !reflect.DeepEqual(beforeRenamePreview, afterRenamePreview) { + t.Fatalf("project rename dry-run mutated exported tables:\nbefore=%#v\nafter=%#v", beforeRenamePreview, afterRenamePreview) + } + + var renameApplyOut bytes.Buffer + if err := (Runner{Stdout: &renameApplyOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "rename", "Friendly Loaf", "--json"}); err != nil { + t.Fatalf("project rename --json error = %v", err) + } + var renamed state.ProjectIdentity + if err := json.Unmarshal(renameApplyOut.Bytes(), &renamed); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", renameApplyOut.String(), err) + } + if renamed.ContractVersion != state.StateJSONContractVersion || renamed.DatabaseScope != "global" || renamed.ID != original.ID || renamed.FriendlyName != "Friendly Loaf" || renamed.CurrentPath != workingDir { + t.Fatalf("renamed project = %#v, want same ID %q renamed at %s", renamed, original.ID, workingDir) + } + + beforeMovePreview := exportAllTablesForCLI(t, workingDir, stateHome) + var movePreviewOut bytes.Buffer + if err := (Runner{Stdout: &movePreviewOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", workingDir, "--dry-run", "--json"}); err != nil { + t.Fatalf("project move --dry-run --json error = %v", err) + } + var movePreview state.ProjectMoveResult + if err := json.Unmarshal(movePreviewOut.Bytes(), &movePreview); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", movePreviewOut.String(), err) + } + if movePreview.ContractVersion != state.StateJSONContractVersion || movePreview.DatabaseScope != "global" || movePreview.Action != "dry-run" { + t.Fatalf("move preview = %#v, want global dry-run contract", movePreview) + } + if movePreview.Project.ID != original.ID || movePreview.Project.CurrentPath != movedDir || movePreview.FromPath != workingDir || movePreview.ToPath != movedDir { + t.Fatalf("move preview = %#v, want same ID %q previewed from %s to %s", movePreview, original.ID, workingDir, movedDir) + } + afterMovePreview := exportAllTablesForCLI(t, workingDir, stateHome) + if !reflect.DeepEqual(beforeMovePreview, afterMovePreview) { + t.Fatalf("project move dry-run mutated exported tables:\nbefore=%#v\nafter=%#v", beforeMovePreview, afterMovePreview) + } + + var moveApplyOut bytes.Buffer + if err := (Runner{Stdout: &moveApplyOut, WorkingDir: movedDir, StateHome: stateHome}).Run([]string{"project", "move", "--from", workingDir, "--json"}); err != nil { + t.Fatalf("project move --json error = %v", err) + } + var moved state.ProjectMoveResult + if err := json.Unmarshal(moveApplyOut.Bytes(), &moved); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", moveApplyOut.String(), err) + } + if moved.ContractVersion != state.StateJSONContractVersion || moved.DatabaseScope != "global" || moved.Action != "moved" { + t.Fatalf("moved project result = %#v, want global moved contract", moved) + } + if moved.Project.ID != original.ID || moved.Project.FriendlyName != "Friendly Loaf" || moved.Project.CurrentPath != movedDir { + t.Fatalf("moved project = %#v, want same renamed ID %q at %s", moved.Project, original.ID, movedDir) + } + db, err := sql.Open("sqlite3", stateDBPathForWorkingDir(t, movedDir, stateHome)) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if got := sqliteCount(t, db, `SELECT COUNT(*) FROM projects`); got != 1 { + t.Fatalf("projects = %d, want one durable project after rename/move", got) + } + if got := sqliteCount(t, db, `SELECT COUNT(*) FROM project_paths WHERE project_id = ? AND is_current = 1`, original.ID); got != 1 { + t.Fatalf("current project paths = %d, want one current path after move", got) + } + assertNoRepositoryAgentsDir(t, workingDir) + assertNoRepositoryAgentsDir(t, movedDir) + }) + + t.Run("relationship-origin repair dry-run preserves relationship rows", func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + var initOut bytes.Buffer + if err := (Runner{Stdout: &initOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + initialized := decodeStateStatus(t, initOut.Bytes()) + db, err := sql.Open("sqlite3", initialized.DatabasePath) + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + if _, err := db.Exec(` +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) +VALUES ('relationship-without-origin', ?, 'task', 'task-one', 'spec', 'spec-one', 'implements', 'legacy row', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, initialized.ProjectID); err != nil { + t.Fatalf("insert relationship without origin error = %v", err) + } + + before := exportAllTablesForCLI(t, workingDir, stateHome) + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "repair", "relationship-origin", "--origin", "imported", "--dry-run", "--json"}); err != nil { + t.Fatalf("state repair relationship-origin --dry-run --json error = %v", err) + } + result := decodeRelationshipOriginRepairResult(t, stdout.Bytes()) + if result.ContractVersion != state.StateJSONContractVersion || result.DatabaseScope != "global" || result.ProjectID != initialized.ProjectID || result.Applied || result.Matched != 1 || result.Updated != 0 || result.BackupPath != "" { + t.Fatalf("relationship repair dry-run = %#v, want one matched row without writes or backup", result) + } + after := exportAllTablesForCLI(t, workingDir, stateHome) + if !reflect.DeepEqual(before, after) { + t.Fatalf("relationship-origin dry-run mutated exported tables:\nbefore=%#v\nafter=%#v", before, after) + } + }) + + t.Run("legacy project database repair dry-run preserves legacy files", func(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + root, err := project.ResolveRoot(workingDir) + if err != nil { + t.Fatalf("ResolveRoot() error = %v", err) + } + legacyPath := initializeCLILegacyStateDatabase(t, root) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir}).Run([]string{"state", "repair", "legacy-project-database", "--dry-run", "--json"}); err != nil { + t.Fatalf("state repair legacy-project-database --dry-run --json error = %v", err) + } + result := decodeLegacyProjectDatabaseArchiveResult(t, stdout.Bytes()) + if result.ContractVersion != state.StateJSONContractVersion || result.DatabaseScope != "global" || result.Action != state.LegacyProjectDatabaseArchiveAction || result.Applied { + t.Fatalf("legacy project database repair dry-run = %#v, want unapplied archive plan", result) + } + if len(result.MatchedPaths) == 0 || len(result.ArchivedPaths) != 0 || result.LegacyDatabasePath != legacyPath { + t.Fatalf("legacy project database repair dry-run = %#v, want matched legacy files and no archived files", result) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy database stat error = %v, want dry-run to preserve %s", err, legacyPath) + } + if result.ArchivePath == "" { + t.Fatal("legacy project database repair dry-run ArchivePath is empty") + } + if _, err := os.Stat(result.ArchivePath); !os.IsNotExist(err) { + t.Fatalf("archive path stat error = %v, want dry-run not to create archive %s", err, result.ArchivePath) + } + }) +} + func TestRunnerTraceJSONUsesSQLiteState(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -4256,6 +8069,7 @@ status: implementing } trace := decodeTraceResult(t, stdout.Bytes()) + assertCLIProjectContext(t, workingDir, trace.ContractVersion, trace.DatabaseScope, trace.DatabasePath, trace.ProjectID, trace.ProjectName, trace.ProjectCurrentPath) if trace.Entity.Kind != "task" || trace.Entity.Alias != "TASK-001" || trace.Entity.Title != "Example Task" { t.Fatalf("Entity = %#v, want imported task", trace.Entity) } @@ -4265,6 +8079,22 @@ status: implementing if !hasTraceRelationship(trace.Relationships, "outbound", "blocked_by", "task", "TASK-000") { t.Fatalf("Relationships = %#v, want task dependency alias", trace.Relationships) } + + var humanOut bytes.Buffer + err = Runner{ + Stdout: &humanOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"trace", "TASK-001"}) + if err != nil { + t.Fatalf("trace TASK-001 human error = %v", err) + } + human := humanOut.String() + for _, want := range []string{"task TASK-001", "scope: global database", "database:", "project:", "project name:", "project path:", "title: Example Task", "outbound implements spec SPEC-001"} { + if !strings.Contains(human, want) { + t.Fatalf("human output = %q, want %q", human, want) + } + } } func TestRunnerTraceHumanMissingDatabase(t *testing.T) { @@ -4281,6 +8111,44 @@ func TestRunnerTraceHumanMissingDatabase(t *testing.T) { } } +func TestRunnerTraceJSONErrorsAreMachineReadable(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing ref", + args: []string{"trace", "--json"}, + want: "trace requires an id", + }, + { + name: "extra ref", + args: []string{"trace", "TASK-001", "TASK-002", "--json"}, + want: "trace accepts exactly one id", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: realpath(t, t.TempDir()), + StateHome: t.TempDir(), + }.Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON validation error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "trace" || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want trace error containing %q", output, tc.want) + } + }) + } +} + func TestRunnerTaskListJSONUsesSQLiteStateWhenInitialized(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -4314,6 +8182,7 @@ status: implementing } tasks := decodeTaskList(t, stdout.Bytes()) + assertCLIProjectContext(t, workingDir, tasks.ContractVersion, tasks.DatabaseScope, tasks.DatabasePath, tasks.ProjectID, tasks.ProjectName, tasks.ProjectCurrentPath) if _, ok := tasks.Tasks["TASK-002"]; ok { t.Fatal("active task list includes done task") } @@ -4346,8 +8215,10 @@ func TestRunnerTaskListHumanUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("task list --status todo error = %v", err) } output := stdout.String() - if !strings.Contains(output, "loaf task list") || !strings.Contains(output, "TASK-001") || !strings.Contains(output, "Example Task") { - t.Fatalf("output = %q, want state-backed task list", output) + for _, want := range []string{"loaf task list", "scope: global database", "database:", "project:", "project name:", "project path:", "TASK-001", "Example Task"} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } } } @@ -4388,6 +8259,11 @@ status: complete output := stdout.String() for _, want := range []string{ "loaf task status", + "scope: global database", + "database:", + "project:", + "project name:", + "project path:", "Tasks:", "1 in_progress", "0 blocked", @@ -4428,9 +8304,27 @@ func TestRunnerTaskCreateUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("task create --json error = %v", err) } created := decodeTaskCreateResult(t, createOut.Bytes()) - if created.Task.Alias != "TASK-002" || created.Task.Title != "Created Task" || created.Task.Status != "todo" || created.Priority != "P1" || created.Spec.Alias != "SPEC-001" || created.EventID == "" { + if created.Task.Alias != "TASK-002" || created.Task.Title != "Created Task" || created.Task.Status != "todo" || created.Priority != "P1" || created.Spec == nil || created.Spec.Alias != "SPEC-001" || created.EventID == "" { t.Fatalf("created = %#v, want TASK-002 under SPEC-001", created) } + if created.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("created ContractVersion = %d, want %d", created.ContractVersion, state.StateJSONContractVersion) + } + if created.DatabaseScope != "global" { + t.Fatalf("created DatabaseScope = %q, want global", created.DatabaseScope) + } + if created.DatabasePath == "" { + t.Fatal("created DatabasePath is empty") + } + if created.ProjectID == "" { + t.Fatal("created ProjectID is empty") + } + if created.ProjectName != filepath.Base(workingDir) { + t.Fatalf("created ProjectName = %q, want %q", created.ProjectName, filepath.Base(workingDir)) + } + if created.ProjectCurrentPath != workingDir { + t.Fatalf("created ProjectCurrentPath = %q, want %q", created.ProjectCurrentPath, workingDir) + } if len(created.Depends) != 1 || created.Depends[0].Alias != "TASK-001" { t.Fatalf("created.Depends = %#v, want TASK-001", created.Depends) } @@ -4484,13 +8378,38 @@ func TestRunnerTaskCreateHumanUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("task create human error = %v", err) } output := stdout.String() - for _, want := range []string{"created task TASK-001: Human Task", "status: todo", "priority: P2", "event:"} { + for _, want := range []string{"created task TASK-001: Human Task", "scope: global database", "database:", "project:", "project name:", "project path:", "status: todo", "priority: P2", "event:"} { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } } } +func TestRunnerTaskCreateJSONOmitsEmptySpecWhenInitialized(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init error = %v", err) + } + + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"task", "create", "--title", "No Spec Task", "--json"}) + if err != nil { + t.Fatalf("task create --json error = %v", err) + } + created := decodeTaskCreateResult(t, stdout.Bytes()) + if created.Spec != nil { + t.Fatalf("created.Spec = %#v, want nil", created.Spec) + } + if bytes.Contains(stdout.Bytes(), []byte(`"spec"`)) { + t.Fatalf("output = %s, want spec omitted", stdout.String()) + } +} + func TestRunnerTaskShowJSONUsesSQLiteStateWhenInitialized(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -4524,6 +8443,7 @@ Imported body. } show := decodeTaskShow(t, stdout.Bytes()) + assertCLIProjectContext(t, workingDir, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath) task := show.Task if show.Query != "TASK-001" || task.Alias != "TASK-001" || task.Title != "Example Task" || task.Status != "todo" || task.Priority != "P1" || task.Spec != "SPEC-001" { t.Fatalf("show = %#v, want imported TASK-001 details", show) @@ -4559,7 +8479,7 @@ func TestRunnerTaskShowHumanUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("task show error = %v", err) } output := stdout.String() - for _, want := range []string{"task TASK-001", "title: Example Task", "status: todo", "priority: P1", "spec: SPEC-001", "source: .agents/tasks/TASK-001-example.md", "# Task Body"} { + for _, want := range []string{"task TASK-001", "scope: global database", "database:", "project:", "project name:", "project path:", "title: Example Task", "status: todo", "priority: P1", "spec: SPEC-001", "source: .agents/tasks/TASK-001-example.md", "# Task Body"} { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } @@ -4609,6 +8529,9 @@ depends_on: TASK-999 t.Fatalf("task list markdown --json --active error = %v", err) } tasks := decodeTaskList(t, jsonOut.Bytes()) + if tasks.ContractVersion != 0 || tasks.DatabaseScope != "" || tasks.DatabasePath != "" || tasks.ProjectID != "" || tasks.ProjectName != "" || tasks.ProjectCurrentPath != "" { + t.Fatalf("markdown task list context = %#v, want empty", tasks) + } if _, ok := tasks.Tasks["TASK-002"]; ok { t.Fatalf("active tasks = %#v, want done task filtered out", tasks.Tasks) } @@ -4649,6 +8572,9 @@ depends_on: TASK-999 t.Fatalf("output = %q, want %q", output, want) } } + if strings.Contains(output, "scope: global database") || strings.Contains(output, "project path:") { + t.Fatalf("output = %q, want markdown fallback without database context", output) + } assertNoStateDatabase(t, workingDir, stateHome) } @@ -4695,6 +8621,9 @@ status: complete t.Fatalf("stdout = %q, want %q", output, want) } } + if strings.Contains(output, "scope: global database") || strings.Contains(output, "project path:") { + t.Fatalf("stdout = %q, want markdown fallback without database context", output) + } assertNoStateDatabase(t, workingDir, stateHome) } @@ -4743,12 +8672,18 @@ func TestRunnerTaskCreateUsesMarkdownIndexWhenMarkdownOnly(t *testing.T) { t.Fatalf("task create markdown --json error = %v", err) } created := decodeTaskCreateResult(t, createOut.Bytes()) - if created.Task.Alias != "TASK-002" || created.Task.Title != "Created Task!" || created.Task.Status != "todo" || created.Priority != "P1" || created.Spec.Alias != "SPEC-001" { + if created.Task.Alias != "TASK-002" || created.Task.Title != "Created Task!" || created.Task.Status != "todo" || created.Priority != "P1" || created.Spec == nil || created.Spec.Alias != "SPEC-001" { t.Fatalf("created = %#v, want TASK-002 under SPEC-001", created) } if len(created.Depends) != 1 || created.Depends[0].Alias != "TASK-001" { t.Fatalf("created.Depends = %#v, want TASK-001", created.Depends) } + if created.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("created ContractVersion = %d, want %d", created.ContractVersion, state.StateJSONContractVersion) + } + if created.DatabaseScope != "" || created.DatabasePath != "" || created.ProjectID != "" || created.ProjectName != "" || created.ProjectCurrentPath != "" { + t.Fatalf("created database context = %#v, want empty for markdown fallback", created) + } var index map[string]any rawIndex, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) @@ -4873,6 +8808,9 @@ Markdown details. t.Fatalf("task show markdown --json error = %v", err) } show := decodeTaskShow(t, jsonOut.Bytes()) + if show.ContractVersion != 0 || show.DatabaseScope != "" || show.DatabasePath != "" || show.ProjectID != "" || show.ProjectName != "" || show.ProjectCurrentPath != "" { + t.Fatalf("markdown task show context = %#v, want empty", show) + } task := show.Task if show.Query != "TASK-001" || task.Alias != "TASK-001" || task.Title != "Example Task" || task.Status != "todo" || task.Priority != "P1" || task.Spec != "SPEC-001" { t.Fatalf("show = %#v, want TASKS.json metadata over frontmatter", show) @@ -5088,6 +9026,24 @@ func TestRunnerTaskUpdateStatusUsesSQLiteStateWhenInitialized(t *testing.T) { if updated.Task.Alias != "TASK-001" || updated.Previous != "todo" || updated.Status != "in_progress" || updated.EventID == "" { t.Fatalf("updated = %#v, want TASK-001 todo -> in_progress", updated) } + if updated.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("updated ContractVersion = %d, want %d", updated.ContractVersion, state.StateJSONContractVersion) + } + if updated.DatabaseScope != "global" { + t.Fatalf("updated DatabaseScope = %q, want global", updated.DatabaseScope) + } + if updated.DatabasePath == "" { + t.Fatal("updated DatabasePath is empty") + } + if updated.ProjectID == "" { + t.Fatal("updated ProjectID is empty") + } + if updated.ProjectName != filepath.Base(workingDir) { + t.Fatalf("updated ProjectName = %q, want %q", updated.ProjectName, filepath.Base(workingDir)) + } + if updated.ProjectCurrentPath != workingDir { + t.Fatalf("updated ProjectCurrentPath = %q, want %q", updated.ProjectCurrentPath, workingDir) + } var listOut bytes.Buffer err = Runner{ @@ -5276,6 +9232,12 @@ Preserve this body. if len(updated.Depends) != 1 || updated.Depends[0].Alias != "TASK-003" { t.Fatalf("updated.Depends = %#v, want TASK-003", updated.Depends) } + if updated.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("updated ContractVersion = %d, want %d", updated.ContractVersion, state.StateJSONContractVersion) + } + if updated.DatabaseScope != "" || updated.DatabasePath != "" || updated.ProjectID != "" || updated.ProjectName != "" || updated.ProjectCurrentPath != "" { + t.Fatalf("updated database context = %#v, want empty for markdown fallback", updated) + } rawIndex, err := os.ReadFile(filepath.Join(workingDir, ".agents", "TASKS.json")) if err != nil { @@ -5454,6 +9416,24 @@ func TestRunnerTaskArchiveUsesSQLiteStateWhenInitialized(t *testing.T) { if len(archive.Archived) != 1 || archive.Archived[0].Task == nil || archive.Archived[0].Task.Alias != "TASK-001" || archive.Archived[0].EventID == "" { t.Fatalf("Archived = %#v, want TASK-001 archived with event", archive.Archived) } + if archive.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) + } + if archive.DatabaseScope != "global" { + t.Fatalf("archive DatabaseScope = %q, want global", archive.DatabaseScope) + } + if archive.DatabasePath == "" { + t.Fatal("archive DatabasePath is empty") + } + if archive.ProjectID == "" { + t.Fatal("archive ProjectID is empty") + } + if archive.ProjectName != filepath.Base(workingDir) { + t.Fatalf("archive ProjectName = %q, want %q", archive.ProjectName, filepath.Base(workingDir)) + } + if archive.ProjectCurrentPath != workingDir { + t.Fatalf("archive ProjectCurrentPath = %q, want %q", archive.ProjectCurrentPath, workingDir) + } if len(archive.Skipped) != 3 { t.Fatalf("Skipped = %#v, want three skipped refs", archive.Skipped) } @@ -5626,6 +9606,12 @@ func TestRunnerTaskArchiveUsesMarkdownIndexWhenMarkdownOnly(t *testing.T) { if archive.Skipped[1].Ref != "TASK-999" || archive.Skipped[1].Reason != "not found in index" { t.Fatalf("Skipped[1] = %#v, want not-found skip", archive.Skipped[1]) } + if archive.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) + } + if archive.DatabaseScope != "" || archive.DatabasePath != "" || archive.ProjectID != "" || archive.ProjectName != "" || archive.ProjectCurrentPath != "" { + t.Fatalf("archive database context = %#v, want empty for markdown fallback", archive) + } if _, err := os.Stat(filepath.Join(workingDir, ".agents", "tasks", "TASK-001-done.md")); !os.IsNotExist(err) { t.Fatalf("active task file stat error = %v, want not exist", err) } @@ -5758,6 +9744,7 @@ status: archived if open.Title != "Open Brainstorm" || open.SourcePath != ".agents/drafts/20260528-brainstorm-open.md" { t.Fatalf("open = %#v, want imported title and source", open) } + assertCLIBrainstormContext(t, defaultList.ContractVersion, defaultList.DatabaseScope, defaultList.DatabasePath, defaultList.ProjectID, defaultList.ProjectName, defaultList.ProjectCurrentPath, workingDir) var allOut bytes.Buffer err = Runner{ @@ -5772,6 +9759,7 @@ status: archived if len(all.Brainstorms) != 3 || all.Brainstorms["20260528-brainstorm-resolved"].Status != "resolved" { t.Fatalf("all = %#v, want all brainstorms", all.Brainstorms) } + assertCLIBrainstormContext(t, all.ContractVersion, all.DatabaseScope, all.DatabasePath, all.ProjectID, all.ProjectName, all.ProjectCurrentPath, workingDir) var archivedOut bytes.Buffer err = Runner{ @@ -5786,6 +9774,7 @@ status: archived if len(archived.Brainstorms) != 1 || archived.Brainstorms["20260528-brainstorm-archived"].Status != "archived" { t.Fatalf("archived = %#v, want archived brainstorm only", archived.Brainstorms) } + assertCLIBrainstormContext(t, archived.ContractVersion, archived.DatabaseScope, archived.DatabasePath, archived.ProjectID, archived.ProjectName, archived.ProjectCurrentPath, workingDir) var humanOut bytes.Buffer err = Runner{ @@ -5797,7 +9786,7 @@ status: archived t.Fatalf("brainstorm list human error = %v", err) } human := humanOut.String() - for _, want := range []string{"loaf brainstorm list", "20260528-brainstorm-open", "Open Brainstorm", "[resolved]", ".agents/drafts/20260528-brainstorm-open.md"} { + for _, want := range []string{"loaf brainstorm list", "scope: global database", "database:", "project:", "project name:", "project path:", "20260528-brainstorm-open", "Open Brainstorm", "[resolved]", ".agents/drafts/20260528-brainstorm-open.md"} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -5875,6 +9864,7 @@ status: open if show.Brainstorm.Alias != "20260528-brainstorm-sqlite" || show.Brainstorm.Title != "SQLite Brainstorm" || show.Brainstorm.Status != "open" { t.Fatalf("show = %#v, want imported brainstorm metadata", show) } + assertCLIBrainstormContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) if len(show.Brainstorm.Sources) != 1 || show.Brainstorm.Sources[0].Path != ".agents/drafts/20260528-brainstorm-sqlite.md" || show.Brainstorm.Sources[0].Hash == "" { t.Fatalf("Sources = %#v, want imported brainstorm source", show.Brainstorm.Sources) } @@ -5895,7 +9885,7 @@ status: open t.Fatalf("brainstorm show human error = %v", err) } human := humanOut.String() - for _, want := range []string{"brainstorm 20260528-brainstorm-sqlite", "title: SQLite Brainstorm", "status: open", "source: .agents/drafts/20260528-brainstorm-sqlite.md", "outbound promoted_to idea 20260528-target-idea", "Imported brainstorm prose."} { + for _, want := range []string{"brainstorm 20260528-brainstorm-sqlite", "title: SQLite Brainstorm", "status: open", "scope: global database", "database:", "project:", "project name:", "project path:", "source: .agents/drafts/20260528-brainstorm-sqlite.md", "outbound promoted_to idea 20260528-target-idea", "Imported brainstorm prose."} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -5970,6 +9960,7 @@ status: open if result.Brainstorm.Alias != "20260528-brainstorm-sqlite" || result.Idea.Alias != "20260528-target-idea" || result.Relationship == "" { t.Fatalf("result = %#v, want brainstorm promoted to target idea with relationship", result) } + assertCLIBrainstormContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) var traceOut bytes.Buffer err = Runner{ @@ -5998,6 +9989,7 @@ status: open if !hasTraceRelationship(show.Brainstorm.Relationships, "outbound", "promoted_to", "idea", "20260528-target-idea") { t.Fatalf("show relationships = %#v, want promoted_to target idea", show.Brainstorm.Relationships) } + assertCLIBrainstormContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) var linkOut bytes.Buffer err = Runner{ @@ -6023,7 +10015,7 @@ status: open t.Fatalf("brainstorm promote human error = %v", err) } human := humanOut.String() - for _, want := range []string{"promoted brainstorm 20260528-brainstorm-sqlite to idea 20260528-target-idea", "relationship:"} { + for _, want := range []string{"promoted brainstorm 20260528-brainstorm-sqlite to idea 20260528-target-idea", "scope: global database", "database:", "project:", "project name:", "project path:", "relationship:"} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -6099,6 +10091,7 @@ status: archived if len(archive.Archived) != 1 || archive.Archived[0].Brainstorm == nil || archive.Archived[0].Brainstorm.Alias != "20260528-brainstorm-open" || archive.Archived[0].EventID == "" || archive.Archived[0].Note != "promoted to idea" { t.Fatalf("Archived = %#v, want open brainstorm archived with event", archive.Archived) } + assertCLIBrainstormContext(t, archive.ContractVersion, archive.DatabaseScope, archive.DatabasePath, archive.ProjectID, archive.ProjectName, archive.ProjectCurrentPath, workingDir) if len(archive.Skipped) != 3 { t.Fatalf("Skipped = %#v, want three skipped refs", archive.Skipped) } @@ -6116,6 +10109,7 @@ status: archived if _, ok := defaultList.Brainstorms["20260528-brainstorm-open"]; ok { t.Fatalf("defaultList.Brainstorms = %#v, want archived brainstorm hidden", defaultList.Brainstorms) } + assertCLIBrainstormContext(t, defaultList.ContractVersion, defaultList.DatabaseScope, defaultList.DatabasePath, defaultList.ProjectID, defaultList.ProjectName, defaultList.ProjectCurrentPath, workingDir) var archivedOut bytes.Buffer err = Runner{ @@ -6130,6 +10124,7 @@ status: archived if archived.Brainstorms["20260528-brainstorm-open"].Status != "archived" || archived.Brainstorms["20260528-brainstorm-archived"].Status != "archived" { t.Fatalf("archived.Brainstorms = %#v, want both archived brainstorms", archived.Brainstorms) } + assertCLIBrainstormContext(t, archived.ContractVersion, archived.DatabaseScope, archived.DatabasePath, archived.ProjectID, archived.ProjectName, archived.ProjectCurrentPath, workingDir) var traceOut bytes.Buffer err = Runner{ @@ -6158,6 +10153,7 @@ status: archived if show.Brainstorm.Status != "archived" { t.Fatalf("show status = %q, want archived", show.Brainstorm.Status) } + assertCLIBrainstormContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) var humanOut bytes.Buffer err = Runner{ @@ -6169,8 +10165,32 @@ status: archived t.Fatalf("brainstorm archive human error = %v", err) } output := humanOut.String() - if !strings.Contains(output, "loaf brainstorm archive") || !strings.Contains(output, "skipped 20260528-brainstorm-open: already archived") || !strings.Contains(output, "Skipped 1 brainstorm(s)") { - t.Fatalf("output = %q, want already-archived human summary", output) + for _, want := range []string{"loaf brainstorm archive", "scope: global database", "database:", "project:", "project name:", "project path:", "skipped 20260528-brainstorm-open: already archived", "Skipped 1 brainstorm(s)"} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } +} + +func assertCLIBrainstormContext(t *testing.T, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string, workingDir string) { + t.Helper() + if contractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) + } + if projectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) } } @@ -6237,6 +10257,7 @@ status: open if before.Ideas["20260528-sqlite-state"].Status != "open" { t.Fatalf("before.Ideas = %#v, want open imported idea", before.Ideas) } + assertCLIIdeaContext(t, before.ContractVersion, before.DatabaseScope, before.DatabasePath, before.ProjectID, before.ProjectName, before.ProjectCurrentPath, workingDir) var resolveOut bytes.Buffer err = Runner{ @@ -6251,6 +10272,7 @@ status: open if result.Idea.Status != "resolved" || result.ResolvedBy.Alias != "SPEC-001" || result.EventID == "" { t.Fatalf("result = %#v, want resolved idea by SPEC-001 with event", result) } + assertCLIIdeaContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) var afterOut bytes.Buffer err = Runner{ @@ -6265,6 +10287,7 @@ status: open if _, ok := after.Ideas["20260528-sqlite-state"]; ok { t.Fatalf("after.Ideas = %#v, want resolved idea omitted by default", after.Ideas) } + assertCLIIdeaContext(t, after.ContractVersion, after.DatabaseScope, after.DatabasePath, after.ProjectID, after.ProjectName, after.ProjectCurrentPath, workingDir) var allOut bytes.Buffer err = Runner{ @@ -6279,6 +10302,7 @@ status: open if all.Ideas["20260528-sqlite-state"].Status != "resolved" { t.Fatalf("all.Ideas = %#v, want resolved idea included with --all", all.Ideas) } + assertCLIIdeaContext(t, all.ContractVersion, all.DatabaseScope, all.DatabasePath, all.ProjectID, all.ProjectName, all.ProjectCurrentPath, workingDir) } func TestRunnerIdeaShowUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -6313,6 +10337,7 @@ Imported idea prose. if show.Idea.Alias != "20260528-sqlite-state" || show.Idea.Title != "SQLite State" || show.Idea.Status != "open" { t.Fatalf("show = %#v, want imported idea metadata", show) } + assertCLIIdeaContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) if len(show.Idea.Sources) != 1 || show.Idea.Sources[0].Path != ".agents/ideas/20260528-sqlite-state.md" || show.Idea.Sources[0].Hash == "" { t.Fatalf("Sources = %#v, want imported idea source", show.Idea.Sources) } @@ -6333,7 +10358,7 @@ Imported idea prose. t.Fatalf("idea show human error = %v", err) } human := humanOut.String() - for _, want := range []string{"idea 20260528-sqlite-state", "title: SQLite State", "status: open", "source: .agents/ideas/20260528-sqlite-state.md", "outbound resolved_by spec SPEC-001", "Imported idea prose."} { + for _, want := range []string{"idea 20260528-sqlite-state", "title: SQLite State", "status: open", "scope: global database", "database:", "project:", "project name:", "project path:", "source: .agents/ideas/20260528-sqlite-state.md", "outbound resolved_by spec SPEC-001", "Imported idea prose."} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -6349,6 +10374,7 @@ Imported idea prose. t.Fatalf("idea capture --json error = %v", err) } captured := decodeIdeaCaptureResult(t, captureOut.Bytes()) + assertCLIIdeaContext(t, captured.ContractVersion, captured.DatabaseScope, captured.DatabasePath, captured.ProjectID, captured.ProjectName, captured.ProjectCurrentPath, workingDir) var capturedShowOut bytes.Buffer err = Runner{ Stdout: &capturedShowOut, @@ -6362,6 +10388,7 @@ Imported idea prose. if capturedShow.Idea.Alias != captured.Idea.Alias || capturedShow.Idea.Title != "Captured Idea" || len(capturedShow.Idea.Sources) != 0 || capturedShow.Idea.Body != "" { t.Fatalf("capturedShow = %#v, want captured idea without source/body", capturedShow) } + assertCLIIdeaContext(t, capturedShow.ContractVersion, capturedShow.DatabaseScope, capturedShow.DatabasePath, capturedShow.ProjectID, capturedShow.ProjectName, capturedShow.ProjectCurrentPath, workingDir) } func TestRunnerIdeaPromoteUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -6392,6 +10419,7 @@ status: open if result.Idea.Alias != "20260528-sqlite-state" || result.Idea.Status != "open" || result.Spec.Alias != "SPEC-001" || result.Relationship == "" { t.Fatalf("result = %#v, want open idea promoted to target spec with relationship", result) } + assertCLIIdeaContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) var traceOut bytes.Buffer err = Runner{ @@ -6420,6 +10448,7 @@ status: open if !hasTraceRelationship(show.Idea.Relationships, "outbound", "promoted_to", "spec", "SPEC-001") { t.Fatalf("show relationships = %#v, want promoted_to target spec", show.Idea.Relationships) } + assertCLIIdeaContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) var linkOut bytes.Buffer err = Runner{ @@ -6445,7 +10474,7 @@ status: open t.Fatalf("idea promote human error = %v", err) } human := humanOut.String() - for _, want := range []string{"promoted idea 20260528-sqlite-state to spec SPEC-001", "relationship:"} { + for _, want := range []string{"promoted idea 20260528-sqlite-state to spec SPEC-001", "scope: global database", "database:", "project:", "project name:", "project path:", "relationship:"} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -6473,6 +10502,7 @@ func TestRunnerIdeaCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { if result.Idea.Status != "open" || result.Idea.Title != "Repeat Idea" || !strings.HasPrefix(result.Idea.Alias, "IDEA-") || !strings.Contains(result.Idea.Alias, "repeat-idea") || result.EventID == "" { t.Fatalf("result = %#v, want captured idea with alias and event", result) } + assertCLIIdeaContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) var secondOut bytes.Buffer err = Runner{ @@ -6487,6 +10517,7 @@ func TestRunnerIdeaCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { if second.Idea.Alias != result.Idea.Alias+"-2" { t.Fatalf("second alias = %q, want collision suffix after %q", second.Idea.Alias, result.Idea.Alias) } + assertCLIIdeaContext(t, second.ContractVersion, second.DatabaseScope, second.DatabasePath, second.ProjectID, second.ProjectName, second.ProjectCurrentPath, workingDir) var listOut bytes.Buffer err = Runner{ @@ -6501,6 +10532,7 @@ func TestRunnerIdeaCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { if ideas.Ideas[result.Idea.Alias].Status != "open" || ideas.Ideas[result.Idea.Alias].Title != "Repeat Idea" { t.Fatalf("ideas = %#v, want captured idea in list", ideas.Ideas) } + assertCLIIdeaContext(t, ideas.ContractVersion, ideas.DatabaseScope, ideas.DatabasePath, ideas.ProjectID, ideas.ProjectName, ideas.ProjectCurrentPath, workingDir) var traceOut bytes.Buffer err = Runner{ @@ -6526,13 +10558,58 @@ func TestRunnerIdeaCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("idea capture human error = %v", err) } human := humanOut.String() - for _, want := range []string{"captured idea IDEA-", "human-idea", "title: Human Idea", "event:"} { + for _, want := range []string{"captured idea IDEA-", "human-idea", "scope: global database", "database:", "project:", "project name:", "project path:", "title: Human Idea", "event:"} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } } } +func TestRunnerIdeaCaptureJSONErrorsAreMachineReadable(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{}}`) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { + t.Fatalf("state migrate markdown --apply error = %v", err) + } + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing title", + args: []string{"idea", "capture", "--json"}, + want: "idea capture requires --title", + }, + { + name: "unknown option", + args: []string{"idea", "capture", "--json", "--bogus"}, + want: "unknown option", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON validation error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "idea capture" || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want idea capture error containing %q", output, tc.want) + } + }) + } +} + func TestRunnerIdeaArchiveUsesSQLiteStateWhenInitialized(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -6567,6 +10644,7 @@ status: archived if len(archive.Archived) != 1 || archive.Archived[0].Idea == nil || archive.Archived[0].Idea.Alias != "20260528-open-idea" || archive.Archived[0].EventID == "" || archive.Archived[0].Note != "covered by SPEC-001" { t.Fatalf("Archived = %#v, want open idea archived with event", archive.Archived) } + assertCLIIdeaContext(t, archive.ContractVersion, archive.DatabaseScope, archive.DatabasePath, archive.ProjectID, archive.ProjectName, archive.ProjectCurrentPath, workingDir) if len(archive.Skipped) != 3 { t.Fatalf("Skipped = %#v, want three skipped refs", archive.Skipped) } @@ -6584,6 +10662,7 @@ status: archived if _, ok := defaultList.Ideas["20260528-open-idea"]; ok { t.Fatalf("defaultList.Ideas = %#v, want archived idea hidden", defaultList.Ideas) } + assertCLIIdeaContext(t, defaultList.ContractVersion, defaultList.DatabaseScope, defaultList.DatabasePath, defaultList.ProjectID, defaultList.ProjectName, defaultList.ProjectCurrentPath, workingDir) var archivedOut bytes.Buffer err = Runner{ @@ -6598,6 +10677,7 @@ status: archived if archived.Ideas["20260528-open-idea"].Status != "archived" || archived.Ideas["20260528-archived-idea"].Status != "archived" { t.Fatalf("archived.Ideas = %#v, want both archived ideas", archived.Ideas) } + assertCLIIdeaContext(t, archived.ContractVersion, archived.DatabaseScope, archived.DatabasePath, archived.ProjectID, archived.ProjectName, archived.ProjectCurrentPath, workingDir) var traceOut bytes.Buffer err = Runner{ @@ -6623,8 +10703,32 @@ status: archived t.Fatalf("idea archive human error = %v", err) } output := humanOut.String() - if !strings.Contains(output, "loaf idea archive") || !strings.Contains(output, "skipped 20260528-open-idea: already archived") || !strings.Contains(output, "Skipped 1 idea(s)") { - t.Fatalf("output = %q, want already-archived human summary", output) + for _, want := range []string{"loaf idea archive", "scope: global database", "database:", "project:", "project name:", "project path:", "skipped 20260528-open-idea: already archived", "Skipped 1 idea(s)"} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } +} + +func assertCLIIdeaContext(t *testing.T, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string, workingDir string) { + t.Helper() + if contractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) + } + if projectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) } } @@ -6738,6 +10842,7 @@ func TestRunnerSparkListAndResolveUseSQLiteStateWhenInitialized(t *testing.T) { if before.Sparks["SPARK-smoke"].Status != "open" { t.Fatalf("before.Sparks = %#v, want open imported spark", before.Sparks) } + assertCLISparkContext(t, before.ContractVersion, before.DatabaseScope, before.DatabasePath, before.ProjectID, before.ProjectName, before.ProjectCurrentPath, workingDir) var resolveOut bytes.Buffer err = Runner{ @@ -6752,6 +10857,7 @@ func TestRunnerSparkListAndResolveUseSQLiteStateWhenInitialized(t *testing.T) { if result.Spark.Status != "resolved" || result.ResolvedBy.Alias != "20260528-target-idea" || result.EventID == "" || result.Reason != "triaged into target idea" { t.Fatalf("result = %#v, want resolved spark by target idea with event", result) } + assertCLISparkContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) var afterOut bytes.Buffer err = Runner{ @@ -6766,6 +10872,7 @@ func TestRunnerSparkListAndResolveUseSQLiteStateWhenInitialized(t *testing.T) { if _, ok := after.Sparks["SPARK-smoke"]; ok { t.Fatalf("after.Sparks = %#v, want resolved spark omitted by default", after.Sparks) } + assertCLISparkContext(t, after.ContractVersion, after.DatabaseScope, after.DatabasePath, after.ProjectID, after.ProjectName, after.ProjectCurrentPath, workingDir) var allOut bytes.Buffer err = Runner{ @@ -6780,6 +10887,7 @@ func TestRunnerSparkListAndResolveUseSQLiteStateWhenInitialized(t *testing.T) { if all.Sparks["SPARK-smoke"].Status != "resolved" { t.Fatalf("all.Sparks = %#v, want resolved spark included with --all", all.Sparks) } + assertCLISparkContext(t, all.ContractVersion, all.DatabaseScope, all.DatabasePath, all.ProjectID, all.ProjectName, all.ProjectCurrentPath, workingDir) } func TestRunnerSparkPromoteUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -6805,6 +10913,7 @@ func TestRunnerSparkPromoteUsesSQLiteStateWhenInitialized(t *testing.T) { if result.Spark.Alias != "SPARK-smoke" || result.Spark.Status != "open" || result.Idea.Alias != "20260528-target-idea" || result.Relationship == "" { t.Fatalf("result = %#v, want open spark promoted to target idea with relationship", result) } + assertCLISparkContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) var traceOut bytes.Buffer err = Runner{ @@ -6844,7 +10953,7 @@ func TestRunnerSparkPromoteUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("spark promote human error = %v", err) } human := humanOut.String() - for _, want := range []string{"promoted spark SPARK-smoke to idea 20260528-target-idea", "relationship:"} { + for _, want := range []string{"promoted spark SPARK-smoke to idea 20260528-target-idea", "scope: global database", "database:", "project:", "project name:", "project path:", "relationship:"} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -6877,6 +10986,7 @@ func TestRunnerSparkShowUsesSQLiteStateWhenInitialized(t *testing.T) { if show.Spark.Alias != "SPARK-smoke" || show.Spark.Text != "smoke spark" || show.Spark.Scope != "sqlite" || show.Spark.Status != "open" { t.Fatalf("show = %#v, want imported spark metadata", show) } + assertCLISparkContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) if len(show.Spark.Sources) != 1 || show.Spark.Sources[0].Path != ".agents/sessions/20260528-session.md" || show.Spark.Sources[0].Hash == "" { t.Fatalf("Sources = %#v, want session source with hash", show.Spark.Sources) } @@ -6894,7 +11004,7 @@ func TestRunnerSparkShowUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("spark show human error = %v", err) } human := humanOut.String() - for _, want := range []string{"spark SPARK-smoke", "scope: sqlite", "status: open", "text: smoke spark", "source: .agents/sessions/20260528-session.md", "outbound promoted_to idea 20260528-target-idea"} { + for _, want := range []string{"spark SPARK-smoke", "scope: global database", "database:", "project:", "project name:", "project path:", "scope: sqlite", "status: open", "text: smoke spark", "source: .agents/sessions/20260528-session.md", "outbound promoted_to idea 20260528-target-idea"} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -6922,6 +11032,7 @@ func TestRunnerSparkCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { if result.Spark.Alias != "SPARK-repeat-spark" || result.Spark.Status != "open" || result.Scope != "architecture" || result.EventID == "" { t.Fatalf("result = %#v, want captured spark with alias, scope, and event", result) } + assertCLISparkContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) var secondOut bytes.Buffer err = Runner{ @@ -6936,6 +11047,7 @@ func TestRunnerSparkCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { if second.Spark.Alias != "SPARK-repeat-spark-2" { t.Fatalf("second alias = %q, want collision suffix", second.Spark.Alias) } + assertCLISparkContext(t, second.ContractVersion, second.DatabaseScope, second.DatabasePath, second.ProjectID, second.ProjectName, second.ProjectCurrentPath, workingDir) var listOut bytes.Buffer err = Runner{ @@ -6950,6 +11062,7 @@ func TestRunnerSparkCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { if sparks.Sparks["SPARK-repeat-spark"].Status != "open" || sparks.Sparks["SPARK-repeat-spark"].Scope != "architecture" { t.Fatalf("sparks = %#v, want captured spark in list", sparks.Sparks) } + assertCLISparkContext(t, sparks.ContractVersion, sparks.DatabaseScope, sparks.DatabasePath, sparks.ProjectID, sparks.ProjectName, sparks.ProjectCurrentPath, workingDir) var traceOut bytes.Buffer err = Runner{ @@ -6975,13 +11088,35 @@ func TestRunnerSparkCaptureUsesSQLiteStateWhenInitialized(t *testing.T) { t.Fatalf("spark capture human error = %v", err) } human := humanOut.String() - for _, want := range []string{"captured spark SPARK-human-spark", "scope: ops", "text: Human Spark", "event:"} { + for _, want := range []string{"captured spark SPARK-human-spark", "scope: global database", "database:", "project:", "project name:", "project path:", "scope: ops", "text: Human Spark", "event:"} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } } } +func assertCLISparkContext(t *testing.T, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string, workingDir string) { + t.Helper() + if contractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) + } + if projectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) + } +} + func TestRunnerSparkCommandRequiresSQLiteWhenMarkdownOnly(t *testing.T) { assertSQLiteRequired(t, "spark", "resolve", "SPARK-smoke", "--by", "20260528-target-idea", "--reason", "covered") assertSQLiteRequired(t, "spark", "show", "SPARK-smoke", "--json") @@ -7091,9 +11226,16 @@ func TestRunnerTagCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if added.Name != "sqlite" || added.Entity.Kind != "spec" || added.Entity.Alias != "SPEC-001" { t.Fatalf("added = %#v, want sqlite tag on SPEC-001", added) } - if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"tag", "add", "20260528-tag-idea", "sqlite"}); err != nil { + assertCLITagMutationContext(t, added, workingDir) + var humanAddOut bytes.Buffer + if err := (Runner{Stdout: &humanAddOut, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"tag", "add", "20260528-tag-idea", "sqlite"}); err != nil { t.Fatalf("tag add idea error = %v", err) } + for _, want := range []string{"tagged idea 20260528-tag-idea with sqlite", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanAddOut.String(), want) { + t.Fatalf("human tag add output = %q, want %q", humanAddOut.String(), want) + } + } var listOut bytes.Buffer err = Runner{ @@ -7108,6 +11250,22 @@ func TestRunnerTagCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if tags.Tags["sqlite"].Count != 2 { t.Fatalf("tags = %#v, want sqlite count 2", tags.Tags) } + assertCLITagListContext(t, tags, workingDir) + + var humanListOut bytes.Buffer + err = Runner{ + Stdout: &humanListOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"tag", "list"}) + if err != nil { + t.Fatalf("tag list human error = %v", err) + } + for _, want := range []string{"loaf tag list", "scope: global database", "database:", "project:", "project name:", "project path:", "sqlite"} { + if !strings.Contains(humanListOut.String(), want) { + t.Fatalf("human tag list output = %q, want %q", humanListOut.String(), want) + } + } var showOut bytes.Buffer err = Runner{ @@ -7122,6 +11280,22 @@ func TestRunnerTagCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if len(show.Members) != 2 { t.Fatalf("show.Members = %#v, want 2 members", show.Members) } + assertCLITagShowContext(t, show, workingDir) + + var humanShowOut bytes.Buffer + err = Runner{ + Stdout: &humanShowOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"tag", "show", "sqlite"}) + if err != nil { + t.Fatalf("tag show human error = %v", err) + } + for _, want := range []string{"tag sqlite", "scope: global database", "database:", "project:", "project name:", "project path:", "SPEC-001", "20260528-tag-idea"} { + if !strings.Contains(humanShowOut.String(), want) { + t.Fatalf("human tag show output = %q, want %q", humanShowOut.String(), want) + } + } var removeOut bytes.Buffer err = Runner{ @@ -7136,6 +11310,88 @@ func TestRunnerTagCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if removed.Entity.Alias != "SPEC-001" { t.Fatalf("removed = %#v, want SPEC-001 removed", removed) } + assertCLITagMutationContext(t, removed, workingDir) + + var humanRemoveOut bytes.Buffer + err = Runner{ + Stdout: &humanRemoveOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"tag", "remove", "20260528-tag-idea", "sqlite"}) + if err != nil { + t.Fatalf("tag remove human error = %v", err) + } + for _, want := range []string{"removed tag sqlite from idea 20260528-tag-idea", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanRemoveOut.String(), want) { + t.Fatalf("human tag remove output = %q, want %q", humanRemoveOut.String(), want) + } + } +} + +func assertCLITagMutationContext(t *testing.T, result state.TagMutationResult, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } +} + +func assertCLITagListContext(t *testing.T, result state.TagList, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } +} + +func assertCLITagShowContext(t *testing.T, result state.TagShowResult, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } } func TestRunnerTagCommandRequiresSQLiteWhenMarkdownOnly(t *testing.T) { @@ -7203,6 +11459,22 @@ func TestRunnerBundleCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if created.Entity != nil { t.Fatalf("created.Entity = %#v, want nil for bundle create", created.Entity) } + assertCLIBundleMutationContext(t, created, workingDir) + + var humanCreateOut bytes.Buffer + err = Runner{ + Stdout: &humanCreateOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"bundle", "create", "sqlite-backend", "--tag", "sqlite", "--title", "SQLite Backend"}) + if err != nil { + t.Fatalf("bundle create human error = %v", err) + } + for _, want := range []string{"created bundle sqlite-backend", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanCreateOut.String(), want) { + t.Fatalf("human bundle create output = %q, want %q", humanCreateOut.String(), want) + } + } var listOut bytes.Buffer err = Runner{ @@ -7218,6 +11490,22 @@ func TestRunnerBundleCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if listed.Title != "SQLite Backend" || listed.TagMatchedCount != 1 || listed.MemberCount != 1 { t.Fatalf("list = %#v, want sqlite-backend bundle with tag-matched spec", list) } + assertCLIBundleListContext(t, list, workingDir) + + var humanListOut bytes.Buffer + err = Runner{ + Stdout: &humanListOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"bundle", "list"}) + if err != nil { + t.Fatalf("bundle list human error = %v", err) + } + for _, want := range []string{"loaf bundle list", "scope: global database", "database:", "project:", "project name:", "project path:", "sqlite-backend"} { + if !strings.Contains(humanListOut.String(), want) { + t.Fatalf("human bundle list output = %q, want %q", humanListOut.String(), want) + } + } var updateOut bytes.Buffer err = Runner{ @@ -7232,6 +11520,22 @@ func TestRunnerBundleCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if updated.Title != "SQLite Runtime" || len(updated.Tags) != 2 || updated.Tags[0] != "sqlite" || updated.Tags[1] != "state" { t.Fatalf("updated = %#v, want replaced title and tags", updated) } + assertCLIBundleMutationContext(t, updated, workingDir) + + var humanUpdateOut bytes.Buffer + err = Runner{ + Stdout: &humanUpdateOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"bundle", "update", "sqlite-backend", "--title", "SQLite Runtime", "--tag", "sqlite", "--tag", "state"}) + if err != nil { + t.Fatalf("bundle update human error = %v", err) + } + for _, want := range []string{"updated bundle sqlite-backend", "scope: global database", "database:", "project:", "project name:", "project path:", "title: SQLite Runtime", "tags: sqlite, state"} { + if !strings.Contains(humanUpdateOut.String(), want) { + t.Fatalf("human bundle update output = %q, want %q", humanUpdateOut.String(), want) + } + } var addOut bytes.Buffer err = Runner{ @@ -7246,6 +11550,22 @@ func TestRunnerBundleCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if added.Entity == nil || added.Entity.Kind != "task" || added.Entity.Alias != "TASK-001" { t.Fatalf("added = %#v, want TASK-001 explicit member", added) } + assertCLIBundleMutationContext(t, added, workingDir) + + var humanAddOut bytes.Buffer + err = Runner{ + Stdout: &humanAddOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"bundle", "add", "sqlite-backend", "TASK-001"}) + if err != nil { + t.Fatalf("bundle add human error = %v", err) + } + for _, want := range []string{"added task TASK-001 to bundle sqlite-backend", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanAddOut.String(), want) { + t.Fatalf("human bundle add output = %q, want %q", humanAddOut.String(), want) + } + } var showOut bytes.Buffer err = Runner{ @@ -7260,6 +11580,22 @@ func TestRunnerBundleCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if show.Title != "SQLite Runtime" || len(show.TagQuery) != 2 || len(show.TagMatched) != 1 || len(show.Explicit) != 1 || len(show.Members) != 2 { t.Fatalf("show = %#v, want updated bundle with tag-matched spec and explicit task", show) } + assertCLIBundleShowContext(t, show, workingDir) + + var humanShowOut bytes.Buffer + err = Runner{ + Stdout: &humanShowOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"bundle", "show", "sqlite-backend"}) + if err != nil { + t.Fatalf("bundle show human error = %v", err) + } + for _, want := range []string{"bundle sqlite-backend", "scope: global database", "database:", "project:", "project name:", "project path:", "SPEC-001", "TASK-001"} { + if !strings.Contains(humanShowOut.String(), want) { + t.Fatalf("human bundle show output = %q, want %q", humanShowOut.String(), want) + } + } var removeOut bytes.Buffer err = Runner{ @@ -7274,6 +11610,91 @@ func TestRunnerBundleCommandsUseSQLiteStateWhenInitialized(t *testing.T) { if removed.Entity == nil || removed.Entity.Alias != "TASK-001" { t.Fatalf("removed = %#v, want TASK-001 removed", removed) } + assertCLIBundleMutationContext(t, removed, workingDir) + + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"bundle", "add", "sqlite-backend", "TASK-001"}); err != nil { + t.Fatalf("bundle add before human remove error = %v", err) + } + var humanRemoveOut bytes.Buffer + err = Runner{ + Stdout: &humanRemoveOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"bundle", "remove", "sqlite-backend", "TASK-001"}) + if err != nil { + t.Fatalf("bundle remove human error = %v", err) + } + for _, want := range []string{"removed task TASK-001 from bundle sqlite-backend", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanRemoveOut.String(), want) { + t.Fatalf("human bundle remove output = %q, want %q", humanRemoveOut.String(), want) + } + } +} + +func assertCLIBundleMutationContext(t *testing.T, result state.BundleMutationResult, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } +} + +func assertCLIBundleListContext(t *testing.T, result state.BundleList, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } +} + +func assertCLIBundleShowContext(t *testing.T, result state.BundleShowResult, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } } func TestRunnerBundleCommandRequiresSQLiteWhenMarkdownOnly(t *testing.T) { @@ -7319,12 +11740,127 @@ func TestRunnerBundleCommandReportsInvalidSQLiteState(t *testing.T) { if err == nil { t.Fatal("bundle update invalid state error = nil, want error") } - if !strings.Contains(err.Error(), "state database is invalid") { - t.Fatalf("error = %v, want invalid state error", err) + if !strings.Contains(err.Error(), "state database is invalid") { + t.Fatalf("error = %v, want invalid state error", err) + } +} + +func TestRunnerLinkCommandsUseSQLiteStateWhenInitialized(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-link.md", "# Link Spec\n") + writeCLIAgentsFile(t, workingDir, "ideas/20260528-link-idea.md", "# Link Idea\n") + writeCLIAgentsFile(t, workingDir, "TASKS.json", `{"tasks":{}}`) + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "migrate", "markdown", "--apply"}); err != nil { + t.Fatalf("state migrate markdown --apply error = %v", err) + } + + var createOut bytes.Buffer + err := Runner{ + Stdout: &createOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"link", "create", "20260528-link-idea", "SPEC-001", "--type", "resolved_by", "--reason", "from cli test", "--json"}) + if err != nil { + t.Fatalf("link create error = %v", err) + } + created := decodeLinkMutationResult(t, createOut.Bytes()) + if created.Type != "resolved_by" || created.From.Alias != "20260528-link-idea" || created.To.Alias != "SPEC-001" || created.Reason != "from cli test" { + t.Fatalf("created = %#v, want idea resolved_by SPEC-001", created) + } + assertCLILinkMutationContext(t, created, workingDir) + + var listOut bytes.Buffer + err = Runner{ + Stdout: &listOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"link", "list", "SPEC-001", "--json"}) + if err != nil { + t.Fatalf("link list error = %v", err) + } + list := decodeLinkListResult(t, listOut.Bytes()) + if len(list.Relationships) != 1 || !hasTraceRelationship(list.Relationships, "inbound", "resolved_by", "idea", "20260528-link-idea") { + t.Fatalf("list = %#v, want inbound idea relationship", list) + } + assertCLILinkListContext(t, list, workingDir) + + var humanListOut bytes.Buffer + err = Runner{ + Stdout: &humanListOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"link", "list", "SPEC-001"}) + if err != nil { + t.Fatalf("link list human error = %v", err) + } + for _, want := range []string{"links for spec SPEC-001", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanListOut.String(), want) { + t.Fatalf("human link list output = %q, want %q", humanListOut.String(), want) + } + } + + var removeOut bytes.Buffer + err = Runner{ + Stdout: &removeOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"link", "remove", "20260528-link-idea", "SPEC-001", "--type", "resolved_by", "--json"}) + if err != nil { + t.Fatalf("link remove error = %v", err) + } + removed := decodeLinkMutationResult(t, removeOut.Bytes()) + if removed.Type != "resolved_by" || removed.From.Alias != "20260528-link-idea" || removed.To.Alias != "SPEC-001" || removed.Reason != "from cli test" { + t.Fatalf("removed = %#v, want removed relationship", removed) + } + assertCLILinkMutationContext(t, removed, workingDir) + + var humanCreateOut bytes.Buffer + err = Runner{ + Stdout: &humanCreateOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"link", "create", "20260528-link-idea", "SPEC-001", "--type", "resolved_by", "--reason", "human cli test"}) + if err != nil { + t.Fatalf("link create human error = %v", err) + } + for _, want := range []string{"linked idea 20260528-link-idea resolved_by spec SPEC-001", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanCreateOut.String(), want) { + t.Fatalf("human link create output = %q, want %q", humanCreateOut.String(), want) + } + } + + var humanRemoveOut bytes.Buffer + err = Runner{ + Stdout: &humanRemoveOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"link", "remove", "20260528-link-idea", "SPEC-001", "--type", "resolved_by"}) + if err != nil { + t.Fatalf("link remove human error = %v", err) + } + for _, want := range []string{"removed link idea 20260528-link-idea resolved_by spec SPEC-001", "scope: global database", "database:", "project:", "project name:", "project path:"} { + if !strings.Contains(humanRemoveOut.String(), want) { + t.Fatalf("human link remove output = %q, want %q", humanRemoveOut.String(), want) + } + } + + var listAfterRemove bytes.Buffer + err = Runner{ + Stdout: &listAfterRemove, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"link", "list", "SPEC-001", "--json"}) + if err != nil { + t.Fatalf("link list after remove error = %v", err) + } + after := decodeLinkListResult(t, listAfterRemove.Bytes()) + if len(after.Relationships) != 0 { + t.Fatalf("relationships after remove = %#v, want none", after.Relationships) } } -func TestRunnerLinkCommandsUseSQLiteStateWhenInitialized(t *testing.T) { +func TestRunnerLinkMutationCommandsAcceptDocumentedFlags(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() writeCLIAgentsFile(t, workingDir, "specs/SPEC-001-link.md", "# Link Spec\n") @@ -7339,27 +11875,13 @@ func TestRunnerLinkCommandsUseSQLiteStateWhenInitialized(t *testing.T) { Stdout: &createOut, WorkingDir: workingDir, StateHome: stateHome, - }.Run([]string{"link", "create", "20260528-link-idea", "SPEC-001", "--type", "resolved_by", "--reason", "from cli test", "--json"}) + }.Run([]string{"link", "create", "--from", "20260528-link-idea", "--to", "SPEC-001", "--type", "resolved_by", "--reason", "flag cli test", "--json"}) if err != nil { - t.Fatalf("link create error = %v", err) + t.Fatalf("link create flags error = %v", err) } created := decodeLinkMutationResult(t, createOut.Bytes()) - if created.Type != "resolved_by" || created.From.Alias != "20260528-link-idea" || created.To.Alias != "SPEC-001" || created.Reason != "from cli test" { - t.Fatalf("created = %#v, want idea resolved_by SPEC-001", created) - } - - var listOut bytes.Buffer - err = Runner{ - Stdout: &listOut, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"link", "list", "SPEC-001", "--json"}) - if err != nil { - t.Fatalf("link list error = %v", err) - } - list := decodeLinkListResult(t, listOut.Bytes()) - if len(list.Relationships) != 1 || !hasTraceRelationship(list.Relationships, "inbound", "resolved_by", "idea", "20260528-link-idea") { - t.Fatalf("list = %#v, want inbound idea relationship", list) + if created.Type != "resolved_by" || created.From.Alias != "20260528-link-idea" || created.To.Alias != "SPEC-001" || created.Reason != "flag cli test" { + t.Fatalf("created = %#v, want idea resolved_by SPEC-001 from documented flags", created) } var removeOut bytes.Buffer @@ -7367,27 +11889,113 @@ func TestRunnerLinkCommandsUseSQLiteStateWhenInitialized(t *testing.T) { Stdout: &removeOut, WorkingDir: workingDir, StateHome: stateHome, - }.Run([]string{"link", "remove", "20260528-link-idea", "SPEC-001", "--type", "resolved_by", "--json"}) + }.Run([]string{"link", "remove", "--from", "20260528-link-idea", "--to", "SPEC-001", "--type", "resolved_by", "--json"}) if err != nil { - t.Fatalf("link remove error = %v", err) + t.Fatalf("link remove flags error = %v", err) } removed := decodeLinkMutationResult(t, removeOut.Bytes()) - if removed.Type != "resolved_by" || removed.From.Alias != "20260528-link-idea" || removed.To.Alias != "SPEC-001" || removed.Reason != "from cli test" { - t.Fatalf("removed = %#v, want removed relationship", removed) + if removed.Type != "resolved_by" || removed.From.Alias != "20260528-link-idea" || removed.To.Alias != "SPEC-001" || removed.Reason != "flag cli test" { + t.Fatalf("removed = %#v, want documented flags to remove relationship", removed) } +} - var listAfterRemove bytes.Buffer - err = Runner{ - Stdout: &listAfterRemove, - WorkingDir: workingDir, - StateHome: stateHome, - }.Run([]string{"link", "list", "SPEC-001", "--json"}) - if err != nil { - t.Fatalf("link list after remove error = %v", err) +func assertCLILinkMutationContext(t *testing.T, result state.LinkMutationResult, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) } - after := decodeLinkListResult(t, listAfterRemove.Bytes()) - if len(after.Relationships) != 0 { - t.Fatalf("relationships after remove = %#v, want none", after.Relationships) + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } +} + +func assertCLILinkListContext(t *testing.T, result state.LinkListResult, workingDir string) { + t.Helper() + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, state.StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(workingDir)) + } + if result.ProjectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, workingDir) + } +} + +func TestRunnerLinkMutationJSONErrorsAreMachineReadable(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + + tests := []struct { + name string + args []string + command string + want string + }{ + { + name: "create missing target", + args: []string{"link", "create", "--from", "TASK-001", "--type", "related_to", "--json"}, + command: "link create", + want: "requires a source entity and target entity", + }, + { + name: "create missing type", + args: []string{"link", "create", "--from", "TASK-001", "--to", "SPEC-001", "--json"}, + command: "link create", + want: "requires --type", + }, + { + name: "create mixed entity forms", + args: []string{"link", "create", "--from", "TASK-001", "SPEC-001", "--type", "related_to", "--json"}, + command: "link create", + want: "cannot mix positional entities", + }, + { + name: "remove missing source", + args: []string{"link", "remove", "--to", "SPEC-001", "--type", "related_to", "--json"}, + command: "link remove", + want: "requires a source entity and target entity", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run(tc.args) + if err == nil { + t.Fatalf("Run(%v) error = nil, want JSON validation error", tc.args) + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != tc.command || !strings.Contains(output.Error, tc.want) { + t.Fatalf("JSON error = %#v, want command %q and error containing %q", output, tc.command, tc.want) + } + }) } } @@ -7459,6 +12067,7 @@ status: implementing } specs := decodeSpecList(t, stdout.Bytes()) + assertCLIProjectContext(t, workingDir, specs.ContractVersion, specs.DatabaseScope, specs.DatabasePath, specs.ProjectID, specs.ProjectName, specs.ProjectCurrentPath) spec := specs.Specs["SPEC-001"] if spec.Title != "Example Spec" || spec.Status != "implementing" || spec.SourcePath != ".agents/specs/SPEC-001-example.md" { t.Fatalf("SPEC-001 = %#v, want imported spec metadata", spec) @@ -7494,7 +12103,7 @@ status: implementing t.Fatalf("spec list error = %v", err) } output := stdout.String() - for _, want := range []string{"loaf spec list", "Implementing (1)", "SPEC-001", "Example Spec", "0 todo / 1 in_progress / 0 done"} { + for _, want := range []string{"loaf spec list", "scope: global database", "database:", "project:", "project name:", "project path:", "Implementing (1)", "SPEC-001", "Example Spec", "0 todo / 1 in_progress / 0 done"} { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } @@ -7537,6 +12146,9 @@ status: drafting t.Fatalf("spec list markdown --json error = %v", err) } specs := decodeSpecList(t, jsonOut.Bytes()) + if specs.ContractVersion != 0 || specs.DatabaseScope != "" || specs.DatabasePath != "" || specs.ProjectID != "" || specs.ProjectName != "" || specs.ProjectCurrentPath != "" { + t.Fatalf("markdown spec list context = %#v, want empty", specs) + } spec := specs.Specs["SPEC-001"] if spec.Title != "Example Spec" || spec.Status != "implementing" || spec.SourcePath != ".agents/specs/SPEC-001-example.md" { t.Fatalf("SPEC-001 = %#v, want markdown spec metadata", spec) @@ -7625,6 +12237,7 @@ Imported spec prose. t.Fatalf("spec show --json error = %v", err) } show := decodeSpecShow(t, showOut.Bytes()) + assertCLIProjectContext(t, workingDir, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath) if show.Spec.Alias != "SPEC-001" || show.Spec.Title != "Example Spec" || show.Spec.Status != "implementing" { t.Fatalf("show = %#v, want imported spec metadata", show) } @@ -7651,7 +12264,7 @@ Imported spec prose. t.Fatalf("spec show human error = %v", err) } human := humanOut.String() - for _, want := range []string{"spec SPEC-001", "title: Example Spec", "status: implementing", "tasks: 1 todo / 0 in_progress / 0 done", "source: .agents/specs/SPEC-001-example.md", "inbound implements task TASK-001", "Imported spec prose."} { + for _, want := range []string{"spec SPEC-001", "scope: global database", "database:", "project:", "project name:", "project path:", "title: Example Spec", "status: implementing", "tasks: 1 todo / 0 in_progress / 0 done", "source: .agents/specs/SPEC-001-example.md", "inbound implements task TASK-001", "Imported spec prose."} { if !strings.Contains(human, want) { t.Fatalf("human output = %q, want %q", human, want) } @@ -7697,6 +12310,9 @@ Markdown spec prose. t.Fatalf("spec show markdown --json error = %v", err) } show := decodeSpecShow(t, jsonOut.Bytes()) + if show.ContractVersion != 0 || show.DatabaseScope != "" || show.DatabasePath != "" || show.ProjectID != "" || show.ProjectName != "" || show.ProjectCurrentPath != "" { + t.Fatalf("markdown spec show context = %#v, want empty", show) + } spec := show.Spec if show.Query != "SPEC-001" || spec.Alias != "SPEC-001" || spec.Title != "Example Spec" || spec.Status != "implementing" { t.Fatalf("show = %#v, want TASKS.json spec metadata over frontmatter", show) @@ -7732,6 +12348,9 @@ Markdown spec prose. t.Fatalf("output = %q, want %q", output, want) } } + if strings.Contains(output, "scope: global database") || strings.Contains(output, "project path:") { + t.Fatalf("output = %q, want markdown fallback without database context", output) + } assertNoStateDatabase(t, workingDir, stateHome) } @@ -7802,6 +12421,24 @@ status: drafting if len(archive.Archived) != 1 || archive.Archived[0].Spec == nil || archive.Archived[0].Spec.Alias != "SPEC-001" || archive.Archived[0].EventID == "" { t.Fatalf("Archived = %#v, want SPEC-001 archived with event", archive.Archived) } + if archive.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) + } + if archive.DatabaseScope != "global" { + t.Fatalf("archive DatabaseScope = %q, want global", archive.DatabaseScope) + } + if archive.DatabasePath == "" { + t.Fatal("archive DatabasePath is empty") + } + if archive.ProjectID == "" { + t.Fatal("archive ProjectID is empty") + } + if archive.ProjectName != filepath.Base(workingDir) { + t.Fatalf("archive ProjectName = %q, want %q", archive.ProjectName, filepath.Base(workingDir)) + } + if archive.ProjectCurrentPath != workingDir { + t.Fatalf("archive ProjectCurrentPath = %q, want %q", archive.ProjectCurrentPath, workingDir) + } if len(archive.Skipped) != 3 { t.Fatalf("Skipped = %#v, want three skipped specs", archive.Skipped) } @@ -7844,8 +12481,10 @@ status: drafting t.Fatalf("spec archive human error = %v", err) } output := humanOut.String() - if !strings.Contains(output, "loaf spec archive") || !strings.Contains(output, "skipped SPEC-001: already archived") || !strings.Contains(output, "Skipped 1 spec(s)") { - t.Fatalf("output = %q, want already-archived human summary", output) + for _, want := range []string{"loaf spec archive", "scope: global database", "database:", "project:", "project name:", "project path:", "skipped SPEC-001: already archived", "Skipped 1 spec(s)"} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } } } @@ -7908,6 +12547,12 @@ status: drafting if len(archive.Skipped) != 2 || archive.Skipped[0].Ref != "SPEC-002" || !strings.Contains(archive.Skipped[0].Reason, "status is drafting") || archive.Skipped[1].Ref != "SPEC-999" || archive.Skipped[1].Reason != "not found in index" { t.Fatalf("Skipped = %#v, want draft and missing skips", archive.Skipped) } + if archive.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("archive ContractVersion = %d, want %d", archive.ContractVersion, state.StateJSONContractVersion) + } + if archive.DatabaseScope != "" || archive.DatabasePath != "" || archive.ProjectID != "" || archive.ProjectName != "" || archive.ProjectCurrentPath != "" { + t.Fatalf("archive database context = %#v, want empty for markdown fallback", archive) + } if _, err := os.Stat(filepath.Join(workingDir, ".agents", "specs", "SPEC-001-complete.md")); !os.IsNotExist(err) { t.Fatalf("active spec still exists or stat failed: %v", err) } @@ -8031,6 +12676,7 @@ claude_session_id: session-archived if archived.Status != "archived" || archived.SourcePath != ".agents/sessions/archive/20260527-archived.md" { t.Fatalf("archived session = %#v, want archived imported session", archived) } + assertCLISessionContext(t, sessions.ContractVersion, sessions.DatabaseScope, sessions.DatabasePath, sessions.ProjectID, sessions.ProjectName, sessions.ProjectCurrentPath, workingDir) } func TestRunnerSessionListHumanUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -8066,7 +12712,7 @@ claude_session_id: session-archived t.Fatalf("session list error = %v", err) } output := activeOnly.String() - for _, want := range []string{"loaf session list", "Active Sessions", "feature/session-list", ".agents/sessions/20260528-active.md", "1 active"} { + for _, want := range []string{"loaf session list", "scope: global database", "database:", "project:", "project name:", "project path:", "Active Sessions", "feature/session-list", ".agents/sessions/20260528-active.md", "1 active"} { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } @@ -8150,6 +12796,9 @@ claude_session_id: session-archived if archived.Status != "archived" || archived.SourcePath != ".agents/sessions/archive/20260527-archived.md" { t.Fatalf("archived session = %#v, want archive directory to force archived status", archived) } + if sessions.ContractVersion != 0 || sessions.DatabaseScope != "" || sessions.DatabasePath != "" || sessions.ProjectID != "" || sessions.ProjectName != "" || sessions.ProjectCurrentPath != "" { + t.Fatalf("markdown session list context = %#v, want no SQLite context", sessions) + } assertNoStateDatabase(t, workingDir, stateHome) var activeOnly bytes.Buffer @@ -8170,6 +12819,31 @@ claude_session_id: session-archived if strings.Contains(output, "old/session") || strings.Contains(output, "args=session list") { t.Fatalf("output = %q, want archive hidden and no legacy delegation", output) } + if strings.Contains(output, "scope: global database") || strings.Contains(output, "project name:") { + t.Fatalf("output = %q, want markdown fallback without SQLite context", output) + } +} + +func assertCLISessionContext(t *testing.T, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string, workingDir string) { + t.Helper() + if contractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) + } + if projectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) + } } func TestRunnerSessionListReportsInvalidSQLiteState(t *testing.T) { @@ -8239,6 +12913,7 @@ claude_session_id: session-active if show.Query != "20260528-active" || session.Alias != "20260528-active" { t.Fatalf("show = %#v, want query and alias", show) } + assertCLISessionContext(t, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath, workingDir) if session.Branch != "feature/session-show" || session.Status != "active" || session.HarnessSessionID != "session-active" { t.Fatalf("session metadata = %#v, want imported frontmatter", session) } @@ -8262,7 +12937,7 @@ claude_session_id: session-active t.Fatalf("session show error = %v", err) } output := humanOut.String() - for _, want := range []string{"session 20260528-active", "branch: feature/session-show", "status: active", "harness session: session-active", ".agents/sessions/20260528-active.md", "decision(sqlite): keep session state queryable", "inbound associated_with task TASK-001"} { + for _, want := range []string{"session 20260528-active", "scope: global database", "database:", "project:", "project name:", "project path:", "branch: feature/session-show", "status: active", "harness session: session-active", ".agents/sessions/20260528-active.md", "decision(sqlite): keep session state queryable", "inbound associated_with task TASK-001"} { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } @@ -8304,6 +12979,9 @@ last_updated: 2026-05-28T10:05:00Z if show.Query != "20260528-active" || session.Alias != "20260528-active" { t.Fatalf("show = %#v, want query and alias", show) } + if show.ContractVersion != 0 || show.DatabaseScope != "" || show.DatabasePath != "" || show.ProjectID != "" || show.ProjectName != "" || show.ProjectCurrentPath != "" { + t.Fatalf("markdown show context = %#v, want no SQLite context", show) + } if session.Branch != "feature/session-show" || session.Status != "active" || session.HarnessSessionID != "session-active" { t.Fatalf("session metadata = %#v, want markdown frontmatter metadata", session) } @@ -8390,6 +13068,7 @@ func TestRunnerSessionLogJSONUsesSQLiteStateWhenInitialized(t *testing.T) { if result.EntryType != "decision" || result.Scope != "sqlite" || result.Message != "write to state" { t.Fatalf("result = %#v, want parsed journal entry", result) } + assertCLISessionContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) if result.ObservedWorktree != workingDir || result.HarnessSessionID != "harness-123" { t.Fatalf("result context = %#v, want observed worktree and harness id", result) } @@ -8685,6 +13364,7 @@ func TestRunnerSessionLogFromHookUsesSQLiteStateWhenInitialized(t *testing.T) { if result.EntryType != "task" || result.Scope != "completed" || result.Message != "port hook logging" { t.Fatalf("result = %#v, want TaskCompleted entry", result) } + assertCLISessionContext(t, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath, workingDir) if result.Session == nil || result.Session.ID != start.Session.ID { t.Fatalf("result session = %#v, want linked started session", result.Session) } @@ -8941,6 +13621,7 @@ source: old if archived.Status != "archived" || archived.SourcePath != ".agents/reports/archive/old.md" { t.Fatalf("archived report = %#v, want archive-location status", archived) } + assertCLIReportContext(t, reports.ContractVersion, reports.DatabaseScope, reports.DatabasePath, reports.ProjectID, reports.ProjectName, reports.ProjectCurrentPath, workingDir) } func TestRunnerReportListHumanUsesSQLiteStateWhenInitialized(t *testing.T) { @@ -8978,7 +13659,7 @@ source: SPEC-001 t.Fatalf("report list --status final error = %v", err) } output := stdout.String() - for _, want := range []string{"loaf report list", "Final:", "Final Report", "[audit]", ".agents/reports/final.md", "1 report(s) total"} { + for _, want := range []string{"loaf report list", "scope: global database", "database:", "project:", "project name:", "project path:", "Final:", "Final Report", "[audit]", ".agents/reports/final.md", "1 report(s) total"} { if !strings.Contains(output, want) { t.Fatalf("output = %q, want %q", output, want) } @@ -9037,6 +13718,9 @@ source: old if archived.Title != "Old Report" || archived.Kind != "research" || archived.Status != "archived" || archived.SourcePath != ".agents/reports/archive/old.md" { t.Fatalf("archived report = %#v, want archive-location status", archived) } + if reports.ContractVersion != 0 || reports.DatabaseScope != "" || reports.DatabasePath != "" || reports.ProjectID != "" || reports.ProjectName != "" || reports.ProjectCurrentPath != "" { + t.Fatalf("markdown report list context = %#v, want no SQLite context", reports) + } var humanOut bytes.Buffer err = Runner{ @@ -9056,9 +13740,87 @@ source: old if strings.Contains(output, "Draft Report") || strings.Contains(output, "Old Report") { t.Fatalf("output = %q, want status filter to hide non-final reports", output) } + if strings.Contains(output, "scope: global database") || strings.Contains(output, "project name:") { + t.Fatalf("output = %q, want markdown fallback without SQLite context", output) + } assertNoStateDatabase(t, workingDir, stateHome) } +func TestRunnerReportListWarnsWhenGlobalDatabaseHasUnimportedMarkdown(t *testing.T) { + registeredDir := realpath(t, t.TempDir()) + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + if err := (Runner{Stdout: &bytes.Buffer{}, WorkingDir: registeredDir, StateHome: stateHome}).Run([]string{"state", "init"}); err != nil { + t.Fatalf("state init registered project error = %v", err) + } + writeCLIAgentsFile(t, workingDir, "reports/local.md", `--- +title: Local Markdown Report +type: audit +status: final +--- +# Local Markdown Report +`) + + var jsonOut bytes.Buffer + err := Runner{ + Stdout: &jsonOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"report", "list", "--json"}) + if err != nil { + t.Fatalf("report list --json error = %v", err) + } + reports := decodeReportList(t, jsonOut.Bytes()) + if !hasDiagnostic(reports.Diagnostics, "local-markdown-not-imported") { + t.Fatalf("diagnostics = %#v, want local-markdown-not-imported", reports.Diagnostics) + } + diagnostic := findCLIDiagnostic(t, reports.Diagnostics, "local-markdown-not-imported") + if diagnostic.Category != state.RepairCategoryMarkdownImport || diagnostic.Policy != state.DiagnosticPolicyImportPending { + t.Fatalf("diagnostic = %#v, want markdown import/import-pending policy", diagnostic) + } + if len(reports.Reports) != 0 { + t.Fatalf("reports = %#v, want empty SQLite list with warning", reports.Reports) + } + + var humanOut bytes.Buffer + err = Runner{ + Stdout: &humanOut, + WorkingDir: workingDir, + StateHome: stateHome, + }.Run([]string{"report", "list"}) + if err != nil { + t.Fatalf("report list human error = %v", err) + } + output := humanOut.String() + for _, want := range []string{"loaf report list", "warn [markdown-import/import-pending]:", "local .agents Markdown has 1 importable artifact", "loaf state migrate markdown --dry-run", "No reports found."} { + if !strings.Contains(output, want) { + t.Fatalf("output = %q, want %q", output, want) + } + } +} + +func assertCLIReportContext(t *testing.T, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string, workingDir string) { + t.Helper() + if contractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) + } + if projectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) + } +} + func TestRunnerReportListReportsInvalidSQLiteState(t *testing.T) { workingDir := realpath(t, t.TempDir()) stateHome := t.TempDir() @@ -9096,9 +13858,76 @@ func decodeStateStatus(t *testing.T, data []byte) state.Status { if err := json.Unmarshal(data, &status); err != nil { t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) } + if status.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d in %s", status.ContractVersion, state.StateJSONContractVersion, string(data)) + } return status } +func decodeCommandError(t *testing.T, data []byte) commandErrorJSON { + t.Helper() + var output commandErrorJSON + if err := json.Unmarshal(data, &output); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + if output.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d in %s", output.ContractVersion, state.StateJSONContractVersion, string(data)) + } + return output +} + +func assertSilentExitCode(t *testing.T, err error, want int) { + t.Helper() + exitErr, ok := err.(interface { + ExitCode() int + Silent() bool + }) + if !ok || exitErr.ExitCode() != want || !exitErr.Silent() { + t.Fatalf("error = %#v, want silent exit code %d", err, want) + } +} + +func assertJSONArrayLength(t *testing.T, data []byte, field string, want int) { + t.Helper() + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + value, ok := payload[field] + if !ok { + t.Fatalf("JSON field %q missing in %s", field, string(data)) + } + items, ok := value.([]any) + if !ok { + t.Fatalf("JSON field %q = %#v, want array", field, value) + } + if len(items) != want { + t.Fatalf("JSON field %q length = %d, want %d", field, len(items), want) + } +} + +func assertJSONFieldPresent(t *testing.T, data []byte, field string) { + t.Helper() + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + if _, ok := payload[field]; !ok { + t.Fatalf("JSON field %q missing in %s", field, string(data)) + } +} + +func assertJSONFieldAbsent(t *testing.T, data []byte, field string) { + t.Helper() + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + if _, ok := payload[field]; ok { + t.Fatalf("JSON field %q present in %s", field, string(data)) + } +} + func decodeStateBackupResult(t *testing.T, data []byte) state.BackupResult { t.Helper() var result state.BackupResult @@ -9108,6 +13937,43 @@ func decodeStateBackupResult(t *testing.T, data []byte) state.BackupResult { return result } +func decodeStateBackupVerificationResult(t *testing.T, data []byte) state.BackupVerificationResult { + t.Helper() + var result state.BackupVerificationResult + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + return result +} + +func assertNoSQLiteSidecars(t *testing.T, path string) { + t.Helper() + for _, suffix := range []string{"-wal", "-shm"} { + sidecar := path + suffix + if _, err := os.Stat(sidecar); !os.IsNotExist(err) { + t.Fatalf("SQLite sidecar %s exists or stat failed: %v", sidecar, err) + } + } +} + +func decodeRelationshipOriginRepairResult(t *testing.T, data []byte) state.RelationshipOriginRepairResult { + t.Helper() + var result state.RelationshipOriginRepairResult + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + return result +} + +func decodeLegacyProjectDatabaseArchiveResult(t *testing.T, data []byte) state.LegacyProjectDatabaseArchiveResult { + t.Helper() + var result state.LegacyProjectDatabaseArchiveResult + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + return result +} + func decodeStateExportSnapshot(t *testing.T, data []byte) state.ExportSnapshot { t.Helper() var snapshot state.ExportSnapshot @@ -9117,6 +13983,28 @@ func decodeStateExportSnapshot(t *testing.T, data []byte) state.ExportSnapshot { return snapshot } +func exportAllTablesForCLI(t *testing.T, workingDir string, stateHome string) map[string][]map[string]any { + t.Helper() + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "export", "all", "--json"}); err != nil { + t.Fatalf("state export all --json error = %v", err) + } + return decodeStateExportSnapshot(t, stdout.Bytes()).Tables +} + +func projectIdentityForCLI(t *testing.T, workingDir string, stateHome string) state.ProjectIdentity { + t.Helper() + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"project", "show", "--json"}); err != nil { + t.Fatalf("project show --json error = %v", err) + } + var identity state.ProjectIdentity + if err := json.Unmarshal(stdout.Bytes(), &identity); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", stdout.String(), err) + } + return identity +} + func decodeMarkdownMigrationPlan(t *testing.T, data []byte) state.MarkdownMigrationPlan { t.Helper() var plan state.MarkdownMigrationPlan @@ -9126,6 +14014,15 @@ func decodeMarkdownMigrationPlan(t *testing.T, data []byte) state.MarkdownMigrat return plan } +func decodeMarkdownMigrationPreviewResult(t *testing.T, data []byte) state.MarkdownMigrationPreviewResult { + t.Helper() + var result state.MarkdownMigrationPreviewResult + if err := json.Unmarshal(data, &result); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) + } + return result +} + func decodeMarkdownMigrationResult(t *testing.T, data []byte) state.MarkdownMigrationResult { t.Helper() var result state.MarkdownMigrationResult @@ -9150,7 +14047,29 @@ func decodeTaskList(t *testing.T, data []byte) state.TaskList { if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) } - return result + return result +} + +func assertCLIProjectContext(t *testing.T, workingDir string, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + t.Helper() + if contractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, state.StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(workingDir) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(workingDir)) + } + if projectCurrentPath != workingDir { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, workingDir) + } } func decodeTaskShow(t *testing.T, data []byte) state.TaskShow { @@ -9393,6 +14312,9 @@ func decodeCompatibilityCommandSummary(t *testing.T, data []byte) compatibilityC if err := json.Unmarshal(data, &result); err != nil { t.Fatalf("json.Unmarshal(%q) error = %v", string(data), err) } + if result.ContractVersion != state.StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d in %s", result.ContractVersion, state.StateJSONContractVersion, string(data)) + } return result } @@ -9595,6 +14517,98 @@ func hasDiagnostic(diagnostics []state.Diagnostic, code string) bool { return false } +func findCLIDiagnostic(t *testing.T, diagnostics []state.Diagnostic, code string) state.Diagnostic { + t.Helper() + for _, diagnostic := range diagnostics { + if diagnostic.Code == code { + return diagnostic + } + } + t.Fatalf("diagnostic %q not found in %#v", code, diagnostics) + return state.Diagnostic{} +} + +func findStateRepairAction(t *testing.T, actions []state.RepairAction, code string) state.RepairAction { + t.Helper() + for _, action := range actions { + if action.Code == code { + return action + } + } + t.Fatalf("repair action %q not found in %#v", code, actions) + return state.RepairAction{} +} + +func initCLIStateForRepairCommand(t *testing.T) (string, string, state.Status) { + t.Helper() + workingDir := realpath(t, t.TempDir()) + stateHome := t.TempDir() + var stdout bytes.Buffer + if err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "init", "--json"}); err != nil { + t.Fatalf("state init --json error = %v", err) + } + return workingDir, stateHome, decodeStateStatus(t, stdout.Bytes()) +} + +func doctorRepairActionForCLI(t *testing.T, workingDir string, stateHome string, code string) state.RepairAction { + t.Helper() + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run([]string{"state", "doctor", "--json"}) + status := decodeStateStatus(t, stdout.Bytes()) + if status.Mode == state.ModeInvalid { + assertSilentExitCode(t, err, 1) + } else if err != nil { + t.Fatalf("state doctor --json error = %v", err) + } + return findStateRepairAction(t, status.RepairPlan, code) +} + +func runRepairActionCommandForCLI(t *testing.T, workingDir string, stateHome string, action state.RepairAction, wantExitCode int) []byte { + t.Helper() + args := repairActionCommandArgs(t, action) + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: workingDir, StateHome: stateHome}).Run(args) + if wantExitCode == 0 { + if err != nil { + t.Fatalf("%s error = %v\nstdout:\n%s", action.Command, err, stdout.String()) + } + } else { + assertSilentExitCode(t, err, wantExitCode) + } + if stdout.Len() == 0 { + t.Fatalf("%s produced empty stdout", action.Command) + } + return stdout.Bytes() +} + +func repairActionCommandArgs(t *testing.T, action state.RepairAction) []string { + t.Helper() + if action.Command == "" { + t.Fatalf("repair action %q has no command", action.Code) + } + parts := strings.Fields(action.Command) + if len(parts) < 2 || parts[0] != "loaf" { + t.Fatalf("repair action %q command = %q, want loaf command", action.Code, action.Command) + } + return parts[1:] +} + +func openCLITestDB(t *testing.T, path string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("sql.Open(%s) error = %v", path, err) + } + return db +} + +func closeCLITestDB(t *testing.T, db *sql.DB) { + t.Helper() + if err := db.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } +} + func sqliteCount(t *testing.T, db *sql.DB, query string, args ...any) int { t.Helper() var count int @@ -9715,6 +14729,81 @@ func gitCLI(t *testing.T, dir string, args ...string) { } } +func TestRunnerStateBackedCommandHelpDoesNotRequireState(t *testing.T) { + parentCases := []struct { + command string + wantHelp string + wantSubcommand string + }{ + {command: "brainstorm", wantHelp: "Usage: loaf brainstorm <subcommand>", wantSubcommand: "promote"}, + {command: "idea", wantHelp: "Usage: loaf idea <subcommand>", wantSubcommand: "capture"}, + {command: "spark", wantHelp: "Usage: loaf spark <subcommand>", wantSubcommand: "capture"}, + {command: "tag", wantHelp: "Usage: loaf tag <subcommand>", wantSubcommand: "add"}, + {command: "bundle", wantHelp: "Usage: loaf bundle <subcommand>", wantSubcommand: "create"}, + {command: "link", wantHelp: "Usage: loaf link <subcommand>", wantSubcommand: "create"}, + } + for _, tc := range parentCases { + t.Run(tc.command, func(t *testing.T) { + for _, args := range [][]string{{tc.command}, {tc.command, "--help"}, {tc.command, "help"}} { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: t.TempDir(), + }.Run(args) + if err != nil { + t.Fatalf("%v error = %v", args, err) + } + if !strings.Contains(stdout.String(), tc.wantHelp) || !strings.Contains(stdout.String(), tc.wantSubcommand) { + t.Fatalf("stdout = %q, want %q and %q", stdout.String(), tc.wantHelp, tc.wantSubcommand) + } + } + }) + } +} + +func TestRunnerNestedStateBackedHelpDoesNotParseAsOption(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {name: "state migrate markdown", args: []string{"state", "migrate", "markdown", "--help"}, want: "Usage: loaf state migrate markdown"}, + {name: "state migrate storage-home", args: []string{"state", "migrate", "storage-home", "--help"}, want: "Usage: loaf state migrate storage-home"}, + {name: "migrate markdown", args: []string{"migrate", "markdown", "--help"}, want: "Usage: loaf migrate markdown"}, + {name: "migrate storage-home", args: []string{"migrate", "storage-home", "--help"}, want: "Usage: loaf migrate storage-home"}, + {name: "state backup", args: []string{"state", "backup", "--help"}, want: "global data-home backups directory"}, + {name: "state backup verify", args: []string{"state", "backup", "verify", "--help"}, want: "Usage: loaf state backup verify"}, + {name: "state export all", args: []string{"state", "export", "all", "--help"}, want: "Usage: loaf state export all"}, + {name: "task update", args: []string{"task", "update", "--help"}, want: "Usage: loaf task update <task>"}, + {name: "task create", args: []string{"task", "create", "--help"}, want: "Usage: loaf task create --title <title>"}, + {name: "spec show", args: []string{"spec", "show", "--help"}, want: "Usage: loaf spec show <spec>"}, + {name: "session log", args: []string{"session", "log", "--help"}, want: "Usage: loaf session log <entry>"}, + {name: "report create", args: []string{"report", "create", "--help"}, want: "Usage: loaf report create <slug>"}, + {name: "brainstorm archive", args: []string{"brainstorm", "archive", "--help"}, want: "Usage: loaf brainstorm archive <brainstorm...>"}, + {name: "idea capture", args: []string{"idea", "capture", "--help"}, want: "Usage: loaf idea capture --title <title>"}, + {name: "spark promote", args: []string{"spark", "promote", "--help"}, want: "Usage: loaf spark promote <spark>"}, + {name: "tag add", args: []string{"tag", "add", "--help"}, want: "Usage: loaf tag add <entity> <tag>"}, + {name: "bundle update", args: []string{"bundle", "update", "--help"}, want: "Usage: loaf bundle update <slug>"}, + {name: "link create", args: []string{"link", "create", "--help"}, want: "Usage: loaf link create --from <entity>"}, + {name: "trace", args: []string{"trace", "--help"}, want: "Usage: loaf trace <entity>"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: t.TempDir(), + }.Run(tc.args) + if err != nil { + t.Fatalf("%v error = %v", tc.args, err) + } + if !strings.Contains(stdout.String(), tc.want) { + t.Fatalf("stdout = %q, want %q", stdout.String(), tc.want) + } + }) + } +} + func TestRunnerRootHelpIsNative(t *testing.T) { for _, args := range [][]string{{}, {"--help"}, {"-h"}, {"help"}} { var stdout bytes.Buffer @@ -9758,6 +14847,63 @@ func TestRunnerUnknownTopLevelCommandIsNative(t *testing.T) { } } +func TestRunnerReportGenerateHelpNamesMarkdownFormat(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: t.TempDir(), + }.Run([]string{"report", "generate", "--help"}) + if err != nil { + t.Fatalf("Run(report generate --help) error = %v", err) + } + for _, want := range []string{ + "Usage: loaf report generate <kind> [ref] [--format markdown] [--json]", + "--format Output format: markdown", + "--json Output contract, command, project context, and markdown content as JSON", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } + } +} + +func TestRunnerReportListHelpNamesLifecycleStatuses(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: t.TempDir(), + }.Run([]string{"report", "list", "--help"}) + if err != nil { + t.Fatalf("Run(report list --help) error = %v", err) + } + want := "--status Filter by status; Loaf lifecycle statuses: draft, final, archived" + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } +} + +func TestRunnerReportCreateHelpMatchesParser(t *testing.T) { + var stdout bytes.Buffer + err := Runner{ + Stdout: &stdout, + WorkingDir: t.TempDir(), + }.Run([]string{"report", "create", "--help"}) + if err != nil { + t.Fatalf("Run(report create --help) error = %v", err) + } + for _, want := range []string{ + "Usage: loaf report create <slug> [--type <type>] [--source <source>] [--json]", + "--source Report source", + } { + if !strings.Contains(stdout.String(), want) { + t.Fatalf("stdout = %q, want %q", stdout.String(), want) + } + } + if strings.Contains(stdout.String(), "--title") { + t.Fatalf("stdout = %q, want help to omit unsupported --title flag", stdout.String()) + } +} + func TestRunnerAgentHelpIsNative(t *testing.T) { var stdout bytes.Buffer err := Runner{ @@ -9794,23 +14940,41 @@ func TestRunnerAgentHelpIsNative(t *testing.T) { t.Fatalf("agent help root = %#v, want loaf metadata", doc) } commands := map[string]struct { - subcommands []string - options []string + subcommands []string + options []string + optionDescriptions map[string]string }{} + seenCommands := map[string]bool{} for _, command := range doc.Commands { + if seenCommands[command.Name] { + t.Fatalf("agent help has duplicate command %q", command.Name) + } + seenCommands[command.Name] = true entry := commands[command.Name] + if entry.optionDescriptions == nil { + entry.optionDescriptions = map[string]string{} + } + seenSubcommands := map[string]bool{} for _, subcommand := range command.Subcommands { + if seenSubcommands[subcommand.Name] { + t.Fatalf("agent help command %q has duplicate subcommand %q", command.Name, subcommand.Name) + } + seenSubcommands[subcommand.Name] = true entry.subcommands = append(entry.subcommands, subcommand.Name) for _, option := range subcommand.Options { - entry.options = append(entry.options, command.Name+" "+subcommand.Name+" "+option.Flags) + key := command.Name + " " + subcommand.Name + " " + option.Flags + entry.options = append(entry.options, key) + entry.optionDescriptions[key] = option.Description } } for _, option := range command.Options { - entry.options = append(entry.options, command.Name+" "+option.Flags) + key := command.Name + " " + option.Flags + entry.options = append(entry.options, key) + entry.optionDescriptions[key] = option.Description } commands[command.Name] = entry } - for _, want := range []string{"build", "session", "task", "spec", "report", "kb", "release", "version"} { + for _, want := range []string{"build", "state", "project", "session", "task", "spec", "report", "kb", "release", "version"} { if _, ok := commands[want]; !ok { t.Fatalf("agent help commands missing %q: %#v", want, commands) } @@ -9818,6 +14982,180 @@ func TestRunnerAgentHelpIsNative(t *testing.T) { if len(doc.Commands) < 15 { t.Fatalf("agent help commands = %d, want full native surface rather than stale release-only JSON", len(doc.Commands)) } + for _, command := range doc.Commands { + if len(command.Subcommands) == 0 { + assertAgentHelpJSONMatchesLiveHelp(t, []string{command.Name}, commands[command.Name].options, command.Name+" --json") + continue + } + for _, subcommand := range command.Subcommands { + args := append([]string{command.Name}, strings.Fields(subcommand.Name)...) + assertAgentHelpJSONMatchesLiveHelp(t, args, commands[command.Name].options, command.Name+" "+subcommand.Name+" --json") + } + } + for _, want := range []string{ + "build -t, --target <name>", + "install -y, --yes", + "install --no-yes", + } { + commandName := strings.Fields(want)[0] + if !stringSliceContains(commands[commandName].options, want) { + t.Fatalf("%s options = %#v, want agent help to include %q", commandName, commands[commandName].options, want) + } + } + for _, want := range []string{"repair", "repair legacy-project-database", "repair relationship-origin", "migrate", "migrate markdown", "migrate storage-home"} { + if !stringSliceContains(commands["state"].subcommands, want) { + t.Fatalf("state subcommands = %#v, want %q", commands["state"].subcommands, want) + } + } + if got := commands["state"].optionDescriptions["state path --verbose"]; !strings.Contains(got, "scope") || !strings.Contains(got, "database path") { + t.Fatalf("state path verbose description = %q, want human context guidance", got) + } + if got := commands["state"].optionDescriptions["state path --json"]; !strings.Contains(got, "contract version") || !strings.Contains(got, "database path") { + t.Fatalf("state path json description = %q, want contract/database path guidance", got) + } + if got := commands["state"].optionDescriptions["state init --json"]; !strings.Contains(got, "global database scope") || !strings.Contains(got, "project identity") { + t.Fatalf("state init json description = %q, want scope/project identity guidance", got) + } + if got := commands["state"].optionDescriptions["state status --json"]; !strings.Contains(got, "readiness mode") || !strings.Contains(got, "diagnostics") || !strings.Contains(got, "project identity") { + t.Fatalf("state status json description = %q, want readiness/diagnostics/project identity guidance", got) + } + if got := commands["state"].optionDescriptions["state doctor --json"]; !strings.Contains(got, "diagnostics") || !strings.Contains(got, "repair plan") || !strings.Contains(got, "global database scope") { + t.Fatalf("state doctor json description = %q, want diagnostics/repair/scope guidance", got) + } + for _, subcommand := range []string{"backup"} { + if !stringSliceContains(commands["state"].subcommands, subcommand) { + t.Fatalf("state subcommands = %#v, want %q", commands["state"].subcommands, subcommand) + } + } + for _, want := range []string{"backup verify", "export all", "export triage", "export session", "export spec", "export release-readiness"} { + if !stringSliceContains(commands["state"].subcommands, want) { + t.Fatalf("state subcommands = %#v, want %q", commands["state"].subcommands, want) + } + } + if got := commands["state"].optionDescriptions["state export --format <format>"]; !strings.Contains(got, "selected export kind") { + t.Fatalf("state export format description = %q, want generic export format guidance", got) + } + if got := commands["state"].optionDescriptions["state repair legacy-project-database --dry-run"]; !strings.Contains(got, "without writing") { + t.Fatalf("legacy repair dry-run description = %q, want non-mutating preview", got) + } + if got := commands["state"].optionDescriptions["state repair legacy-project-database --apply"]; !strings.Contains(got, "Move legacy SQLite files") { + t.Fatalf("legacy repair apply description = %q, want apply action", got) + } + if got := commands["state"].optionDescriptions["state repair legacy-project-database --json"]; !strings.Contains(got, "archive plan/result") || !strings.Contains(got, "project identity") { + t.Fatalf("legacy repair json description = %q, want archive/project identity guidance", got) + } + if got := commands["state"].optionDescriptions["state repair relationship-origin --origin <imported|manual>"]; !strings.Contains(got, "Provenance value") { + t.Fatalf("relationship repair origin description = %q, want provenance guidance", got) + } + if got := commands["state"].optionDescriptions["state repair relationship-origin --dry-run"]; !strings.Contains(got, "without writing") { + t.Fatalf("relationship repair dry-run description = %q, want non-mutating preview", got) + } + if got := commands["state"].optionDescriptions["state repair relationship-origin --json"]; !strings.Contains(got, "repair plan/result") || !strings.Contains(got, "global database scope") { + t.Fatalf("relationship repair json description = %q, want repair/scope guidance", got) + } + if got := commands["state"].optionDescriptions["state migrate markdown --json"]; !strings.Contains(got, "migration contract") || !strings.Contains(got, "project context") { + t.Fatalf("state migrate markdown json description = %q, want contract/project context guidance", got) + } + if got := commands["state"].optionDescriptions["state migrate storage-home --json"]; !strings.Contains(got, "global database paths") || !strings.Contains(got, "project identity") { + t.Fatalf("state migrate storage-home json description = %q, want global path/project identity guidance", got) + } + if got := commands["state"].optionDescriptions["state backup --json"]; !strings.Contains(got, "checksum") || !strings.Contains(got, "current project identity") { + t.Fatalf("state backup json description = %q, want checksum/current project identity guidance", got) + } + if got := commands["state"].optionDescriptions["state backup verify --json"]; !strings.Contains(got, "restore guidance") || !strings.Contains(got, "captured project identities") { + t.Fatalf("state backup verify json description = %q, want restore/project identity guidance", got) + } + if got := commands["state"].optionDescriptions["state export all --format <format>"]; !strings.Contains(got, "json") { + t.Fatalf("state export all format description = %q, want JSON guidance", got) + } + if got := commands["state"].optionDescriptions["state export all --json"]; !strings.Contains(got, "Alias for --format json") { + t.Fatalf("state export all json description = %q, want JSON alias guidance", got) + } + if got := commands["state"].optionDescriptions["state export release-readiness --format <format>"]; !strings.Contains(got, "markdown") { + t.Fatalf("state export release-readiness format description = %q, want Markdown guidance", got) + } + if got := commands["report"].optionDescriptions["report generate --format <format>"]; !strings.Contains(got, "markdown") { + t.Fatalf("report generate format description = %q, want Markdown guidance", got) + } + if got := commands["report"].optionDescriptions["report generate --json"]; !strings.Contains(got, "contract") || !strings.Contains(got, "project context") || !strings.Contains(got, "markdown content") { + t.Fatalf("report generate json description = %q, want contract/project context/Markdown content guidance", got) + } + if got := commands["report"].optionDescriptions["report list --status <status>"]; !strings.Contains(got, "draft, final, archived") { + t.Fatalf("report list status description = %q, want lifecycle status guidance", got) + } + for _, want := range []string{ + "kb status --json", + "kb validate --json", + "kb check --file <path>", + "kb check --json", + "kb review --json", + "kb init --json", + "kb import --path <path>", + "kb import --json", + } { + if !stringSliceContains(commands["kb"].options, want) { + t.Fatalf("kb options = %#v, want agent help to include %q", commands["kb"].options, want) + } + } + for option, wants := range map[string][]string{ + "kb status --json": {"knowledge file totals", "coverage counts", "directories"}, + "kb validate --json": {"frontmatter errors", "warnings"}, + "kb check --json": {"staleness", "coverage", "commit", "review metadata"}, + "kb review --json": {"updated knowledge frontmatter"}, + "kb init --json": {"directory actions", "config status", "QMD collections"}, + "kb import --json": {"QMD import collection status", "import error"}, + } { + got := commands["kb"].optionDescriptions[option] + for _, want := range wants { + if !strings.Contains(got, want) { + t.Fatalf("agent help option %q description = %q, want %q", option, got, want) + } + } + } + if got := commands["check"].optionDescriptions["check --json"]; !strings.Contains(got, "hook result") || !strings.Contains(got, "pass/block status") || !strings.Contains(got, "exit code") { + t.Fatalf("check json description = %q, want hook result/pass-block/exit code guidance", got) + } + if got := commands["housekeeping"].optionDescriptions["housekeeping --json"]; !strings.Contains(got, "housekeeping sections") || !strings.Contains(got, "cleanup candidates") || !strings.Contains(got, "project identity") { + t.Fatalf("housekeeping json description = %q, want sections/cleanup/project identity guidance", got) + } + if got := commands["trace"].optionDescriptions["trace --json"]; !strings.Contains(got, "traced entity") || !strings.Contains(got, "sources") || !strings.Contains(got, "relationships") || !strings.Contains(got, "project identity") { + t.Fatalf("trace json description = %q, want entity/sources/relationships/project identity guidance", got) + } + for _, want := range []string{ + "migrate worktree-storage --apply", + "migrate worktree-storage --force-from-worktree", + "migrate worktree-storage --force-from-main", + } { + if !stringSliceContains(commands["migrate"].options, want) { + t.Fatalf("migrate options = %#v, want agent help to include %q", commands["migrate"].options, want) + } + } + if got := commands["migrate"].optionDescriptions["migrate worktree-storage --apply"]; !strings.Contains(got, "dry-run") { + t.Fatalf("worktree-storage apply description = %q, want dry-run guidance", got) + } + if got := commands["session"].optionDescriptions["session start --json"]; !strings.Contains(got, "action") || !strings.Contains(got, "journal IDs") || !strings.Contains(got, "project identity") { + t.Fatalf("session start json description = %q, want action/journal/project identity guidance", got) + } + if got := commands["session"].optionDescriptions["session list --json"]; !strings.Contains(got, "sessions") || !strings.Contains(got, "diagnostics") || !strings.Contains(got, "global database scope") { + t.Fatalf("session list json description = %q, want sessions/diagnostics/scope guidance", got) + } + if got := commands["session"].optionDescriptions["session show --json"]; !strings.Contains(got, "journal entries") || !strings.Contains(got, "relationships") || !strings.Contains(got, "project identity") { + t.Fatalf("session show json description = %q, want journal/relationship/project identity guidance", got) + } + if got := commands["session"].optionDescriptions["session report --json"]; !strings.Contains(got, "export contract") || !strings.Contains(got, "markdown content") { + t.Fatalf("session report json description = %q, want export/markdown guidance", got) + } + if got := commands["session"].optionDescriptions["session enrich --json"]; !strings.Contains(got, "compatibility mode") || !strings.Contains(got, "counts") { + t.Fatalf("session enrich json description = %q, want compatibility/count guidance", got) + } + for _, want := range []string{ + "housekeeping --plans", + "housekeeping --handoffs", + } { + if !stringSliceContains(commands["housekeeping"].options, want) { + t.Fatalf("housekeeping options = %#v, want agent help to include %q", commands["housekeeping"].options, want) + } + } for _, want := range []string{"refresh", "sync"} { if !stringSliceContains(commands["task"].subcommands, want) { t.Fatalf("task subcommands = %#v, want %q", commands["task"].subcommands, want) @@ -9826,6 +15164,132 @@ func TestRunnerAgentHelpIsNative(t *testing.T) { if !stringSliceContains(commands["task"].options, "task sync --import") || !stringSliceContains(commands["task"].options, "task sync --push") { t.Fatalf("task options = %#v, want sync import/push options", commands["task"].options) } + for _, want := range []string{ + "task list --json", + "task show --json", + "task create --json", + "task update --json", + "task archive --json", + "task refresh --json", + "task sync --json", + } { + if !stringSliceContains(commands["task"].options, want) { + t.Fatalf("task options = %#v, want agent help to include %q", commands["task"].options, want) + } + } + if got := commands["task"].optionDescriptions["task list --status <status>"]; !strings.Contains(got, "in_progress, blocked, todo, review, done, archived") { + t.Fatalf("task list status description = %q, want valid list statuses", got) + } + if got := commands["task"].optionDescriptions["task update --status <status>"]; !strings.Contains(got, "in_progress, blocked, todo, review, done") { + t.Fatalf("task update status description = %q, want valid update statuses", got) + } + if got := commands["task"].optionDescriptions["task create --priority <level>"]; !strings.Contains(got, "P0, P1, P2, P3") { + t.Fatalf("task create priority description = %q, want valid priorities", got) + } + if got := commands["task"].optionDescriptions["task update --priority <level>"]; !strings.Contains(got, "P0, P1, P2, P3") { + t.Fatalf("task update priority description = %q, want valid priorities", got) + } + if got := commands["task"].optionDescriptions["task list --json"]; !strings.Contains(got, "tasks") || !strings.Contains(got, "diagnostics") || !strings.Contains(got, "project identity") { + t.Fatalf("task list json description = %q, want tasks/diagnostics/project identity guidance", got) + } + if got := commands["task"].optionDescriptions["task create --json"]; !strings.Contains(got, "created task") || !strings.Contains(got, "event") || !strings.Contains(got, "global database scope") { + t.Fatalf("task create json description = %q, want created/event/scope guidance", got) + } + if got := commands["task"].optionDescriptions["task refresh --json"]; !strings.Contains(got, "compatibility mode") || !strings.Contains(got, "counts") { + t.Fatalf("task refresh json description = %q, want compatibility/count guidance", got) + } + if got := commands["spec"].optionDescriptions["spec list --json"]; !strings.Contains(got, "specs") || !strings.Contains(got, "task counts") || !strings.Contains(got, "project identity") { + t.Fatalf("spec list json description = %q, want specs/task counts/project identity guidance", got) + } + if got := commands["spec"].optionDescriptions["spec show --json"]; !strings.Contains(got, "relationships") || !strings.Contains(got, "global database scope") { + t.Fatalf("spec show json description = %q, want relationship/scope guidance", got) + } + if got := commands["report"].optionDescriptions["report list --json"]; !strings.Contains(got, "reports") || !strings.Contains(got, "diagnostics") || !strings.Contains(got, "project identity") { + t.Fatalf("report list json description = %q, want reports/diagnostics/project identity guidance", got) + } + if got := commands["report"].optionDescriptions["report create --json"]; !strings.Contains(got, "created report") || !strings.Contains(got, "event") || !strings.Contains(got, "global database scope") { + t.Fatalf("report create json description = %q, want created/event/scope guidance", got) + } + if got := commands["report"].optionDescriptions["report finalize --json"]; !strings.Contains(got, "status transition") || !strings.Contains(got, "project identity") { + t.Fatalf("report finalize json description = %q, want status transition/project identity guidance", got) + } + for _, want := range []string{"list", "show", "identity", "rename", "move"} { + if !stringSliceContains(commands["project"].subcommands, want) { + t.Fatalf("project subcommands = %#v, want %q", commands["project"].subcommands, want) + } + } + if got := commands["project"].optionDescriptions["project rename --dry-run"]; !strings.Contains(got, "preview without writing") { + t.Fatalf("project rename dry-run description = %q, want preview safeguard", got) + } + if got := commands["project"].optionDescriptions["project move --dry-run"]; !strings.Contains(got, "preview without writing") { + t.Fatalf("project move dry-run description = %q, want preview safeguard", got) + } + if got := commands["project"].optionDescriptions["project list --json"]; !strings.Contains(got, "database path") || !strings.Contains(got, "friendly names") || !strings.Contains(got, "current paths") { + t.Fatalf("project list json description = %q, want global project identity fields", got) + } + for command, wants := range map[string][]string{ + "brainstorm": {"list", "show", "promote", "archive"}, + "idea": {"list", "show", "capture", "promote", "resolve", "archive"}, + "spark": {"list", "show", "capture", "resolve", "promote"}, + "tag": {"list", "show", "add", "remove"}, + "bundle": {"list", "create", "update", "show", "add", "remove"}, + "link": {"create", "list", "remove"}, + } { + for _, want := range wants { + if !stringSliceContains(commands[command].subcommands, want) { + t.Fatalf("%s subcommands = %#v, want %q", command, commands[command].subcommands, want) + } + } + } + for _, want := range []string{ + "idea capture --title <title>", + "idea resolve --by <entity>", + "spark capture --scope <scope>", + "spark capture --text <text>", + "bundle create --tags <tags>", + "bundle update --title <title>", + "link create --from <entity>", + "link create --to <entity>", + "link remove --type <type>", + } { + if !stringSliceContains(commands[strings.Fields(want)[0]].options, want) { + t.Fatalf("agent help options missing %q", want) + } + } + for option, wants := range map[string][]string{ + "brainstorm list --json": {"brainstorms", "global database scope", "project identity"}, + "idea resolve --json": {"resolution relationship", "event", "project identity"}, + "spark capture --json": {"created spark", "event", "global database scope"}, + "tag add --json": {"tag mutation", "entity", "project identity"}, + "bundle show --json": {"members", "global database scope"}, + "link create --json": {"relationship ID", "source/target", "project identity"}, + } { + command := strings.Fields(option)[0] + got := commands[command].optionDescriptions[option] + for _, want := range wants { + if !strings.Contains(got, want) { + t.Fatalf("agent help option %q description = %q, want %q", option, got, want) + } + } + } +} + +func assertAgentHelpJSONMatchesLiveHelp(t *testing.T, commandArgs []string, agentOptions []string, jsonOption string) { + t.Helper() + helpArgs := append(append([]string{}, commandArgs...), "--help") + var stdout bytes.Buffer + var stderr bytes.Buffer + err := Runner{ + Stdout: &stdout, + Stderr: &stderr, + WorkingDir: t.TempDir(), + }.Run(helpArgs) + if err != nil { + return + } + if strings.Contains(stdout.String(), "--json") && !stringSliceContains(agentOptions, jsonOption) { + t.Fatalf("live help for %q includes --json, but agent help options = %#v missing %q", strings.Join(commandArgs, " "), agentOptions, jsonOption) + } } func realpath(t *testing.T, path string) string { @@ -9837,6 +15301,27 @@ func realpath(t *testing.T, path string) string { return realpath } +func testFileSHA256(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", path, err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func copyFileForCLITest(t *testing.T, source string, destination string, perm os.FileMode) { + t.Helper() + data, err := os.ReadFile(source) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", source, err) + } + if err := os.WriteFile(destination, data, perm); err != nil { + t.Fatalf("WriteFile(%s) error = %v", destination, err) + } +} + func assertNoStateDatabase(t *testing.T, workingDir string, stateHome string) { t.Helper() root, err := project.ResolveRoot(workingDir) @@ -9851,3 +15336,10 @@ func assertNoStateDatabase(t *testing.T, workingDir string, stateHome string) { t.Fatalf("state database stat error = %v, want missing database at %s", err, databasePath) } } + +func assertNoRepositoryAgentsDir(t *testing.T, workingDir string) { + t.Helper() + if _, err := os.Stat(filepath.Join(workingDir, ".agents")); !os.IsNotExist(err) { + t.Fatalf("repository .agents directory exists or stat failed after read-only command; err = %v", err) + } +} diff --git a/internal/cli/kb.go b/internal/cli/kb.go index 501d9bb8..e725eb60 100644 --- a/internal/cli/kb.go +++ b/internal/cli/kb.go @@ -163,6 +163,17 @@ func (r Runner) runKb(args []string, out io.Writer, runtimeRoot string) error { writeKbHelp(out) return nil } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "status": writeKbStatusHelp, + "validate": writeKbValidateHelp, + "check": writeKbCheckHelp, + "review": writeKbReviewHelp, + "init": writeKbInitHelp, + "import": writeKbImportHelp, + "glossary": writeKbGlossaryHelp, + }) { + return nil + } switch args[0] { case "check": stderr := r.Stderr @@ -212,7 +223,82 @@ func writeKbHelp(out io.Writer) { }, "\n")) } +func writeKbStatusHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb status [--json]", "Show knowledge base overview.", "--json Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON") +} + +func writeKbValidateHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb validate [--json]", "Validate knowledge file frontmatter.", "--json Output per-file frontmatter errors and warnings as JSON") +} + +func writeKbCheckHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb check [--file <path>] [--json]", "Check knowledge file staleness against git history.", "--file Reverse lookup: find knowledge files covering this path", "--json Output per-file staleness, coverage, commit, and review metadata as JSON") +} + +func writeKbReviewHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb review <file> [--json]", "Mark a knowledge file as reviewed today.", "--json Output updated knowledge frontmatter as JSON") +} + +func writeKbInitHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb init [--json]", "Initialize knowledge base directories and QMD collections.", "--json Output directory actions, config status, and QMD collections as JSON") +} + +func writeKbImportHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb import <name> --path <path> [--json]", "Register an external QMD collection as a knowledge source.", "--path Path to the external project's knowledge directory", "--json Output QMD import collection status or import error as JSON") +} + +func writeKbGlossaryHelp(out io.Writer) { + fmt.Fprintln(out, strings.Join([]string{ + "Usage: loaf kb glossary <subcommand> [options]", + "", + "Domain glossary mutation and lookup.", + "", + "Subcommands:", + " upsert Create or update a canonical term", + " propose Create or update a candidate term", + " check Check one term", + " list List glossary terms", + " stabilize Promote a candidate term to canonical", + "", + "Options:", + " -h, --help Show help", + }, "\n")) +} + +func writeKbGlossaryUpsertHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb glossary upsert <term> --definition <text> [--avoid <terms>]", "Create or update a canonical glossary term.", "--definition Canonical definition", "--avoid Comma-separated discouraged alternatives") +} + +func writeKbGlossaryProposeHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb glossary propose <term> --definition <text> [--avoid <terms>]", "Create or update a candidate glossary term.", "--definition Candidate definition", "--avoid Comma-separated discouraged alternatives") +} + +func writeKbGlossaryCheckHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb glossary check <term>", "Check one glossary term.") +} + +func writeKbGlossaryListHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb glossary list [--canonical|--candidates|--all]", "List glossary terms.", "--canonical Show canonical terms", "--candidates Show candidate terms", "--all Show canonical and candidate terms") +} + +func writeKbGlossaryStabilizeHelp(out io.Writer) { + writeUsageHelp(out, "loaf kb glossary stabilize <term> --definition <text>", "Promote a candidate glossary term to canonical.", "--definition Canonical definition") +} + func (r Runner) runKbGlossary(args []string, out io.Writer, runtimeRoot string) error { + if len(args) == 0 || isHelpArg(args) { + writeKbGlossaryHelp(out) + return nil + } + if writeNestedHelp(out, args, map[string]func(io.Writer){ + "upsert": writeKbGlossaryUpsertHelp, + "propose": writeKbGlossaryProposeHelp, + "check": writeKbGlossaryCheckHelp, + "list": writeKbGlossaryListHelp, + "stabilize": writeKbGlossaryStabilizeHelp, + }) { + return nil + } options, err := parseKbGlossaryArgs(args) if err != nil { return err @@ -297,6 +383,7 @@ func (r Runner) runKbImport(args []string, out io.Writer, runtimeRoot string) er if err := writeJSON(out, kbImportResult{Error: message}); err != nil { return err } + return ExitError{Code: 1} } return fmt.Errorf("%s", message) } @@ -317,6 +404,9 @@ func (r Runner) runKbImport(args []string, out io.Writer, runtimeRoot string) er } } if err != nil { + if options.jsonOutput && result.Error != "" { + return ExitError{Code: 1} + } return err } if !options.jsonOutput { @@ -403,6 +493,9 @@ func (r Runner) runKbValidate(args []string, out io.Writer, errOut io.Writer, ru writeKbValidation(out, results) } if countValidationErrors(results) > 0 { + if jsonOutput { + return ExitError{Code: 1} + } return fmt.Errorf("kb validation failed: %d error(s)", countValidationErrors(results)) } return nil @@ -432,6 +525,7 @@ func (r Runner) runKbReview(args []string, out io.Writer, runtimeRoot string) er if err := writeJSON(out, map[string]string{"error": message}); err != nil { return err } + return ExitError{Code: 1} } return fmt.Errorf("%s", message) } diff --git a/internal/cli/kb_test.go b/internal/cli/kb_test.go index ec8566bd..effd2f71 100644 --- a/internal/cli/kb_test.go +++ b/internal/cli/kb_test.go @@ -64,8 +64,13 @@ func TestRunnerKbStatusRequiresGitRepository(t *testing.T) { Stdout: &stdout, WorkingDir: t.TempDir(), }.Run([]string{"kb", "status", "--json"}) - if err == nil || !strings.Contains(err.Error(), "not inside a git repository") { - t.Fatalf("kb status error = %v, want git repository error", err) + if err == nil { + t.Fatal("kb status error = nil, want git repository error") + } + assertSilentExitCode(t, err, 1) + output := decodeCommandError(t, stdout.Bytes()) + if output.Command != "kb status" || !strings.Contains(output.Error, "not inside a git repository") { + t.Fatalf("kb status JSON error = %#v, want git repository error", output) } } @@ -124,9 +129,10 @@ func TestRunnerKbValidateJSONReportsErrorsAndWarnings(t *testing.T) { Stdout: &stdout, WorkingDir: repo, }.Run([]string{"kb", "validate", "--json"}) - if err == nil || !strings.Contains(err.Error(), "kb validation failed") { - t.Fatalf("kb validate error = %v, want validation failure", err) + if err == nil { + t.Fatal("kb validate error = nil, want validation failure") } + assertSilentExitCode(t, err, 1) var results []kbValidationResult if err := json.Unmarshal(stdout.Bytes(), &results); err != nil { @@ -316,9 +322,10 @@ func TestRunnerKbReviewRejectsNonKnowledgeFile(t *testing.T) { Stdout: &stdout, WorkingDir: repo, }.Run([]string{"kb", "review", "docs/note.md", "--json"}) - if err == nil || !strings.Contains(err.Error(), "Not a knowledge file") { - t.Fatalf("kb review error = %v, want non-knowledge error", err) + if err == nil { + t.Fatal("kb review error = nil, want non-knowledge error") } + assertSilentExitCode(t, err, 1) var output map[string]string if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { @@ -461,9 +468,10 @@ func TestRunnerKbImportRequiresQMDNatively(t *testing.T) { Stdout: &stdout, WorkingDir: t.TempDir(), }.Run([]string{"kb", "import", "other", "--json"}) - if err == nil || !strings.Contains(err.Error(), "QMD is required") { - t.Fatalf("kb import error = %v, want QMD required", err) + if err == nil { + t.Fatal("kb import error = nil, want QMD required") } + assertSilentExitCode(t, err, 1) var result kbImportResult if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { @@ -489,9 +497,10 @@ func TestRunnerKbImportRejectsMalformedConfigBeforeRegistering(t *testing.T) { Stdout: &stdout, WorkingDir: repo, }.Run([]string{"kb", "import", "other", "--json"}) - if err == nil || !strings.Contains(err.Error(), "Cannot parse .agents/loaf.json") { - t.Fatalf("kb import error = %v, want malformed config error", err) + if err == nil { + t.Fatal("kb import error = nil, want malformed config error") } + assertSilentExitCode(t, err, 1) if registered { t.Fatalf("qmd register was called before config validation") } @@ -893,12 +902,53 @@ func TestRunnerKbHelpAndUnknownSubcommandAreNative(t *testing.T) { } } + stdout.Reset() err = Runner{ - Stdout: &bytes.Buffer{}, + Stdout: &stdout, WorkingDir: workingDir, }.Run([]string{"kb", "legacy-tail", "--json"}) - if err == nil || !strings.Contains(err.Error(), `unknown loaf kb subcommand "legacy-tail"`) { - t.Fatalf("kb unknown error = %v, want native unknown subcommand", err) + if err == nil { + t.Fatal("kb unknown error = nil, want native unknown subcommand") + } + assertSilentExitCode(t, err, 1) + unknown := decodeCommandError(t, stdout.Bytes()) + if unknown.Command != "kb legacy-tail" || !strings.Contains(unknown.Error, `unknown loaf kb subcommand "legacy-tail"`) { + t.Fatalf("kb unknown JSON error = %#v, want native unknown subcommand", unknown) + } +} + +func TestRunnerKbSubcommandHelpIsNative(t *testing.T) { + workingDir := realpath(t, t.TempDir()) + cases := []struct { + name string + args []string + want string + }{ + {name: "status", args: []string{"kb", "status", "--help"}, want: "Usage: loaf kb status [--json]"}, + {name: "validate", args: []string{"kb", "validate", "--help"}, want: "Usage: loaf kb validate [--json]"}, + {name: "check", args: []string{"kb", "check", "--help"}, want: "Usage: loaf kb check [--file <path>] [--json]"}, + {name: "review", args: []string{"kb", "review", "--help"}, want: "Usage: loaf kb review <file> [--json]"}, + {name: "init", args: []string{"kb", "init", "--help"}, want: "Usage: loaf kb init [--json]"}, + {name: "import", args: []string{"kb", "import", "--help"}, want: "Usage: loaf kb import <name> --path <path> [--json]"}, + {name: "glossary", args: []string{"kb", "glossary", "--help"}, want: "Usage: loaf kb glossary <subcommand> [options]"}, + {name: "glossary list", args: []string{"kb", "glossary", "list", "--help"}, want: "Usage: loaf kb glossary list [--canonical|--candidates|--all]"}, + {name: "glossary upsert", args: []string{"kb", "glossary", "upsert", "--help"}, want: "Usage: loaf kb glossary upsert <term> --definition <text> [--avoid <terms>]"}, + {name: "glossary stabilize", args: []string{"kb", "glossary", "stabilize", "--help"}, want: "Usage: loaf kb glossary stabilize <term> --definition <text>"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + stdout := bytes.Buffer{} + err := Runner{ + Stdout: &stdout, + WorkingDir: workingDir, + }.Run(tc.args) + if err != nil { + t.Fatalf("Run(%v) error = %v", tc.args, err) + } + if !strings.Contains(stdout.String(), tc.want) { + t.Fatalf("stdout = %q, want %q", stdout.String(), tc.want) + } + }) } } diff --git a/internal/state/backup.go b/internal/state/backup.go index 14f8d313..ff21780f 100644 --- a/internal/state/backup.go +++ b/internal/state/backup.go @@ -2,7 +2,12 @@ package state import ( "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "errors" "fmt" + "io" "os" "path/filepath" "time" @@ -12,10 +17,39 @@ import ( // BackupResult describes a repository-external SQLite database backup. type BackupResult struct { - DatabasePath string `json:"database_path"` - BackupPath string `json:"backup_path"` - Bytes int64 `json:"bytes"` - CreatedAt string `json:"created_at"` + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + DatabasePath string `json:"database_path"` + BackupPath string `json:"backup_path"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` + CreatedAt string `json:"created_at"` + Verified bool `json:"verified"` + SchemaVersion int `json:"schema_version"` + ProjectCount int `json:"project_count"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + IntegrityCheck string `json:"integrity_check"` + ForeignKeyCheck string `json:"foreign_key_check"` +} + +// BackupVerificationResult describes a read-only verification of an existing SQLite backup. +type BackupVerificationResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + BackupPath string `json:"backup_path"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` + Verified bool `json:"verified"` + SchemaVersion int `json:"schema_version"` + ProjectCount int `json:"project_count"` + Projects []ProjectIdentity `json:"projects"` + IntegrityCheck string `json:"integrity_check"` + ForeignKeyCheck string `json:"foreign_key_check"` + RestoreDatabasePath string `json:"restore_database_path,omitempty"` + RestorePreservePath string `json:"restore_preserve_path,omitempty"` + RestoreValidationCommands []string `json:"restore_validation_commands,omitempty"` } // Backup creates a timestamped SQLite backup under the project's state directory. @@ -60,15 +94,203 @@ func Backup(ctx context.Context, root project.Root, resolver PathResolver) (Back if err != nil { return BackupResult{}, fmt.Errorf("stat state backup: %w", err) } + sha256Sum, err := fileSHA256(backupPath) + if err != nil { + return BackupResult{}, fmt.Errorf("checksum state backup: %w", err) + } + verification, err := verifyBackup(ctx, backupPath, root) + if err != nil { + return BackupResult{}, err + } return BackupResult{ - DatabasePath: status.DatabasePath, - BackupPath: backupPath, - Bytes: info.Size(), - CreatedAt: now.Format(time.RFC3339Nano), + ContractVersion: StateJSONContractVersion, + DatabaseScope: "global", + DatabasePath: status.DatabasePath, + BackupPath: backupPath, + Bytes: info.Size(), + SHA256: sha256Sum, + CreatedAt: now.Format(time.RFC3339Nano), + Verified: true, + SchemaVersion: verification.schemaVersion, + ProjectCount: verification.projectCount, + ProjectID: verification.projectID, + ProjectName: verification.projectName, + ProjectCurrentPath: verification.projectCurrentPath, + IntegrityCheck: verification.integrityCheck, + ForeignKeyCheck: verification.foreignKeyCheck, }, nil } +// VerifyBackup verifies an existing SQLite backup without consulting or mutating live state. +func VerifyBackup(ctx context.Context, backupPath string) (BackupVerificationResult, error) { + info, err := os.Stat(backupPath) + if err != nil { + return BackupVerificationResult{}, fmt.Errorf("stat state backup: %w", err) + } + if info.IsDir() { + return BackupVerificationResult{}, fmt.Errorf("state backup path is a directory: %s", backupPath) + } + sha256Sum, err := fileSHA256(backupPath) + if err != nil { + return BackupVerificationResult{}, fmt.Errorf("checksum state backup: %w", err) + } + + store, err := OpenStoreReadOnly(backupPath) + if err != nil { + return BackupVerificationResult{}, fmt.Errorf("open state backup for verification: %w", err) + } + defer store.Close() + + integrityCheck, err := verifySQLiteIntegrity(ctx, store) + if err != nil { + return BackupVerificationResult{}, fmt.Errorf("verify state backup integrity: %w", err) + } + foreignKeyCheck, err := verifyNoForeignKeyViolations(ctx, store) + if err != nil { + return BackupVerificationResult{}, fmt.Errorf("verify state backup foreign keys: %w", err) + } + version, err := store.SchemaVersion(ctx) + if err != nil { + return BackupVerificationResult{}, fmt.Errorf("verify state backup schema version: %w", err) + } + if version != CurrentSchemaVersion() { + return BackupVerificationResult{}, fmt.Errorf("verify state backup schema version: got %d, want %d", version, CurrentSchemaVersion()) + } + projects, err := store.ListProjects(ctx) + if err != nil { + return BackupVerificationResult{}, fmt.Errorf("verify state backup projects: %w", err) + } + if len(projects.Projects) == 0 { + return BackupVerificationResult{}, fmt.Errorf("verify state backup project count: empty projects table") + } + + return BackupVerificationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: "global", + BackupPath: backupPath, + Bytes: info.Size(), + SHA256: sha256Sum, + Verified: true, + SchemaVersion: version, + ProjectCount: len(projects.Projects), + Projects: projects.Projects, + IntegrityCheck: integrityCheck, + ForeignKeyCheck: foreignKeyCheck, + }, nil +} + +func fileSHA256(path string) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +type backupVerification struct { + schemaVersion int + projectCount int + projectID string + projectName string + projectCurrentPath string + integrityCheck string + foreignKeyCheck string +} + +func verifyBackup(ctx context.Context, backupPath string, root project.Root) (backupVerification, error) { + store, err := OpenStoreReadOnly(backupPath) + if err != nil { + return backupVerification{}, fmt.Errorf("open state backup for verification: %w", err) + } + defer store.Close() + + integrityCheck, err := verifySQLiteIntegrity(ctx, store) + if err != nil { + return backupVerification{}, fmt.Errorf("verify state backup integrity: %w", err) + } + foreignKeyCheck, err := verifyNoForeignKeyViolations(ctx, store) + if err != nil { + return backupVerification{}, fmt.Errorf("verify state backup foreign keys: %w", err) + } + version, err := store.SchemaVersion(ctx) + if err != nil { + return backupVerification{}, fmt.Errorf("verify state backup schema version: %w", err) + } + if version != CurrentSchemaVersion() { + return backupVerification{}, fmt.Errorf("verify state backup schema version: got %d, want %d", version, CurrentSchemaVersion()) + } + var projectCount int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects`).Scan(&projectCount); err != nil { + return backupVerification{}, fmt.Errorf("verify state backup project count: %w", err) + } + if projectCount <= 0 { + return backupVerification{}, fmt.Errorf("verify state backup project count: empty projects table") + } + identity, err := store.LookupProjectIdentityForRoot(ctx, root) + if err != nil { + return backupVerification{}, fmt.Errorf("verify state backup project identity: %w", err) + } + if identity.ID == "" { + return backupVerification{}, fmt.Errorf("verify state backup project identity: empty project id") + } + return backupVerification{ + schemaVersion: version, + projectCount: projectCount, + projectID: identity.ID, + projectName: identity.FriendlyName, + projectCurrentPath: identity.CurrentPath, + integrityCheck: integrityCheck, + foreignKeyCheck: foreignKeyCheck, + }, nil +} + +func verifySQLiteIntegrity(ctx context.Context, store *Store) (string, error) { + var integrityCheck string + if err := store.db.QueryRowContext(ctx, `PRAGMA integrity_check`).Scan(&integrityCheck); err != nil { + return "", err + } + if integrityCheck != "ok" { + return "", fmt.Errorf("%s", integrityCheck) + } + return integrityCheck, nil +} + +func verifyNoForeignKeyViolations(ctx context.Context, store *Store) (string, error) { + rows, err := store.db.QueryContext(ctx, `PRAGMA foreign_key_check`) + if err != nil { + return "", err + } + defer rows.Close() + if rows.Next() { + var tableName, parentTable string + var rowID sql.NullInt64 + var foreignKeyID int + if err := rows.Scan(&tableName, &rowID, &parentTable, &foreignKeyID); err != nil { + return "", err + } + return "", errors.New(formatSQLiteForeignKeyViolation(tableName, rowID, parentTable, foreignKeyID)) + } + if err := rows.Err(); err != nil { + return "", err + } + return "ok", nil +} + +func formatSQLiteForeignKeyViolation(tableName string, rowID sql.NullInt64, parentTable string, foreignKeyID int) string { + rowLabel := "unknown row" + if rowID.Valid { + rowLabel = fmt.Sprintf("rowid %d", rowID.Int64) + } + return fmt.Sprintf("SQLite foreign key violation in %s %s referencing %s constraint %d", tableName, rowLabel, parentTable, foreignKeyID) +} + func nextBackupPath(backupDir string, now time.Time) (string, error) { stamp := fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond()) for i := 0; i < 1000; i++ { diff --git a/internal/state/backup_test.go b/internal/state/backup_test.go index 43675545..04b91d07 100644 --- a/internal/state/backup_test.go +++ b/internal/state/backup_test.go @@ -2,6 +2,8 @@ package state import ( "context" + "crypto/sha256" + "encoding/hex" "os" "path/filepath" "strings" @@ -25,13 +27,19 @@ func TestBackupCreatesSQLiteCopyOutsideRepository(t *testing.T) { if result.DatabasePath != status.DatabasePath { t.Fatalf("DatabasePath = %q, want %q", result.DatabasePath, status.DatabasePath) } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } if result.BackupPath == "" { t.Fatal("BackupPath is empty") } if isWithinRoot(result.BackupPath, root.Path()) { t.Fatalf("BackupPath = %q, want outside repository %q", result.BackupPath, root.Path()) } - if !strings.HasPrefix(result.BackupPath, filepath.Join(stateHome, "loaf", "projects")+string(filepath.Separator)) { + if !strings.HasPrefix(result.BackupPath, filepath.Join(stateHome, "loaf", "backups")+string(filepath.Separator)) { t.Fatalf("BackupPath = %q, want under state home %q", result.BackupPath, stateHome) } if !strings.HasSuffix(result.BackupPath, ".sqlite") { @@ -47,13 +55,41 @@ func TestBackupCreatesSQLiteCopyOutsideRepository(t *testing.T) { if result.Bytes != info.Size() { t.Fatalf("Bytes = %d, want %d", result.Bytes, info.Size()) } + if result.SHA256 != testFileSHA256(t, result.BackupPath) { + t.Fatalf("SHA256 = %q, want actual backup digest", result.SHA256) + } if result.CreatedAt == "" { t.Fatal("CreatedAt is empty") } + if !result.Verified { + t.Fatal("Verified = false, want true") + } + if result.SchemaVersion != CurrentSchemaVersion() { + t.Fatalf("SchemaVersion = %d, want %d", result.SchemaVersion, CurrentSchemaVersion()) + } + if result.ProjectCount != 1 { + t.Fatalf("ProjectCount = %d, want 1", result.ProjectCount) + } + if result.ProjectID != status.ProjectID { + t.Fatalf("ProjectID = %q, want %q", result.ProjectID, status.ProjectID) + } + if result.ProjectName != status.ProjectName { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, status.ProjectName) + } + if result.ProjectCurrentPath != status.ProjectCurrentPath { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, status.ProjectCurrentPath) + } + if result.IntegrityCheck != "ok" { + t.Fatalf("IntegrityCheck = %q, want ok", result.IntegrityCheck) + } + if result.ForeignKeyCheck != "ok" { + t.Fatalf("ForeignKeyCheck = %q, want ok", result.ForeignKeyCheck) + } + assertNoSQLiteSidecars(t, result.BackupPath) - backupStore, err := OpenStore(result.BackupPath) + backupStore, err := OpenStoreReadOnly(result.BackupPath) if err != nil { - t.Fatalf("OpenStore(backup) error = %v", err) + t.Fatalf("OpenStoreReadOnly(backup) error = %v", err) } defer backupStore.Close() version, err := backupStore.SchemaVersion(context.Background()) @@ -63,6 +99,98 @@ func TestBackupCreatesSQLiteCopyOutsideRepository(t *testing.T) { if version != CurrentSchemaVersion() { t.Fatalf("backup schema version = %d, want %d", version, CurrentSchemaVersion()) } + assertNoSQLiteSidecars(t, result.BackupPath) +} + +func TestBackupReportsGlobalProjectCount(t *testing.T) { + firstRoot := projectRoot(t) + secondRoot := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), firstRoot, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize(firstRoot) error = %v", err) + } + if _, err := Initialize(context.Background(), secondRoot, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize(secondRoot) error = %v", err) + } + + result, err := Backup(context.Background(), firstRoot, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Backup() error = %v", err) + } + + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.ProjectCount != 2 { + t.Fatalf("ProjectCount = %d, want 2", result.ProjectCount) + } +} + +func TestVerifyBackupReportsAllProjectsWithoutLiveState(t *testing.T) { + firstRoot := projectRoot(t) + secondRoot := projectRoot(t) + stateHome := t.TempDir() + firstStatus, err := Initialize(context.Background(), firstRoot, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize(firstRoot) error = %v", err) + } + secondStatus, err := Initialize(context.Background(), secondRoot, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize(secondRoot) error = %v", err) + } + backup, err := Backup(context.Background(), firstRoot, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Backup() error = %v", err) + } + if err := os.Remove(firstStatus.DatabasePath); err != nil { + t.Fatalf("remove live database error = %v", err) + } + + result, err := VerifyBackup(context.Background(), backup.BackupPath) + if err != nil { + t.Fatalf("VerifyBackup() error = %v", err) + } + + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.BackupPath != backup.BackupPath { + t.Fatalf("BackupPath = %q, want %q", result.BackupPath, backup.BackupPath) + } + if result.Bytes != backup.Bytes { + t.Fatalf("Bytes = %d, want %d", result.Bytes, backup.Bytes) + } + if result.SHA256 != backup.SHA256 { + t.Fatalf("SHA256 = %q, want %q", result.SHA256, backup.SHA256) + } + if !result.Verified { + t.Fatal("Verified = false, want true") + } + if result.SchemaVersion != CurrentSchemaVersion() { + t.Fatalf("SchemaVersion = %d, want %d", result.SchemaVersion, CurrentSchemaVersion()) + } + if result.ProjectCount != 2 || len(result.Projects) != 2 { + t.Fatalf("projects = %d/%d, want two projects", result.ProjectCount, len(result.Projects)) + } + seen := map[string]bool{} + for _, project := range result.Projects { + seen[project.ID] = true + if project.DatabasePath != backup.BackupPath { + t.Fatalf("project DatabasePath = %q, want backup path %q", project.DatabasePath, backup.BackupPath) + } + } + if !seen[firstStatus.ProjectID] || !seen[secondStatus.ProjectID] { + t.Fatalf("verified projects = %#v, want %q and %q", seen, firstStatus.ProjectID, secondStatus.ProjectID) + } + if result.IntegrityCheck != "ok" { + t.Fatalf("IntegrityCheck = %q, want ok", result.IntegrityCheck) + } + if result.ForeignKeyCheck != "ok" { + t.Fatalf("ForeignKeyCheck = %q, want ok", result.ForeignKeyCheck) + } } func TestBackupCreatesTimestampedFilesWithoutOverwriting(t *testing.T) { @@ -132,3 +260,51 @@ func TestBackupRejectsInvalidSQLiteState(t *testing.T) { t.Fatalf("error = %v, want doctor message", err) } } + +func TestVerifyNoForeignKeyViolationsReportsDetails(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + if _, err := store.db.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF`); err != nil { + t.Fatalf("disable foreign keys error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO aliases (id, project_id, entity_kind, entity_id, namespace, alias, created_at, updated_at) +VALUES ('alias-orphaned-project', 'project-missing', 'task', 'task-missing', 'task', 'TASK-MISSING', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`); err != nil { + t.Fatalf("insert orphaned alias fixture error = %v", err) + } + + _, err := verifyNoForeignKeyViolations(context.Background(), store) + if err == nil { + t.Fatal("verifyNoForeignKeyViolations() error = nil, want detailed violation") + } + for _, want := range []string{"SQLite foreign key violation", "aliases", "projects", "constraint"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error = %v, want %q", err, want) + } + } +} + +func assertNoSQLiteSidecars(t *testing.T, path string) { + t.Helper() + for _, suffix := range []string{"-wal", "-shm"} { + sidecar := path + suffix + if _, err := os.Stat(sidecar); !os.IsNotExist(err) { + t.Fatalf("backup sidecar %s exists or stat failed: %v", sidecar, err) + } + } +} + +func testFileSHA256(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", path, err) + } + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} diff --git a/internal/state/brainstorm.go b/internal/state/brainstorm.go index 923b38a6..994bea97 100644 --- a/internal/state/brainstorm.go +++ b/internal/state/brainstorm.go @@ -9,8 +9,14 @@ import ( // BrainstormList is the state-backed brainstorm-list read model. type BrainstormList struct { - Version int `json:"version"` - Brainstorms map[string]BrainstormItem `json:"brainstorms"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Brainstorms map[string]BrainstormItem `json:"brainstorms"` } // BrainstormItem is a brainstorm entry returned by the state-backed brainstorm list. @@ -38,7 +44,14 @@ func ListBrainstorms(ctx context.Context, root project.Root, resolver PathResolv // ListBrainstorms returns imported brainstorms from an open store. func (s *Store) ListBrainstorms(ctx context.Context, root project.Root, options BrainstormListOptions) (BrainstormList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BrainstormList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BrainstormList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT brainstorm_alias.alias, @@ -59,7 +72,16 @@ ORDER BY brainstorm_alias.alias return BrainstormList{}, fmt.Errorf("query brainstorms: %w", err) } - brainstorms := BrainstormList{Version: 1, Brainstorms: map[string]BrainstormItem{}} + brainstorms := BrainstormList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Brainstorms: map[string]BrainstormItem{}, + } for rows.Next() { var alias, title, status, sourcePath string if err := rows.Scan(&alias, &title, &status, &sourcePath); err != nil { diff --git a/internal/state/brainstorm_archive.go b/internal/state/brainstorm_archive.go index 03f80452..51c87b19 100644 --- a/internal/state/brainstorm_archive.go +++ b/internal/state/brainstorm_archive.go @@ -19,8 +19,14 @@ type BrainstormArchiveOptions struct { // BrainstormArchiveResult describes a state-backed brainstorm archive mutation. type BrainstormArchiveResult struct { - Archived []BrainstormArchiveItem `json:"archived"` - Skipped []BrainstormArchiveItem `json:"skipped"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Archived []BrainstormArchiveItem `json:"archived"` + Skipped []BrainstormArchiveItem `json:"skipped"` } // BrainstormArchiveItem describes one requested brainstorm archive outcome. @@ -49,10 +55,23 @@ func (s *Store) ArchiveBrainstorms(ctx context.Context, root project.Root, optio if len(options.Refs) == 0 { return BrainstormArchiveResult{}, fmt.Errorf("brainstorm archive requires at least one brainstorm") } - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BrainstormArchiveResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BrainstormArchiveResult{}, err + } result := BrainstormArchiveResult{ - Archived: []BrainstormArchiveItem{}, - Skipped: []BrainstormArchiveItem{}, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Archived: []BrainstormArchiveItem{}, + Skipped: []BrainstormArchiveItem{}, } for _, ref := range options.Refs { item, archived, err := s.archiveBrainstorm(ctx, projectID, ref, options.Reason) diff --git a/internal/state/brainstorm_promote.go b/internal/state/brainstorm_promote.go index 782be9f5..4597d1b9 100644 --- a/internal/state/brainstorm_promote.go +++ b/internal/state/brainstorm_promote.go @@ -16,9 +16,15 @@ type BrainstormPromoteOptions struct { // BrainstormPromoteResult describes a state-backed brainstorm promotion mutation. type BrainstormPromoteResult struct { - Brainstorm TraceEntity `json:"brainstorm"` - Idea TraceEntity `json:"idea"` - Relationship string `json:"relationship"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Brainstorm TraceEntity `json:"brainstorm"` + Idea TraceEntity `json:"idea"` + Relationship string `json:"relationship"` } // PromoteBrainstorm records that a brainstorm promoted to an idea in initialized SQLite state. @@ -33,7 +39,14 @@ func PromoteBrainstorm(ctx context.Context, root project.Root, resolver PathReso // PromoteBrainstorm records that a brainstorm promoted to an idea in an open store. func (s *Store) PromoteBrainstorm(ctx context.Context, root project.Root, options BrainstormPromoteOptions) (BrainstormPromoteResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BrainstormPromoteResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BrainstormPromoteResult{}, err + } brainstorm, err := s.resolveTraceEntity(ctx, projectID, options.Brainstorm) if err != nil { return BrainstormPromoteResult{}, err @@ -52,10 +65,11 @@ func (s *Store) PromoteBrainstorm(ctx context.Context, root project.Root, option now := time.Now().UTC().Format(time.RFC3339) relationshipID := stableMigrationID("relationship", projectID, "brainstorm", brainstorm.ID, "promoted_to", "idea", idea.ID) _, err = s.db.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'command', ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, updated_at = excluded.updated_at `, relationshipID, projectID, "brainstorm", brainstorm.ID, "idea", idea.ID, "promoted_to", "recorded by brainstorm promote", now, now) if err != nil { @@ -63,8 +77,14 @@ ON CONFLICT(id) DO UPDATE SET } return BrainstormPromoteResult{ - Brainstorm: brainstorm, - Idea: idea, - Relationship: relationshipID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Brainstorm: brainstorm, + Idea: idea, + Relationship: relationshipID, }, nil } diff --git a/internal/state/brainstorm_show.go b/internal/state/brainstorm_show.go index 923e3b3c..7495d6ba 100644 --- a/internal/state/brainstorm_show.go +++ b/internal/state/brainstorm_show.go @@ -12,8 +12,14 @@ import ( // BrainstormShow is the state-backed single-brainstorm read model. type BrainstormShow struct { - Query string `json:"query"` - Brainstorm BrainstormDetail `json:"brainstorm"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Brainstorm BrainstormDetail `json:"brainstorm"` } // BrainstormDetail contains operational brainstorm metadata plus imported source context. @@ -41,7 +47,14 @@ func ShowBrainstorm(ctx context.Context, root project.Root, resolver PathResolve // ShowBrainstorm returns one brainstorm from an open store. func (s *Store) ShowBrainstorm(ctx context.Context, root project.Root, ref string) (BrainstormShow, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BrainstormShow{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BrainstormShow{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return BrainstormShow{}, err @@ -54,7 +67,16 @@ func (s *Store) ShowBrainstorm(ctx context.Context, root project.Root, ref strin if err != nil { return BrainstormShow{}, err } - return BrainstormShow{Query: ref, Brainstorm: brainstorm}, nil + return BrainstormShow{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Brainstorm: brainstorm, + }, nil } func (s *Store) brainstormDetail(ctx context.Context, root project.Root, projectID string, entity TraceEntity) (BrainstormDetail, error) { diff --git a/internal/state/brainstorm_test.go b/internal/state/brainstorm_test.go index 9d57a746..9d370e3a 100644 --- a/internal/state/brainstorm_test.go +++ b/internal/state/brainstorm_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" "github.com/levifig/loaf/internal/project" @@ -54,6 +55,7 @@ status: archived if open.Title != "Open Brainstorm" || open.SourcePath != ".agents/drafts/20260528-brainstorm-open.md" { t.Fatalf("open = %#v, want imported title and source path", open) } + assertBrainstormProjectContext(t, root, defaultList.ContractVersion, defaultList.DatabaseScope, defaultList.DatabasePath, defaultList.ProjectID, defaultList.ProjectName, defaultList.ProjectCurrentPath) all, err := ListBrainstorms(context.Background(), root, PathResolver{StateHome: stateHome}, BrainstormListOptions{All: true}) if err != nil { @@ -62,6 +64,7 @@ status: archived if len(all.Brainstorms) != 3 { t.Fatalf("all = %#v, want all three brainstorms", all.Brainstorms) } + assertBrainstormProjectContext(t, root, all.ContractVersion, all.DatabaseScope, all.DatabasePath, all.ProjectID, all.ProjectName, all.ProjectCurrentPath) archived, err := ListBrainstorms(context.Background(), root, PathResolver{StateHome: stateHome}, BrainstormListOptions{Status: "archived"}) if err != nil { @@ -106,6 +109,7 @@ status: open if show.Brainstorm.Alias != "20260528-brainstorm-sqlite" || show.Brainstorm.Title != "SQLite Brainstorm" || show.Brainstorm.Status != "open" { t.Fatalf("show = %#v, want imported brainstorm metadata", show) } + assertBrainstormProjectContext(t, root, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath) if len(show.Brainstorm.Sources) != 1 || show.Brainstorm.Sources[0].Path != ".agents/drafts/20260528-brainstorm-sqlite.md" || show.Brainstorm.Sources[0].Hash == "" { t.Fatalf("Sources = %#v, want imported brainstorm source", show.Brainstorm.Sources) } @@ -151,6 +155,7 @@ status: open if result.Brainstorm.Alias != "20260528-brainstorm-sqlite" || result.Idea.Alias != "20260528-target-idea" || result.Relationship == "" { t.Fatalf("result = %#v, want brainstorm promoted to target idea with relationship", result) } + assertBrainstormProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) trace, err := Trace(context.Background(), root, PathResolver{StateHome: stateHome}, "20260528-brainstorm-sqlite") if err != nil { @@ -261,6 +266,7 @@ status: archived if len(result.Archived) != 1 || result.Archived[0].Brainstorm == nil || result.Archived[0].Brainstorm.Alias != "20260528-brainstorm-open" || result.Archived[0].Previous != "open" || result.Archived[0].EventID == "" || result.Archived[0].Note != "promoted to idea" { t.Fatalf("Archived = %#v, want open brainstorm archived with event", result.Archived) } + assertBrainstormProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if len(result.Skipped) != 3 { t.Fatalf("Skipped = %#v, want already archived, wrong-kind, and missing refs", result.Skipped) } @@ -294,6 +300,7 @@ status: archived if show.Brainstorm.Status != "archived" { t.Fatalf("show status = %q, want archived", show.Brainstorm.Status) } + assertBrainstormProjectContext(t, root, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath) store, err := OpenStore(mustDatabasePath(t, root, stateHome)) if err != nil { @@ -306,7 +313,7 @@ status: archived SELECT COUNT(*), COALESCE(MAX(note), '') FROM events WHERE project_id = ? AND entity_kind = 'brainstorm' AND event_type = 'status_changed' AND from_status = 'open' AND to_status = 'archived' -`, ProjectID(root)).Scan(&events, ¬e) +`, projectIDForTest(t, store, root)).Scan(&events, ¬e) if err != nil { t.Fatalf("count archive events error = %v", err) } @@ -317,3 +324,25 @@ WHERE project_id = ? AND entity_kind = 'brainstorm' AND event_type = 'status_cha t.Fatalf("event note = %q, want archive reason", note) } } + +func assertBrainstormProjectContext(t *testing.T, root project.Root, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + t.Helper() + if contractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(root.Path())) + } + if projectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, root.Path()) + } +} diff --git a/internal/state/bundle.go b/internal/state/bundle.go index 2c3ea521..cc7899a8 100644 --- a/internal/state/bundle.go +++ b/internal/state/bundle.go @@ -14,8 +14,14 @@ import ( // BundleList is the state-backed bundle-list read model. type BundleList struct { - Version int `json:"version"` - Bundles map[string]BundleItem `json:"bundles"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Bundles map[string]BundleItem `json:"bundles"` } // BundleItem is one bundle row returned by the state-backed bundle list. @@ -31,20 +37,32 @@ type BundleItem struct { // BundleShowResult describes a bundle and its resolved member set. type BundleShowResult struct { - Slug string `json:"slug"` - Title string `json:"title"` - TagQuery []string `json:"tag_query,omitempty"` - Members []TraceEntity `json:"members"` - Explicit []TraceEntity `json:"explicit,omitempty"` - TagMatched []TraceEntity `json:"tag_matched,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Slug string `json:"slug"` + Title string `json:"title"` + TagQuery []string `json:"tag_query,omitempty"` + Members []TraceEntity `json:"members"` + Explicit []TraceEntity `json:"explicit,omitempty"` + TagMatched []TraceEntity `json:"tag_matched,omitempty"` } // BundleMutationResult describes create/add/remove bundle mutations. type BundleMutationResult struct { - Slug string `json:"slug"` - Title string `json:"title,omitempty"` - Tags []string `json:"tags,omitempty"` - Entity *TraceEntity `json:"entity,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Slug string `json:"slug"` + Title string `json:"title,omitempty"` + Tags []string `json:"tags,omitempty"` + Entity *TraceEntity `json:"entity,omitempty"` } // BundleCreateOptions describes bundle creation. @@ -125,7 +143,14 @@ func RemoveBundleMember(ctx context.Context, root project.Root, resolver PathRes // CreateBundle creates or updates a bundle in an open store. func (s *Store) CreateBundle(ctx context.Context, root project.Root, options BundleCreateOptions) (BundleMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BundleMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BundleMutationResult{}, err + } slug, err := normalizeBundleSlug(options.Slug) if err != nil { return BundleMutationResult{}, err @@ -151,12 +176,29 @@ ON CONFLICT(project_id, slug) DO UPDATE SET if err != nil { return BundleMutationResult{}, fmt.Errorf("upsert bundle %s: %w", slug, err) } - return BundleMutationResult{Slug: slug, Title: title, Tags: tags}, nil + return BundleMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Slug: slug, + Title: title, + Tags: tags, + }, nil } // ListBundles returns all bundles in an open store. func (s *Store) ListBundles(ctx context.Context, root project.Root) (BundleList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BundleList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BundleList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT id, slug, title, COALESCE(tag_query, ''), created_at, updated_at FROM bundles @@ -192,7 +234,16 @@ ORDER BY slug return BundleList{}, fmt.Errorf("close bundle rows: %w", err) } - list := BundleList{Version: 1, Bundles: map[string]BundleItem{}} + list := BundleList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Bundles: map[string]BundleItem{}, + } for _, row := range bundleRows { tags, err := normalizeBundleTags(splitCommaList(row.tagQuery)) if err != nil { @@ -221,7 +272,14 @@ ORDER BY slug // ShowBundle returns a bundle and its full related set from an open store. func (s *Store) ShowBundle(ctx context.Context, root project.Root, slugValue string) (BundleShowResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BundleShowResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BundleShowResult{}, err + } bundleID, slug, title, tags, err := s.resolveBundle(ctx, projectID, slugValue) if err != nil { return BundleShowResult{}, err @@ -236,18 +294,31 @@ func (s *Store) ShowBundle(ctx context.Context, root project.Root, slugValue str } members := mergeTraceEntities(tagMatched, explicit) return BundleShowResult{ - Slug: slug, - Title: title, - TagQuery: tags, - Members: members, - Explicit: explicit, - TagMatched: tagMatched, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Slug: slug, + Title: title, + TagQuery: tags, + Members: members, + Explicit: explicit, + TagMatched: tagMatched, }, nil } // UpdateBundle updates an existing bundle in an open store. func (s *Store) UpdateBundle(ctx context.Context, root project.Root, options BundleUpdateOptions) (BundleMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BundleMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BundleMutationResult{}, err + } bundleID, slug, currentTitle, currentTags, err := s.resolveBundle(ctx, projectID, options.Slug) if err != nil { return BundleMutationResult{}, err @@ -280,12 +351,29 @@ WHERE project_id = ? AND id = ? if err != nil { return BundleMutationResult{}, fmt.Errorf("update bundle %s: %w", slug, err) } - return BundleMutationResult{Slug: slug, Title: title, Tags: tags}, nil + return BundleMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Slug: slug, + Title: title, + Tags: tags, + }, nil } // AddBundleMember adds an explicit member in an open store. func (s *Store) AddBundleMember(ctx context.Context, root project.Root, slugValue string, ref string) (BundleMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BundleMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BundleMutationResult{}, err + } bundleID, slug, title, _, err := s.resolveBundle(ctx, projectID, slugValue) if err != nil { return BundleMutationResult{}, err @@ -304,12 +392,29 @@ ON CONFLICT(project_id, bundle_id, entity_kind, entity_id) DO UPDATE SET updated if err != nil { return BundleMutationResult{}, fmt.Errorf("add bundle member: %w", err) } - return BundleMutationResult{Slug: slug, Title: title, Entity: &entity}, nil + return BundleMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Slug: slug, + Title: title, + Entity: &entity, + }, nil } // RemoveBundleMember removes an explicit member in an open store. func (s *Store) RemoveBundleMember(ctx context.Context, root project.Root, slugValue string, ref string) (BundleMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return BundleMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return BundleMutationResult{}, err + } bundleID, slug, title, _, err := s.resolveBundle(ctx, projectID, slugValue) if err != nil { return BundleMutationResult{}, err @@ -332,7 +437,17 @@ WHERE project_id = ? AND bundle_id = ? AND entity_kind = ? AND entity_id = ? if rows == 0 { return BundleMutationResult{}, fmt.Errorf("%s %q is not an explicit member of bundle %q", entity.Kind, firstNonEmpty(entity.Alias, entity.ID), slug) } - return BundleMutationResult{Slug: slug, Title: title, Entity: &entity}, nil + return BundleMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Slug: slug, + Title: title, + Entity: &entity, + }, nil } func (s *Store) resolveBundle(ctx context.Context, projectID string, slugValue string) (string, string, string, []string, error) { diff --git a/internal/state/bundle_test.go b/internal/state/bundle_test.go index 6036f5cd..f85ae652 100644 --- a/internal/state/bundle_test.go +++ b/internal/state/bundle_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" "github.com/levifig/loaf/internal/project" @@ -39,10 +40,13 @@ func TestBundlesCollectRowsByTagQueryAndExplicitMembership(t *testing.T) { if created.Slug != "sqlite-backend" || created.Title != "SQLite Backend" || len(created.Tags) != 1 || created.Tags[0] != "sqlite" { t.Fatalf("created = %#v, want sqlite-backend bundle with sqlite tag", created) } + assertBundleMutationContext(t, created, root) - if _, err := AddBundleMember(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite-backend", "TASK-001"); err != nil { + added, err := AddBundleMember(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite-backend", "TASK-001") + if err != nil { t.Fatalf("AddBundleMember(task) error = %v", err) } + assertBundleMutationContext(t, added, root) if _, err := AddBundleMember(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite-backend", "SPEC-001"); err != nil { t.Fatalf("AddBundleMember(spec duplicate) error = %v", err) } @@ -57,6 +61,7 @@ func TestBundlesCollectRowsByTagQueryAndExplicitMembership(t *testing.T) { if len(show.TagMatched) != 2 || len(show.Explicit) != 2 || len(show.Members) != 3 { t.Fatalf("show = %#v, want 2 tag-matched, 2 explicit, 3 union members", show) } + assertBundleShowContext(t, show, root) if !hasBundleMember(show.Members, "spec", "SPEC-001") || !hasBundleMember(show.Members, "task", "TASK-001") || !hasBundleMember(show.Members, "idea", "20260528-bundle-idea") { t.Fatalf("members = %#v, want spec, task, and idea", show.Members) } @@ -69,6 +74,7 @@ func TestBundlesCollectRowsByTagQueryAndExplicitMembership(t *testing.T) { if item.Title != "SQLite Backend" || item.ExplicitCount != 2 || item.TagMatchedCount != 2 || item.MemberCount != 3 { t.Fatalf("bundle list item = %#v, want explicit/tag/union counts", item) } + assertBundleListContext(t, list, root) updated, err := UpdateBundle(context.Background(), root, PathResolver{StateHome: stateHome}, BundleUpdateOptions{ Slug: "sqlite-backend", @@ -83,6 +89,7 @@ func TestBundlesCollectRowsByTagQueryAndExplicitMembership(t *testing.T) { if updated.Title != "SQLite Runtime" || len(updated.Tags) != 2 || updated.Tags[0] != "sqlite" || updated.Tags[1] != "state" { t.Fatalf("updated = %#v, want new title and sorted tags", updated) } + assertBundleMutationContext(t, updated, root) show, err = ShowBundle(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite-backend") if err != nil { t.Fatalf("ShowBundle() after update error = %v", err) @@ -102,7 +109,7 @@ SELECT COUNT(*) FROM bundle_members JOIN bundles ON bundles.id = bundle_members.bundle_id AND bundles.project_id = bundle_members.project_id WHERE bundle_members.project_id = ? AND bundles.slug = 'sqlite-backend' -`, ProjectID(root)).Scan(&explicit) +`, projectIDForTest(t, store, root)).Scan(&explicit) if err != nil { t.Fatalf("count explicit bundle members error = %v", err) } @@ -110,9 +117,11 @@ WHERE bundle_members.project_id = ? AND bundles.slug = 'sqlite-backend' t.Fatalf("explicit members = %d, want 2 after idempotent add", explicit) } - if _, err := RemoveBundleMember(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite-backend", "TASK-001"); err != nil { + removed, err := RemoveBundleMember(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite-backend", "TASK-001") + if err != nil { t.Fatalf("RemoveBundleMember(task) error = %v", err) } + assertBundleMutationContext(t, removed, root) show, err = ShowBundle(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite-backend") if err != nil { t.Fatalf("ShowBundle() after remove error = %v", err) @@ -122,6 +131,72 @@ WHERE bundle_members.project_id = ? AND bundles.slug = 'sqlite-backend' } } +func assertBundleMutationContext(t *testing.T, result BundleMutationResult, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + +func assertBundleListContext(t *testing.T, result BundleList, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + +func assertBundleShowContext(t *testing.T, result BundleShowResult, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + func TestRemoveBundleMemberRejectsMissingExplicitMembership(t *testing.T) { repo := initGitRepo(t) root, err := project.ResolveRoot(repo) diff --git a/internal/state/export.go b/internal/state/export.go index 93c119da..b987fa95 100644 --- a/internal/state/export.go +++ b/internal/state/export.go @@ -22,26 +22,64 @@ const ( ExportFormatMarkdown = "markdown" ExportAudienceLocal = "internal" ExportAudienceExternal = "external" + StateJSONContractVersion = 1 ) // ExportSnapshot is a complete internal JSON view of current SQLite state. type ExportSnapshot struct { - ExportKind string `json:"export_kind"` - Format string `json:"format"` - Audience string `json:"audience"` - GeneratedAt string `json:"generated_at"` - ProjectID string `json:"project_id"` - DatabasePath string `json:"database_path"` - SchemaVersion int `json:"schema_version"` - Tables map[string][]map[string]any `json:"tables"` + ContractVersion int `json:"contract_version"` + ExportKind string `json:"export_kind"` + Format string `json:"format"` + Audience string `json:"audience"` + DatabaseScope string `json:"database_scope"` + ExportScope string `json:"export_scope"` + GeneratedAt string `json:"generated_at"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + DatabasePath string `json:"database_path"` + SchemaVersion int `json:"schema_version"` + Diagnostics []Diagnostic `json:"diagnostics"` + RepairPlan []RepairAction `json:"repair_plan"` + Manifest ExportManifest `json:"manifest"` + Tables map[string][]map[string]any `json:"tables"` +} + +// ExportManifest is a compact, agent-friendly summary of an export snapshot. +type ExportManifest struct { + ContractVersion int `json:"contract_version"` + Verified bool `json:"verified"` + DatabaseScope string `json:"database_scope"` + ExportScope string `json:"export_scope"` + SchemaVersion int `json:"schema_version"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + IntegrityCheck string `json:"integrity_check"` + ForeignKeyCheck string `json:"foreign_key_check"` + DiagnosticCount int `json:"diagnostic_count"` + RepairActionCount int `json:"repair_action_count"` + TableCount int `json:"table_count"` + TableOrder []string `json:"table_order"` + RowCounts map[string]int `json:"row_counts"` + TotalRows int `json:"total_rows"` + GeneratedAt string `json:"generated_at"` } // MarkdownExport is a generated Markdown view of SQLite state. type MarkdownExport struct { - ExportKind string `json:"export_kind"` - Format string `json:"format"` - Audience string `json:"audience"` - Content string `json:"content"` + ContractVersion int `json:"contract_version"` + Command string `json:"command,omitempty"` + ExportKind string `json:"export_kind"` + Format string `json:"format"` + Audience string `json:"audience"` + DatabaseScope string `json:"database_scope"` + ExportScope string `json:"export_scope"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + Content string `json:"content"` } type exportTable struct { @@ -63,6 +101,16 @@ type releaseReadinessExportData struct { RecentSessions []releaseReadinessSession } +type markdownExportContext struct { + Audience string + DatabaseScope string + ExportScope string + ProjectID string + ProjectName string + ProjectCurrentPath string + DatabasePath string +} + type releaseReadinessSourceCoverage struct { Label string With int @@ -97,6 +145,7 @@ var externalLeakPatterns = []*regexp.Regexp{ var exportAllTables = []exportTable{ {Name: "schema_migrations", OrderBy: "version"}, {Name: "projects", OrderBy: "id", FilterColumn: "id"}, + {Name: "project_paths", OrderBy: "id", FilterColumn: "project_id"}, {Name: "aliases", OrderBy: "id", FilterColumn: "project_id"}, {Name: "specs", OrderBy: "id", FilterColumn: "project_id"}, {Name: "tasks", OrderBy: "id", FilterColumn: "project_id"}, @@ -133,14 +182,29 @@ func ExportAllJSON(ctx context.Context, root project.Root, resolver PathResolver return ExportSnapshot{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") } - store, err := OpenStore(status.DatabasePath) + store, err := OpenStoreReadOnly(status.DatabasePath) if err != nil { return ExportSnapshot{}, fmt.Errorf("open state database for export: %w", err) } defer store.Close() tables := make(map[string][]map[string]any, len(exportAllTables)) - projectID := ProjectID(root) + tableOrder := make([]string, 0, len(exportAllTables)) + rowCounts := make(map[string]int, len(exportAllTables)) + totalRows := 0 + identity, err := store.LookupProjectIdentityForRoot(ctx, root) + if err != nil { + return ExportSnapshot{}, err + } + projectID := identity.ID + integrityCheck, err := verifySQLiteIntegrity(ctx, store) + if err != nil { + return ExportSnapshot{}, fmt.Errorf("verify export integrity: %w", err) + } + foreignKeyCheck, err := verifyNoForeignKeyViolations(ctx, store) + if err != nil { + return ExportSnapshot{}, fmt.Errorf("verify export foreign keys: %w", err) + } if err := store.validateExportTableFilters(ctx, exportAllTables); err != nil { return ExportSnapshot{}, err } @@ -150,17 +214,48 @@ func ExportAllJSON(ctx context.Context, root project.Root, resolver PathResolver return ExportSnapshot{}, err } tables[table.Name] = rows + tableOrder = append(tableOrder, table.Name) + rowCounts[table.Name] = len(rows) + totalRows += len(rows) } + generatedAt := time.Now().UTC().Format(time.RFC3339Nano) + repairPlan := RepairPlanForStatus(status) return ExportSnapshot{ - ExportKind: ExportKindAll, - Format: ExportFormatJSON, - Audience: ExportAudienceLocal, - GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), - ProjectID: projectID, - DatabasePath: status.DatabasePath, - SchemaVersion: status.SchemaVersion, - Tables: tables, + ContractVersion: StateJSONContractVersion, + ExportKind: ExportKindAll, + Format: ExportFormatJSON, + Audience: ExportAudienceLocal, + DatabaseScope: "global", + ExportScope: "project", + GeneratedAt: generatedAt, + ProjectID: projectID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + DatabasePath: status.DatabasePath, + SchemaVersion: status.SchemaVersion, + Diagnostics: status.Diagnostics, + RepairPlan: repairPlan, + Manifest: ExportManifest{ + ContractVersion: StateJSONContractVersion, + Verified: true, + DatabaseScope: "global", + ExportScope: "project", + SchemaVersion: status.SchemaVersion, + ProjectID: projectID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + IntegrityCheck: integrityCheck, + ForeignKeyCheck: foreignKeyCheck, + DiagnosticCount: len(status.Diagnostics), + RepairActionCount: len(repairPlan), + TableCount: len(tableOrder), + TableOrder: tableOrder, + RowCounts: rowCounts, + TotalRows: totalRows, + GeneratedAt: generatedAt, + }, + Tables: tables, }, nil } @@ -177,7 +272,7 @@ func ExportTriageMarkdown(ctx context.Context, root project.Root, resolver PathR return MarkdownExport{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") } - store, err := OpenStore(status.DatabasePath) + store, err := OpenStoreReadOnly(status.DatabasePath) if err != nil { return MarkdownExport{}, fmt.Errorf("open state database for export: %w", err) } @@ -196,16 +291,12 @@ func ExportTriageMarkdown(ctx context.Context, root project.Root, resolver PathR return MarkdownExport{}, err } - content := renderTriageMarkdown(ideas, sparks, brainstorms) + exportContext := markdownExportContextFromStatus(status, ExportAudienceExternal) + content := renderTriageMarkdown(exportContext, ideas, sparks, brainstorms) if err := ValidateExternalMarkdownExport(content); err != nil { return MarkdownExport{}, err } - return MarkdownExport{ - ExportKind: ExportKindTriage, - Format: ExportFormatMarkdown, - Audience: ExportAudienceExternal, - Content: content, - }, nil + return markdownExportResult(ExportKindTriage, exportContext, content), nil } // ExportReleaseReadinessMarkdown returns an external-safe Markdown release readiness summary. @@ -221,7 +312,7 @@ func ExportReleaseReadinessMarkdown(ctx context.Context, root project.Root, reso return MarkdownExport{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") } - store, err := OpenStore(status.DatabasePath) + store, err := OpenStoreReadOnly(status.DatabasePath) if err != nil { return MarkdownExport{}, fmt.Errorf("open state database for export: %w", err) } @@ -231,16 +322,12 @@ func ExportReleaseReadinessMarkdown(ctx context.Context, root project.Root, reso if err != nil { return MarkdownExport{}, err } - content := renderReleaseReadinessMarkdown(data) + exportContext := markdownExportContextFromStatus(status, ExportAudienceExternal) + content := renderReleaseReadinessMarkdown(exportContext, data) if err := ValidateExternalMarkdownExport(content); err != nil { return MarkdownExport{}, err } - return MarkdownExport{ - ExportKind: ExportKindReleaseReadiness, - Format: ExportFormatMarkdown, - Audience: ExportAudienceExternal, - Content: content, - }, nil + return markdownExportResult(ExportKindReleaseReadiness, exportContext, content), nil } // ExportSpecMarkdown returns an internal Markdown summary for one spec. @@ -256,7 +343,7 @@ func ExportSpecMarkdown(ctx context.Context, root project.Root, resolver PathRes return MarkdownExport{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") } - store, err := OpenStore(status.DatabasePath) + store, err := OpenStoreReadOnly(status.DatabasePath) if err != nil { return MarkdownExport{}, fmt.Errorf("open state database for export: %w", err) } @@ -266,12 +353,8 @@ func ExportSpecMarkdown(ctx context.Context, root project.Root, resolver PathRes if err != nil { return MarkdownExport{}, err } - return MarkdownExport{ - ExportKind: ExportKindSpec, - Format: ExportFormatMarkdown, - Audience: ExportAudienceLocal, - Content: renderSpecMarkdown(show.Spec), - }, nil + exportContext := markdownExportContextFromStatus(status, ExportAudienceLocal) + return markdownExportResult(ExportKindSpec, exportContext, renderSpecMarkdown(exportContext, show.Spec)), nil } // ExportSessionMarkdown returns an internal Markdown summary for one session. @@ -287,7 +370,7 @@ func ExportSessionMarkdown(ctx context.Context, root project.Root, resolver Path return MarkdownExport{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") } - store, err := OpenStore(status.DatabasePath) + store, err := OpenStoreReadOnly(status.DatabasePath) if err != nil { return MarkdownExport{}, fmt.Errorf("open state database for export: %w", err) } @@ -297,12 +380,8 @@ func ExportSessionMarkdown(ctx context.Context, root project.Root, resolver Path if err != nil { return MarkdownExport{}, err } - return MarkdownExport{ - ExportKind: ExportKindSession, - Format: ExportFormatMarkdown, - Audience: ExportAudienceLocal, - Content: renderSessionMarkdown(show.Session), - }, nil + exportContext := markdownExportContextFromStatus(status, ExportAudienceLocal) + return markdownExportResult(ExportKindSession, exportContext, renderSessionMarkdown(exportContext, show.Session)), nil } func (s *Store) releaseReadinessExportData(ctx context.Context, root project.Root, schemaVersion int) (releaseReadinessExportData, error) { @@ -322,7 +401,11 @@ func (s *Store) releaseReadinessExportData(ctx context.Context, root project.Roo if err != nil { return releaseReadinessExportData{}, err } - projectID := ProjectID(root) + identity, err := s.LookupProjectIdentityForRoot(ctx, root) + if err != nil { + return releaseReadinessExportData{}, err + } + projectID := identity.ID sourceCoverage, err := s.releaseReadinessSourceCoverage(ctx, projectID) if err != nil { return releaseReadinessExportData{}, err @@ -588,18 +671,74 @@ func ValidateExternalMarkdownExport(content string) error { return nil } -func renderTriageMarkdown(ideas IdeaList, sparks SparkList, brainstorms BrainstormList) string { +func markdownExportContextFromStatus(status Status, audience string) markdownExportContext { + return markdownExportContext{ + Audience: audience, + DatabaseScope: firstNonEmpty(status.DatabaseScope, "global"), + ExportScope: "project", + ProjectID: status.ProjectID, + ProjectName: status.ProjectName, + ProjectCurrentPath: status.ProjectCurrentPath, + DatabasePath: status.DatabasePath, + } +} + +func markdownExportResult(kind string, ctx markdownExportContext, content string) MarkdownExport { + result := MarkdownExport{ + ContractVersion: StateJSONContractVersion, + ExportKind: kind, + Format: ExportFormatMarkdown, + Audience: ctx.Audience, + DatabaseScope: firstNonEmpty(ctx.DatabaseScope, "global"), + ExportScope: firstNonEmpty(ctx.ExportScope, "project"), + ProjectID: ctx.ProjectID, + ProjectName: ctx.ProjectName, + Content: content, + } + if ctx.Audience == ExportAudienceLocal { + result.ProjectCurrentPath = ctx.ProjectCurrentPath + result.DatabasePath = ctx.DatabasePath + } + return result +} + +func renderMarkdownExportContext(b *strings.Builder, ctx markdownExportContext) { + b.WriteString("## Project Context\n\n") + fmt.Fprintf(b, "- Scope: %s database, %s export\n", firstNonEmpty(ctx.DatabaseScope, "global"), firstNonEmpty(ctx.ExportScope, "project")) + if ctx.ProjectID != "" { + fmt.Fprintf(b, "- Project: `%s`\n", ctx.ProjectID) + } + if ctx.ProjectName != "" { + projectName := ctx.ProjectName + if ctx.Audience == ExportAudienceExternal { + projectName = sanitizeExternalText(projectName) + } + fmt.Fprintf(b, "- Project name: %s\n", projectName) + } + if ctx.Audience == ExportAudienceLocal { + if ctx.ProjectCurrentPath != "" { + fmt.Fprintf(b, "- Project path: `%s`\n", ctx.ProjectCurrentPath) + } + if ctx.DatabasePath != "" { + fmt.Fprintf(b, "- Database: `%s`\n", ctx.DatabasePath) + } + } + b.WriteString("\n") +} + +func renderTriageMarkdown(ctx markdownExportContext, ideas IdeaList, sparks SparkList, brainstorms BrainstormList) string { var b strings.Builder b.WriteString("# Triage Export\n\n") b.WriteString("Audience: external\n") b.WriteString("Source: Loaf SQLite state\n\n") + renderMarkdownExportContext(&b, ctx) renderIdeaExportSection(&b, ideas) renderSparkExportSection(&b, sparks) renderBrainstormExportSection(&b, brainstorms) return b.String() } -func renderReleaseReadinessMarkdown(data releaseReadinessExportData) string { +func renderReleaseReadinessMarkdown(ctx markdownExportContext, data releaseReadinessExportData) string { specActive, specComplete, specArchived := releaseSpecStatusCounts(data.Specs) taskUnresolved, taskDone, taskArchived := releaseTaskStatusCounts(data.Tasks) activeSessions := releaseSessionStatusCount(data.Sessions, "active") @@ -611,6 +750,7 @@ func renderReleaseReadinessMarkdown(data releaseReadinessExportData) string { b.WriteString("# Release Readiness Export\n\n") b.WriteString("Audience: external\n") b.WriteString("Source: Loaf SQLite state\n\n") + renderMarkdownExportContext(&b, ctx) b.WriteString("## State\n\n") b.WriteString("- SQLite state: ready\n") @@ -694,11 +834,12 @@ func renderReleaseReadinessMarkdown(data releaseReadinessExportData) string { return b.String() } -func renderSpecMarkdown(spec SpecDetail) string { +func renderSpecMarkdown(ctx markdownExportContext, spec SpecDetail) string { var b strings.Builder b.WriteString("# Spec Export\n\n") b.WriteString("Audience: internal\n") b.WriteString("Source: Loaf SQLite state\n\n") + renderMarkdownExportContext(&b, ctx) b.WriteString("## Spec\n\n") fmt.Fprintf(&b, "- Spec: `%s`\n", firstNonEmpty(spec.Alias, spec.ID)) fmt.Fprintf(&b, "- Title: %s\n", spec.Title) @@ -751,11 +892,12 @@ func renderSpecMarkdown(spec SpecDetail) string { return b.String() } -func renderSessionMarkdown(session SessionDetail) string { +func renderSessionMarkdown(ctx markdownExportContext, session SessionDetail) string { var b strings.Builder b.WriteString("# Session Export\n\n") b.WriteString("Audience: internal\n") b.WriteString("Source: Loaf SQLite state\n\n") + renderMarkdownExportContext(&b, ctx) b.WriteString("## Session\n\n") fmt.Fprintf(&b, "- Session: `%s`\n", firstNonEmpty(session.Alias, session.ID)) fmt.Fprintf(&b, "- Status: %s\n", session.Status) diff --git a/internal/state/export_test.go b/internal/state/export_test.go index 0f547356..5a1d72df 100644 --- a/internal/state/export_test.go +++ b/internal/state/export_test.go @@ -28,15 +28,39 @@ func TestExportAllJSONReturnsInternalSnapshot(t *testing.T) { if snapshot.ExportKind != ExportKindAll { t.Fatalf("ExportKind = %q, want %q", snapshot.ExportKind, ExportKindAll) } + if snapshot.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", snapshot.ContractVersion, StateJSONContractVersion) + } if snapshot.Format != ExportFormatJSON { t.Fatalf("Format = %q, want %q", snapshot.Format, ExportFormatJSON) } if snapshot.Audience != ExportAudienceLocal { t.Fatalf("Audience = %q, want %q", snapshot.Audience, ExportAudienceLocal) } - if snapshot.ProjectID != ProjectID(root) { + if snapshot.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", snapshot.DatabaseScope) + } + if snapshot.ExportScope != "project" { + t.Fatalf("ExportScope = %q, want project", snapshot.ExportScope) + } + store, err := OpenStoreReadOnly(snapshot.DatabasePath) + if err != nil { + t.Fatalf("OpenStoreReadOnly() error = %v", err) + } + defer store.Close() + if snapshot.ProjectID != projectIDForTest(t, store, root) { t.Fatalf("ProjectID = %q, want project id", snapshot.ProjectID) } + identity, err := store.LookupProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("LookupProjectIdentityForRoot() error = %v", err) + } + if snapshot.ProjectName != identity.FriendlyName { + t.Fatalf("ProjectName = %q, want %q", snapshot.ProjectName, identity.FriendlyName) + } + if snapshot.ProjectCurrentPath != identity.CurrentPath { + t.Fatalf("ProjectCurrentPath = %q, want %q", snapshot.ProjectCurrentPath, identity.CurrentPath) + } if snapshot.DatabasePath == "" { t.Fatal("DatabasePath is empty") } @@ -46,12 +70,57 @@ func TestExportAllJSONReturnsInternalSnapshot(t *testing.T) { if snapshot.GeneratedAt == "" { t.Fatal("GeneratedAt is empty") } + if !snapshot.Manifest.Verified { + t.Fatal("Manifest.Verified = false, want true") + } + if snapshot.Manifest.DatabaseScope != snapshot.DatabaseScope { + t.Fatalf("Manifest.DatabaseScope = %q, want %q", snapshot.Manifest.DatabaseScope, snapshot.DatabaseScope) + } + if snapshot.Manifest.ExportScope != snapshot.ExportScope { + t.Fatalf("Manifest.ExportScope = %q, want %q", snapshot.Manifest.ExportScope, snapshot.ExportScope) + } + if snapshot.Manifest.ContractVersion != snapshot.ContractVersion { + t.Fatalf("Manifest.ContractVersion = %d, want %d", snapshot.Manifest.ContractVersion, snapshot.ContractVersion) + } + if snapshot.Manifest.SchemaVersion != snapshot.SchemaVersion { + t.Fatalf("Manifest.SchemaVersion = %d, want %d", snapshot.Manifest.SchemaVersion, snapshot.SchemaVersion) + } + if snapshot.Manifest.ProjectID != snapshot.ProjectID { + t.Fatalf("Manifest.ProjectID = %q, want %q", snapshot.Manifest.ProjectID, snapshot.ProjectID) + } + if snapshot.Manifest.ProjectName != snapshot.ProjectName { + t.Fatalf("Manifest.ProjectName = %q, want %q", snapshot.Manifest.ProjectName, snapshot.ProjectName) + } + if snapshot.Manifest.ProjectCurrentPath != snapshot.ProjectCurrentPath { + t.Fatalf("Manifest.ProjectCurrentPath = %q, want %q", snapshot.Manifest.ProjectCurrentPath, snapshot.ProjectCurrentPath) + } + if snapshot.Manifest.IntegrityCheck != "ok" { + t.Fatalf("Manifest.IntegrityCheck = %q, want ok", snapshot.Manifest.IntegrityCheck) + } + if snapshot.Manifest.ForeignKeyCheck != "ok" { + t.Fatalf("Manifest.ForeignKeyCheck = %q, want ok", snapshot.Manifest.ForeignKeyCheck) + } + if snapshot.Manifest.GeneratedAt != snapshot.GeneratedAt { + t.Fatalf("Manifest.GeneratedAt = %q, want %q", snapshot.Manifest.GeneratedAt, snapshot.GeneratedAt) + } + if snapshot.Manifest.TableCount != len(exportAllTables) { + t.Fatalf("Manifest.TableCount = %d, want %d", snapshot.Manifest.TableCount, len(exportAllTables)) + } + if len(snapshot.Manifest.TableOrder) != len(exportAllTables) { + t.Fatalf("Manifest.TableOrder length = %d, want %d", len(snapshot.Manifest.TableOrder), len(exportAllTables)) + } if len(snapshot.Tables["schema_migrations"]) != len(SchemaMigrations()) { t.Fatalf("schema_migrations rows = %d, want %d", len(snapshot.Tables["schema_migrations"]), len(SchemaMigrations())) } if len(snapshot.Tables["projects"]) != 1 { t.Fatalf("projects rows = %d, want 1", len(snapshot.Tables["projects"])) } + if len(snapshot.Tables["project_paths"]) != 1 { + t.Fatalf("project_paths rows = %d, want 1", len(snapshot.Tables["project_paths"])) + } + if snapshot.Tables["project_paths"][0]["path"] != identity.CurrentPath { + t.Fatalf("project_paths path = %#v, want %q", snapshot.Tables["project_paths"][0]["path"], identity.CurrentPath) + } if len(snapshot.Tables["tasks"]) != 1 { t.Fatalf("tasks rows = %d, want 1", len(snapshot.Tables["tasks"])) } @@ -61,6 +130,66 @@ func TestExportAllJSONReturnsInternalSnapshot(t *testing.T) { if snapshot.Tables["tasks"][0]["title"] != "Example Task" { t.Fatalf("task title = %#v, want imported title", snapshot.Tables["tasks"][0]["title"]) } + assertExportManifestCounts(t, snapshot) +} + +func TestExportAllJSONIncludesProjectPathHistory(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + movedRoot := projectRoot(t) + movedPath := movedRoot.Path() + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + if _, err := store.MoveProject(context.Background(), root, root.Path(), movedPath); err != nil { + t.Fatalf("MoveProject() error = %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + snapshot, err := ExportAllJSON(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("ExportAllJSON() error = %v", err) + } + + if snapshot.ProjectID != status.ProjectID { + t.Fatalf("ProjectID = %q, want %q", snapshot.ProjectID, status.ProjectID) + } + if snapshot.ProjectCurrentPath != movedPath { + t.Fatalf("ProjectCurrentPath = %q, want %q", snapshot.ProjectCurrentPath, movedPath) + } + paths := snapshot.Tables["project_paths"] + if len(paths) != 2 { + t.Fatalf("project_paths rows = %#v, want old and current paths", paths) + } + currentPaths := 0 + seen := map[string]bool{} + for _, row := range paths { + path, _ := row["path"].(string) + seen[path] = true + if row["is_current"] == int64(1) || row["is_current"] == 1 { + currentPaths++ + if path != movedPath { + t.Fatalf("current project path = %q, want %q", path, movedPath) + } + } + } + if !seen[root.Path()] || !seen[movedPath] { + t.Fatalf("project_paths = %#v, want %q and %q", paths, root.Path(), movedPath) + } + if currentPaths != 1 { + t.Fatalf("current project paths = %d, want 1", currentPaths) + } + if snapshot.Manifest.RowCounts["project_paths"] != 2 { + t.Fatalf("manifest project_paths count = %d, want 2", snapshot.Manifest.RowCounts["project_paths"]) + } + assertExportManifestCounts(t, snapshot) } func TestExportTableValidationRejectsUnfilteredProjectTables(t *testing.T) { @@ -171,6 +300,7 @@ func TestExportTriageMarkdownReturnsExternalSafeSummary(t *testing.T) { t.Fatalf("content = %q, want %q", export.Content, want) } } + assertExternalMarkdownProjectContext(t, root, stateHome, export.Content) for _, banned := range []string{"SPEC-001", "TASK-002", ".agents/", "Track A", "Phase 2"} { if strings.Contains(export.Content, banned) { t.Fatalf("content leaked %q:\n%s", banned, export.Content) @@ -332,6 +462,7 @@ status: final t.Fatalf("content = %q, want %q", export.Content, want) } } + assertExternalMarkdownProjectContext(t, root, stateHome, export.Content) for _, banned := range []string{"SPEC-001", "TASK-001", ".agents/", "Track A", "Phase 2"} { if strings.Contains(export.Content, banned) { t.Fatalf("content leaked %q:\n%s", banned, export.Content) @@ -452,6 +583,7 @@ Imported spec prose. t.Fatalf("content = %q, want %q", export.Content, want) } } + assertInternalMarkdownProjectContext(t, root, stateHome, export.Content) if strings.Contains(export.Content, "status: implementing") || strings.Contains(export.Content, "---") { t.Fatalf("content = %q, want stripped frontmatter", export.Content) } @@ -572,6 +704,7 @@ claude_session_id: harness-export t.Fatalf("content = %q, want %q", export.Content, want) } } + assertInternalMarkdownProjectContext(t, root, stateHome, export.Content) } func TestExportSessionMarkdownIsDeterministicAndDoesNotMutateDatabase(t *testing.T) { @@ -643,7 +776,7 @@ func insertBrainstormForExport(t *testing.T, root project.Root, stateHome string } defer store.Close() now := time.Now().UTC().Format(time.RFC3339Nano) - projectID := ProjectID(root) + projectID := projectIDForTest(t, store, root) _, err = store.db.ExecContext(context.Background(), ` INSERT INTO brainstorms (id, project_id, title, status, created_at, updated_at) VALUES ('brainstorm-export', ?, ?, 'open', ?, ?) @@ -660,6 +793,78 @@ VALUES ('alias-brainstorm-export', ?, 'brainstorm', 'brainstorm-export', 'brains } } +func assertExternalMarkdownProjectContext(t *testing.T, root project.Root, stateHome string, content string) { + t.Helper() + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + for _, want := range []string{ + "## Project Context", + "- Scope: global database, project export", + "- Project: `" + status.ProjectID + "`", + "- Project name: " + status.ProjectName, + } { + if !strings.Contains(content, want) { + t.Fatalf("content = %q, want project context %q", content, want) + } + } + for _, banned := range []string{ + "Project path:", + "Database:", + root.Path(), + status.DatabasePath, + } { + if strings.Contains(content, banned) { + t.Fatalf("external content leaked %q:\n%s", banned, content) + } + } +} + +func assertInternalMarkdownProjectContext(t *testing.T, root project.Root, stateHome string, content string) { + t.Helper() + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + for _, want := range []string{ + "## Project Context", + "- Scope: global database, project export", + "- Project: `" + status.ProjectID + "`", + "- Project name: " + status.ProjectName, + "- Project path: `" + status.ProjectCurrentPath + "`", + "- Database: `" + status.DatabasePath + "`", + } { + if !strings.Contains(content, want) { + t.Fatalf("content = %q, want project context %q", content, want) + } + } +} + +func assertExportManifestCounts(t *testing.T, snapshot ExportSnapshot) { + t.Helper() + if snapshot.Manifest.TableCount != len(snapshot.Manifest.TableOrder) { + t.Fatalf("manifest table count = %d, want table order length %d", snapshot.Manifest.TableCount, len(snapshot.Manifest.TableOrder)) + } + if snapshot.Manifest.TableCount != len(snapshot.Tables) { + t.Fatalf("manifest table count = %d, want snapshot table map length %d", snapshot.Manifest.TableCount, len(snapshot.Tables)) + } + total := 0 + for _, tableName := range snapshot.Manifest.TableOrder { + rows, ok := snapshot.Tables[tableName] + if !ok { + t.Fatalf("manifest table %q missing from snapshot tables", tableName) + } + total += len(rows) + if got := snapshot.Manifest.RowCounts[tableName]; got != len(rows) { + t.Fatalf("manifest row count for %s = %d, want %d", tableName, got, len(rows)) + } + } + if snapshot.Manifest.TotalRows != total { + t.Fatalf("manifest total rows = %d, want %d", snapshot.Manifest.TotalRows, total) + } +} + func insertGeneratedExportForReadiness(t *testing.T, root project.Root, stateHome string) { t.Helper() store, err := OpenStore(mustDatabasePath(t, root, stateHome)) @@ -668,7 +873,7 @@ func insertGeneratedExportForReadiness(t *testing.T, root project.Root, stateHom } defer store.Close() now := time.Now().UTC().Format(time.RFC3339Nano) - projectID := ProjectID(root) + projectID := projectIDForTest(t, store, root) _, err = store.db.ExecContext(context.Background(), ` INSERT INTO exports (id, project_id, export_kind, format, path, state_version, generated_at, created_at, updated_at) VALUES ('export-release-readiness', ?, 'release-readiness', 'markdown', '.agents/reports/SPEC-001-release.md', 1, ?, ?, ?) diff --git a/internal/state/housekeeping.go b/internal/state/housekeeping.go index 18faa7a0..e9a5dceb 100644 --- a/internal/state/housekeeping.go +++ b/internal/state/housekeeping.go @@ -9,10 +9,15 @@ import ( // HousekeepingSummary is the SQLite-backed housekeeping read model. type HousekeepingSummary struct { - Version int `json:"version"` - DatabasePath string `json:"database_path"` - Sections map[string]HousekeepingSection `json:"sections"` - Signals []string `json:"signals"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Sections map[string]HousekeepingSection `json:"sections"` + Signals []string `json:"signals"` } // HousekeepingSection summarizes one operational state area. @@ -38,7 +43,14 @@ func Housekeeping(ctx context.Context, root project.Root, resolver PathResolver) // Housekeeping returns lifecycle and cleanup signals from an open store. func (s *Store) Housekeeping(ctx context.Context, root project.Root, databasePath string) (HousekeepingSummary, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return HousekeepingSummary{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return HousekeepingSummary{}, err + } sections := map[string]HousekeepingSection{} specs, err := s.housekeepingStatusSection(ctx, "specs", projectID, "complete", "archived") if err != nil { @@ -82,10 +94,15 @@ func (s *Store) Housekeeping(ctx context.Context, root project.Root, databasePat sections["shaping_drafts"] = shapingDrafts return HousekeepingSummary{ - Version: 1, - DatabasePath: databasePath, - Sections: sections, - Signals: housekeepingSignals(sections), + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Sections: sections, + Signals: housekeepingSignals(sections), }, nil } diff --git a/internal/state/housekeeping_test.go b/internal/state/housekeeping_test.go index fb7f4ecf..1a85fe5b 100644 --- a/internal/state/housekeeping_test.go +++ b/internal/state/housekeeping_test.go @@ -18,7 +18,7 @@ func TestHousekeepingSummarizesSQLiteLifecycleState(t *testing.T) { } defer store.Close() - projectID := ProjectID(root) + projectID := projectIDForTest(t, store, root) now := "2026-05-28T23:25:55Z" insertHousekeepingEntity(t, store, "specs", projectID, "spec-complete", "Complete Spec", "complete", now) insertHousekeepingEntity(t, store, "tasks", projectID, "task-done", "Done Task", "done", now) @@ -36,6 +36,7 @@ func TestHousekeepingSummarizesSQLiteLifecycleState(t *testing.T) { if summary.DatabasePath != result.DatabasePath { t.Fatalf("DatabasePath = %q, want %q", summary.DatabasePath, result.DatabasePath) } + assertTaskProjectContext(t, root.Path(), summary.ContractVersion, summary.DatabaseScope, summary.DatabasePath, summary.ProjectID, summary.ProjectName, summary.ProjectCurrentPath) for name, status := range map[string]string{ "specs": "complete", "tasks": "done", diff --git a/internal/state/idea.go b/internal/state/idea.go index 4e26ebe4..fd65349f 100644 --- a/internal/state/idea.go +++ b/internal/state/idea.go @@ -14,8 +14,14 @@ import ( // IdeaList is the state-backed idea-list read model. type IdeaList struct { - Version int `json:"version"` - Ideas map[string]IdeaItem `json:"ideas"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Ideas map[string]IdeaItem `json:"ideas"` } // IdeaItem is an idea entry returned by the state-backed idea list. @@ -33,10 +39,16 @@ type IdeaListOptions struct { // IdeaResolveResult describes a state-backed idea resolution mutation. type IdeaResolveResult struct { - Idea TraceEntity `json:"idea"` - ResolvedBy TraceEntity `json:"resolved_by"` - Relationship string `json:"relationship"` - EventID string `json:"event_id,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Idea TraceEntity `json:"idea"` + ResolvedBy TraceEntity `json:"resolved_by"` + Relationship string `json:"relationship"` + EventID string `json:"event_id,omitempty"` } // IdeaPromoteOptions describes a SQLite-backed idea promotion request. @@ -47,9 +59,15 @@ type IdeaPromoteOptions struct { // IdeaPromoteResult describes a state-backed idea promotion mutation. type IdeaPromoteResult struct { - Idea TraceEntity `json:"idea"` - Spec TraceEntity `json:"spec"` - Relationship string `json:"relationship"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Idea TraceEntity `json:"idea"` + Spec TraceEntity `json:"spec"` + Relationship string `json:"relationship"` } // IdeaCaptureOptions describes a SQLite-backed idea capture request. @@ -59,8 +77,14 @@ type IdeaCaptureOptions struct { // IdeaCaptureResult describes a captured SQLite-backed idea. type IdeaCaptureResult struct { - Idea TraceEntity `json:"idea"` - EventID string `json:"event_id"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Idea TraceEntity `json:"idea"` + EventID string `json:"event_id"` } // IdeaArchiveOptions describes a SQLite-backed idea archive request. @@ -71,8 +95,14 @@ type IdeaArchiveOptions struct { // IdeaArchiveResult describes a state-backed idea archive mutation. type IdeaArchiveResult struct { - Archived []IdeaArchiveItem `json:"archived"` - Skipped []IdeaArchiveItem `json:"skipped"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Archived []IdeaArchiveItem `json:"archived"` + Skipped []IdeaArchiveItem `json:"skipped"` } // IdeaArchiveItem describes one requested idea archive outcome. @@ -107,7 +137,14 @@ func ListIdeas(ctx context.Context, root project.Root, resolver PathResolver, op // ListIdeas returns imported ideas from an open store. func (s *Store) ListIdeas(ctx context.Context, root project.Root, options IdeaListOptions) (IdeaList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return IdeaList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IdeaList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT idea_alias.alias, @@ -128,7 +165,16 @@ ORDER BY idea_alias.alias return IdeaList{}, fmt.Errorf("query ideas: %w", err) } - ideas := IdeaList{Version: 1, Ideas: map[string]IdeaItem{}} + ideas := IdeaList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Ideas: map[string]IdeaItem{}, + } for rows.Next() { var alias, title, status, sourcePath string if err := rows.Scan(&alias, &title, &status, &sourcePath); err != nil { @@ -165,7 +211,14 @@ func CaptureIdea(ctx context.Context, root project.Root, resolver PathResolver, // CaptureIdea captures an idea in an open store. func (s *Store) CaptureIdea(ctx context.Context, root project.Root, options IdeaCaptureOptions) (IdeaCaptureResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return IdeaCaptureResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IdeaCaptureResult{}, err + } title := strings.TrimSpace(options.Title) if title == "" { return IdeaCaptureResult{}, fmt.Errorf("idea capture requires --title") @@ -209,8 +262,14 @@ VALUES (?, ?, 'idea', ?, 'status_changed', NULL, 'open', 'recorded by idea captu } return IdeaCaptureResult{ - Idea: TraceEntity{Kind: "idea", ID: ideaID, Alias: alias, Title: title, Status: "open"}, - EventID: eventID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Idea: TraceEntity{Kind: "idea", ID: ideaID, Alias: alias, Title: title, Status: "open"}, + EventID: eventID, }, nil } @@ -257,7 +316,14 @@ func ResolveIdea(ctx context.Context, root project.Root, resolver PathResolver, // ResolveIdea marks an idea resolved in an open store. func (s *Store) ResolveIdea(ctx context.Context, root project.Root, ideaRef string, byRef string) (IdeaResolveResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return IdeaResolveResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IdeaResolveResult{}, err + } idea, err := s.resolveTraceEntity(ctx, projectID, ideaRef) if err != nil { return IdeaResolveResult{}, err @@ -295,10 +361,11 @@ func (s *Store) ResolveIdea(ctx context.Context, root project.Root, ideaRef stri relationshipID := stableMigrationID("relationship", projectID, "idea", idea.ID, "resolved_by", target.Kind, target.ID) _, err = tx.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'command', ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, updated_at = excluded.updated_at `, relationshipID, projectID, "idea", idea.ID, target.Kind, target.ID, "resolved_by", "recorded by idea resolve", now, now) if err != nil { @@ -324,10 +391,16 @@ ON CONFLICT(id) DO NOTHING idea.Status = "resolved" return IdeaResolveResult{ - Idea: idea, - ResolvedBy: target, - Relationship: relationshipID, - EventID: eventID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Idea: idea, + ResolvedBy: target, + Relationship: relationshipID, + EventID: eventID, }, nil } @@ -343,7 +416,14 @@ func PromoteIdea(ctx context.Context, root project.Root, resolver PathResolver, // PromoteIdea records that an idea promoted to a spec in an open store. func (s *Store) PromoteIdea(ctx context.Context, root project.Root, options IdeaPromoteOptions) (IdeaPromoteResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return IdeaPromoteResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IdeaPromoteResult{}, err + } idea, err := s.resolveTraceEntity(ctx, projectID, options.Idea) if err != nil { return IdeaPromoteResult{}, err @@ -362,10 +442,11 @@ func (s *Store) PromoteIdea(ctx context.Context, root project.Root, options Idea now := time.Now().UTC().Format(time.RFC3339) relationshipID := stableMigrationID("relationship", projectID, "idea", idea.ID, "promoted_to", "spec", spec.ID) _, err = s.db.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'command', ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, updated_at = excluded.updated_at `, relationshipID, projectID, "idea", idea.ID, "spec", spec.ID, "promoted_to", "recorded by idea promote", now, now) if err != nil { @@ -373,9 +454,15 @@ ON CONFLICT(id) DO UPDATE SET } return IdeaPromoteResult{ - Idea: idea, - Spec: spec, - Relationship: relationshipID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Idea: idea, + Spec: spec, + Relationship: relationshipID, }, nil } @@ -394,10 +481,23 @@ func (s *Store) ArchiveIdeas(ctx context.Context, root project.Root, options Ide if len(options.Refs) == 0 { return IdeaArchiveResult{}, fmt.Errorf("idea archive requires at least one idea") } - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return IdeaArchiveResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IdeaArchiveResult{}, err + } result := IdeaArchiveResult{ - Archived: []IdeaArchiveItem{}, - Skipped: []IdeaArchiveItem{}, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Archived: []IdeaArchiveItem{}, + Skipped: []IdeaArchiveItem{}, } for _, ref := range options.Refs { item, archived, err := s.archiveIdea(ctx, projectID, ref, options.Reason) diff --git a/internal/state/idea_show.go b/internal/state/idea_show.go index cc18091f..7c1dbb75 100644 --- a/internal/state/idea_show.go +++ b/internal/state/idea_show.go @@ -12,8 +12,14 @@ import ( // IdeaShow is the state-backed single-idea read model. type IdeaShow struct { - Query string `json:"query"` - Idea IdeaDetail `json:"idea"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Idea IdeaDetail `json:"idea"` } // IdeaDetail contains operational idea metadata plus imported source context. @@ -41,7 +47,14 @@ func ShowIdea(ctx context.Context, root project.Root, resolver PathResolver, ref // ShowIdea returns one idea from an open store. func (s *Store) ShowIdea(ctx context.Context, root project.Root, ref string) (IdeaShow, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return IdeaShow{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return IdeaShow{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return IdeaShow{}, err @@ -54,7 +67,16 @@ func (s *Store) ShowIdea(ctx context.Context, root project.Root, ref string) (Id if err != nil { return IdeaShow{}, err } - return IdeaShow{Query: ref, Idea: idea}, nil + return IdeaShow{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Idea: idea, + }, nil } func (s *Store) ideaDetail(ctx context.Context, root project.Root, projectID string, entity TraceEntity) (IdeaDetail, error) { diff --git a/internal/state/idea_test.go b/internal/state/idea_test.go index 894fdda3..9cbc8ba9 100644 --- a/internal/state/idea_test.go +++ b/internal/state/idea_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "strings" "testing" @@ -34,6 +35,7 @@ status: open if _, ok := before.Ideas["20260528-sqlite-state"]; !ok { t.Fatalf("before.Ideas = %#v, want imported idea in default list", before.Ideas) } + assertIdeaProjectContext(t, root, before.ContractVersion, before.DatabaseScope, before.DatabasePath, before.ProjectID, before.ProjectName, before.ProjectCurrentPath) result, err := ResolveIdea(context.Background(), root, PathResolver{StateHome: stateHome}, "20260528-sqlite-state", "SPEC-001") if err != nil { @@ -42,6 +44,7 @@ status: open if result.Idea.Status != "resolved" || result.ResolvedBy.Alias != "SPEC-001" || result.Relationship == "" || result.EventID == "" { t.Fatalf("result = %#v, want resolved idea, SPEC-001 target, relationship, and event", result) } + assertIdeaProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) after, err := ListIdeas(context.Background(), root, PathResolver{StateHome: stateHome}, IdeaListOptions{}) if err != nil { @@ -57,6 +60,7 @@ status: open if all.Ideas["20260528-sqlite-state"].Status != "resolved" { t.Fatalf("all.Ideas = %#v, want resolved idea included with status", all.Ideas) } + assertIdeaProjectContext(t, root, all.ContractVersion, all.DatabaseScope, all.DatabasePath, all.ProjectID, all.ProjectName, all.ProjectCurrentPath) resolvedOnly, err := ListIdeas(context.Background(), root, PathResolver{StateHome: stateHome}, IdeaListOptions{Status: "resolved"}) if err != nil { t.Fatalf("ListIdeas(Status resolved) error = %v", err) @@ -83,7 +87,7 @@ status: open SELECT COUNT(*) FROM events WHERE project_id = ? AND entity_kind = 'idea' AND event_type = 'status_changed' AND from_status = 'open' AND to_status = 'resolved' -`, ProjectID(root)).Scan(&events) +`, projectIDForTest(t, store, root)).Scan(&events) if err != nil { t.Fatalf("count events error = %v", err) } @@ -143,6 +147,7 @@ Imported idea prose. if result.Query != "20260528-sqlite-state" { t.Fatalf("Query = %q, want 20260528-sqlite-state", result.Query) } + assertIdeaProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if idea.Alias != "20260528-sqlite-state" || idea.Title != "SQLite State" || idea.Status != "open" { t.Fatalf("Idea = %#v, want imported idea metadata", idea) } @@ -186,6 +191,7 @@ func TestShowIdeaReadsCapturedIdeaWithoutSource(t *testing.T) { if result.Idea.Alias != captured.Idea.Alias || result.Idea.Title != "Captured Idea" || result.Idea.Status != "open" { t.Fatalf("Idea = %#v, want captured idea metadata", result.Idea) } + assertIdeaProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if len(result.Idea.Sources) != 0 || result.Idea.Body != "" { t.Fatalf("Idea = %#v, want no source/body for captured idea", result.Idea) } @@ -251,6 +257,7 @@ status: open if result.Idea.Alias != "20260528-sqlite-state" || result.Idea.Status != "open" || result.Spec.Alias != "SPEC-001" || result.Relationship == "" { t.Fatalf("result = %#v, want open idea promoted to target spec with relationship", result) } + assertIdeaProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) ideas, err := ListIdeas(context.Background(), root, PathResolver{StateHome: stateHome}, IdeaListOptions{}) if err != nil { @@ -361,6 +368,7 @@ func TestCaptureIdeaCreatesOpenIdeaWithAliasAndEvent(t *testing.T) { if first.Idea.Status != "open" || first.Idea.Title != "Repeat Idea" || !strings.Contains(first.Idea.Alias, "repeat-idea") || !strings.HasPrefix(first.Idea.Alias, "IDEA-") || first.EventID == "" { t.Fatalf("first = %#v, want open idea with dated slug alias and event", first) } + assertIdeaProjectContext(t, root, first.ContractVersion, first.DatabaseScope, first.DatabasePath, first.ProjectID, first.ProjectName, first.ProjectCurrentPath) second, err := CaptureIdea(context.Background(), root, PathResolver{StateHome: stateHome}, IdeaCaptureOptions{Title: "Repeat Idea"}) if err != nil { t.Fatalf("CaptureIdea() second error = %v", err) @@ -376,6 +384,7 @@ func TestCaptureIdeaCreatesOpenIdeaWithAliasAndEvent(t *testing.T) { if ideas.Ideas[first.Idea.Alias].Status != "open" || ideas.Ideas[first.Idea.Alias].Title != "Repeat Idea" { t.Fatalf("ideas = %#v, want captured idea visible in default list", ideas.Ideas) } + assertIdeaProjectContext(t, root, ideas.ContractVersion, ideas.DatabaseScope, ideas.DatabasePath, ideas.ProjectID, ideas.ProjectName, ideas.ProjectCurrentPath) trace, err := Trace(context.Background(), root, PathResolver{StateHome: stateHome}, first.Idea.Alias) if err != nil { t.Fatalf("Trace() error = %v", err) @@ -394,7 +403,7 @@ func TestCaptureIdeaCreatesOpenIdeaWithAliasAndEvent(t *testing.T) { SELECT COUNT(*) FROM events WHERE project_id = ? AND entity_kind = 'idea' AND event_type = 'status_changed' AND from_status IS NULL AND to_status = 'open' -`, ProjectID(root)).Scan(&events) +`, projectIDForTest(t, store, root)).Scan(&events) if err != nil { t.Fatalf("count capture events error = %v", err) } @@ -438,6 +447,7 @@ status: archived if len(result.Archived) != 1 || result.Archived[0].Idea == nil || result.Archived[0].Idea.Alias != "20260528-open-idea" || result.Archived[0].Previous != "open" || result.Archived[0].EventID == "" || result.Archived[0].Note != "covered by SPEC-001" { t.Fatalf("Archived = %#v, want open idea archived with event", result.Archived) } + assertIdeaProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if len(result.Skipped) != 3 { t.Fatalf("Skipped = %#v, want already archived, wrong-kind, and missing refs", result.Skipped) } @@ -476,7 +486,7 @@ status: archived SELECT COUNT(*), COALESCE(MAX(note), '') FROM events WHERE project_id = ? AND entity_kind = 'idea' AND event_type = 'status_changed' AND from_status = 'open' AND to_status = 'archived' -`, ProjectID(root)).Scan(&events, ¬e) +`, projectIDForTest(t, store, root)).Scan(&events, ¬e) if err != nil { t.Fatalf("count archive events error = %v", err) } @@ -505,3 +515,25 @@ func hasStateTraceRelationship(relationships []TraceRelationship, direction stri } return false } + +func assertIdeaProjectContext(t *testing.T, root project.Root, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + t.Helper() + if contractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(root.Path())) + } + if projectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, root.Path()) + } +} diff --git a/internal/state/journal.go b/internal/state/journal.go index bee74b4c..2d80377c 100644 --- a/internal/state/journal.go +++ b/internal/state/journal.go @@ -24,15 +24,21 @@ type JournalLogOptions struct { // JournalLogResult is returned after a state-backed journal entry write. type JournalLogResult struct { - ID string `json:"id"` - EntryType string `json:"entry_type"` - Scope string `json:"scope,omitempty"` - Message string `json:"message"` - ObservedBranch string `json:"observed_branch,omitempty"` - ObservedWorktree string `json:"observed_worktree,omitempty"` - HarnessSessionID string `json:"harness_session_id,omitempty"` - Session *TraceEntity `json:"session,omitempty"` - NoopReason string `json:"noop_reason,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + ID string `json:"id"` + EntryType string `json:"entry_type"` + Scope string `json:"scope,omitempty"` + Message string `json:"message"` + ObservedBranch string `json:"observed_branch,omitempty"` + ObservedWorktree string `json:"observed_worktree,omitempty"` + HarnessSessionID string `json:"harness_session_id,omitempty"` + Session *TraceEntity `json:"session,omitempty"` + NoopReason string `json:"noop_reason,omitempty"` } // LogJournal writes a journal entry into initialized SQLite state. @@ -61,7 +67,14 @@ func (s *Store) LogJournal(ctx context.Context, root project.Root, options Journ return JournalLogResult{}, err } now := time.Now().UTC().Format(time.RFC3339Nano) - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return JournalLogResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return JournalLogResult{}, err + } var session sessionRow if options.LinkSession { tx, err := s.db.BeginTx(ctx, nil) @@ -86,13 +99,19 @@ func (s *Store) LogJournal(ctx context.Context, root project.Root, options Journ if session.ID == "" { if options.IfSessionActive { return JournalLogResult{ - EntryType: entryType, - Scope: scope, - Message: message, - ObservedBranch: options.ObservedBranch, - ObservedWorktree: options.ObservedWorktree, - HarnessSessionID: options.HarnessSessionID, - NoopReason: "no active session found", + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + EntryType: entryType, + Scope: scope, + Message: message, + ObservedBranch: options.ObservedBranch, + ObservedWorktree: options.ObservedWorktree, + HarnessSessionID: options.HarnessSessionID, + NoopReason: "no active session found", }, nil } return JournalLogResult{}, fmt.Errorf("no active session found") @@ -133,14 +152,20 @@ INSERT INTO journal_entries ( return JournalLogResult{}, fmt.Errorf("commit journal transaction: %w", err) } return JournalLogResult{ - ID: id, - EntryType: entryType, - Scope: scope, - Message: message, - ObservedBranch: options.ObservedBranch, - ObservedWorktree: options.ObservedWorktree, - HarnessSessionID: options.HarnessSessionID, - Session: &TraceEntity{Kind: "session", ID: session.ID, Alias: session.Alias, Status: session.Status}, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + ID: id, + EntryType: entryType, + Scope: scope, + Message: message, + ObservedBranch: options.ObservedBranch, + ObservedWorktree: options.ObservedWorktree, + HarnessSessionID: options.HarnessSessionID, + Session: &TraceEntity{Kind: "session", ID: session.ID, Alias: session.Alias, Status: session.Status}, }, nil } id := stableMigrationID("journal", projectID, now, entryType, scope, message) @@ -165,13 +190,19 @@ INSERT INTO journal_entries ( return JournalLogResult{}, fmt.Errorf("insert journal entry: %w", err) } return JournalLogResult{ - ID: id, - EntryType: entryType, - Scope: scope, - Message: message, - ObservedBranch: options.ObservedBranch, - ObservedWorktree: options.ObservedWorktree, - HarnessSessionID: options.HarnessSessionID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + ID: id, + EntryType: entryType, + Scope: scope, + Message: message, + ObservedBranch: options.ObservedBranch, + ObservedWorktree: options.ObservedWorktree, + HarnessSessionID: options.HarnessSessionID, }, nil } diff --git a/internal/state/journal_test.go b/internal/state/journal_test.go index 4da34e58..d5961a84 100644 --- a/internal/state/journal_test.go +++ b/internal/state/journal_test.go @@ -38,6 +38,7 @@ func TestLogJournalWritesEntryWithNullableUnresolvedContext(t *testing.T) { if result.EntryType != "decision" || result.Scope != "sqlite" || result.Message != "write to state first" { t.Fatalf("result = %#v, want parsed journal entry", result) } + assertSessionProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if result.ObservedBranch != "main" || result.ObservedWorktree != repo || result.HarnessSessionID != "harness-123" { t.Fatalf("result context = %#v, want observed context", result) } @@ -106,6 +107,7 @@ func TestLogJournalLinksHookEntryToHarnessSession(t *testing.T) { if result.Session == nil || result.Session.ID != start.Session.ID { t.Fatalf("result session = %#v, want linked session %s", result.Session, start.Session.ID) } + assertSessionProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) show, err := ShowSession(context.Background(), root, PathResolver{StateHome: stateHome}, start.Session.Alias) if err != nil { @@ -135,4 +137,5 @@ func TestLogJournalHookNoopsWhenNoActiveSessionExists(t *testing.T) { if result.ID != "" || result.NoopReason == "" { t.Fatalf("result = %#v, want noop without inserted journal", result) } + assertSessionProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) } diff --git a/internal/state/link.go b/internal/state/link.go index 3e301fe6..cc06de09 100644 --- a/internal/state/link.go +++ b/internal/state/link.go @@ -21,18 +21,30 @@ type LinkMutationOptions struct { // LinkMutationResult describes a relationship mutation. type LinkMutationResult struct { - RelationshipID string `json:"relationship_id"` - Type string `json:"type"` - Reason string `json:"reason,omitempty"` - From TraceEntity `json:"from"` - To TraceEntity `json:"to"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + RelationshipID string `json:"relationship_id"` + Type string `json:"type"` + Reason string `json:"reason,omitempty"` + From TraceEntity `json:"from"` + To TraceEntity `json:"to"` } // LinkListResult describes immediate relationships for one entity. type LinkListResult struct { - Query string `json:"query"` - Entity TraceEntity `json:"entity"` - Relationships []TraceRelationship `json:"relationships"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Entity TraceEntity `json:"entity"` + Relationships []TraceRelationship `json:"relationships"` } // CreateLink writes an explicit relationship in initialized SQLite state. @@ -67,7 +79,14 @@ func RemoveLink(ctx context.Context, root project.Root, resolver PathResolver, o // CreateLink writes an explicit relationship in an open store. func (s *Store) CreateLink(ctx context.Context, root project.Root, options LinkMutationOptions) (LinkMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return LinkMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return LinkMutationResult{}, err + } from, to, relationshipType, reason, err := s.resolveLinkOptions(ctx, projectID, options) if err != nil { return LinkMutationResult{}, err @@ -75,27 +94,41 @@ func (s *Store) CreateLink(ctx context.Context, root project.Root, options LinkM now := time.Now().UTC().Format(time.RFC3339) relationshipID := stableMigrationID("relationship", projectID, from.Kind, from.ID, relationshipType, to.Kind, to.ID) _, err = s.db.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, updated_at = excluded.updated_at -`, relationshipID, projectID, from.Kind, from.ID, to.Kind, to.ID, relationshipType, reason, now, now) +`, relationshipID, projectID, from.Kind, from.ID, to.Kind, to.ID, relationshipType, reason, "manual", now, now) if err != nil { return LinkMutationResult{}, fmt.Errorf("create link: %w", err) } return LinkMutationResult{ - RelationshipID: relationshipID, - Type: relationshipType, - Reason: reason, - From: from, - To: to, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + RelationshipID: relationshipID, + Type: relationshipType, + Reason: reason, + From: from, + To: to, }, nil } // ListLinks returns immediate relationships for one entity from an open store. func (s *Store) ListLinks(ctx context.Context, root project.Root, ref string) (LinkListResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return LinkListResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return LinkListResult{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return LinkListResult{}, err @@ -105,15 +138,28 @@ func (s *Store) ListLinks(ctx context.Context, root project.Root, ref string) (L return LinkListResult{}, err } return LinkListResult{ - Query: ref, - Entity: entity, - Relationships: relationships, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Entity: entity, + Relationships: relationships, }, nil } // RemoveLink removes one explicit relationship from an open store. func (s *Store) RemoveLink(ctx context.Context, root project.Root, options LinkMutationOptions) (LinkMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return LinkMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return LinkMutationResult{}, err + } from, to, relationshipType, _, err := s.resolveLinkOptions(ctx, projectID, options) if err != nil { return LinkMutationResult{}, err @@ -142,11 +188,17 @@ WHERE id = ? AND project_id = ? return LinkMutationResult{}, fmt.Errorf("link %s %q -> %s %q with type %q not found", from.Kind, firstNonEmpty(from.Alias, from.ID), to.Kind, firstNonEmpty(to.Alias, to.ID), relationshipType) } return LinkMutationResult{ - RelationshipID: relationshipID, - Type: relationshipType, - Reason: reason.String, - From: from, - To: to, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + RelationshipID: relationshipID, + Type: relationshipType, + Reason: reason.String, + From: from, + To: to, }, nil } diff --git a/internal/state/link_test.go b/internal/state/link_test.go index cdc66ba8..8078d83d 100644 --- a/internal/state/link_test.go +++ b/internal/state/link_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" "github.com/levifig/loaf/internal/project" @@ -34,6 +35,7 @@ func TestLinksCreateListRemoveAndTraceRelationships(t *testing.T) { if created.Type != "resolved_by" || created.From.Alias != "20260528-link-idea" || created.To.Alias != "SPEC-001" || created.Reason != "captured in task test" { t.Fatalf("created = %#v, want idea resolved_by SPEC-001", created) } + assertLinkMutationContext(t, created, root) updated, err := CreateLink(context.Background(), root, PathResolver{StateHome: stateHome}, LinkMutationOptions{ From: "20260528-link-idea", @@ -62,7 +64,7 @@ func TestLinksCreateListRemoveAndTraceRelationships(t *testing.T) { } defer store.Close() var relationshipRows int - if err := store.db.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM relationships WHERE project_id = ?`, ProjectID(root)).Scan(&relationshipRows); err != nil { + if err := store.db.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM relationships WHERE project_id = ?`, projectIDForTest(t, store, root)).Scan(&relationshipRows); err != nil { t.Fatalf("count relationships error = %v", err) } if relationshipRows != 2 { @@ -76,6 +78,7 @@ func TestLinksCreateListRemoveAndTraceRelationships(t *testing.T) { if len(list.Relationships) != 2 { t.Fatalf("relationships = %#v, want two inbound links", list.Relationships) } + assertLinkListContext(t, list, root) if !hasStateTraceRelationship(list.Relationships, "inbound", "resolved_by", "idea", "20260528-link-idea") { t.Fatalf("relationships = %#v, want inbound idea resolution", list.Relationships) } @@ -102,6 +105,7 @@ func TestLinksCreateListRemoveAndTraceRelationships(t *testing.T) { if removed.Reason != "updated reason" { t.Fatalf("removed.Reason = %q, want stored reason", removed.Reason) } + assertLinkMutationContext(t, removed, root) trace, err = Trace(context.Background(), root, PathResolver{StateHome: stateHome}, "20260528-link-idea") if err != nil { t.Fatalf("Trace(idea) after remove error = %v", err) @@ -111,6 +115,50 @@ func TestLinksCreateListRemoveAndTraceRelationships(t *testing.T) { } } +func assertLinkMutationContext(t *testing.T, result LinkMutationResult, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + +func assertLinkListContext(t *testing.T, result LinkListResult, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + func TestRemoveLinkRejectsMissingRelationship(t *testing.T) { repo := initGitRepo(t) root, err := project.ResolveRoot(repo) diff --git a/internal/state/markdown_import.go b/internal/state/markdown_import.go index deeffa3f..b92874a9 100644 --- a/internal/state/markdown_import.go +++ b/internal/state/markdown_import.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -19,10 +20,21 @@ import ( // MarkdownMigrationResult is the structured result for a markdown migration apply. type MarkdownMigrationResult struct { MarkdownMigrationPlan - DatabasePath string `json:"database_path"` - Applied bool `json:"applied"` + DatabaseScope string `json:"database_scope"` + ImportScope string `json:"import_scope"` + DatabasePath string `json:"database_path"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + Action string `json:"action"` + Applied bool `json:"applied"` } +const ( + MarkdownMigrationActionApply = "apply" + MarkdownMigrationActionResume = "resume" +) + type taskIndexEntry struct { Title string `json:"title"` Spec string `json:"spec"` @@ -66,13 +78,23 @@ func ApplyMarkdownMigration(ctx context.Context, root project.Root, resolver Pat } return MarkdownMigrationResult{ MarkdownMigrationPlan: plan, + DatabaseScope: "global", + ImportScope: "project", DatabasePath: status.DatabasePath, + ProjectID: status.ProjectID, + ProjectName: status.ProjectName, + ProjectCurrentPath: status.ProjectCurrentPath, + Action: MarkdownMigrationActionApply, Applied: true, }, nil } // ImportMarkdown imports .agents artifacts into an initialized state database. func (s *Store) ImportMarkdown(ctx context.Context, root project.Root) error { + projectID, err := s.projectID(ctx, root) + if err != nil { + return err + } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("begin markdown import transaction: %w", err) @@ -82,7 +104,7 @@ func (s *Store) ImportMarkdown(ctx context.Context, root project.Root) error { importer := markdownImporter{ tx: tx, root: root, - projectID: ProjectID(root), + projectID: projectID, now: time.Now().UTC().Format(time.RFC3339), taskIndex: loadTaskIndex(root.Path()), sparkAliases: map[string]string{}, @@ -157,6 +179,9 @@ func (m markdownImporter) importSpecs(ctx context.Context, agentsPath string) er if err := m.upsertAlias(ctx, "spec", id, "spec", alias); err != nil { return err } + if err := m.deleteImportedRelationships(ctx, "spec", id); err != nil { + return err + } if err := m.importArtifactRelationships(ctx, "spec", id, artifact); err != nil { return err } @@ -201,6 +226,9 @@ func (m markdownImporter) importTasks(ctx context.Context, agentsPath string) er if err := m.upsertAlias(ctx, "task", id, "task", alias); err != nil { return err } + if err := m.deleteImportedRelationships(ctx, "task", id); err != nil { + return err + } if specAlias != "" { toID := stableMigrationID("spec", m.projectID, specAlias) if err := m.upsertRelationship(ctx, "task", id, "spec", toID, "implements", "imported from task metadata"); err != nil { @@ -251,6 +279,9 @@ func (m markdownImporter) importSimpleMarkdown(ctx context.Context, agentsPath s if err := m.upsertAlias(ctx, kind, id, kind, alias); err != nil { return err } + if err := m.deleteImportedRelationships(ctx, kind, id); err != nil { + return err + } if err := m.importArtifactRelationships(ctx, kind, id, artifact); err != nil { return err } @@ -285,6 +316,9 @@ func (m markdownImporter) importShapingDrafts(ctx context.Context, agentsPath st if err := m.upsertAlias(ctx, "shaping_draft", id, "shaping_draft", alias); err != nil { return err } + if err := m.deleteImportedRelationships(ctx, "shaping_draft", id); err != nil { + return err + } if err := m.importArtifactRelationships(ctx, "shaping_draft", id, artifact); err != nil { return err } @@ -359,6 +393,9 @@ func (m markdownImporter) importReports(ctx context.Context, agentsPath string) if err := m.upsertAlias(ctx, "report", id, "report", alias); err != nil { return err } + if err := m.deleteImportedRelationships(ctx, "report", id); err != nil { + return err + } if err := m.importArtifactRelationships(ctx, "report", id, artifact); err != nil { return err } @@ -368,7 +405,7 @@ func (m markdownImporter) importReports(ctx context.Context, agentsPath string) func (m markdownImporter) importJSONSource(ctx context.Context, path string) error { artifact, err := readSourceArtifact(m.root.Path(), path) - if os.IsNotExist(err) { + if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { @@ -665,18 +702,35 @@ ON CONFLICT(id) DO UPDATE SET func (m markdownImporter) upsertRelationship(ctx context.Context, fromKind string, fromID string, toKind string, toID string, relationshipType string, reason string) error { id := stableMigrationID("relationship", m.projectID, fromKind, fromID, relationshipType, toKind, toID) _, err := m.tx.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, source_id, source_field, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, + source_id = excluded.source_id, + source_field = excluded.source_field, updated_at = excluded.updated_at -`, id, m.projectID, fromKind, fromID, toKind, toID, relationshipType, reason, m.now, m.now) +`, id, m.projectID, fromKind, fromID, toKind, toID, relationshipType, reason, "imported", nil, relationshipType, m.now, m.now) if err != nil { return fmt.Errorf("upsert relationship %s: %w", id, err) } return nil } +func (m markdownImporter) deleteImportedRelationships(ctx context.Context, fromKind string, fromID string) error { + _, err := m.tx.ExecContext(ctx, ` +DELETE FROM relationships +WHERE project_id = ? + AND from_entity_kind = ? + AND from_entity_id = ? + AND (origin = 'imported' OR reason LIKE 'imported from %') +`, m.projectID, fromKind, fromID) + if err != nil { + return fmt.Errorf("delete imported relationships for %s %s: %w", fromKind, fromID, err) + } + return nil +} + func (m markdownImporter) upsertAlias(ctx context.Context, entityKind string, entityID string, namespace string, alias string) error { id := stableMigrationID("alias", m.projectID, namespace, alias) _, err := m.tx.ExecContext(ctx, ` @@ -820,16 +874,7 @@ func taskDependencies(meta taskIndexEntry, frontmatterDependsOn string) []string if len(meta.DependsOn) > 0 { return meta.DependsOn } - if frontmatterDependsOn == "" { - return nil - } - var dependencies []string - for _, dependency := range strings.Split(frontmatterDependsOn, ",") { - if trimmed := strings.TrimSpace(dependency); trimmed != "" { - dependencies = append(dependencies, trimmed) - } - } - return dependencies + return splitFrontmatterList(frontmatterDependsOn) } func stableMigrationID(parts ...string) string { @@ -889,17 +934,24 @@ func relationshipAliasAndKind(value string) (string, string, bool) { } func splitFrontmatterList(value string) []string { - if value == "" { + value = strings.TrimSpace(value) + if value == "" || value == "[]" { return nil } + if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]")) + if value == "" { + return nil + } + } separator := "," if strings.Contains(value, frontmatterListSeparator) { separator = frontmatterListSeparator } var values []string for _, part := range strings.Split(value, separator) { - trimmed := strings.TrimSpace(part) - if trimmed != "" { + trimmed := strings.Trim(strings.TrimSpace(part), `"'`) + if trimmed != "" && trimmed != "[]" { values = append(values, trimmed) } } diff --git a/internal/state/markdown_import_test.go b/internal/state/markdown_import_test.go index 3ca6398e..12095869 100644 --- a/internal/state/markdown_import_test.go +++ b/internal/state/markdown_import_test.go @@ -22,12 +22,30 @@ func TestApplyMarkdownMigrationImportsArtifactsAndPreservesSources(t *testing.T) t.Fatalf("ApplyMarkdownMigration() error = %v", err) } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } if !result.Applied { t.Fatal("Applied = false, want true") } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.ImportScope != "project" { + t.Fatalf("ImportScope = %q, want project", result.ImportScope) + } if result.DatabasePath == "" { t.Fatal("DatabasePath is empty") } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName == "" { + t.Fatal("ProjectName is empty") + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } if _, err := os.Stat(result.DatabasePath); err != nil { t.Fatalf("database was not created: %v", err) } @@ -88,6 +106,42 @@ func TestApplyMarkdownMigrationImportsArtifactsAndPreservesSources(t *testing.T) assertTableCount(t, store, "relationships", 2) } +func TestApplyMarkdownMigrationDoesNotRequireTasksJSON(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + writeAgentsFile(t, root.Path(), "tasks/TASK-001-markdown-only.md", `--- +id: TASK-001 +title: Markdown Only Task +status: todo +priority: P2 +depends_on: [] +--- + +# Markdown Only Task +`) + + result, err := ApplyMarkdownMigration(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("ApplyMarkdownMigration() error = %v", err) + } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if !result.Applied || result.Tasks != 1 { + t.Fatalf("result = %#v, want one applied markdown task", result) + } + + store, err := OpenStore(result.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + assertTableCount(t, store, "tasks", 1) + assertTableCount(t, store, "sources", 1) + assertTableCount(t, store, "relationships", 0) +} + func TestFrontmatterListItemsPreserveCommas(t *testing.T) { frontmatter := parseFrontmatterMap([]byte(`--- implements: diff --git a/internal/state/markdown_migration.go b/internal/state/markdown_migration.go index ff2316fb..89e3dc89 100644 --- a/internal/state/markdown_migration.go +++ b/internal/state/markdown_migration.go @@ -13,27 +13,55 @@ import ( // MarkdownMigrationPlan is the read-only preview for importing .agents files. type MarkdownMigrationPlan struct { - AgentsPath string `json:"agents_path"` - Specs int `json:"specs"` - Tasks int `json:"tasks"` - Ideas int `json:"ideas"` - Sparks int `json:"sparks"` - Brainstorms int `json:"brainstorms"` - ShapingDrafts int `json:"shaping_drafts"` - Sessions int `json:"sessions"` - Reports int `json:"reports"` - Relationships int `json:"relationships"` - SkippedFiles []string `json:"skipped_files"` - Warnings []string `json:"warnings"` + ContractVersion int `json:"contract_version"` + AgentsPath string `json:"agents_path"` + Specs int `json:"specs"` + Tasks int `json:"tasks"` + Ideas int `json:"ideas"` + Sparks int `json:"sparks"` + Brainstorms int `json:"brainstorms"` + ShapingDrafts int `json:"shaping_drafts"` + Sessions int `json:"sessions"` + Reports int `json:"reports"` + Relationships int `json:"relationships"` + SkippedFiles []string `json:"skipped_files"` + Warnings []string `json:"warnings"` +} + +// MarkdownMigrationPreviewResult is the CLI-facing dry-run envelope for a +// Markdown import preview. It does not imply initialized SQLite state. +type MarkdownMigrationPreviewResult struct { + MarkdownMigrationPlan + DatabaseScope string `json:"database_scope"` + ImportScope string `json:"import_scope"` + DatabasePath string `json:"database_path"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + Applied bool `json:"applied"` +} + +// NewMarkdownMigrationPreviewResult adds global DB and project context to a +// read-only Markdown migration preview without creating SQLite state. +func NewMarkdownMigrationPreviewResult(plan MarkdownMigrationPlan, root project.Root, databasePath string) MarkdownMigrationPreviewResult { + return MarkdownMigrationPreviewResult{ + MarkdownMigrationPlan: plan, + DatabaseScope: "global", + ImportScope: "project", + DatabasePath: databasePath, + ProjectName: filepath.Base(root.Path()), + ProjectCurrentPath: root.Path(), + Applied: false, + } } // PreviewMarkdownMigration inspects .agents without mutating files or SQLite state. func PreviewMarkdownMigration(root project.Root) (MarkdownMigrationPlan, error) { agentsPath := filepath.Join(root.Path(), ".agents") plan := MarkdownMigrationPlan{ - AgentsPath: agentsPath, - SkippedFiles: []string{}, - Warnings: []string{}, + ContractVersion: StateJSONContractVersion, + AgentsPath: agentsPath, + SkippedFiles: []string{}, + Warnings: []string{}, } info, err := os.Stat(agentsPath) diff --git a/internal/state/markdown_migration_test.go b/internal/state/markdown_migration_test.go index 64da9471..33420553 100644 --- a/internal/state/markdown_migration_test.go +++ b/internal/state/markdown_migration_test.go @@ -32,6 +32,9 @@ func TestPreviewMarkdownMigrationCountsAgentsArtifacts(t *testing.T) { t.Fatalf("PreviewMarkdownMigration() error = %v", err) } + if plan.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", plan.ContractVersion, StateJSONContractVersion) + } if plan.AgentsPath != filepath.Join(root.Path(), ".agents") { t.Fatalf("AgentsPath = %q, want project .agents", plan.AgentsPath) } diff --git a/internal/state/migrations/0003_project_identity_and_relationship_origin.sql b/internal/state/migrations/0003_project_identity_and_relationship_origin.sql new file mode 100644 index 00000000..075fec51 --- /dev/null +++ b/internal/state/migrations/0003_project_identity_and_relationship_origin.sql @@ -0,0 +1,24 @@ +ALTER TABLE projects ADD COLUMN friendly_name TEXT; +ALTER TABLE projects ADD COLUMN current_path TEXT; +ALTER TABLE projects ADD COLUMN last_seen_at TEXT; + +CREATE TABLE IF NOT EXISTS project_paths ( + id TEXT PRIMARY KEY NOT NULL, + project_id TEXT NOT NULL, + path TEXT NOT NULL, + is_current INTEGER NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (project_id) REFERENCES projects(id), + UNIQUE (path) +); + +CREATE INDEX IF NOT EXISTS idx_project_paths_project_current ON project_paths (project_id, is_current); + +ALTER TABLE relationships ADD COLUMN origin TEXT; +ALTER TABLE relationships ADD COLUMN source_id TEXT; +ALTER TABLE relationships ADD COLUMN source_field TEXT; + +CREATE INDEX IF NOT EXISTS idx_relationships_origin ON relationships (project_id, origin); diff --git a/internal/state/migrations/0004_project_path_current_uniqueness.sql b/internal/state/migrations/0004_project_path_current_uniqueness.sql new file mode 100644 index 00000000..c3ce338e --- /dev/null +++ b/internal/state/migrations/0004_project_path_current_uniqueness.sql @@ -0,0 +1 @@ +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_paths_one_current ON project_paths (project_id) WHERE is_current = 1; diff --git a/internal/state/path.go b/internal/state/path.go index f5432d4e..8f45d3ed 100644 --- a/internal/state/path.go +++ b/internal/state/path.go @@ -20,8 +20,21 @@ type PathResolver struct { StateHome string } -// DatabasePath returns the intended SQLite database path for a project root. +// DatabasePath returns the intended global SQLite database path. func (r PathResolver) DatabasePath(root project.Root) (string, error) { + dataHome, err := r.dataHome() + if err != nil { + return "", err + } + if isWithinRoot(dataHome, root.Path()) { + return "", fmt.Errorf("data home must be outside project root") + } + return filepath.Join(dataHome, "loaf", databaseFileName), nil +} + +// ProjectDatabasePath returns the prior project-sharded XDG_DATA_HOME SQLite +// location used before Loaf converged on one global database file. +func (r PathResolver) ProjectDatabasePath(root project.Root) (string, error) { dataHome, err := r.dataHome() if err != nil { return "", err diff --git a/internal/state/path_test.go b/internal/state/path_test.go index 20d63101..3ed3b6b2 100644 --- a/internal/state/path_test.go +++ b/internal/state/path_test.go @@ -24,7 +24,7 @@ func TestDatabasePathUsesStateHomeAndStaysOutsideRepository(t *testing.T) { t.Fatalf("DatabasePath() error = %v", err) } - if !strings.HasPrefix(got, filepath.Join(stateHome, "loaf", "projects")+string(filepath.Separator)) { + if got != filepath.Join(stateHome, "loaf", databaseFileName) { t.Fatalf("DatabasePath() = %q, want under state home %q", got, stateHome) } if strings.HasPrefix(got, repo+string(filepath.Separator)) { @@ -93,6 +93,35 @@ func TestDatabasePathNonGitFallbackIsDeterministicForCurrentDirectory(t *testing } } +func TestDatabasePathIsSingleGlobalDatabaseAcrossProjects(t *testing.T) { + firstDir := t.TempDir() + secondDir := t.TempDir() + stateHome := t.TempDir() + + firstRoot, err := project.ResolveRoot(firstDir) + if err != nil { + t.Fatalf("ResolveRoot(first) error = %v", err) + } + secondRoot, err := project.ResolveRoot(secondDir) + if err != nil { + t.Fatalf("ResolveRoot(second) error = %v", err) + } + + resolver := PathResolver{StateHome: stateHome} + first, err := resolver.DatabasePath(firstRoot) + if err != nil { + t.Fatalf("DatabasePath(first) error = %v", err) + } + second, err := resolver.DatabasePath(secondRoot) + if err != nil { + t.Fatalf("DatabasePath(second) error = %v", err) + } + + if first != second { + t.Fatalf("DatabasePath() = %q and %q, want one global database", first, second) + } +} + func TestPathResolverUsesXDGDataHome(t *testing.T) { dir := t.TempDir() dataHome := t.TempDir() diff --git a/internal/state/project_identity.go b/internal/state/project_identity.go new file mode 100644 index 00000000..60ed88de --- /dev/null +++ b/internal/state/project_identity.go @@ -0,0 +1,511 @@ +package state + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/levifig/loaf/internal/project" +) + +// ProjectIdentity is the durable database identity for one working project. +// ID is intentionally independent of the current path and friendly name. +type ProjectIdentity struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + ID string `json:"id"` + FriendlyName string `json:"friendly_name"` + CurrentPath string `json:"current_path"` + LastSeenAt string `json:"last_seen_at"` + DatabasePath string `json:"database_path,omitempty"` +} + +// ProjectList is the global project identity index. +type ProjectList struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + DatabasePath string `json:"database_path"` + Projects []ProjectIdentity `json:"projects"` +} + +// ProjectMoveResult describes a safe path remapping for a project. +type ProjectMoveResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + Project ProjectIdentity `json:"project"` + FromPath string `json:"from_path"` + ToPath string `json:"to_path"` + Action string `json:"action"` +} + +// ProjectRenameResult describes a friendly-name change preview. +type ProjectRenameResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + Project ProjectIdentity `json:"project"` + FromName string `json:"from_name"` + ToName string `json:"to_name"` + Action string `json:"action"` +} + +// ProjectIdentityForRoot returns the DB-backed project identity. +// Writable stores refresh current path metadata; read-only stores only look up +// an existing mapping. +func (s *Store) ProjectIdentityForRoot(ctx context.Context, root project.Root) (ProjectIdentity, error) { + if s.readOnly { + return s.LookupProjectIdentityForRoot(ctx, root) + } + return s.EnsureProject(ctx, root) +} + +// LookupProjectIdentityForRoot reads project identity without refreshing paths. +func (s *Store) LookupProjectIdentityForRoot(ctx context.Context, root project.Root) (ProjectIdentity, error) { + projectID, err := s.projectIDByPath(ctx, root.Path()) + if err != nil { + return ProjectIdentity{}, err + } + if projectID == "" { + return ProjectIdentity{}, sql.ErrNoRows + } + return s.projectIdentity(ctx, projectID) +} + +// ListProjects returns all durable project identities without refreshing any path. +func (s *Store) ListProjects(ctx context.Context) (ProjectList, error) { + rows, err := s.db.QueryContext(ctx, ` +SELECT + projects.id, + COALESCE(NULLIF(projects.friendly_name, ''), projects.id), + COALESCE(current_path.path, projects.current_path, ''), + COALESCE(projects.last_seen_at, '') +FROM projects +LEFT JOIN project_paths AS current_path + ON current_path.project_id = projects.id + AND current_path.is_current = 1 +ORDER BY lower(COALESCE(NULLIF(projects.friendly_name, ''), projects.id)), projects.id +`) + if err != nil { + return ProjectList{}, fmt.Errorf("list projects: %w", err) + } + defer rows.Close() + + list := ProjectList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: "global", + DatabasePath: s.path, + Projects: []ProjectIdentity{}, + } + for rows.Next() { + identity := ProjectIdentity{ContractVersion: StateJSONContractVersion, DatabaseScope: "global"} + if err := rows.Scan(&identity.ID, &identity.FriendlyName, &identity.CurrentPath, &identity.LastSeenAt); err != nil { + return ProjectList{}, fmt.Errorf("scan project identity: %w", err) + } + identity.DatabasePath = s.path + list.Projects = append(list.Projects, identity) + } + if err := rows.Err(); err != nil { + return ProjectList{}, fmt.Errorf("iterate project identities: %w", err) + } + return list, nil +} + +// EnsureProject records the current path and returns the durable project identity. +func (s *Store) EnsureProject(ctx context.Context, root project.Root) (ProjectIdentity, error) { + currentPath := root.Path() + now := time.Now().UTC().Format(time.RFC3339) + friendlyName := defaultProjectFriendlyName(currentPath) + + projectID, err := s.projectIDByPath(ctx, currentPath) + if err != nil { + return ProjectIdentity{}, err + } + if projectID == "" { + legacyID := ProjectID(root) + if exists, err := s.projectExists(ctx, legacyID); err != nil { + return ProjectIdentity{}, err + } else if exists { + projectID, err = s.rekeyLegacyProject(ctx, legacyID, currentPath, friendlyName, now) + if err != nil { + return ProjectIdentity{}, err + } + } + } else if projectID == ProjectID(root) { + projectID, err = s.rekeyLegacyProject(ctx, projectID, currentPath, friendlyName, now) + if err != nil { + return ProjectIdentity{}, err + } + } + if projectID == "" { + projectID, err = newProjectID() + if err != nil { + return ProjectIdentity{}, err + } + if _, err := s.db.ExecContext(ctx, ` +INSERT INTO projects (id, identity_hash, friendly_name, current_path, last_seen_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +`, projectID, projectID, friendlyName, currentPath, now, now, now); err != nil { + return ProjectIdentity{}, fmt.Errorf("create project identity: %w", err) + } + } else { + if _, err := s.db.ExecContext(ctx, ` +UPDATE projects +SET friendly_name = COALESCE(NULLIF(friendly_name, ''), ?), + current_path = ?, + last_seen_at = ?, + updated_at = ? +WHERE id = ? +`, friendlyName, currentPath, now, now, projectID); err != nil { + return ProjectIdentity{}, fmt.Errorf("refresh project identity: %w", err) + } + } + + if err := s.markCurrentProjectPath(ctx, projectID, currentPath, now); err != nil { + return ProjectIdentity{}, err + } + return s.projectIdentity(ctx, projectID) +} + +// UpsertProject records the project identity row after migrations are applied. +func (s *Store) UpsertProject(ctx context.Context, root project.Root) error { + if _, err := s.EnsureProject(ctx, root); err != nil { + if isMissingProjectIdentitySchema(err) { + return s.upsertLegacyProject(ctx, root) + } + return err + } + return nil +} + +// RenameProject updates the human-friendly project name without changing identity. +func (s *Store) RenameProject(ctx context.Context, root project.Root, friendlyName string) (ProjectIdentity, error) { + friendlyName = strings.TrimSpace(friendlyName) + if friendlyName == "" { + return ProjectIdentity{}, fmt.Errorf("project name cannot be empty") + } + identity, err := s.LookupProjectIdentityForRoot(ctx, root) + if err != nil { + return ProjectIdentity{}, err + } + now := time.Now().UTC().Format(time.RFC3339) + if _, err := s.db.ExecContext(ctx, ` +UPDATE projects +SET friendly_name = ?, updated_at = ? +WHERE id = ? +`, friendlyName, now, identity.ID); err != nil { + return ProjectIdentity{}, fmt.Errorf("rename project: %w", err) + } + return s.projectIdentity(ctx, identity.ID) +} + +// PreviewRenameProject validates a friendly-name change without mutating project identity rows. +func (s *Store) PreviewRenameProject(ctx context.Context, root project.Root, friendlyName string) (ProjectRenameResult, error) { + friendlyName = strings.TrimSpace(friendlyName) + if friendlyName == "" { + return ProjectRenameResult{}, fmt.Errorf("project name cannot be empty") + } + identity, err := s.LookupProjectIdentityForRoot(ctx, root) + if err != nil { + return ProjectRenameResult{}, err + } + fromName := identity.FriendlyName + identity.FriendlyName = friendlyName + return ProjectRenameResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + Project: identity, + FromName: fromName, + ToName: friendlyName, + Action: "dry-run", + }, nil +} + +// MoveProject changes the current path mapping with safeguards against collisions. +func (s *Store) MoveProject(ctx context.Context, root project.Root, fromPath string, toPath string) (ProjectMoveResult, error) { + return s.moveProject(ctx, root, fromPath, toPath, false) +} + +// PreviewMoveProject validates a path remapping without mutating project identity rows. +func (s *Store) PreviewMoveProject(ctx context.Context, root project.Root, fromPath string, toPath string) (ProjectMoveResult, error) { + return s.moveProject(ctx, root, fromPath, toPath, true) +} + +func (s *Store) moveProject(ctx context.Context, root project.Root, fromPath string, toPath string, dryRun bool) (ProjectMoveResult, error) { + fromPath = filepath.Clean(fromPath) + toPath = filepath.Clean(toPath) + if fromPath == "." || toPath == "." || fromPath == "" || toPath == "" { + return ProjectMoveResult{}, fmt.Errorf("project move requires absolute --from and --to paths") + } + if !filepath.IsAbs(fromPath) || !filepath.IsAbs(toPath) { + return ProjectMoveResult{}, fmt.Errorf("project move requires absolute --from and --to paths") + } + if fromPath == toPath { + return ProjectMoveResult{}, fmt.Errorf("project move requires distinct --from and --to paths") + } + if info, err := os.Stat(toPath); err != nil { + if errors.Is(err, os.ErrNotExist) { + return ProjectMoveResult{}, fmt.Errorf("project move target path does not exist: %s", toPath) + } + return ProjectMoveResult{}, fmt.Errorf("inspect project move target path %s: %w", toPath, err) + } else if !info.IsDir() { + return ProjectMoveResult{}, fmt.Errorf("project move target path is not a directory: %s", toPath) + } + + projectID, err := s.projectIDByPath(ctx, fromPath) + if err != nil { + return ProjectMoveResult{}, err + } + if projectID == "" { + if fromPath == root.Path() { + current, err := s.LookupProjectIdentityForRoot(ctx, root) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return ProjectMoveResult{}, err + } + if err == nil && current.CurrentPath == fromPath { + projectID = current.ID + } + } + if projectID == "" { + return ProjectMoveResult{}, fmt.Errorf("project path %s is not registered; run from the old checkout or initialize it first", fromPath) + } + } + + existingProjectID, err := s.projectIDByPath(ctx, toPath) + if err != nil { + return ProjectMoveResult{}, err + } + if existingProjectID != "" && existingProjectID != projectID { + return ProjectMoveResult{}, fmt.Errorf("target path %s is already registered to project %s", toPath, existingProjectID) + } + + if dryRun { + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ProjectMoveResult{}, err + } + identity.CurrentPath = toPath + return ProjectMoveResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + Project: identity, + FromPath: fromPath, + ToPath: toPath, + Action: "dry-run", + }, nil + } + + now := time.Now().UTC().Format(time.RFC3339) + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return ProjectMoveResult{}, fmt.Errorf("begin project move: %w", err) + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, ` +UPDATE project_paths +SET is_current = 0, updated_at = ? +WHERE project_id = ? +`, now, projectID); err != nil { + return ProjectMoveResult{}, fmt.Errorf("clear current project paths: %w", err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO project_paths (id, project_id, path, is_current, first_seen_at, last_seen_at, created_at, updated_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?) +ON CONFLICT(path) DO UPDATE SET + project_id = excluded.project_id, + is_current = excluded.is_current, + last_seen_at = excluded.last_seen_at, + updated_at = excluded.updated_at +`, stableMigrationID("project-path", projectID, toPath), projectID, toPath, now, now, now, now); err != nil { + return ProjectMoveResult{}, fmt.Errorf("record target project path: %w", err) + } + if _, err := tx.ExecContext(ctx, ` +UPDATE projects +SET current_path = ?, last_seen_at = ?, updated_at = ? +WHERE id = ? +`, toPath, now, now, projectID); err != nil { + return ProjectMoveResult{}, fmt.Errorf("update project current path: %w", err) + } + if err := tx.Commit(); err != nil { + return ProjectMoveResult{}, fmt.Errorf("commit project move: %w", err) + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ProjectMoveResult{}, err + } + return ProjectMoveResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + Project: identity, + FromPath: fromPath, + ToPath: toPath, + Action: "moved", + }, nil +} + +func (s *Store) projectID(ctx context.Context, root project.Root) (string, error) { + identity, err := s.ProjectIdentityForRoot(ctx, root) + if err != nil { + return "", err + } + return identity.ID, nil +} + +func (s *Store) projectIdentity(ctx context.Context, projectID string) (ProjectIdentity, error) { + identity := ProjectIdentity{ContractVersion: StateJSONContractVersion, DatabaseScope: "global"} + var friendlyName sql.NullString + var currentPath sql.NullString + var lastSeenAt sql.NullString + err := s.db.QueryRowContext(ctx, ` +SELECT id, friendly_name, current_path, last_seen_at +FROM projects +WHERE id = ? +`, projectID).Scan(&identity.ID, &friendlyName, ¤tPath, &lastSeenAt) + if err != nil { + return ProjectIdentity{}, fmt.Errorf("read project identity: %w", err) + } + identity.FriendlyName = friendlyName.String + identity.CurrentPath = currentPath.String + identity.LastSeenAt = lastSeenAt.String + identity.DatabasePath = s.path + return identity, nil +} + +func (s *Store) projectIDByPath(ctx context.Context, path string) (string, error) { + var projectID string + err := s.db.QueryRowContext(ctx, `SELECT project_id FROM project_paths WHERE path = ?`, path).Scan(&projectID) + switch { + case err == nil: + return projectID, nil + case errors.Is(err, sql.ErrNoRows): + return "", nil + default: + return "", fmt.Errorf("read project path mapping: %w", err) + } +} + +func (s *Store) projectExists(ctx context.Context, projectID string) (bool, error) { + var count int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM projects WHERE id = ?`, projectID).Scan(&count); err != nil { + return false, fmt.Errorf("read project row: %w", err) + } + return count > 0, nil +} + +func (s *Store) markCurrentProjectPath(ctx context.Context, projectID string, path string, now string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin project path refresh: %w", err) + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, ` +UPDATE project_paths +SET is_current = 0, updated_at = ? +WHERE project_id = ? AND path <> ? +`, now, projectID, path); err != nil { + return fmt.Errorf("clear stale current paths: %w", err) + } + if _, err := tx.ExecContext(ctx, ` +INSERT INTO project_paths (id, project_id, path, is_current, first_seen_at, last_seen_at, created_at, updated_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?) +ON CONFLICT(path) DO UPDATE SET + is_current = 1, + last_seen_at = excluded.last_seen_at, + updated_at = excluded.updated_at +`, stableMigrationID("project-path", projectID, path), projectID, path, now, now, now, now); err != nil { + return fmt.Errorf("upsert project path: %w", err) + } + return tx.Commit() +} + +func (s *Store) rekeyLegacyProject(ctx context.Context, legacyID string, currentPath string, friendlyName string, now string) (string, error) { + nextID, err := newProjectID() + if err != nil { + return "", err + } + var createdAt string + var storedFriendly sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT created_at, friendly_name FROM projects WHERE id = ?`, legacyID).Scan(&createdAt, &storedFriendly); err != nil { + return "", fmt.Errorf("read legacy project before rekey: %w", err) + } + if storedFriendly.Valid && strings.TrimSpace(storedFriendly.String) != "" { + friendlyName = storedFriendly.String + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return "", fmt.Errorf("begin project rekey: %w", err) + } + defer tx.Rollback() + if _, err := tx.ExecContext(ctx, ` +INSERT INTO projects (id, identity_hash, friendly_name, current_path, last_seen_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +`, nextID, nextID, friendlyName, currentPath, now, createdAt, now); err != nil { + return "", fmt.Errorf("insert rekeyed project: %w", err) + } + for _, table := range projectScopedRekeyTables() { + if _, err := tx.ExecContext(ctx, fmt.Sprintf(` +UPDATE %s +SET project_id = ? +WHERE project_id = ? +`, quoteSQLiteIdentifier(table)), nextID, legacyID); err != nil { + return "", fmt.Errorf("rekey %s project rows: %w", table, err) + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM projects WHERE id = ?`, legacyID); err != nil { + return "", fmt.Errorf("delete legacy project row: %w", err) + } + if err := tx.Commit(); err != nil { + return "", fmt.Errorf("commit project rekey: %w", err) + } + return nextID, nil +} + +func projectScopedRekeyTables() []string { + tables := append([]string{"project_paths"}, projectScopedMergeTables...) + return tables +} + +func newProjectID() (string, error) { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("generate project id: %w", err) + } + return "proj_" + hex.EncodeToString(raw[:]), nil +} + +func defaultProjectFriendlyName(path string) string { + name := strings.TrimSpace(filepath.Base(path)) + if name == "" || name == "." || name == string(filepath.Separator) { + return "project" + } + return name +} + +func (s *Store) upsertLegacyProject(ctx context.Context, root project.Root) error { + now := time.Now().UTC().Format(time.RFC3339) + projectID := ProjectID(root) + _, err := s.db.ExecContext(ctx, ` +INSERT INTO projects (id, identity_hash, created_at, updated_at) +VALUES (?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + identity_hash = excluded.identity_hash, + updated_at = excluded.updated_at +`, projectID, projectID, now, now) + if err != nil { + return fmt.Errorf("upsert legacy project: %w", err) + } + return nil +} + +func isMissingProjectIdentitySchema(err error) bool { + message := err.Error() + return strings.Contains(message, "no such table: project_paths") || + strings.Contains(message, "no such column: friendly_name") || + strings.Contains(message, "no such column: current_path") || + strings.Contains(message, "no such column: last_seen_at") +} diff --git a/internal/state/repair.go b/internal/state/repair.go new file mode 100644 index 00000000..f1c91f61 --- /dev/null +++ b/internal/state/repair.go @@ -0,0 +1,265 @@ +package state + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/levifig/loaf/internal/project" +) + +const ( + LegacyProjectDatabaseArchiveAction = "archive-legacy-project-database" + LegacyProjectDatabaseNoopAction = "no-legacy-project-database" +) + +// RelationshipOriginRepairOptions controls a guarded relationship provenance backfill. +type RelationshipOriginRepairOptions struct { + Origin string + Apply bool +} + +// RelationshipOriginRepairResult describes a dry-run or applied relationship provenance repair. +type RelationshipOriginRepairResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + DatabasePath string `json:"database_path"` + BackupPath string `json:"backup_path,omitempty"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + Origin string `json:"origin"` + Matched int `json:"matched"` + Updated int `json:"updated"` + Applied bool `json:"applied"` + GeneratedAt string `json:"generated_at"` +} + +// LegacyProjectDatabaseArchiveResult describes a guarded legacy project database archive. +type LegacyProjectDatabaseArchiveResult struct { + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + ProjectRoot string `json:"project_root"` + ProjectID string `json:"project_id"` + ProjectName string `json:"project_name"` + ProjectCurrentPath string `json:"project_current_path"` + DatabasePath string `json:"database_path"` + LegacyDatabasePath string `json:"legacy_database_path"` + ArchivePath string `json:"archive_path,omitempty"` + Action string `json:"action"` + MatchedPaths []string `json:"matched_paths"` + ArchivedPaths []string `json:"archived_paths"` + Applied bool `json:"applied"` + GeneratedAt string `json:"generated_at"` + Warnings []string `json:"warnings"` +} + +// RepairMissingRelationshipOrigins backfills missing relationship origin values +// for the current project only. It is dry-run unless options.Apply is true. +func RepairMissingRelationshipOrigins(ctx context.Context, root project.Root, resolver PathResolver, options RelationshipOriginRepairOptions) (RelationshipOriginRepairResult, error) { + if options.Origin != "imported" && options.Origin != "manual" { + return RelationshipOriginRepairResult{}, fmt.Errorf("relationship origin must be imported or manual") + } + + status, err := Inspect(root, resolver) + if err != nil { + return RelationshipOriginRepairResult{}, err + } + switch status.Mode { + case ModeMarkdownOnly: + return RelationshipOriginRepairResult{}, fmt.Errorf("SQLite state database is not initialized; run `loaf state init` or `loaf state migrate markdown --apply` first") + case ModeInvalid: + return RelationshipOriginRepairResult{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") + } + + store, err := OpenStore(status.DatabasePath) + if err != nil { + return RelationshipOriginRepairResult{}, fmt.Errorf("open state database for relationship origin repair: %w", err) + } + defer store.Close() + + identity, err := store.LookupProjectIdentityForRoot(ctx, root) + if err != nil { + return RelationshipOriginRepairResult{}, err + } + matched, err := store.countMissingRelationshipOrigins(ctx, identity.ID) + if err != nil { + return RelationshipOriginRepairResult{}, err + } + + result := RelationshipOriginRepairResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: status.DatabaseScope, + DatabasePath: status.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Origin: options.Origin, + Matched: matched, + Applied: options.Apply, + GeneratedAt: time.Now().UTC().Format(time.RFC3339Nano), + } + if !options.Apply || matched == 0 { + return result, nil + } + + backup, err := Backup(ctx, root, resolver) + if err != nil { + return RelationshipOriginRepairResult{}, fmt.Errorf("backup state database before relationship origin repair: %w", err) + } + result.BackupPath = backup.BackupPath + + updated, err := store.backfillMissingRelationshipOrigins(ctx, identity.ID, options.Origin, result.GeneratedAt) + if err != nil { + return RelationshipOriginRepairResult{}, err + } + result.Updated = updated + return result, nil +} + +// ArchiveLegacyProjectDatabase moves a migrated per-project SQLite database out +// of the legacy project path. It refuses to archive when migration is still due. +func ArchiveLegacyProjectDatabase(root project.Root, resolver PathResolver, apply bool) (LegacyProjectDatabaseArchiveResult, error) { + status, err := Inspect(root, resolver) + if err != nil { + return LegacyProjectDatabaseArchiveResult{}, err + } + switch status.Mode { + case ModeMarkdownOnly: + return LegacyProjectDatabaseArchiveResult{}, fmt.Errorf("SQLite state database is not initialized; run `loaf state init` or `loaf state migrate markdown --apply` first") + case ModeInvalid: + return LegacyProjectDatabaseArchiveResult{}, fmt.Errorf("state database is invalid; run `loaf state doctor`") + } + + plan, err := PreviewStorageHomeMigration(root, resolver) + if err != nil { + return LegacyProjectDatabaseArchiveResult{}, err + } + now := time.Now().UTC() + result := LegacyProjectDatabaseArchiveResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: status.DatabaseScope, + ProjectRoot: root.Path(), + ProjectID: status.ProjectID, + ProjectName: status.ProjectName, + ProjectCurrentPath: status.ProjectCurrentPath, + DatabasePath: plan.DatabasePath, + LegacyDatabasePath: plan.LegacyDatabasePath, + MatchedPaths: []string{}, + ArchivedPaths: []string{}, + Applied: apply, + GeneratedAt: now.Format(time.RFC3339Nano), + Warnings: []string{}, + } + if plan.DatabasePath == plan.LegacyDatabasePath || !plan.LegacyDatabaseExists { + result.Action = LegacyProjectDatabaseNoopAction + return result, nil + } + if plan.Action != StorageHomeActionAlreadyMigrated || !plan.DatabaseExists { + return LegacyProjectDatabaseArchiveResult{}, fmt.Errorf("legacy project database still needs migration; run `loaf state migrate storage-home --dry-run`") + } + + archiveDir := filepath.Join(filepath.Dir(plan.DatabasePath), "legacy-archives") + if isWithinRoot(archiveDir, root.Path()) { + return LegacyProjectDatabaseArchiveResult{}, fmt.Errorf("legacy archive directory must be outside project root") + } + archivePath, err := nextLegacyProjectArchivePath(archiveDir, ProjectID(root), now) + if err != nil { + return LegacyProjectDatabaseArchiveResult{}, err + } + result.Action = LegacyProjectDatabaseArchiveAction + result.ArchivePath = archivePath + result.MatchedPaths = existingSQLiteFileSet(plan.LegacyDatabasePath) + if len(result.MatchedPaths) == 0 { + result.Action = LegacyProjectDatabaseNoopAction + return result, nil + } + if !apply { + result.Applied = false + return result, nil + } + + if err := os.MkdirAll(archiveDir, 0o700); err != nil { + return LegacyProjectDatabaseArchiveResult{}, fmt.Errorf("create legacy archive directory: %w", err) + } + for _, sourcePath := range result.MatchedPaths { + targetPath := archiveTargetPath(plan.LegacyDatabasePath, archivePath, sourcePath) + if err := os.Rename(sourcePath, targetPath); err != nil { + return LegacyProjectDatabaseArchiveResult{}, fmt.Errorf("archive legacy state file %s: %w", sourcePath, err) + } + result.ArchivedPaths = append(result.ArchivedPaths, targetPath) + } + result.Warnings = append(result.Warnings, "legacy database archived, not deleted") + return result, nil +} + +func (s *Store) countMissingRelationshipOrigins(ctx context.Context, projectID string) (int, error) { + var count int + if err := s.db.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM relationships +WHERE project_id = ? + AND (origin IS NULL OR TRIM(origin) = '') +`, projectID).Scan(&count); err != nil { + return 0, fmt.Errorf("count missing relationship origins: %w", err) + } + return count, nil +} + +func (s *Store) backfillMissingRelationshipOrigins(ctx context.Context, projectID string, origin string, updatedAt string) (int, error) { + result, err := s.db.ExecContext(ctx, ` +UPDATE relationships +SET origin = ?, + updated_at = ? +WHERE project_id = ? + AND (origin IS NULL OR TRIM(origin) = '') +`, origin, updatedAt, projectID) + if err != nil { + return 0, fmt.Errorf("backfill missing relationship origins: %w", err) + } + rows, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("count backfilled relationship origins: %w", err) + } + return int(rows), nil +} + +func existingSQLiteFileSet(databasePath string) []string { + paths := []string{} + for _, path := range []string{databasePath, databasePath + "-wal", databasePath + "-shm"} { + if regularFileExists(path) { + paths = append(paths, path) + } + } + return paths +} + +func archiveTargetPath(sourceDatabasePath string, archiveDatabasePath string, sourcePath string) string { + switch sourcePath { + case sourceDatabasePath + "-wal": + return archiveDatabasePath + "-wal" + case sourceDatabasePath + "-shm": + return archiveDatabasePath + "-shm" + default: + return archiveDatabasePath + } +} + +func nextLegacyProjectArchivePath(archiveDir string, projectID string, now time.Time) (string, error) { + stamp := fmt.Sprintf("%s-%09d", now.Format("20060102-150405"), now.Nanosecond()) + for i := 0; i < 1000; i++ { + suffix := "" + if i > 0 { + suffix = fmt.Sprintf("-%03d", i) + } + path := filepath.Join(archiveDir, fmt.Sprintf("legacy-project-%s-%s%s.sqlite", projectID, stamp, suffix)) + if _, err := os.Stat(path); os.IsNotExist(err) { + return path, nil + } else if err != nil { + return "", fmt.Errorf("check legacy archive path: %w", err) + } + } + return "", fmt.Errorf("allocate legacy archive path: too many archives for timestamp %s", stamp) +} diff --git a/internal/state/repair_test.go b/internal/state/repair_test.go new file mode 100644 index 00000000..946ddf11 --- /dev/null +++ b/internal/state/repair_test.go @@ -0,0 +1,292 @@ +package state + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRepairMissingRelationshipOriginsDryRunDoesNotWrite(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + projectID := projectIDForTest(t, store, root) + insertRelationshipWithoutOrigin(t, store, projectID) + if err := store.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + result, err := RepairMissingRelationshipOrigins(context.Background(), root, PathResolver{StateHome: stateHome}, RelationshipOriginRepairOptions{Origin: "imported"}) + if err != nil { + t.Fatalf("RepairMissingRelationshipOrigins() error = %v", err) + } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.ProjectID != projectID { + t.Fatalf("ProjectID = %q, want %q", result.ProjectID, projectID) + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } + if result.Applied { + t.Fatal("Applied = true, want dry-run") + } + if result.Matched != 1 { + t.Fatalf("Matched = %d, want 1", result.Matched) + } + if result.Updated != 0 { + t.Fatalf("Updated = %d, want 0 for dry-run", result.Updated) + } + + store = openTestStore(t, root, stateHome) + defer store.Close() + assertMissingRelationshipOrigins(t, store, projectID, 1) +} + +func TestRepairMissingRelationshipOriginsApplyBackfillsCurrentProject(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + projectID := projectIDForTest(t, store, root) + insertRelationshipWithoutOrigin(t, store, projectID) + if err := store.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + result, err := RepairMissingRelationshipOrigins(context.Background(), root, PathResolver{StateHome: stateHome}, RelationshipOriginRepairOptions{Origin: "imported", Apply: true}) + if err != nil { + t.Fatalf("RepairMissingRelationshipOrigins() error = %v", err) + } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.ProjectID != projectID { + t.Fatalf("ProjectID = %q, want %q", result.ProjectID, projectID) + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } + if !result.Applied { + t.Fatal("Applied = false, want true") + } + if result.Matched != 1 { + t.Fatalf("Matched = %d, want 1", result.Matched) + } + if result.Updated != 1 { + t.Fatalf("Updated = %d, want 1", result.Updated) + } + if result.BackupPath == "" { + t.Fatal("BackupPath is empty for applied repair") + } + if _, err := os.Stat(result.BackupPath); err != nil { + t.Fatalf("repair backup does not exist: %v", err) + } + + store = openTestStore(t, root, stateHome) + defer store.Close() + assertMissingRelationshipOrigins(t, store, projectID, 0) + var origin string + if err := store.db.QueryRowContext(context.Background(), `SELECT origin FROM relationships WHERE id = 'relationship-without-origin'`).Scan(&origin); err != nil { + t.Fatalf("read repaired relationship origin error = %v", err) + } + if origin != "imported" { + t.Fatalf("origin = %q, want imported", origin) + } +} + +func TestRepairMissingRelationshipOriginsRejectsUnknownOrigin(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + + _, err := RepairMissingRelationshipOrigins(context.Background(), root, PathResolver{StateHome: stateHome}, RelationshipOriginRepairOptions{Origin: "guessed", Apply: true}) + if err == nil { + t.Fatal("RepairMissingRelationshipOrigins() error = nil, want invalid origin error") + } +} + +func TestArchiveLegacyProjectDatabaseDryRunDoesNotMoveFiles(t *testing.T) { + root := projectRoot(t) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + legacyPath := initializeLegacyStateDatabase(t, root, PathResolver{}) + if _, err := ApplyStorageHomeMigration(context.Background(), root, PathResolver{}); err != nil { + t.Fatalf("ApplyStorageHomeMigration() error = %v", err) + } + + result, err := ArchiveLegacyProjectDatabase(root, PathResolver{}, false) + if err != nil { + t.Fatalf("ArchiveLegacyProjectDatabase() error = %v", err) + } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } + if result.Applied { + t.Fatal("Applied = true, want dry-run") + } + if result.Action != LegacyProjectDatabaseArchiveAction { + t.Fatalf("Action = %q, want %q", result.Action, LegacyProjectDatabaseArchiveAction) + } + if len(result.MatchedPaths) != 1 || result.MatchedPaths[0] != legacyPath { + t.Fatalf("MatchedPaths = %#v, want legacy path %q", result.MatchedPaths, legacyPath) + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy database moved during dry-run: %v", err) + } + if result.ArchivePath == "" { + t.Fatal("ArchivePath is empty") + } + if _, err := os.Stat(result.ArchivePath); !os.IsNotExist(err) { + t.Fatalf("archive path exists during dry-run; err = %v", err) + } +} + +func TestArchiveLegacyProjectDatabaseApplyMovesDatabaseAndSidecars(t *testing.T) { + root := projectRoot(t) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + legacyPath := initializeLegacyStateDatabase(t, root, PathResolver{}) + sidecarPath := legacyPath + "-wal" + if _, err := ApplyStorageHomeMigration(context.Background(), root, PathResolver{}); err != nil { + t.Fatalf("ApplyStorageHomeMigration() error = %v", err) + } + if err := os.WriteFile(sidecarPath, []byte("sidecar"), 0o600); err != nil { + t.Fatalf("write legacy sidecar error = %v", err) + } + + result, err := ArchiveLegacyProjectDatabase(root, PathResolver{}, true) + if err != nil { + t.Fatalf("ArchiveLegacyProjectDatabase() error = %v", err) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } + if !result.Applied { + t.Fatal("Applied = false, want true") + } + if len(result.ArchivedPaths) != 2 { + t.Fatalf("ArchivedPaths = %#v, want database and sidecar", result.ArchivedPaths) + } + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("legacy database still exists after archive; err = %v", err) + } + if _, err := os.Stat(sidecarPath); !os.IsNotExist(err) { + t.Fatalf("legacy sidecar still exists after archive; err = %v", err) + } + for _, path := range result.ArchivedPaths { + if _, err := os.Stat(path); err != nil { + t.Fatalf("archived path %q missing: %v", path, err) + } + } +} + +func TestArchiveLegacyProjectDatabaseRejectsPendingMigration(t *testing.T) { + root := projectRoot(t) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + initializeLegacyStateDatabase(t, root, PathResolver{}) + databasePath, err := (PathResolver{}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(databasePath), 0o700); err != nil { + t.Fatalf("create global database dir error = %v", err) + } + store, err := OpenStore(databasePath) + if err != nil { + t.Fatalf("OpenStore(global) error = %v", err) + } + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations(global) error = %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close(global) error = %v", err) + } + + _, err = ArchiveLegacyProjectDatabase(root, PathResolver{}, true) + if err == nil { + t.Fatal("ArchiveLegacyProjectDatabase() error = nil, want pending migration error") + } + if !strings.Contains(err.Error(), "legacy project database still needs migration") { + t.Fatalf("error = %v, want pending migration error", err) + } +} + +func insertRelationshipWithoutOrigin(t *testing.T, store *Store, projectID string) { + t.Helper() + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) +VALUES ('relationship-without-origin', ?, 'task', 'task-one', 'spec', 'spec-one', 'implements', 'legacy row', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert relationship without origin error = %v", err) + } +} + +func assertMissingRelationshipOrigins(t *testing.T, store *Store, projectID string, want int) { + t.Helper() + var got int + if err := store.db.QueryRowContext(context.Background(), ` +SELECT COUNT(*) +FROM relationships +WHERE project_id = ? + AND (origin IS NULL OR TRIM(origin) = '') +`, projectID).Scan(&got); err != nil { + t.Fatalf("count missing relationship origins error = %v", err) + } + if got != want { + t.Fatalf("missing relationship origins = %d, want %d", got, want) + } +} diff --git a/internal/state/report_lifecycle.go b/internal/state/report_lifecycle.go index d5eb4f05..a4f04d33 100644 --- a/internal/state/report_lifecycle.go +++ b/internal/state/report_lifecycle.go @@ -23,18 +23,30 @@ type ReportCreateOptions struct { // ReportCreateResult describes a created SQLite-backed report. type ReportCreateResult struct { - Report TraceEntity `json:"report"` - Kind string `json:"kind"` - Source string `json:"source"` - EventID string `json:"event_id"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Report TraceEntity `json:"report"` + Kind string `json:"kind"` + Source string `json:"source"` + EventID string `json:"event_id"` } // ReportStatusResult describes a SQLite-backed report status transition. type ReportStatusResult struct { - Report TraceEntity `json:"report"` - Previous string `json:"previous"` - Status string `json:"status"` - EventID string `json:"event_id"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Report TraceEntity `json:"report"` + Previous string `json:"previous"` + Status string `json:"status"` + EventID string `json:"event_id"` } // CreateReport creates a draft report in initialized SQLite state. @@ -69,7 +81,14 @@ func ArchiveReport(ctx context.Context, root project.Root, resolver PathResolver // CreateReport creates a draft report in an open store. func (s *Store) CreateReport(ctx context.Context, root project.Root, options ReportCreateOptions) (ReportCreateResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return ReportCreateResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ReportCreateResult{}, err + } slug := normalizeReportSlug(options.Slug) if slug == "" { return ReportCreateResult{}, fmt.Errorf("report create requires a slug") @@ -121,10 +140,16 @@ VALUES (?, ?, 'report', ?, 'status_changed', NULL, 'draft', ?, ?, ?) } return ReportCreateResult{ - Report: TraceEntity{Kind: "report", ID: reportID, Alias: alias, Title: title, Status: "draft"}, - Kind: kind, - Source: source, - EventID: eventID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Report: TraceEntity{Kind: "report", ID: reportID, Alias: alias, Title: title, Status: "draft"}, + Kind: kind, + Source: source, + EventID: eventID, }, nil } @@ -139,7 +164,14 @@ func (s *Store) ArchiveReport(ctx context.Context, root project.Root, ref string } func (s *Store) updateReportStatus(ctx context.Context, root project.Root, ref string, requiredStatus string, nextStatus string, command string) (ReportStatusResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return ReportStatusResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ReportStatusResult{}, err + } report, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return ReportStatusResult{}, err @@ -187,10 +219,16 @@ ON CONFLICT(id) DO NOTHING report.Title = title report.Status = nextStatus return ReportStatusResult{ - Report: report, - Previous: previousStatus, - Status: nextStatus, - EventID: eventID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Report: report, + Previous: previousStatus, + Status: nextStatus, + EventID: eventID, }, nil } diff --git a/internal/state/report_lifecycle_test.go b/internal/state/report_lifecycle_test.go index c7188d4e..b3d06b19 100644 --- a/internal/state/report_lifecycle_test.go +++ b/internal/state/report_lifecycle_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "strings" "testing" @@ -26,12 +27,14 @@ func TestReportLifecycleCreatesFinalizesAndArchivesReport(t *testing.T) { if created.Report.Alias != "report-release-readiness" || created.Report.Title != "Release Readiness" || created.Report.Status != "draft" || created.Kind != "audit" || created.Source != "manual" || created.EventID == "" { t.Fatalf("created = %#v, want draft report metadata", created) } + assertReportProjectContext(t, root, created.ContractVersion, created.DatabaseScope, created.DatabasePath, created.ProjectID, created.ProjectName, created.ProjectCurrentPath) assertReportEvent(t, root, stateHome, created.Report.ID, "", "draft", "source=manual") reports, err := ListReports(context.Background(), root, PathResolver{StateHome: stateHome}, ReportListOptions{}) if err != nil { t.Fatalf("ListReports() error = %v", err) } + assertReportProjectContext(t, root, reports.ContractVersion, reports.DatabaseScope, reports.DatabasePath, reports.ProjectID, reports.ProjectName, reports.ProjectCurrentPath) report := reports.Reports["report-release-readiness"] if report.Title != "Release Readiness" || report.Kind != "audit" || report.Status != "draft" { t.Fatalf("report = %#v, want created draft report", report) @@ -44,6 +47,7 @@ func TestReportLifecycleCreatesFinalizesAndArchivesReport(t *testing.T) { if finalized.Report.Alias != "report-release-readiness" || finalized.Previous != "draft" || finalized.Status != "final" || finalized.EventID == "" { t.Fatalf("finalized = %#v, want final transition", finalized) } + assertReportProjectContext(t, root, finalized.ContractVersion, finalized.DatabaseScope, finalized.DatabasePath, finalized.ProjectID, finalized.ProjectName, finalized.ProjectCurrentPath) assertReportEvent(t, root, stateHome, created.Report.ID, "draft", "final", "recorded by report finalize") archived, err := ArchiveReport(context.Background(), root, PathResolver{StateHome: stateHome}, "report-release-readiness") @@ -53,12 +57,14 @@ func TestReportLifecycleCreatesFinalizesAndArchivesReport(t *testing.T) { if archived.Report.Alias != "report-release-readiness" || archived.Previous != "final" || archived.Status != "archived" || archived.EventID == "" { t.Fatalf("archived = %#v, want archived transition", archived) } + assertReportProjectContext(t, root, archived.ContractVersion, archived.DatabaseScope, archived.DatabasePath, archived.ProjectID, archived.ProjectName, archived.ProjectCurrentPath) assertReportEvent(t, root, stateHome, created.Report.ID, "final", "archived", "recorded by report archive") archivedReports, err := ListReports(context.Background(), root, PathResolver{StateHome: stateHome}, ReportListOptions{Status: "archived"}) if err != nil { t.Fatalf("ListReports(archived) error = %v", err) } + assertReportProjectContext(t, root, archivedReports.ContractVersion, archivedReports.DatabaseScope, archivedReports.DatabasePath, archivedReports.ProjectID, archivedReports.ProjectName, archivedReports.ProjectCurrentPath) if archivedReports.Reports["report-release-readiness"].Status != "archived" { t.Fatalf("archived reports = %#v, want archived report", archivedReports.Reports) } @@ -118,6 +124,30 @@ func TestReportLifecycleValidationAndAliasCollisions(t *testing.T) { if first.Report.Alias != "report-notes" || second.Report.Alias != "report-notes-2" { t.Fatalf("aliases = %q, %q; want collision-safe report aliases", first.Report.Alias, second.Report.Alias) } + assertReportProjectContext(t, root, first.ContractVersion, first.DatabaseScope, first.DatabasePath, first.ProjectID, first.ProjectName, first.ProjectCurrentPath) + assertReportProjectContext(t, root, second.ContractVersion, second.DatabaseScope, second.DatabasePath, second.ProjectID, second.ProjectName, second.ProjectCurrentPath) +} + +func assertReportProjectContext(t *testing.T, root project.Root, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + t.Helper() + if contractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(root.Path())) + } + if projectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, root.Path()) + } } func assertReportStatus(t *testing.T, root project.Root, stateHome string, alias string, want string) { @@ -143,7 +173,7 @@ func assertReportEvent(t *testing.T, root project.Root, stateHome string, report SELECT COUNT(*), COALESCE(MAX(note), '') FROM events WHERE project_id = ? AND entity_kind = 'report' AND entity_id = ? AND COALESCE(from_status, '') = ? AND to_status = ? -`, ProjectID(root), reportID, fromStatus, toStatus).Scan(&count, ¬e) +`, projectIDForTest(t, store, root), reportID, fromStatus, toStatus).Scan(&count, ¬e) if err != nil { t.Fatalf("read report event error = %v", err) } diff --git a/internal/state/report_list.go b/internal/state/report_list.go index 123ef85a..ba3770bc 100644 --- a/internal/state/report_list.go +++ b/internal/state/report_list.go @@ -10,8 +10,15 @@ import ( // ReportList is the state-backed report-list read model. type ReportList struct { - Version int `json:"version"` - Reports map[string]ReportItem `json:"reports"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + Version int `json:"version"` + Reports map[string]ReportItem `json:"reports"` } // ReportItem is a report entry returned by the state-backed report list. @@ -49,7 +56,14 @@ func ListReports(ctx context.Context, root project.Root, resolver PathResolver, // ListReports returns imported reports from an open store. func (s *Store) ListReports(ctx context.Context, root project.Root, options ReportListOptions) (ReportList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return ReportList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return ReportList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT report_alias.alias, @@ -71,7 +85,16 @@ ORDER BY report_alias.alias return ReportList{}, fmt.Errorf("query reports: %w", err) } - reportList := ReportList{Version: 1, Reports: map[string]ReportItem{}} + reportList := ReportList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Reports: map[string]ReportItem{}, + } for rows.Next() { var alias, title, kind, status, sourcePath string if err := rows.Scan(&alias, &title, &kind, &status, &sourcePath); err != nil { diff --git a/internal/state/report_list_test.go b/internal/state/report_list_test.go index 039ad447..a3cf0411 100644 --- a/internal/state/report_list_test.go +++ b/internal/state/report_list_test.go @@ -42,6 +42,7 @@ source: old if err != nil { t.Fatalf("ListReports() error = %v", err) } + assertReportProjectContext(t, root, reports.ContractVersion, reports.DatabaseScope, reports.DatabasePath, reports.ProjectID, reports.ProjectName, reports.ProjectCurrentPath) draft := reports.Reports["draft"] if draft.Title != "Draft Report" || draft.Kind != "research" || draft.Status != "draft" || draft.SourcePath != ".agents/reports/draft.md" { @@ -60,6 +61,7 @@ source: old if err != nil { t.Fatalf("ListReports(type) error = %v", err) } + assertReportProjectContext(t, root, research.ContractVersion, research.DatabaseScope, research.DatabasePath, research.ProjectID, research.ProjectName, research.ProjectCurrentPath) if len(research.Reports) != 2 || research.Reports["draft"].Kind != "research" || research.Reports["old"].Kind != "research" { t.Fatalf("research reports = %#v, want draft and archived research reports", research.Reports) } @@ -68,6 +70,7 @@ source: old if err != nil { t.Fatalf("ListReports(status) error = %v", err) } + assertReportProjectContext(t, root, finalOnly.ContractVersion, finalOnly.DatabaseScope, finalOnly.DatabasePath, finalOnly.ProjectID, finalOnly.ProjectName, finalOnly.ProjectCurrentPath) if len(finalOnly.Reports) != 1 || finalOnly.Reports["final"].Status != "final" { t.Fatalf("final reports = %#v, want only final report", finalOnly.Reports) } diff --git a/internal/state/schema.go b/internal/state/schema.go index 7e62615b..d68ffab7 100644 --- a/internal/state/schema.go +++ b/internal/state/schema.go @@ -13,6 +13,12 @@ var initialSchemaSQL string //go:embed migrations/0002_session_state_snapshots.sql var sessionStateSnapshotsSQL string +//go:embed migrations/0003_project_identity_and_relationship_origin.sql +var projectIdentityAndRelationshipOriginSQL string + +//go:embed migrations/0004_project_path_current_uniqueness.sql +var projectPathCurrentUniquenessSQL string + const schemaMigrationsDDL = `CREATE TABLE IF NOT EXISTS schema_migrations ( version INTEGER PRIMARY KEY NOT NULL, name TEXT NOT NULL, @@ -41,6 +47,16 @@ func SchemaMigrations() []SchemaMigration { Name: "session_state_snapshots", SQL: normalizeMigrationSQL(sessionStateSnapshotsSQL), }, + { + Version: 3, + Name: "project_identity_and_relationship_origin", + SQL: normalizeMigrationSQL(projectIdentityAndRelationshipOriginSQL), + }, + { + Version: 4, + Name: "project_path_current_uniqueness", + SQL: normalizeMigrationSQL(projectPathCurrentUniquenessSQL), + }, } } diff --git a/internal/state/schema_test.go b/internal/state/schema_test.go index c6203e40..aa0a0d4f 100644 --- a/internal/state/schema_test.go +++ b/internal/state/schema_test.go @@ -11,6 +11,7 @@ import ( var requiredInitialTables = []string{ "projects", + "project_paths", "aliases", "specs", "tasks", @@ -37,8 +38,8 @@ var requiredInitialTables = []string{ func TestSchemaMigrationsAreOrderedAndChecksummed(t *testing.T) { migrations := SchemaMigrations() - if len(migrations) != 2 { - t.Fatalf("len(SchemaMigrations()) = %d, want 2", len(migrations)) + if len(migrations) != 4 { + t.Fatalf("len(SchemaMigrations()) = %d, want 4", len(migrations)) } for i, migration := range migrations { @@ -52,6 +53,12 @@ func TestSchemaMigrationsAreOrderedAndChecksummed(t *testing.T) { if migrations[1].Name != "session_state_snapshots" { t.Fatalf("migration[1].Name = %q, want session_state_snapshots", migrations[1].Name) } + if migrations[2].Name != "project_identity_and_relationship_origin" { + t.Fatalf("migration[2].Name = %q, want project_identity_and_relationship_origin", migrations[2].Name) + } + if migrations[3].Name != "project_path_current_uniqueness" { + t.Fatalf("migration[3].Name = %q, want project_path_current_uniqueness", migrations[3].Name) + } for _, migration := range migrations { if strings.TrimSpace(migration.SQL) == "" { t.Fatalf("migration %d SQL is empty", migration.Version) @@ -102,6 +109,7 @@ func TestInitialSchemaPreservesLineageAndExports(t *testing.T) { for table, columns := range map[string][]string{ "relationships": {"relationship_type", "from_entity_kind", "to_entity_kind", "reason"}, + "project_paths": {"project_id", "path", "is_current", "first_seen_at", "last_seen_at"}, "sources": {"source_kind", "path", "hash", "imported_at"}, "exports": {"export_kind", "format", "state_version", "generated_at"}, "session_state_snapshots": {"content", "observed_branch", "observed_worktree"}, @@ -178,6 +186,14 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { if sqlDoc != SchemaMigrations()[1].SQL { t.Fatal("docs/schema/0002_session_state_snapshots.sql must match embedded migration 0002 exactly") } + sqlDoc = readRepoFile(t, "docs", "schema", "0003_project_identity_and_relationship_origin.sql") + if sqlDoc != SchemaMigrations()[2].SQL { + t.Fatal("docs/schema/0003_project_identity_and_relationship_origin.sql must match embedded migration 0003 exactly") + } + sqlDoc = readRepoFile(t, "docs", "schema", "0004_project_path_current_uniqueness.sql") + if sqlDoc != SchemaMigrations()[3].SQL { + t.Fatal("docs/schema/0004_project_path_current_uniqueness.sql must match embedded migration 0004 exactly") + } dbmlDoc := readRepoFile(t, "docs", "schema", "operational-state.dbml") mermaidDoc := readRepoFile(t, "docs", "schema", "operational-state.mmd") @@ -213,6 +229,7 @@ func TestSchemaDocumentationMirrorsExecutableMigration(t *testing.T) { "projects ||--o{ hook_events : scopes", "projects ||--o{ ideas : scopes", "projects ||--o{ journal_entries : scopes", + "projects ||--o{ project_paths : locates", "projects ||--o{ relationships : scopes", "projects ||--o{ reports : scopes", "projects ||--o{ session_state_snapshots : scopes", @@ -297,6 +314,10 @@ func schemaColumnNames(t *testing.T, sql string) map[string][]string { columnsByTable[table] = append(columnsByTable[table], strings.ToLower(fields[0])) } } + alterRe := regexp.MustCompile(`(?im)^ALTER TABLE ([a-z_]+) ADD COLUMN ([a-z_]+) `) + for _, match := range alterRe.FindAllStringSubmatch(sql, -1) { + columnsByTable[match[1]] = append(columnsByTable[match[1]], strings.ToLower(match[2])) + } return columnsByTable } diff --git a/internal/state/session_archive.go b/internal/state/session_archive.go index cfe8279c..81cb022e 100644 --- a/internal/state/session_archive.go +++ b/internal/state/session_archive.go @@ -23,10 +23,16 @@ type SessionArchiveOptions struct { // SessionArchiveResult describes the affected session after `loaf session archive`. type SessionArchiveResult struct { - Version int `json:"version"` - Action string `json:"action"` - Session TraceEntity `json:"session"` - HarnessSessionID string `json:"harness_session_id,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Action string `json:"action"` + Session TraceEntity `json:"session"` + HarnessSessionID string `json:"harness_session_id,omitempty"` } // ArchiveSession marks a session archived in initialized SQLite state. @@ -41,7 +47,14 @@ func ArchiveSession(ctx context.Context, root project.Root, resolver PathResolve // ArchiveSession marks a session archived in an open store. func (s *Store) ArchiveSession(ctx context.Context, root project.Root, options SessionArchiveOptions) (SessionArchiveResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SessionArchiveResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SessionArchiveResult{}, err + } branch := strings.TrimSpace(options.Branch) harnessSessionID := strings.TrimSpace(options.HarnessSessionID) if branch == "" && harnessSessionID == "" { @@ -73,10 +86,16 @@ func (s *Store) ArchiveSession(ctx context.Context, root project.Root, options S } if target.Status == "archived" { return SessionArchiveResult{ - Version: 1, - Action: SessionArchiveActionAlreadyArchived, - Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: target.Status}, - HarnessSessionID: target.HarnessSessionID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Action: SessionArchiveActionAlreadyArchived, + Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: target.Status}, + HarnessSessionID: target.HarnessSessionID, }, nil } if err := updateSessionStatusTransition(ctx, tx, projectID, target.ID, target.Status, "archived", "recorded by session archive", now); err != nil { @@ -86,10 +105,16 @@ func (s *Store) ArchiveSession(ctx context.Context, root project.Root, options S return SessionArchiveResult{}, fmt.Errorf("commit session archive transaction: %w", err) } return SessionArchiveResult{ - Version: 1, - Action: SessionArchiveActionArchived, - Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: "archived"}, - HarnessSessionID: firstNonEmpty(harnessSessionID, target.HarnessSessionID), + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Action: SessionArchiveActionArchived, + Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: "archived"}, + HarnessSessionID: firstNonEmpty(harnessSessionID, target.HarnessSessionID), }, nil } diff --git a/internal/state/session_archive_test.go b/internal/state/session_archive_test.go index 6dc1bfdb..2f514b35 100644 --- a/internal/state/session_archive_test.go +++ b/internal/state/session_archive_test.go @@ -36,6 +36,7 @@ func TestArchiveSessionTargetsHarnessSessionOnly(t *testing.T) { if result.Action != SessionArchiveActionArchived || result.Session.ID != target.Session.ID || result.Session.Status != "archived" { t.Fatalf("result = %#v, want archived target session", result) } + assertSessionProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) activeOnly, err := ListSessions(context.Background(), root, PathResolver{StateHome: stateHome}, SessionListOptions{}) if err != nil { @@ -80,6 +81,7 @@ func TestArchiveSessionFallsBackToBranch(t *testing.T) { if result.Session.ID != start.Session.ID || result.Session.Status != "archived" { t.Fatalf("result = %#v, want branch session archived", result) } + assertSessionProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) } func TestArchiveSessionAlreadyArchivedIsIdempotent(t *testing.T) { diff --git a/internal/state/session_end.go b/internal/state/session_end.go index 77196fdf..0ad5e8b1 100644 --- a/internal/state/session_end.go +++ b/internal/state/session_end.go @@ -29,12 +29,18 @@ type SessionEndOptions struct { // SessionEndResult describes the affected session after `loaf session end`. type SessionEndResult struct { - Version int `json:"version"` - Action string `json:"action"` - Session TraceEntity `json:"session,omitempty"` - HarnessSessionID string `json:"harness_session_id,omitempty"` - JournalEntryIDs []string `json:"journal_entry_ids,omitempty"` - NoopReason string `json:"noop_reason,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Action string `json:"action"` + Session TraceEntity `json:"session,omitempty"` + HarnessSessionID string `json:"harness_session_id,omitempty"` + JournalEntryIDs []string `json:"journal_entry_ids,omitempty"` + NoopReason string `json:"noop_reason,omitempty"` } // EndSession ends, wraps, or clears a session in initialized SQLite state. @@ -49,7 +55,14 @@ func EndSession(ctx context.Context, root project.Root, resolver PathResolver, o // EndSession ends, wraps, or clears a session in an open store. func (s *Store) EndSession(ctx context.Context, root project.Root, options SessionEndOptions) (SessionEndResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SessionEndResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SessionEndResult{}, err + } branch := strings.TrimSpace(options.Branch) harnessSessionID := strings.TrimSpace(options.HarnessSessionID) if harnessSessionID == "" && branch == "" { @@ -78,26 +91,48 @@ func (s *Store) EndSession(ctx context.Context, root project.Root, options Sessi } if target.ID == "" { if options.IfActive { - return SessionEndResult{Version: 1, Action: SessionEndActionNoop, NoopReason: "no active session found"}, nil + return SessionEndResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Action: SessionEndActionNoop, + NoopReason: "no active session found", + }, nil } return SessionEndResult{}, fmt.Errorf("no active session found") } if target.Status != "active" { if options.IfActive { return SessionEndResult{ - Version: 1, - Action: SessionEndActionNoop, - Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: target.Status}, - HarnessSessionID: target.HarnessSessionID, - NoopReason: fmt.Sprintf("session is %s", target.Status), + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Action: SessionEndActionNoop, + Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: target.Status}, + HarnessSessionID: target.HarnessSessionID, + NoopReason: fmt.Sprintf("session is %s", target.Status), }, nil } return SessionEndResult{ - Version: 1, - Action: SessionEndActionAlreadyClosed, - Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: target.Status}, - HarnessSessionID: target.HarnessSessionID, - NoopReason: fmt.Sprintf("session is %s", target.Status), + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Action: SessionEndActionAlreadyClosed, + Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: target.Status}, + HarnessSessionID: target.HarnessSessionID, + NoopReason: fmt.Sprintf("session is %s", target.Status), }, nil } @@ -144,11 +179,17 @@ func (s *Store) EndSession(ctx context.Context, root project.Root, options Sessi return SessionEndResult{}, fmt.Errorf("commit session end transaction: %w", err) } return SessionEndResult{ - Version: 1, - Action: action, - Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: status}, - HarnessSessionID: firstNonEmpty(harnessSessionID, target.HarnessSessionID), - JournalEntryIDs: journalEntryIDs, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Action: action, + Session: TraceEntity{Kind: "session", ID: target.ID, Alias: target.Alias, Status: status}, + HarnessSessionID: firstNonEmpty(harnessSessionID, target.HarnessSessionID), + JournalEntryIDs: journalEntryIDs, }, nil } diff --git a/internal/state/session_list.go b/internal/state/session_list.go index 99e0e2f6..7d7087b7 100644 --- a/internal/state/session_list.go +++ b/internal/state/session_list.go @@ -10,8 +10,15 @@ import ( // SessionList is the state-backed session-list read model. type SessionList struct { - Version int `json:"version"` - Sessions map[string]SessionItem `json:"sessions"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + Version int `json:"version"` + Sessions map[string]SessionItem `json:"sessions"` } // SessionItem is a session entry returned by the state-backed session list. @@ -49,7 +56,14 @@ func ListSessions(ctx context.Context, root project.Root, resolver PathResolver, // ListSessions returns imported sessions from an open store. func (s *Store) ListSessions(ctx context.Context, root project.Root, options SessionListOptions) (SessionList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SessionList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SessionList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT session_alias.alias, @@ -76,7 +90,16 @@ ORDER BY session_alias.alias return SessionList{}, fmt.Errorf("query sessions: %w", err) } - sessionList := SessionList{Version: 1, Sessions: map[string]SessionItem{}} + sessionList := SessionList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Sessions: map[string]SessionItem{}, + } for rows.Next() { var alias, branch, status, harnessSessionID, sourcePath string var journalEntries int diff --git a/internal/state/session_list_test.go b/internal/state/session_list_test.go index 00f34bc3..948004c1 100644 --- a/internal/state/session_list_test.go +++ b/internal/state/session_list_test.go @@ -32,6 +32,7 @@ claude_session_id: session-archived if err != nil { t.Fatalf("ListSessions(activeOnly) error = %v", err) } + assertSessionProjectContext(t, root, activeOnly.ContractVersion, activeOnly.DatabaseScope, activeOnly.DatabasePath, activeOnly.ProjectID, activeOnly.ProjectName, activeOnly.ProjectCurrentPath) if _, ok := activeOnly.Sessions["20260527-archived"]; ok { t.Fatal("active-only session list includes archived session") } @@ -47,6 +48,7 @@ claude_session_id: session-archived if err != nil { t.Fatalf("ListSessions(all) error = %v", err) } + assertSessionProjectContext(t, root, withArchived.ContractVersion, withArchived.DatabaseScope, withArchived.DatabasePath, withArchived.ProjectID, withArchived.ProjectName, withArchived.ProjectCurrentPath) archived := withArchived.Sessions["20260527-archived"] if archived.Status != "archived" || archived.SourcePath != ".agents/sessions/archive/20260527-archived.md" || archived.HarnessSessionID != "session-archived" { t.Fatalf("archived session = %#v, want archived imported metadata", archived) diff --git a/internal/state/session_show.go b/internal/state/session_show.go index 98364722..6737b743 100644 --- a/internal/state/session_show.go +++ b/internal/state/session_show.go @@ -12,8 +12,14 @@ import ( // SessionShow is the state-backed single-session read model. type SessionShow struct { - Query string `json:"query"` - Session SessionDetail `json:"session"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Session SessionDetail `json:"session"` } // SessionDetail contains operational session metadata plus journal context. @@ -52,7 +58,14 @@ func ShowSession(ctx context.Context, root project.Root, resolver PathResolver, // ShowSession returns one session from an open store. func (s *Store) ShowSession(ctx context.Context, root project.Root, ref string) (SessionShow, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SessionShow{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SessionShow{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return SessionShow{}, err @@ -65,7 +78,16 @@ func (s *Store) ShowSession(ctx context.Context, root project.Root, ref string) if err != nil { return SessionShow{}, err } - return SessionShow{Query: ref, Session: session}, nil + return SessionShow{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Session: session, + }, nil } func (s *Store) sessionDetail(ctx context.Context, projectID string, entity TraceEntity) (SessionDetail, error) { diff --git a/internal/state/session_show_test.go b/internal/state/session_show_test.go index 4050171b..5252fdf9 100644 --- a/internal/state/session_show_test.go +++ b/internal/state/session_show_test.go @@ -41,6 +41,7 @@ claude_session_id: session-active if show.Query != "20260528-active" || session.Alias != "20260528-active" { t.Fatalf("show = %#v, want query and alias", show) } + assertSessionProjectContext(t, root, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath) if session.Branch != "feature/session-show" || session.Status != "active" || session.HarnessSessionID != "session-active" { t.Fatalf("session metadata = %#v, want imported frontmatter", session) } diff --git a/internal/state/session_start.go b/internal/state/session_start.go index 761e4e78..529dc3dc 100644 --- a/internal/state/session_start.go +++ b/internal/state/session_start.go @@ -26,6 +26,12 @@ type SessionStartOptions struct { // SessionStartResult describes the active session after `loaf session start`. type SessionStartResult struct { + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` Version int `json:"version"` Action string `json:"action"` Session TraceEntity `json:"session"` @@ -56,7 +62,14 @@ func StartSession(ctx context.Context, root project.Root, resolver PathResolver, // StartSession creates or resumes a session in an open store. func (s *Store) StartSession(ctx context.Context, root project.Root, options SessionStartOptions) (SessionStartResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SessionStartResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SessionStartResult{}, err + } branch := strings.TrimSpace(options.Branch) if branch == "" { return SessionStartResult{}, fmt.Errorf("session start requires a git branch") @@ -155,6 +168,12 @@ VALUES (?, ?, ?, ?, 'active', NULL, ?, ?) } return SessionStartResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, Version: 1, Action: action, Session: TraceEntity{Kind: "session", ID: active.ID, Alias: active.Alias, Status: active.Status}, diff --git a/internal/state/session_start_test.go b/internal/state/session_start_test.go index 7236cf6d..4c649fe7 100644 --- a/internal/state/session_start_test.go +++ b/internal/state/session_start_test.go @@ -3,7 +3,10 @@ package state import ( "context" "database/sql" + "path/filepath" "testing" + + "github.com/levifig/loaf/internal/project" ) func TestStartSessionCreatesSQLiteSessionWithLinkedStartJournal(t *testing.T) { @@ -26,6 +29,7 @@ func TestStartSessionCreatesSQLiteSessionWithLinkedStartJournal(t *testing.T) { if len(result.JournalEntryIDs) != 1 { t.Fatalf("JournalEntryIDs = %#v, want one start entry", result.JournalEntryIDs) } + assertSessionProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) show, err := ShowSession(context.Background(), root, PathResolver{StateHome: stateHome}, result.Session.Alias) if err != nil { @@ -38,6 +42,7 @@ func TestStartSessionCreatesSQLiteSessionWithLinkedStartJournal(t *testing.T) { if !hasJournalEntry(session.JournalEntries, "session", "start", "=== SESSION STARTED === (session harness-)") { t.Fatalf("journal entries = %#v, want linked session(start)", session.JournalEntries) } + assertSessionProjectContext(t, root, show.ContractVersion, show.DatabaseScope, show.DatabasePath, show.ProjectID, show.ProjectName, show.ProjectCurrentPath) } func TestStartSessionResumesSameHarnessSessionWithoutCreatingDuplicate(t *testing.T) { @@ -67,7 +72,7 @@ func TestStartSessionResumesSameHarnessSessionWithoutCreatingDuplicate(t *testin store := openTestStore(t, root, stateHome) defer store.Close() - if got := countRows(t, store, `SELECT COUNT(*) FROM sessions WHERE project_id = ?`, ProjectID(root)); got != 1 { + if got := countRows(t, store, `SELECT COUNT(*) FROM sessions WHERE project_id = ?`, projectIDForTest(t, store, root)); got != 1 { t.Fatalf("session rows = %d, want 1", got) } show, err := ShowSession(context.Background(), root, PathResolver{StateHome: stateHome}, first.Session.Alias) @@ -148,6 +153,7 @@ func TestEndSessionStopsTargetHarnessSessionOnly(t *testing.T) { if result.Action != SessionEndActionStopped || result.Session.ID != target.Session.ID || len(result.JournalEntryIDs) != 2 { t.Fatalf("result = %#v, want stopped target session", result) } + assertSessionProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) targetShow, err := ShowSession(context.Background(), root, PathResolver{StateHome: stateHome}, target.Session.Alias) if err != nil { @@ -169,6 +175,28 @@ func TestEndSessionStopsTargetHarnessSessionOnly(t *testing.T) { } } +func assertSessionProjectContext(t *testing.T, root project.Root, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + t.Helper() + if contractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(root.Path())) + } + if projectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, root.Path()) + } +} + func TestEndSessionIfActiveNoopsWhenNoSessionExists(t *testing.T) { root := projectRoot(t) stateHome := t.TempDir() diff --git a/internal/state/session_state_snapshot.go b/internal/state/session_state_snapshot.go index 3554e44e..8d7db6cf 100644 --- a/internal/state/session_state_snapshot.go +++ b/internal/state/session_state_snapshot.go @@ -41,7 +41,10 @@ func RecordSessionStateSnapshot(ctx context.Context, root project.Root, resolver // RecordSessionStateSnapshot upserts the latest state snapshot for a session in an open store. func (s *Store) RecordSessionStateSnapshot(ctx context.Context, root project.Root, options SessionStateSnapshotOptions) (SessionStateSnapshot, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SessionStateSnapshot{}, err + } content := strings.TrimSpace(options.Content) if content == "" { return SessionStateSnapshot{}, fmt.Errorf("session state snapshot content cannot be empty") diff --git a/internal/state/spark.go b/internal/state/spark.go index 7d56f08b..c177fa41 100644 --- a/internal/state/spark.go +++ b/internal/state/spark.go @@ -15,8 +15,14 @@ import ( // SparkList is the state-backed spark-list read model. type SparkList struct { - Version int `json:"version"` - Sparks map[string]SparkItem `json:"sparks"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Sparks map[string]SparkItem `json:"sparks"` } // SparkItem is a spark entry returned by the state-backed spark list. @@ -29,8 +35,14 @@ type SparkItem struct { // SparkShow is the state-backed single-spark read model. type SparkShow struct { - Query string `json:"query"` - Spark SparkDetail `json:"spark"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Spark SparkDetail `json:"spark"` } // SparkDetail contains operational spark metadata plus imported source context. @@ -54,11 +66,17 @@ type SparkListOptions struct { // SparkResolveResult describes a state-backed spark resolution mutation. type SparkResolveResult struct { - Spark TraceEntity `json:"spark"` - ResolvedBy TraceEntity `json:"resolved_by"` - Relationship string `json:"relationship"` - EventID string `json:"event_id,omitempty"` - Reason string `json:"reason,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Spark TraceEntity `json:"spark"` + ResolvedBy TraceEntity `json:"resolved_by"` + Relationship string `json:"relationship"` + EventID string `json:"event_id,omitempty"` + Reason string `json:"reason,omitempty"` } // SparkResolveOptions describes a SQLite-backed spark resolution request. @@ -76,9 +94,15 @@ type SparkPromoteOptions struct { // SparkPromoteResult describes a state-backed spark promotion mutation. type SparkPromoteResult struct { - Spark TraceEntity `json:"spark"` - Idea TraceEntity `json:"idea"` - Relationship string `json:"relationship"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Spark TraceEntity `json:"spark"` + Idea TraceEntity `json:"idea"` + Relationship string `json:"relationship"` } // SparkCaptureOptions describes a SQLite-backed spark capture request. @@ -89,9 +113,15 @@ type SparkCaptureOptions struct { // SparkCaptureResult describes a captured SQLite-backed spark. type SparkCaptureResult struct { - Spark TraceEntity `json:"spark"` - Scope string `json:"scope,omitempty"` - EventID string `json:"event_id"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Spark TraceEntity `json:"spark"` + Scope string `json:"scope,omitempty"` + EventID string `json:"event_id"` } // ListSparks returns imported sparks from initialized SQLite state. @@ -115,7 +145,14 @@ func ListSparks(ctx context.Context, root project.Root, resolver PathResolver, o // ListSparks returns imported sparks from an open store. func (s *Store) ListSparks(ctx context.Context, root project.Root, options SparkListOptions) (SparkList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SparkList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SparkList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT spark_alias.alias, @@ -137,7 +174,16 @@ ORDER BY spark_alias.alias return SparkList{}, fmt.Errorf("query sparks: %w", err) } - sparks := SparkList{Version: 1, Sparks: map[string]SparkItem{}} + sparks := SparkList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Sparks: map[string]SparkItem{}, + } for rows.Next() { var alias, text, scope, status, sourcePath string if err := rows.Scan(&alias, &text, &scope, &status, &sourcePath); err != nil { @@ -175,7 +221,14 @@ func ShowSpark(ctx context.Context, root project.Root, resolver PathResolver, re // ShowSpark returns one spark from an open store. func (s *Store) ShowSpark(ctx context.Context, root project.Root, ref string) (SparkShow, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SparkShow{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SparkShow{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return SparkShow{}, err @@ -188,7 +241,16 @@ func (s *Store) ShowSpark(ctx context.Context, root project.Root, ref string) (S if err != nil { return SparkShow{}, err } - return SparkShow{Query: ref, Spark: spark}, nil + return SparkShow{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Spark: spark, + }, nil } func (s *Store) sparkDetail(ctx context.Context, projectID string, entity TraceEntity) (SparkDetail, error) { @@ -262,7 +324,14 @@ func CaptureSpark(ctx context.Context, root project.Root, resolver PathResolver, // CaptureSpark captures a spark in an open store. func (s *Store) CaptureSpark(ctx context.Context, root project.Root, options SparkCaptureOptions) (SparkCaptureResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SparkCaptureResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SparkCaptureResult{}, err + } text := strings.TrimSpace(options.Text) if text == "" { return SparkCaptureResult{}, fmt.Errorf("spark capture requires --text") @@ -306,9 +375,15 @@ VALUES (?, ?, 'spark', ?, 'status_changed', NULL, 'open', 'recorded by spark cap } return SparkCaptureResult{ - Spark: TraceEntity{Kind: "spark", ID: sparkID, Alias: alias, Title: text, Status: "open"}, - Scope: scope, - EventID: eventID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Spark: TraceEntity{Kind: "spark", ID: sparkID, Alias: alias, Title: text, Status: "open"}, + Scope: scope, + EventID: eventID, }, nil } @@ -364,7 +439,14 @@ func (s *Store) ResolveSpark(ctx context.Context, root project.Root, sparkRef st // ResolveSparkWithOptions marks a spark resolved in an open store. func (s *Store) ResolveSparkWithOptions(ctx context.Context, root project.Root, options SparkResolveOptions) (SparkResolveResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SparkResolveResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SparkResolveResult{}, err + } spark, err := s.resolveTraceEntity(ctx, projectID, options.Spark) if err != nil { return SparkResolveResult{}, err @@ -403,10 +485,11 @@ func (s *Store) ResolveSparkWithOptions(ctx context.Context, root project.Root, reason := firstNonEmpty(strings.TrimSpace(options.Reason), "recorded by spark resolve") relationshipID := stableMigrationID("relationship", projectID, "spark", spark.ID, "resolved_by", target.Kind, target.ID) _, err = tx.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'command', ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, updated_at = excluded.updated_at `, relationshipID, projectID, "spark", spark.ID, target.Kind, target.ID, "resolved_by", reason, now, now) if err != nil { @@ -432,11 +515,17 @@ ON CONFLICT(id) DO NOTHING spark.Status = "resolved" return SparkResolveResult{ - Spark: spark, - ResolvedBy: target, - Relationship: relationshipID, - EventID: eventID, - Reason: reason, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Spark: spark, + ResolvedBy: target, + Relationship: relationshipID, + EventID: eventID, + Reason: reason, }, nil } @@ -452,7 +541,14 @@ func PromoteSpark(ctx context.Context, root project.Root, resolver PathResolver, // PromoteSpark records that a spark promoted to an idea in an open store. func (s *Store) PromoteSpark(ctx context.Context, root project.Root, options SparkPromoteOptions) (SparkPromoteResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SparkPromoteResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SparkPromoteResult{}, err + } spark, err := s.resolveTraceEntity(ctx, projectID, options.Spark) if err != nil { return SparkPromoteResult{}, err @@ -471,10 +567,11 @@ func (s *Store) PromoteSpark(ctx context.Context, root project.Root, options Spa now := time.Now().UTC().Format(time.RFC3339) relationshipID := stableMigrationID("relationship", projectID, "spark", spark.ID, "promoted_to", "idea", idea.ID) _, err = s.db.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'command', ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, updated_at = excluded.updated_at `, relationshipID, projectID, "spark", spark.ID, "idea", idea.ID, "promoted_to", "recorded by spark promote", now, now) if err != nil { @@ -482,9 +579,15 @@ ON CONFLICT(id) DO UPDATE SET } return SparkPromoteResult{ - Spark: spark, - Idea: idea, - Relationship: relationshipID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Spark: spark, + Idea: idea, + Relationship: relationshipID, }, nil } diff --git a/internal/state/spark_test.go b/internal/state/spark_test.go index f94a14dc..a4ebe6b4 100644 --- a/internal/state/spark_test.go +++ b/internal/state/spark_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "strings" "testing" @@ -29,6 +30,7 @@ func TestResolveSparkMarksResolvedAndRecordsRelationshipEvent(t *testing.T) { if before.Sparks["SPARK-smoke"].Status != "open" { t.Fatalf("before.Sparks = %#v, want imported open spark", before.Sparks) } + assertSparkProjectContext(t, root, before.ContractVersion, before.DatabaseScope, before.DatabasePath, before.ProjectID, before.ProjectName, before.ProjectCurrentPath) result, err := ResolveSparkWithOptions(context.Background(), root, PathResolver{StateHome: stateHome}, SparkResolveOptions{ Spark: "SPARK-smoke", @@ -41,6 +43,7 @@ func TestResolveSparkMarksResolvedAndRecordsRelationshipEvent(t *testing.T) { if result.Spark.Status != "resolved" || result.ResolvedBy.Alias != "20260528-target-idea" || result.Relationship == "" || result.EventID == "" || result.Reason != "triaged into target idea" { t.Fatalf("result = %#v, want resolved spark, target idea, relationship, and event", result) } + assertSparkProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) after, err := ListSparks(context.Background(), root, PathResolver{StateHome: stateHome}, SparkListOptions{}) if err != nil { @@ -56,6 +59,7 @@ func TestResolveSparkMarksResolvedAndRecordsRelationshipEvent(t *testing.T) { if all.Sparks["SPARK-smoke"].Status != "resolved" { t.Fatalf("all.Sparks = %#v, want resolved spark included with status", all.Sparks) } + assertSparkProjectContext(t, root, all.ContractVersion, all.DatabaseScope, all.DatabasePath, all.ProjectID, all.ProjectName, all.ProjectCurrentPath) resolvedOnly, err := ListSparks(context.Background(), root, PathResolver{StateHome: stateHome}, SparkListOptions{Status: "resolved"}) if err != nil { t.Fatalf("ListSparks(Status resolved) error = %v", err) @@ -83,7 +87,7 @@ func TestResolveSparkMarksResolvedAndRecordsRelationshipEvent(t *testing.T) { SELECT COUNT(*), COALESCE(MAX(note), '') FROM events WHERE project_id = ? AND entity_kind = 'spark' AND event_type = 'status_changed' AND from_status = 'open' AND to_status = 'resolved' -`, ProjectID(root)).Scan(&events, &eventNote) +`, projectIDForTest(t, store, root)).Scan(&events, &eventNote) if err != nil { t.Fatalf("count events error = %v", err) } @@ -105,11 +109,12 @@ WHERE project_id = ? AND entity_kind = 'spark' AND event_type = 'status_changed' if result.EventID != "" || result.Reason != "updated rationale" { t.Fatalf("repeat result = %#v, want relationship update without duplicate event", result) } + assertSparkProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) err = store.db.QueryRowContext(context.Background(), ` SELECT COUNT(*) FROM events WHERE project_id = ? AND entity_kind = 'spark' AND event_type = 'status_changed' AND from_status = 'open' AND to_status = 'resolved' -`, ProjectID(root)).Scan(&events) +`, projectIDForTest(t, store, root)).Scan(&events) if err != nil { t.Fatalf("count events after repeat error = %v", err) } @@ -178,6 +183,7 @@ func TestShowSparkReadsImportedSQLiteSpark(t *testing.T) { if result.Query != "SPARK-smoke" { t.Fatalf("Query = %q, want SPARK-smoke", result.Query) } + assertSparkProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) if spark.Alias != "SPARK-smoke" || spark.Text != "smoke spark" || spark.Scope != "sqlite" || spark.Status != "open" { t.Fatalf("Spark = %#v, want imported spark metadata", spark) } @@ -238,6 +244,7 @@ func TestPromoteSparkRecordsPromotedToRelationship(t *testing.T) { if result.Spark.Alias != "SPARK-smoke" || result.Spark.Status != "open" || result.Idea.Alias != "20260528-target-idea" || result.Relationship == "" { t.Fatalf("result = %#v, want open spark promoted to target idea with relationship", result) } + assertSparkProjectContext(t, root, result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) sparks, err := ListSparks(context.Background(), root, PathResolver{StateHome: stateHome}, SparkListOptions{}) if err != nil { @@ -343,6 +350,7 @@ func TestCaptureSparkCreatesOpenSparkWithAliasAndEvent(t *testing.T) { if first.Spark.Alias != "SPARK-repeat-spark" || first.Spark.Status != "open" || first.Scope != "architecture" || first.EventID == "" { t.Fatalf("first = %#v, want open spark with slug alias, scope, and event", first) } + assertSparkProjectContext(t, root, first.ContractVersion, first.DatabaseScope, first.DatabasePath, first.ProjectID, first.ProjectName, first.ProjectCurrentPath) second, err := CaptureSpark(context.Background(), root, PathResolver{StateHome: stateHome}, SparkCaptureOptions{ Text: "Repeat Spark", }) @@ -360,6 +368,7 @@ func TestCaptureSparkCreatesOpenSparkWithAliasAndEvent(t *testing.T) { if sparks.Sparks["SPARK-repeat-spark"].Status != "open" || sparks.Sparks["SPARK-repeat-spark"].Scope != "architecture" { t.Fatalf("sparks = %#v, want captured spark visible in default list", sparks.Sparks) } + assertSparkProjectContext(t, root, sparks.ContractVersion, sparks.DatabaseScope, sparks.DatabasePath, sparks.ProjectID, sparks.ProjectName, sparks.ProjectCurrentPath) trace, err := Trace(context.Background(), root, PathResolver{StateHome: stateHome}, "SPARK-repeat-spark") if err != nil { t.Fatalf("Trace() error = %v", err) @@ -378,7 +387,7 @@ func TestCaptureSparkCreatesOpenSparkWithAliasAndEvent(t *testing.T) { SELECT COUNT(*) FROM events WHERE project_id = ? AND entity_kind = 'spark' AND event_type = 'status_changed' AND from_status IS NULL AND to_status = 'open' -`, ProjectID(root)).Scan(&events) +`, projectIDForTest(t, store, root)).Scan(&events) if err != nil { t.Fatalf("count capture events error = %v", err) } @@ -386,3 +395,25 @@ WHERE project_id = ? AND entity_kind = 'spark' AND event_type = 'status_changed' t.Fatalf("events = %d, want one status event per captured spark", events) } } + +func assertSparkProjectContext(t *testing.T, root project.Root, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + t.Helper() + if contractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(root.Path())) + } + if projectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, root.Path()) + } +} diff --git a/internal/state/spec_archive.go b/internal/state/spec_archive.go index c5a3d3d5..d9942e85 100644 --- a/internal/state/spec_archive.go +++ b/internal/state/spec_archive.go @@ -13,8 +13,14 @@ import ( // SpecArchiveResult describes a state-backed spec archive mutation. type SpecArchiveResult struct { - Archived []SpecArchiveItem `json:"archived"` - Skipped []SpecArchiveItem `json:"skipped"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Archived []SpecArchiveItem `json:"archived"` + Skipped []SpecArchiveItem `json:"skipped"` } // SpecArchiveItem describes one requested spec archive outcome. @@ -51,10 +57,23 @@ func (s *Store) ArchiveSpecs(ctx context.Context, root project.Root, refs []stri if len(refs) == 0 { return SpecArchiveResult{}, fmt.Errorf("spec archive requires at least one spec") } - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SpecArchiveResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SpecArchiveResult{}, err + } result := SpecArchiveResult{ - Archived: []SpecArchiveItem{}, - Skipped: []SpecArchiveItem{}, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Archived: []SpecArchiveItem{}, + Skipped: []SpecArchiveItem{}, } for _, ref := range refs { item, archived, err := s.archiveSpec(ctx, projectID, ref) diff --git a/internal/state/spec_archive_test.go b/internal/state/spec_archive_test.go index 32780f7c..481315e2 100644 --- a/internal/state/spec_archive_test.go +++ b/internal/state/spec_archive_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" ) @@ -27,6 +28,24 @@ status: complete if len(result.Archived) != 1 || result.Archived[0].Spec == nil || result.Archived[0].Spec.Alias != "SPEC-001" || result.Archived[0].Previous != "complete" || result.Archived[0].Status != "archived" || result.Archived[0].EventID == "" { t.Fatalf("Archived = %#v, want SPEC-001 archived with event", result.Archived) } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } if len(result.Skipped) != 0 { t.Fatalf("Skipped = %#v, want none", result.Skipped) } diff --git a/internal/state/spec_list.go b/internal/state/spec_list.go index def1a54b..31843095 100644 --- a/internal/state/spec_list.go +++ b/internal/state/spec_list.go @@ -12,8 +12,15 @@ var specStatusOrder = []string{"implementing", "approved", "drafting", "complete // SpecList is the state-backed spec-list read model. type SpecList struct { - Version int `json:"version"` - Specs map[string]SpecItem `json:"specs"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + Version int `json:"version"` + Specs map[string]SpecItem `json:"specs"` } // SpecItem is a spec entry returned by the state-backed spec list. @@ -52,7 +59,14 @@ func ListSpecs(ctx context.Context, root project.Root, resolver PathResolver) (S // ListSpecs returns imported specs from an open store. func (s *Store) ListSpecs(ctx context.Context, root project.Root) (SpecList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SpecList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SpecList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT spec_alias.alias, @@ -73,7 +87,16 @@ ORDER BY spec_alias.alias return SpecList{}, fmt.Errorf("query specs: %w", err) } - specList := SpecList{Version: 1, Specs: map[string]SpecItem{}} + specList := SpecList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Specs: map[string]SpecItem{}, + } for rows.Next() { var alias, title, status, sourcePath string if err := rows.Scan(&alias, &title, &status, &sourcePath); err != nil { diff --git a/internal/state/spec_list_test.go b/internal/state/spec_list_test.go index 799468d8..16b5a52e 100644 --- a/internal/state/spec_list_test.go +++ b/internal/state/spec_list_test.go @@ -43,6 +43,7 @@ status: drafting if err != nil { t.Fatalf("ListSpecs() error = %v", err) } + assertTaskProjectContext(t, root.Path(), specs.ContractVersion, specs.DatabaseScope, specs.DatabasePath, specs.ProjectID, specs.ProjectName, specs.ProjectCurrentPath) spec := specs.Specs["SPEC-001"] if spec.Title != "Example Spec" || spec.Status != "implementing" || spec.SourcePath != ".agents/specs/SPEC-001-example.md" { diff --git a/internal/state/spec_show.go b/internal/state/spec_show.go index 8686ff1f..cb70df66 100644 --- a/internal/state/spec_show.go +++ b/internal/state/spec_show.go @@ -12,8 +12,14 @@ import ( // SpecShow is the state-backed single-spec read model. type SpecShow struct { - Query string `json:"query"` - Spec SpecDetail `json:"spec"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Spec SpecDetail `json:"spec"` } // SpecDetail contains operational spec metadata plus imported source context. @@ -42,7 +48,14 @@ func ShowSpec(ctx context.Context, root project.Root, resolver PathResolver, ref // ShowSpec returns one spec from an open store. func (s *Store) ShowSpec(ctx context.Context, root project.Root, ref string) (SpecShow, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return SpecShow{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return SpecShow{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return SpecShow{}, err @@ -55,7 +68,16 @@ func (s *Store) ShowSpec(ctx context.Context, root project.Root, ref string) (Sp if err != nil { return SpecShow{}, err } - return SpecShow{Query: ref, Spec: spec}, nil + return SpecShow{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Spec: spec, + }, nil } func (s *Store) specDetail(ctx context.Context, root project.Root, projectID string, entity TraceEntity) (SpecDetail, error) { diff --git a/internal/state/spec_show_test.go b/internal/state/spec_show_test.go index 112983e1..8c9276aa 100644 --- a/internal/state/spec_show_test.go +++ b/internal/state/spec_show_test.go @@ -37,6 +37,7 @@ Imported spec prose. if err != nil { t.Fatalf("ShowSpec() error = %v", err) } + assertTaskProjectContext(t, root.Path(), result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) spec := result.Spec if result.Query != "SPEC-001" { diff --git a/internal/state/status.go b/internal/state/status.go index dc0f574f..1f17cdfa 100644 --- a/internal/state/status.go +++ b/internal/state/status.go @@ -3,10 +3,12 @@ package state import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "os" "path/filepath" + "strings" "github.com/levifig/loaf/internal/project" ) @@ -17,25 +19,67 @@ const ( ModeInvalid = "invalid" ) +const ( + RepairCategoryLocalDatabase = "local-database" + RepairCategoryStorageMigration = "storage-migration" + RepairCategoryProjectIdentity = "project-identity" + RepairCategoryRelationshipProvenance = "relationship-provenance" + RepairCategoryBackendMapping = "backend-mapping" + RepairCategoryExternalSync = "external-sync" + RepairCategoryMarkdownImport = "markdown-import" + RepairCategoryCompatibilityExport = "compatibility-export" +) + +const ( + DiagnosticPolicyInvalidLocalData = "invalid-local-data" + DiagnosticPolicyWarningDrift = "warning-drift" + DiagnosticPolicyExternalSyncGap = "external-sync-gap" + DiagnosticPolicyImportPending = "import-pending" + DiagnosticPolicyStaleExport = "stale-export" +) + // Diagnostic describes a state-runtime observation without mutating state. type Diagnostic struct { - Severity string `json:"severity"` - Code string `json:"code"` - Message string `json:"message"` + Severity string `json:"severity"` + Code string `json:"code"` + Category string `json:"category,omitempty"` + Policy string `json:"policy,omitempty"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` + RequiresExternalSync bool `json:"requires_external_sync,omitempty"` +} + +// RepairAction describes an explicit repair recommendation from diagnostics. +type RepairAction struct { + Code string `json:"code"` + DiagnosticCode string `json:"diagnostic_code"` + Category string `json:"category"` + Description string `json:"description"` + Command string `json:"command,omitempty"` + Path string `json:"path,omitempty"` + Safe bool `json:"safe"` + Applied bool `json:"applied"` + RequiresExternalSync bool `json:"requires_external_sync"` } // Status is the pre-init state view exposed by `loaf state status`. type Status struct { - ProjectRoot string `json:"project_root"` - ProjectID string `json:"project_id"` - DatabasePath string `json:"database_path"` - LegacyDatabasePath string `json:"legacy_database_path,omitempty"` - DatabaseExists bool `json:"database_exists"` - LegacyDatabaseExists bool `json:"legacy_database_exists"` - DatabaseParentExists bool `json:"database_parent_exists"` - SchemaVersion int `json:"schema_version"` - Mode string `json:"mode"` - Diagnostics []Diagnostic `json:"diagnostics"` + ContractVersion int `json:"contract_version"` + DatabaseScope string `json:"database_scope"` + ProjectRoot string `json:"project_root"` + ProjectID string `json:"project_id,omitempty"` + LegacyProjectKey string `json:"legacy_project_key,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + DatabasePath string `json:"database_path"` + LegacyDatabasePath string `json:"legacy_database_path,omitempty"` + DatabaseExists bool `json:"database_exists"` + LegacyDatabaseExists bool `json:"legacy_database_exists"` + DatabaseParentExists bool `json:"database_parent_exists"` + SchemaVersion int `json:"schema_version"` + Mode string `json:"mode"` + Diagnostics []Diagnostic `json:"diagnostics"` + RepairPlan []RepairAction `json:"repair_plan"` } // Inspect returns the current state-runtime status without creating files. @@ -46,11 +90,15 @@ func Inspect(root project.Root, resolver PathResolver) (Status, error) { } status := Status{ - ProjectRoot: root.Path(), - ProjectID: ProjectID(root), - DatabasePath: databasePath, + ContractVersion: StateJSONContractVersion, + DatabaseScope: "global", + ProjectRoot: root.Path(), + LegacyProjectKey: ProjectID(root), + DatabasePath: databasePath, + Diagnostics: []Diagnostic{}, + RepairPlan: []RepairAction{}, } - if legacyPath, err := resolver.LegacyDatabasePath(root); err == nil && legacyPath != databasePath { + if legacyPath, err := migrationSourceDatabasePath(root, resolver); err == nil && legacyPath != databasePath { status.LegacyDatabasePath = legacyPath if info, err := os.Stat(legacyPath); err == nil && !info.IsDir() { status.LegacyDatabaseExists = true @@ -82,7 +130,7 @@ func Inspect(root project.Root, resolver PathResolver) (Status, error) { }) case err == nil: status.DatabaseExists = true - store, err := OpenStore(databasePath) + store, err := OpenStoreReadOnly(databasePath) if err != nil { status.Mode = ModeInvalid status.Diagnostics = append(status.Diagnostics, Diagnostic{ @@ -110,12 +158,64 @@ func Inspect(root project.Root, resolver PathResolver) (Status, error) { status.Mode = ModeInvalid return status, nil } + invariantDiagnostics, invariantValid, err := inspectOperationalInvariants(context.Background(), store) + if err != nil { + status.Diagnostics = append(status.Diagnostics, Diagnostic{ + Severity: "error", + Code: "state-invariants-unreadable", + Message: err.Error(), + }) + } else { + status.Diagnostics = append(status.Diagnostics, invariantDiagnostics...) + } + if !invariantValid { + status.Mode = ModeInvalid + return status, nil + } status.Mode = ModeSQLiteReady + if identity, err := store.LookupProjectIdentityForRoot(context.Background(), root); err == nil { + status.ProjectID = identity.ID + status.ProjectName = identity.FriendlyName + status.ProjectCurrentPath = identity.CurrentPath + } else { + status.Diagnostics = append(status.Diagnostics, Diagnostic{ + Severity: "warn", + Code: "project-identity-unreadable", + Message: err.Error(), + }) + } status.Diagnostics = append(status.Diagnostics, Diagnostic{ Severity: "info", Code: "sqlite-ready", Message: fmt.Sprintf("SQLite state database is ready at schema version %d", version), }) + if status.LegacyDatabaseExists { + status.Diagnostics = append(status.Diagnostics, Diagnostic{ + Severity: "warn", + Code: "legacy-project-database-leftover", + Message: fmt.Sprintf("legacy project database remains at %s after global DB initialization", status.LegacyDatabasePath), + }) + } + markdownDiagnostics, err := inspectUnimportedLocalMarkdown(context.Background(), root, store, status.ProjectID) + if err != nil { + status.Diagnostics = append(status.Diagnostics, Diagnostic{ + Severity: "warn", + Code: "local-markdown-import-check-unreadable", + Message: err.Error(), + }) + } else { + status.Diagnostics = append(status.Diagnostics, markdownDiagnostics...) + } + linearDiagnostics, err := inspectLinearModeTaskMappings(context.Background(), root, store, status.ProjectID) + if err != nil { + status.Diagnostics = append(status.Diagnostics, Diagnostic{ + Severity: "warn", + Code: "linear-mode-task-mappings-unreadable", + Message: err.Error(), + }) + } else { + status.Diagnostics = append(status.Diagnostics, linearDiagnostics...) + } exportDiagnostics, err := inspectStaleExports(context.Background(), store) if err != nil { status.Diagnostics = append(status.Diagnostics, Diagnostic{ @@ -154,6 +254,147 @@ func Inspect(root project.Root, resolver PathResolver) (Status, error) { return status, nil } +// RepairPlanForStatus turns diagnostics into explicit, non-surprising repair actions. +func RepairPlanForStatus(status Status) []RepairAction { + actions := []RepairAction{} + for _, diagnostic := range status.Diagnostics { + switch diagnostic.Code { + case "database-missing": + actions = appendRepairAction(actions, RepairAction{ + Code: "initialize-database", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryLocalDatabase, + Description: "Initialize the global SQLite database for this project.", + Command: "loaf state doctor --fix", + Path: status.DatabasePath, + Safe: true, + }) + case "legacy-state-database-detected": + actions = appendRepairAction(actions, RepairAction{ + Code: "migrate-storage-home", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryStorageMigration, + Description: "Preview and then apply storage-home migration to copy legacy state into the global database.", + Command: "loaf state migrate storage-home --dry-run", + Path: status.LegacyDatabasePath, + Safe: false, + }) + case "legacy-project-database-leftover": + actions = appendRepairAction(actions, RepairAction{ + Code: "review-legacy-project-database", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryLocalDatabase, + Description: "Preview archiving the leftover legacy project database after verifying the global database.", + Command: "loaf state repair legacy-project-database --dry-run --json", + Path: status.LegacyDatabasePath, + Safe: false, + }) + case "schema-version-mismatch", "schema-checksum-mismatch", "schema-migration-missing": + actions = appendRepairAction(actions, RepairAction{ + Code: "inspect-schema-migrations", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryLocalDatabase, + Description: "Inspect schema migration drift before applying any repair.", + Command: "loaf state doctor --json", + Path: status.DatabasePath, + Safe: false, + }) + case "state-invariants-unreadable", "sqlite-integrity-check-failed", "sqlite-foreign-key-violation": + actions = appendRepairAction(actions, RepairAction{ + Code: "inspect-state-invariants", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryLocalDatabase, + Description: "Inspect SQLite table integrity before applying any state repair.", + Command: "loaf state doctor --json", + Path: status.DatabasePath, + Safe: false, + }) + case "project-current-path-missing", "project-current-path-mismatch", "orphaned-project-path": + actions = appendRepairAction(actions, RepairAction{ + Code: "repair-project-path-invariants", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryProjectIdentity, + Description: "Inspect project identity and path history before repairing project path invariants.", + Command: "loaf project list --json", + Path: status.DatabasePath, + Safe: false, + }) + case "relationship-origin-missing", "relationship-origin-unknown": + actions = appendRepairAction(actions, RepairAction{ + Code: "audit-relationship-origin", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryRelationshipProvenance, + Description: "Audit relationship provenance before backfilling or pruning relationship rows.", + Command: "loaf state repair relationship-origin --origin imported --dry-run --json", + Path: status.DatabasePath, + Safe: false, + }) + case "backend-mapping-field-empty", "backend-mapping-sensitive-value", "backend-mapping-entity-kind-unknown", "backend-mapping-entity-missing": + actions = appendRepairAction(actions, RepairAction{ + Code: "inspect-backend-mappings", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryBackendMapping, + Description: "Inspect invalid local backend mapping rows before pruning or reconnecting integration metadata.", + Command: "loaf state doctor --json", + Path: status.DatabasePath, + Safe: false, + }) + case "backend-mapping-entity-ambiguous", "backend-mapping-sync-status-unknown": + actions = appendRepairAction(actions, RepairAction{ + Code: "audit-backend-mappings", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryBackendMapping, + Description: "Audit local backend mapping drift before pruning or reconnecting integration metadata.", + Command: "loaf state export all --format json", + Path: status.DatabasePath, + Safe: false, + }) + case "linear-mode-local-task-unmapped": + actions = appendRepairAction(actions, RepairAction{ + Code: "reconcile-linear-task-mappings", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryExternalSync, + Description: "Export local task state, then reconcile active local tasks with Linear or future backend sync tooling.", + Command: "loaf state export all --format json", + Path: status.DatabasePath, + Safe: false, + RequiresExternalSync: true, + }) + case "stale-compatibility-export": + actions = appendRepairAction(actions, RepairAction{ + Code: "regenerate-export", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryCompatibilityExport, + Description: "Regenerate the stale compatibility export from SQLite state.", + Safe: false, + }) + case "local-markdown-not-imported": + actions = appendRepairAction(actions, RepairAction{ + Code: "migrate-current-project-markdown", + DiagnosticCode: diagnostic.Code, + Category: RepairCategoryMarkdownImport, + Description: "Preview importing this project's local .agents Markdown artifacts into the global SQLite database.", + Command: "loaf state migrate markdown --dry-run", + Path: status.ProjectRoot, + Safe: true, + }) + } + } + return actions +} + +func appendRepairAction(actions []RepairAction, action RepairAction) []RepairAction { + for _, existing := range actions { + if existing.Code == action.Code && + existing.DiagnosticCode == action.DiagnosticCode && + existing.Command == action.Command && + existing.Path == action.Path { + return actions + } + } + return append(actions, action) +} + func inspectSchemaMigrations(ctx context.Context, store *Store, version int) ([]Diagnostic, bool) { diagnostics := []Diagnostic{} valid := true @@ -197,6 +438,677 @@ func inspectSchemaMigrations(ctx context.Context, store *Store, version int) ([] return diagnostics, valid } +func inspectOperationalInvariants(ctx context.Context, store *Store) ([]Diagnostic, bool, error) { + diagnostics := []Diagnostic{} + valid := true + + sqliteDiagnostics, sqliteValid, err := inspectSQLiteIntegrity(ctx, store) + if err != nil { + return nil, false, err + } + diagnostics = append(diagnostics, sqliteDiagnostics...) + if !sqliteValid { + valid = false + } + + projectPathDiagnostics, projectPathsValid, err := inspectProjectPathInvariants(ctx, store) + if err != nil { + return nil, false, err + } + diagnostics = append(diagnostics, projectPathDiagnostics...) + if !projectPathsValid { + valid = false + } + + relationshipDiagnostics, err := inspectRelationshipOriginInvariants(ctx, store) + if err != nil { + return nil, false, err + } + diagnostics = append(diagnostics, relationshipDiagnostics...) + + backendDiagnostics, backendMappingsValid, err := inspectBackendMappingInvariants(ctx, store) + if err != nil { + return nil, false, err + } + diagnostics = append(diagnostics, backendDiagnostics...) + if !backendMappingsValid { + valid = false + } + + return diagnostics, valid, nil +} + +func inspectUnimportedLocalMarkdown(ctx context.Context, root project.Root, store *Store, projectID string) ([]Diagnostic, error) { + plan, err := PreviewMarkdownMigration(root) + if err != nil { + return nil, err + } + importableCount := markdownMigrationImportableCount(plan) + if importableCount == 0 { + return nil, nil + } + if projectID != "" { + var importedSources int + if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM sources WHERE project_id = ? AND path LIKE '.agents/%'`, projectID).Scan(&importedSources); err != nil { + return nil, fmt.Errorf("inspect imported Markdown sources: %w", err) + } + if importedSources > 0 { + return nil, nil + } + } + return []Diagnostic{{ + Severity: "warn", + Code: "local-markdown-not-imported", + Category: RepairCategoryMarkdownImport, + Policy: DiagnosticPolicyImportPending, + Message: fmt.Sprintf("local .agents Markdown has %d importable artifact(s), but this project has no imported Markdown sources in the global SQLite database; run `loaf state migrate markdown --dry-run` before trusting empty SQLite output", importableCount), + Details: map[string]any{ + "agents_path": plan.AgentsPath, + "importable_count": importableCount, + "specs": plan.Specs, + "tasks": plan.Tasks, + "ideas": plan.Ideas, + "sparks": plan.Sparks, + "brainstorms": plan.Brainstorms, + "shaping_drafts": plan.ShapingDrafts, + "sessions": plan.Sessions, + "reports": plan.Reports, + "relationships": plan.Relationships, + "imported_sources": 0, + "preview_command": "loaf state migrate markdown --dry-run", + "migration_command": "loaf state migrate markdown --apply", + }, + }}, nil +} + +func markdownMigrationImportableCount(plan MarkdownMigrationPlan) int { + return plan.Specs + + plan.Tasks + + plan.Ideas + + plan.Sparks + + plan.Brainstorms + + plan.ShapingDrafts + + plan.Sessions + + plan.Reports + + plan.Relationships +} + +func inspectSQLiteIntegrity(ctx context.Context, store *Store) ([]Diagnostic, bool, error) { + diagnostics := []Diagnostic{} + valid := true + + checkRows, err := store.db.QueryContext(ctx, `PRAGMA quick_check`) + if err != nil { + return nil, false, fmt.Errorf("run SQLite quick_check: %w", err) + } + defer checkRows.Close() + for checkRows.Next() { + var result string + if err := checkRows.Scan(&result); err != nil { + return nil, false, fmt.Errorf("scan SQLite quick_check: %w", err) + } + if result != "ok" { + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "sqlite-integrity-check-failed", + Message: fmt.Sprintf("SQLite quick_check reported: %s", result), + }) + } + } + if err := checkRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate SQLite quick_check: %w", err) + } + + foreignKeyRows, err := store.db.QueryContext(ctx, `PRAGMA foreign_key_check`) + if err != nil { + return nil, false, fmt.Errorf("run SQLite foreign_key_check: %w", err) + } + defer foreignKeyRows.Close() + for foreignKeyRows.Next() { + var tableName, parentTable string + var rowID sql.NullInt64 + var foreignKeyID int + if err := foreignKeyRows.Scan(&tableName, &rowID, &parentTable, &foreignKeyID); err != nil { + return nil, false, fmt.Errorf("scan SQLite foreign_key_check: %w", err) + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "sqlite-foreign-key-violation", + Message: formatSQLiteForeignKeyViolation(tableName, rowID, parentTable, foreignKeyID), + }) + } + if err := foreignKeyRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate SQLite foreign_key_check: %w", err) + } + + return diagnostics, valid, nil +} + +func inspectProjectPathInvariants(ctx context.Context, store *Store) ([]Diagnostic, bool, error) { + diagnostics := []Diagnostic{} + valid := true + + missingRows, err := store.db.QueryContext(ctx, ` +SELECT projects.id +FROM projects +LEFT JOIN project_paths ON project_paths.project_id = projects.id AND project_paths.is_current = 1 +WHERE project_paths.id IS NULL +ORDER BY projects.id +`) + if err != nil { + return nil, false, fmt.Errorf("inspect missing current project paths: %w", err) + } + defer missingRows.Close() + for missingRows.Next() { + var projectID string + if err := missingRows.Scan(&projectID); err != nil { + return nil, false, fmt.Errorf("scan missing current project path: %w", err) + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "project-current-path-missing", + Message: fmt.Sprintf("project %s has no current project_paths row", projectID), + }) + } + if err := missingRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate missing current project paths: %w", err) + } + + mismatchRows, err := store.db.QueryContext(ctx, ` +SELECT projects.id, COALESCE(projects.current_path, ''), project_paths.path +FROM projects +JOIN project_paths ON project_paths.project_id = projects.id AND project_paths.is_current = 1 +WHERE COALESCE(projects.current_path, '') <> project_paths.path +ORDER BY projects.id +`) + if err != nil { + return nil, false, fmt.Errorf("inspect current project path mismatches: %w", err) + } + defer mismatchRows.Close() + for mismatchRows.Next() { + var projectID, projectCurrentPath, currentPathRow string + if err := mismatchRows.Scan(&projectID, &projectCurrentPath, ¤tPathRow); err != nil { + return nil, false, fmt.Errorf("scan current project path mismatch: %w", err) + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "project-current-path-mismatch", + Message: fmt.Sprintf("project %s current_path %q does not match current project_paths row %q", projectID, projectCurrentPath, currentPathRow), + }) + } + if err := mismatchRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate current project path mismatches: %w", err) + } + + orphanRows, err := store.db.QueryContext(ctx, ` +SELECT project_paths.id, project_paths.path +FROM project_paths +LEFT JOIN projects ON projects.id = project_paths.project_id +WHERE projects.id IS NULL +ORDER BY project_paths.id +`) + if err != nil { + return nil, false, fmt.Errorf("inspect orphaned project paths: %w", err) + } + defer orphanRows.Close() + for orphanRows.Next() { + var pathID, path string + if err := orphanRows.Scan(&pathID, &path); err != nil { + return nil, false, fmt.Errorf("scan orphaned project path: %w", err) + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "orphaned-project-path", + Message: fmt.Sprintf("project path %s at %s references a missing project", pathID, path), + }) + } + if err := orphanRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate orphaned project paths: %w", err) + } + + return diagnostics, valid, nil +} + +func inspectRelationshipOriginInvariants(ctx context.Context, store *Store) ([]Diagnostic, error) { + diagnostics := []Diagnostic{} + var missingCount int + if err := store.db.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM relationships +WHERE origin IS NULL OR TRIM(origin) = '' +`).Scan(&missingCount); err != nil { + return nil, fmt.Errorf("inspect missing relationship origins: %w", err) + } + if missingCount > 0 { + diagnostics = append(diagnostics, Diagnostic{ + Severity: "warn", + Code: "relationship-origin-missing", + Message: fmt.Sprintf("%d relationship row(s) are missing provenance origin", missingCount), + }) + } + + unknownRows, err := store.db.QueryContext(ctx, ` +SELECT origin, COUNT(*) +FROM relationships +WHERE origin IS NOT NULL AND TRIM(origin) != '' AND origin NOT IN ('imported', 'manual') +GROUP BY origin +ORDER BY origin +`) + if err != nil { + return nil, fmt.Errorf("inspect unknown relationship origins: %w", err) + } + defer unknownRows.Close() + for unknownRows.Next() { + var origin string + var count int + if err := unknownRows.Scan(&origin, &count); err != nil { + return nil, fmt.Errorf("scan unknown relationship origin: %w", err) + } + diagnostics = append(diagnostics, Diagnostic{ + Severity: "warn", + Code: "relationship-origin-unknown", + Message: fmt.Sprintf("%d relationship row(s) have unknown provenance origin %q", count, origin), + }) + } + if err := unknownRows.Err(); err != nil { + return nil, fmt.Errorf("iterate unknown relationship origins: %w", err) + } + return diagnostics, nil +} + +func inspectBackendMappingInvariants(ctx context.Context, store *Store) ([]Diagnostic, bool, error) { + diagnostics := []Diagnostic{} + valid := true + + blankRows, err := store.db.QueryContext(ctx, ` +SELECT field, COUNT(*) +FROM ( + SELECT 'backend' AS field FROM backend_mappings WHERE TRIM(backend) = '' + UNION ALL SELECT 'entity_kind' FROM backend_mappings WHERE TRIM(entity_kind) = '' + UNION ALL SELECT 'entity_id' FROM backend_mappings WHERE TRIM(entity_id) = '' + UNION ALL SELECT 'external_kind' FROM backend_mappings WHERE TRIM(external_kind) = '' + UNION ALL SELECT 'external_id' FROM backend_mappings WHERE TRIM(external_id) = '' + UNION ALL SELECT 'sync_status' FROM backend_mappings WHERE TRIM(sync_status) = '' +) +GROUP BY field +ORDER BY field +`) + if err != nil { + return nil, false, fmt.Errorf("inspect blank backend mapping fields: %w", err) + } + defer blankRows.Close() + for blankRows.Next() { + var field string + var count int + if err := blankRows.Scan(&field, &count); err != nil { + return nil, false, fmt.Errorf("scan blank backend mapping field: %w", err) + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "backend-mapping-field-empty", + Category: RepairCategoryBackendMapping, + Policy: DiagnosticPolicyInvalidLocalData, + Message: fmt.Sprintf("%d backend mapping row(s) have an empty %s field; fix or remove the local backend mapping row before trusting integration state", count, field), + Details: map[string]any{ + "field": field, + "row_count": count, + }, + }) + } + if err := blankRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate blank backend mapping fields: %w", err) + } + + sensitiveRows, err := store.db.QueryContext(ctx, ` +SELECT field, value +FROM ( + SELECT 'external_id' AS field, external_id AS value FROM backend_mappings + UNION ALL SELECT 'external_url', COALESCE(external_url, '') FROM backend_mappings +) +ORDER BY field +`) + if err != nil { + return nil, false, fmt.Errorf("inspect sensitive backend mapping values: %w", err) + } + defer sensitiveRows.Close() + sensitiveCounts := map[string]int{} + for sensitiveRows.Next() { + var field, value string + if err := sensitiveRows.Scan(&field, &value); err != nil { + return nil, false, fmt.Errorf("scan sensitive backend mapping value: %w", err) + } + if backendMappingHasSensitiveValue(value) { + sensitiveCounts[field]++ + } + } + if err := sensitiveRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate sensitive backend mapping values: %w", err) + } + for _, field := range []string{"external_id", "external_url"} { + count := sensitiveCounts[field] + if count == 0 { + continue + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "backend-mapping-sensitive-value", + Category: RepairCategoryBackendMapping, + Policy: DiagnosticPolicyInvalidLocalData, + Message: fmt.Sprintf("%d backend mapping row(s) contain sensitive-looking %s values; replace them with external record identifiers or URLs before trusting integration state", count, field), + Details: map[string]any{ + "field": field, + "row_count": count, + }, + }) + } + + unknownRows, err := store.db.QueryContext(ctx, ` +SELECT entity_kind, COUNT(*) +FROM backend_mappings +WHERE entity_kind NOT IN ( + 'project', + 'alias', + 'spec', + 'task', + 'idea', + 'spark', + 'brainstorm', + 'shaping_draft', + 'session', + 'report', + 'journal_entry', + 'event', + 'relationship', + 'tag', + 'entity_tag', + 'bundle', + 'bundle_member', + 'source', + 'hook_event', + 'export' +) +GROUP BY entity_kind +ORDER BY entity_kind +`) + if err != nil { + return nil, false, fmt.Errorf("inspect unknown backend mapping entity kinds: %w", err) + } + defer unknownRows.Close() + for unknownRows.Next() { + var entityKind string + var count int + if err := unknownRows.Scan(&entityKind, &count); err != nil { + return nil, false, fmt.Errorf("scan unknown backend mapping entity kind: %w", err) + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "backend-mapping-entity-kind-unknown", + Category: RepairCategoryBackendMapping, + Policy: DiagnosticPolicyInvalidLocalData, + Message: fmt.Sprintf("%d backend mapping row(s) reference unknown local entity kind %q; fix or remove the local backend mapping row before trusting integration state", count, entityKind), + Details: map[string]any{ + "entity_kind": entityKind, + "row_count": count, + }, + }) + } + if err := unknownRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate unknown backend mapping entity kinds: %w", err) + } + + unknownStatusRows, err := store.db.QueryContext(ctx, ` +SELECT sync_status, COUNT(*) +FROM backend_mappings +WHERE TRIM(sync_status) <> '' + AND sync_status NOT IN ('linked', 'pending', 'stale', 'conflict', 'error') +GROUP BY sync_status +ORDER BY sync_status +`) + if err != nil { + return nil, false, fmt.Errorf("inspect unknown backend mapping sync statuses: %w", err) + } + defer unknownStatusRows.Close() + for unknownStatusRows.Next() { + var syncStatus string + var count int + if err := unknownStatusRows.Scan(&syncStatus, &count); err != nil { + return nil, false, fmt.Errorf("scan unknown backend mapping sync status: %w", err) + } + diagnostics = append(diagnostics, Diagnostic{ + Severity: "warn", + Code: "backend-mapping-sync-status-unknown", + Category: RepairCategoryBackendMapping, + Policy: DiagnosticPolicyWarningDrift, + Message: fmt.Sprintf("%d backend mapping row(s) have unknown sync_status %q; audit local integration metadata before pruning or reconnecting external records", count, syncStatus), + Details: map[string]any{ + "row_count": count, + "sync_status": syncStatus, + }, + }) + } + if err := unknownStatusRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate unknown backend mapping sync statuses: %w", err) + } + + missingRows, err := store.db.QueryContext(ctx, ` +WITH local_entities(entity_kind, project_id, entity_id) AS ( + SELECT 'project', id, id FROM projects + UNION ALL SELECT 'alias', project_id, id FROM aliases + UNION ALL SELECT 'spec', project_id, id FROM specs + UNION ALL SELECT 'task', project_id, id FROM tasks + UNION ALL SELECT 'idea', project_id, id FROM ideas + UNION ALL SELECT 'spark', project_id, id FROM sparks + UNION ALL SELECT 'brainstorm', project_id, id FROM brainstorms + UNION ALL SELECT 'shaping_draft', project_id, id FROM shaping_drafts + UNION ALL SELECT 'session', project_id, id FROM sessions + UNION ALL SELECT 'report', project_id, id FROM reports + UNION ALL SELECT 'journal_entry', project_id, id FROM journal_entries + UNION ALL SELECT 'event', project_id, id FROM events + UNION ALL SELECT 'relationship', project_id, id FROM relationships + UNION ALL SELECT 'tag', project_id, id FROM tags + UNION ALL SELECT 'entity_tag', project_id, id FROM entity_tags + UNION ALL SELECT 'bundle', project_id, id FROM bundles + UNION ALL SELECT 'bundle_member', project_id, id FROM bundle_members + UNION ALL SELECT 'source', project_id, id FROM sources + UNION ALL SELECT 'hook_event', project_id, id FROM hook_events + UNION ALL SELECT 'export', project_id, id FROM exports +) +SELECT backend_mappings.id, backend_mappings.backend, backend_mappings.entity_kind, backend_mappings.entity_id, backend_mappings.external_kind, backend_mappings.external_id +FROM backend_mappings +LEFT JOIN local_entities + ON local_entities.project_id = backend_mappings.project_id + AND local_entities.entity_kind = backend_mappings.entity_kind + AND local_entities.entity_id = backend_mappings.entity_id +WHERE local_entities.entity_id IS NULL + AND backend_mappings.entity_kind IN ( + 'project', + 'alias', + 'spec', + 'task', + 'idea', + 'spark', + 'brainstorm', + 'shaping_draft', + 'session', + 'report', + 'journal_entry', + 'event', + 'relationship', + 'tag', + 'entity_tag', + 'bundle', + 'bundle_member', + 'source', + 'hook_event', + 'export' + ) +ORDER BY backend_mappings.id +`) + if err != nil { + return nil, false, fmt.Errorf("inspect orphaned backend mappings: %w", err) + } + defer missingRows.Close() + for missingRows.Next() { + var mappingID, backend, entityKind, entityID, externalKind, externalID string + if err := missingRows.Scan(&mappingID, &backend, &entityKind, &entityID, &externalKind, &externalID); err != nil { + return nil, false, fmt.Errorf("scan orphaned backend mapping: %w", err) + } + valid = false + diagnostics = append(diagnostics, Diagnostic{ + Severity: "error", + Code: "backend-mapping-entity-missing", + Category: RepairCategoryBackendMapping, + Policy: DiagnosticPolicyInvalidLocalData, + Message: fmt.Sprintf("backend mapping %s links local %s %s to %s %s:%s, but the local entity is missing; fix or remove the local backend mapping row before trusting integration state", mappingID, entityKind, entityID, backend, externalKind, externalID), + Details: map[string]any{ + "backend": backend, + "entity_id": entityID, + "entity_kind": entityKind, + "external_id": externalID, + "external_kind": externalKind, + "mapping_id": mappingID, + }, + }) + } + if err := missingRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate orphaned backend mappings: %w", err) + } + + ambiguousRows, err := store.db.QueryContext(ctx, ` +SELECT project_id, backend, entity_kind, entity_id, external_kind, COUNT(DISTINCT external_id) +FROM backend_mappings +GROUP BY project_id, backend, entity_kind, entity_id, external_kind +HAVING COUNT(DISTINCT external_id) > 1 +ORDER BY project_id, backend, entity_kind, entity_id, external_kind +`) + if err != nil { + return nil, false, fmt.Errorf("inspect ambiguous backend mappings: %w", err) + } + defer ambiguousRows.Close() + for ambiguousRows.Next() { + var projectID, backend, entityKind, entityID, externalKind string + var count int + if err := ambiguousRows.Scan(&projectID, &backend, &entityKind, &entityID, &externalKind, &count); err != nil { + return nil, false, fmt.Errorf("scan ambiguous backend mapping: %w", err) + } + diagnostics = append(diagnostics, Diagnostic{ + Severity: "warn", + Code: "backend-mapping-entity-ambiguous", + Category: RepairCategoryBackendMapping, + Policy: DiagnosticPolicyWarningDrift, + Message: fmt.Sprintf("local %s %s in project %s maps to %d %s %s records; audit local integration metadata before pruning or reconnecting external records", entityKind, entityID, projectID, count, backend, externalKind), + Details: map[string]any{ + "backend": backend, + "distinct_external_id_count": count, + "entity_id": entityID, + "entity_kind": entityKind, + "external_kind": externalKind, + "project_id": projectID, + }, + }) + } + if err := ambiguousRows.Err(); err != nil { + return nil, false, fmt.Errorf("iterate ambiguous backend mappings: %w", err) + } + + return diagnostics, valid, nil +} + +func backendMappingHasSensitiveValue(value string) bool { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return false + } + markers := []string{ + "authorization:", + "bearer ", + strings.Join([]string{"api", "_", "key", "="}, ""), + strings.Join([]string{"x-api", "-", "key"}, ""), + strings.Join([]string{"access_", "tok", "en", "="}, ""), + strings.Join([]string{"refresh_", "tok", "en", "="}, ""), + strings.Join([]string{"tok", "en", "="}, ""), + strings.Join([]string{"pass", "word", "="}, ""), + strings.Join([]string{"sec", "ret", "="}, ""), + "ghp_", + "xoxb-", + "sk_live_", + } + for _, marker := range markers { + if strings.Contains(normalized, marker) { + return true + } + } + return false +} + +func inspectLinearModeTaskMappings(ctx context.Context, root project.Root, store *Store, projectID string) ([]Diagnostic, error) { + enabled, err := linearIntegrationEnabled(root.Path()) + if err != nil || !enabled { + return nil, err + } + + var unmappedCount int + if err := store.db.QueryRowContext(ctx, ` +SELECT COUNT(*) +FROM tasks +LEFT JOIN backend_mappings + ON backend_mappings.project_id = tasks.project_id + AND backend_mappings.backend = 'linear' + AND backend_mappings.entity_kind = 'task' + AND backend_mappings.entity_id = tasks.id +WHERE tasks.project_id = ? + AND tasks.status <> 'archived' + AND backend_mappings.id IS NULL +`, projectID).Scan(&unmappedCount); err != nil { + return nil, fmt.Errorf("inspect Linear-mode task backend mappings: %w", err) + } + if unmappedCount == 0 { + return nil, nil + } + return []Diagnostic{{ + Severity: "warn", + Code: "linear-mode-local-task-unmapped", + Category: RepairCategoryExternalSync, + Policy: DiagnosticPolicyExternalSyncGap, + Message: fmt.Sprintf("Linear integration is enabled, but %d active local task row(s) have no Linear backend mapping; export local task state and reconcile it through Linear or future backend sync tooling", unmappedCount), + Details: map[string]any{ + "backend": "linear", + "entity_kind": "task", + "unmapped_task_count": unmappedCount, + }, + RequiresExternalSync: true, + }}, nil +} + +func linearIntegrationEnabled(rootPath string) (bool, error) { + data, err := os.ReadFile(filepath.Join(rootPath, ".agents", "loaf.json")) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read .agents/loaf.json: %w", err) + } + var config struct { + Integrations struct { + Linear struct { + Enabled *bool `json:"enabled"` + } `json:"linear"` + } `json:"integrations"` + } + if err := json.Unmarshal(data, &config); err != nil { + return false, fmt.Errorf("parse .agents/loaf.json: %w", err) + } + return config.Integrations.Linear.Enabled != nil && *config.Integrations.Linear.Enabled, nil +} + func inspectStaleExports(ctx context.Context, store *Store) ([]Diagnostic, error) { rows, err := store.db.QueryContext(ctx, ` SELECT exports.id, exports.path, exports.source_entity_kind, exports.source_entity_id, exports.generated_at, specs.updated_at @@ -241,7 +1153,17 @@ FROM exports JOIN journal_entries ON exports.source_entity_kind = 'journal_entry diagnostics = append(diagnostics, Diagnostic{ Severity: "warn", Code: "stale-compatibility-export", + Category: RepairCategoryCompatibilityExport, + Policy: DiagnosticPolicyStaleExport, Message: fmt.Sprintf("export %s at %s is stale for %s %s", id, path, kind, entityID), + Details: map[string]any{ + "export_id": id, + "path": path, + "source_entity_kind": kind, + "source_entity_id": entityID, + "generated_at": generatedAt, + "source_updated_at": updatedAt, + }, }) } } diff --git a/internal/state/status_test.go b/internal/state/status_test.go index 8d41ebdf..ebd5d0d1 100644 --- a/internal/state/status_test.go +++ b/internal/state/status_test.go @@ -22,12 +22,21 @@ func TestInspectReportsMarkdownOnlyWithoutCreatingFiles(t *testing.T) { if status.Mode != ModeMarkdownOnly { t.Fatalf("Mode = %q, want %q", status.Mode, ModeMarkdownOnly) } + if status.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", status.DatabaseScope) + } if status.DatabaseExists { t.Fatal("DatabaseExists = true, want false") } if status.DatabaseParentExists { t.Fatal("DatabaseParentExists = true, want false before init") } + if status.ProjectID != "" { + t.Fatalf("ProjectID = %q, want empty before SQLite records durable identity", status.ProjectID) + } + if status.LegacyProjectKey != ProjectID(root) { + t.Fatalf("LegacyProjectKey = %q, want %q", status.LegacyProjectKey, ProjectID(root)) + } if _, err := os.Stat(filepath.Dir(status.DatabasePath)); !os.IsNotExist(err) { t.Fatalf("database parent exists after Inspect(); err = %v", err) } @@ -42,10 +51,7 @@ func TestInspectReportsLegacyStateDatabaseWhenDataHomeIsMissing(t *testing.T) { t.Setenv("XDG_DATA_HOME", dataHome) t.Setenv("XDG_STATE_HOME", stateHome) - legacyStatus, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) - if err != nil { - t.Fatalf("Initialize(legacy) error = %v", err) - } + legacyPath := initializeLegacyStateDatabase(t, root, PathResolver{}) status, err := Inspect(root, PathResolver{}) if err != nil { @@ -55,14 +61,17 @@ func TestInspectReportsLegacyStateDatabaseWhenDataHomeIsMissing(t *testing.T) { if status.Mode != ModeMarkdownOnly { t.Fatalf("Mode = %q, want %q before storage-home migration", status.Mode, ModeMarkdownOnly) } + if status.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", status.DatabaseScope) + } if status.DatabaseExists { t.Fatal("DatabaseExists = true, want false for new data home before migration") } if !status.LegacyDatabaseExists { t.Fatal("LegacyDatabaseExists = false, want true") } - if status.LegacyDatabasePath != legacyStatus.DatabasePath { - t.Fatalf("LegacyDatabasePath = %q, want %q", status.LegacyDatabasePath, legacyStatus.DatabasePath) + if status.LegacyDatabasePath != legacyPath { + t.Fatalf("LegacyDatabasePath = %q, want %q", status.LegacyDatabasePath, legacyPath) } if !strings.HasPrefix(status.DatabasePath, dataHome+string(filepath.Separator)) { t.Fatalf("DatabasePath = %q, want under XDG_DATA_HOME %q", status.DatabasePath, dataHome) @@ -70,6 +79,132 @@ func TestInspectReportsLegacyStateDatabaseWhenDataHomeIsMissing(t *testing.T) { assertDiagnostic(t, status.Diagnostics, "legacy-state-database-detected") } +func TestRepairPlanRecommendsSafeInitializationForMissingDatabase(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + + plan := RepairPlanForStatus(status) + action := findRepairAction(t, plan, "initialize-database") + if !action.Safe { + t.Fatalf("initialize action Safe = false, want true") + } + if action.Applied { + t.Fatalf("initialize action Applied = true, want false for dry-run plan") + } + if action.Command != "loaf state doctor --fix" { + t.Fatalf("initialize action Command = %q, want doctor --fix", action.Command) + } + if action.Category != RepairCategoryLocalDatabase { + t.Fatalf("initialize action Category = %q, want %q", action.Category, RepairCategoryLocalDatabase) + } + if action.Path != status.DatabasePath { + t.Fatalf("initialize action Path = %q, want %q", action.Path, status.DatabasePath) + } +} + +func TestRepairPlanTreatsLegacyLeftoverAsManualReview(t *testing.T) { + root := projectRoot(t) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + legacyPath := initializeLegacyStateDatabase(t, root, PathResolver{}) + if _, err := Initialize(context.Background(), root, PathResolver{}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + status, err := Inspect(root, PathResolver{}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + assertDiagnostic(t, status.Diagnostics, "legacy-project-database-leftover") + + action := findRepairAction(t, RepairPlanForStatus(status), "review-legacy-project-database") + if action.Safe { + t.Fatal("legacy leftover action Safe = true, want manual review") + } + if action.Applied { + t.Fatal("legacy leftover action Applied = true, want false") + } + if action.Command != "loaf state repair legacy-project-database --dry-run --json" { + t.Fatalf("legacy leftover action Command = %q, want legacy archive dry-run", action.Command) + } + if action.Category != RepairCategoryLocalDatabase { + t.Fatalf("legacy leftover action Category = %q, want %q", action.Category, RepairCategoryLocalDatabase) + } + if action.Path != legacyPath { + t.Fatalf("legacy leftover action Path = %q, want %q", action.Path, legacyPath) + } +} + +func TestRepairPlanDeduplicatesRepeatedActions(t *testing.T) { + status := Status{ + DatabasePath: "/tmp/loaf.sqlite", + Diagnostics: []Diagnostic{ + {Severity: "error", Code: "backend-mapping-entity-missing", Message: "first missing backend mapping"}, + {Severity: "error", Code: "backend-mapping-entity-missing", Message: "second missing backend mapping"}, + }, + } + + actions := RepairPlanForStatus(status) + if len(actions) != 1 { + t.Fatalf("len(actions) = %d, want 1: %#v", len(actions), actions) + } + if actions[0].Code != "inspect-backend-mappings" { + t.Fatalf("action Code = %q, want inspect-backend-mappings", actions[0].Code) + } + if actions[0].Category != RepairCategoryBackendMapping { + t.Fatalf("action Category = %q, want %q", actions[0].Category, RepairCategoryBackendMapping) + } +} + +func TestRepairPlanPreservesDistinctDiagnosticActions(t *testing.T) { + status := Status{ + DatabasePath: "/tmp/loaf.sqlite", + Diagnostics: []Diagnostic{ + {Severity: "error", Code: "backend-mapping-entity-missing", Message: "missing backend mapping"}, + {Severity: "warn", Code: "backend-mapping-entity-ambiguous", Message: "ambiguous backend mapping"}, + }, + } + + actions := RepairPlanForStatus(status) + if len(actions) != 2 { + t.Fatalf("len(actions) = %d, want 2: %#v", len(actions), actions) + } + if actions[0].DiagnosticCode == actions[1].DiagnosticCode { + t.Fatalf("diagnostic codes should remain distinct: %#v", actions) + } +} + +func TestRepairPlanClassifiesBackendAndExternalSyncActions(t *testing.T) { + status := Status{ + DatabasePath: "/tmp/loaf.sqlite", + Diagnostics: []Diagnostic{ + {Severity: "error", Code: "backend-mapping-entity-missing", Message: "missing backend mapping"}, + {Severity: "warn", Code: "backend-mapping-sync-status-unknown", Message: "unknown sync status"}, + {Severity: "warn", Code: "linear-mode-local-task-unmapped", Message: "unmapped local task"}, + }, + } + + actions := RepairPlanForStatus(status) + invalidMapping := findRepairAction(t, actions, "inspect-backend-mappings") + if invalidMapping.Category != RepairCategoryBackendMapping || invalidMapping.RequiresExternalSync { + t.Fatalf("invalid mapping action = %#v, want local backend-mapping audit", invalidMapping) + } + driftMapping := findRepairAction(t, actions, "audit-backend-mappings") + if driftMapping.Category != RepairCategoryBackendMapping || driftMapping.RequiresExternalSync { + t.Fatalf("drift mapping action = %#v, want local backend-mapping audit", driftMapping) + } + linearSync := findRepairAction(t, actions, "reconcile-linear-task-mappings") + if linearSync.Category != RepairCategoryExternalSync || !linearSync.RequiresExternalSync { + t.Fatalf("linear sync action = %#v, want external sync requirement", linearSync) + } +} + func TestInspectReportsSQLiteReadyWhenDatabaseIsInitialized(t *testing.T) { root := projectRoot(t) stateHome := t.TempDir() @@ -86,6 +221,12 @@ func TestInspectReportsSQLiteReadyWhenDatabaseIsInitialized(t *testing.T) { if status.Mode != ModeSQLiteReady { t.Fatalf("Mode = %q, want %q", status.Mode, ModeSQLiteReady) } + if status.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", status.ContractVersion, StateJSONContractVersion) + } + if status.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", status.DatabaseScope) + } if status.DatabasePath != initialized.DatabasePath { t.Fatalf("DatabasePath = %q, want %q", status.DatabasePath, initialized.DatabasePath) } @@ -98,9 +239,93 @@ func TestInspectReportsSQLiteReadyWhenDatabaseIsInitialized(t *testing.T) { if status.SchemaVersion != CurrentSchemaVersion() { t.Fatalf("SchemaVersion = %d, want %d", status.SchemaVersion, CurrentSchemaVersion()) } + if status.ProjectID == "" { + t.Fatal("ProjectID is empty after SQLite records durable identity") + } + if status.ProjectID == ProjectID(root) { + t.Fatalf("ProjectID = legacy path key %q, want generated durable identity", status.ProjectID) + } + if status.LegacyProjectKey != ProjectID(root) { + t.Fatalf("LegacyProjectKey = %q, want %q", status.LegacyProjectKey, ProjectID(root)) + } + if status.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want folder name", status.ProjectName) + } + if status.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", status.ProjectCurrentPath, root.Path()) + } assertDiagnostic(t, status.Diagnostics, "sqlite-ready") } +func TestInspectWarnsWhenGlobalDatabaseHasNotImportedCurrentMarkdown(t *testing.T) { + registeredRoot := projectRoot(t) + unimportedRoot := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), registeredRoot, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + writeAgentsFile(t, unimportedRoot.Path(), "reports/local.md", `--- +title: Local Markdown Report +status: final +--- +# Local Markdown Report +`) + + status, err := Inspect(unimportedRoot, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect(unimported) error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeSQLiteReady) + } + diagnostic := findDiagnostic(t, status.Diagnostics, "local-markdown-not-imported") + if !strings.Contains(diagnostic.Message, "1 importable artifact") || !strings.Contains(diagnostic.Message, "loaf state migrate markdown --dry-run") { + t.Fatalf("diagnostic Message = %q, want import guidance", diagnostic.Message) + } + assertDiagnosticPolicy(t, status.Diagnostics, "local-markdown-not-imported", RepairCategoryMarkdownImport, DiagnosticPolicyImportPending, false) + assertDiagnosticDetail(t, status.Diagnostics, "local-markdown-not-imported", "importable_count", 1) + assertDiagnosticDetail(t, status.Diagnostics, "local-markdown-not-imported", "reports", 1) + assertDiagnosticDetail(t, status.Diagnostics, "local-markdown-not-imported", "preview_command", "loaf state migrate markdown --dry-run") + action := findRepairAction(t, RepairPlanForStatus(status), "migrate-current-project-markdown") + if action.Command != "loaf state migrate markdown --dry-run" || !action.Safe { + t.Fatalf("repair action = %#v, want safe markdown migration preview", action) + } + + if _, err := ApplyMarkdownMigration(context.Background(), unimportedRoot, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("ApplyMarkdownMigration() error = %v", err) + } + migrated, err := Inspect(unimportedRoot, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect(migrated) error = %v", err) + } + assertNoDiagnostic(t, migrated.Diagnostics, "local-markdown-not-imported") +} + +func TestInspectWarnsWhenInitializedProjectHasUnimportedMarkdown(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + writeAgentsFile(t, root.Path(), "tasks/TASK-001-local.md", `--- +title: Local Markdown Task +status: todo +--- +# Local Markdown Task +`) + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + diagnostic := findDiagnostic(t, status.Diagnostics, "local-markdown-not-imported") + if !strings.Contains(diagnostic.Message, "1 importable artifact") { + t.Fatalf("diagnostic Message = %q, want local artifact count", diagnostic.Message) + } + assertDiagnosticPolicy(t, status.Diagnostics, "local-markdown-not-imported", RepairCategoryMarkdownImport, DiagnosticPolicyImportPending, false) + assertDiagnosticDetail(t, status.Diagnostics, "local-markdown-not-imported", "tasks", 1) +} + func TestInspectReportsInvalidWhenDatabaseFileIsNotSQLite(t *testing.T) { root := projectRoot(t) stateHome := t.TempDir() @@ -187,7 +412,7 @@ func TestInspectReportsStaleCompatibilityExportsAsWarnings(t *testing.T) { store := openTestStore(t, root, stateHome) defer store.Close() - projectID := ProjectID(root) + projectID := projectIDForTest(t, store, root) _, err := store.db.ExecContext(context.Background(), ` INSERT INTO ideas (id, project_id, title, status, created_at, updated_at) VALUES ('idea-stale-export', ?, 'Stale Export Idea', 'open', '2026-05-28T10:00:00Z', '2026-05-28T12:00:00Z'); @@ -212,7 +437,573 @@ VALUES ('export-stale', ?, 'triage', 'markdown', '.agents/exports/triage.md', 1, t.Fatalf("Mode = %q, want %q despite stale export warning", status.Mode, ModeSQLiteReady) } assertDiagnostic(t, status.Diagnostics, "sqlite-ready") - assertDiagnostic(t, status.Diagnostics, "stale-compatibility-export") + assertDiagnosticPolicy(t, status.Diagnostics, "stale-compatibility-export", RepairCategoryCompatibilityExport, DiagnosticPolicyStaleExport, false) + assertDiagnosticDetail(t, status.Diagnostics, "stale-compatibility-export", "export_id", "export-stale") + assertDiagnosticDetail(t, status.Diagnostics, "stale-compatibility-export", "source_entity_kind", "idea") + assertDiagnosticDetail(t, status.Diagnostics, "stale-compatibility-export", "source_entity_id", "idea-stale-export") +} + +func TestInspectReportsInvalidProjectPathInvariants(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +UPDATE projects +SET current_path = ? +WHERE id = ? +`, filepath.Join(root.Path(), "stale"), projectID); err != nil { + t.Fatalf("drift project current_path error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeInvalid) + } + assertDiagnostic(t, status.Diagnostics, "project-current-path-mismatch") + + action := findRepairAction(t, RepairPlanForStatus(status), "repair-project-path-invariants") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual project path repair") + } + if action.Command != "loaf project list --json" { + t.Fatalf("repair action Command = %q, want project list", action.Command) + } +} + +func TestInspectReportsSQLiteForeignKeyViolations(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + if _, err := store.db.ExecContext(context.Background(), `PRAGMA foreign_keys = OFF`); err != nil { + t.Fatalf("disable foreign keys error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO aliases (id, project_id, entity_kind, entity_id, namespace, alias, created_at, updated_at) +VALUES ('alias-orphaned-project', 'project-missing', 'task', 'task-missing', 'task', 'TASK-MISSING', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`); err != nil { + t.Fatalf("insert orphaned alias fixture error = %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeInvalid) + } + assertDiagnostic(t, status.Diagnostics, "sqlite-foreign-key-violation") + + action := findRepairAction(t, RepairPlanForStatus(status), "inspect-state-invariants") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual integrity inspection") + } + if action.Command != "loaf state doctor --json" { + t.Fatalf("repair action Command = %q, want state doctor JSON inspection", action.Command) + } +} + +func TestInspectUsesReadOnlyConnection(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + var journalMode string + if err := store.db.QueryRowContext(context.Background(), `PRAGMA journal_mode = DELETE`).Scan(&journalMode); err != nil { + t.Fatalf("set rollback journal mode error = %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + removeSQLiteSidecars(t, status.DatabasePath) + if err := os.Chmod(status.DatabasePath, 0o400); err != nil { + t.Fatalf("chmod database read-only error = %v", err) + } + defer os.Chmod(status.DatabasePath, 0o600) + databaseDir := filepath.Dir(status.DatabasePath) + if err := os.Chmod(databaseDir, 0o500); err != nil { + t.Fatalf("chmod database directory read-only error = %v", err) + } + defer os.Chmod(databaseDir, 0o700) + + inspected, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if inspected.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q", inspected.Mode, ModeSQLiteReady) + } +} + +func TestInspectReportsMissingRelationshipOriginAsWarning(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) +VALUES ('relationship-without-origin', ?, 'task', 'task-one', 'spec', 'spec-one', 'implements', 'legacy row', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert relationship without origin error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q for relationship provenance warning", status.Mode, ModeSQLiteReady) + } + assertDiagnostic(t, status.Diagnostics, "relationship-origin-missing") + + action := findRepairAction(t, RepairPlanForStatus(status), "audit-relationship-origin") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual relationship audit") + } + if action.Command != "loaf state repair relationship-origin --origin imported --dry-run --json" { + t.Fatalf("repair action Command = %q, want relationship origin repair dry-run", action.Command) + } +} + +func TestInspectReportsInvalidBackendMappingMissingEntity(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-orphaned', ?, 'linear', 'task', 'task-missing', 'issue', 'ENG-123', 'https://linear.app/workspace/issue/ENG-123', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert orphaned backend mapping error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeInvalid) + } + assertDiagnostic(t, status.Diagnostics, "backend-mapping-entity-missing") + assertDiagnosticPolicy(t, status.Diagnostics, "backend-mapping-entity-missing", RepairCategoryBackendMapping, DiagnosticPolicyInvalidLocalData, false) + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-missing", "mapping_id", "backend-mapping-orphaned") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-missing", "entity_kind", "task") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-missing", "entity_id", "task-missing") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-missing", "external_id", "ENG-123") + + action := findRepairAction(t, RepairPlanForStatus(status), "inspect-backend-mappings") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual backend mapping audit") + } + if action.Command != "loaf state doctor --json" { + t.Fatalf("repair action Command = %q, want state doctor JSON", action.Command) + } + if action.Category != RepairCategoryBackendMapping || action.RequiresExternalSync { + t.Fatalf("repair action = %#v, want local backend mapping inspection", action) + } +} + +func TestInspectReportsInvalidBackendMappingEmptyFields(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES ('task-linear-empty-field', ?, NULL, 'Linear task with empty mapping field', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert task fixture error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-empty-field', ?, 'linear', 'task', 'task-linear-empty-field', 'issue', ' ', NULL, 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert empty-field backend mapping error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeInvalid) + } + diagnostic := findDiagnostic(t, status.Diagnostics, "backend-mapping-field-empty") + if !strings.Contains(diagnostic.Message, "external_id") { + t.Fatalf("diagnostic Message = %q, want field name", diagnostic.Message) + } + assertDiagnosticPolicy(t, status.Diagnostics, "backend-mapping-field-empty", RepairCategoryBackendMapping, DiagnosticPolicyInvalidLocalData, false) + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-field-empty", "field", "external_id") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-field-empty", "row_count", 1) + + action := findRepairAction(t, RepairPlanForStatus(status), "inspect-backend-mappings") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual backend mapping audit") + } + if action.Command != "loaf state doctor --json" { + t.Fatalf("repair action Command = %q, want state doctor JSON", action.Command) + } + if action.Category != RepairCategoryBackendMapping || action.RequiresExternalSync { + t.Fatalf("repair action = %#v, want local backend mapping inspection", action) + } +} + +func TestInspectReportsSensitiveBackendMappingValues(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES ('task-linear-sensitive', ?, NULL, 'Linear task with sensitive mapping value', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert task fixture error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-sensitive-value', ?, 'linear', 'task', 'task-linear-sensitive', 'issue', 'ENG-130', 'https://linear.app/workspace/issue/ENG-130?access_token=REDACTED', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert sensitive backend mapping error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeInvalid) + } + diagnostic := findDiagnostic(t, status.Diagnostics, "backend-mapping-sensitive-value") + if !strings.Contains(diagnostic.Message, "external_url") { + t.Fatalf("diagnostic Message = %q, want field name", diagnostic.Message) + } + assertDiagnosticPolicy(t, status.Diagnostics, "backend-mapping-sensitive-value", RepairCategoryBackendMapping, DiagnosticPolicyInvalidLocalData, false) + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-sensitive-value", "field", "external_url") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-sensitive-value", "row_count", 1) + + action := findRepairAction(t, RepairPlanForStatus(status), "inspect-backend-mappings") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual backend mapping audit") + } + if action.Category != RepairCategoryBackendMapping || action.RequiresExternalSync { + t.Fatalf("repair action = %#v, want local backend mapping inspection", action) + } +} + +func TestInspectReportsUnknownBackendMappingEntityKind(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-unknown-kind', ?, 'linear', 'milestone', 'milestone-one', 'project_milestone', 'milestone-123', NULL, 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert unknown-kind backend mapping error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeInvalid) + } + assertDiagnostic(t, status.Diagnostics, "backend-mapping-entity-kind-unknown") + assertDiagnosticPolicy(t, status.Diagnostics, "backend-mapping-entity-kind-unknown", RepairCategoryBackendMapping, DiagnosticPolicyInvalidLocalData, false) + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-kind-unknown", "entity_kind", "milestone") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-kind-unknown", "row_count", 1) + assertNoDiagnostic(t, status.Diagnostics, "backend-mapping-entity-missing") +} + +func TestInspectAcceptsProjectBackendMapping(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-linear-project', ?, 'linear', 'project', ?, 'project', 'LIN-PROJ-123', 'https://linear.app/workspace/project/LIN-PROJ-123', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID, projectID); err != nil { + t.Fatalf("insert project backend mapping error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q for valid project backend mapping", status.Mode, ModeSQLiteReady) + } + assertNoDiagnostic(t, status.Diagnostics, "backend-mapping-entity-kind-unknown") + assertNoDiagnostic(t, status.Diagnostics, "backend-mapping-entity-missing") +} + +func TestInspectRejectsProjectBackendMappingToDifferentProjectID(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-wrong-project', ?, 'linear', 'project', 'project-missing', 'project', 'LIN-PROJ-124', 'https://linear.app/workspace/project/LIN-PROJ-124', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert wrong project backend mapping error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q for mismatched project backend mapping", status.Mode, ModeInvalid) + } + diagnostic := findDiagnostic(t, status.Diagnostics, "backend-mapping-entity-missing") + if !strings.Contains(diagnostic.Message, "project project-missing") { + t.Fatalf("diagnostic Message = %q, want project entity reference", diagnostic.Message) + } + assertDiagnosticPolicy(t, status.Diagnostics, "backend-mapping-entity-missing", RepairCategoryBackendMapping, DiagnosticPolicyInvalidLocalData, false) +} + +func TestInspectReportsAmbiguousBackendMappingAsWarning(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES ('task-linear', ?, NULL, 'Linear-backed task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert task fixture error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES + ('backend-mapping-linear-one', ?, 'linear', 'task', 'task-linear', 'issue', 'ENG-123', 'https://linear.app/workspace/issue/ENG-123', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z'), + ('backend-mapping-linear-two', ?, 'linear', 'task', 'task-linear', 'issue', 'ENG-124', 'https://linear.app/workspace/issue/ENG-124', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID, projectID); err != nil { + t.Fatalf("insert ambiguous backend mapping fixtures error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q for ambiguous backend mapping warning", status.Mode, ModeSQLiteReady) + } + assertDiagnostic(t, status.Diagnostics, "backend-mapping-entity-ambiguous") + assertDiagnosticPolicy(t, status.Diagnostics, "backend-mapping-entity-ambiguous", RepairCategoryBackendMapping, DiagnosticPolicyWarningDrift, false) + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-ambiguous", "entity_kind", "task") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-ambiguous", "entity_id", "task-linear") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-entity-ambiguous", "distinct_external_id_count", 2) + + action := findRepairAction(t, RepairPlanForStatus(status), "audit-backend-mappings") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual backend mapping audit") + } + if action.Category != RepairCategoryBackendMapping || action.RequiresExternalSync { + t.Fatalf("repair action = %#v, want local backend mapping audit", action) + } +} + +func TestInspectWarnsOnUnknownBackendMappingSyncStatus(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES + ('task-linear-linked', ?, NULL, 'Linear linked task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z'), + ('task-linear-typo', ?, NULL, 'Linear typo task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID, projectID); err != nil { + t.Fatalf("insert task fixtures error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES + ('backend-mapping-linear-linked', ?, 'linear', 'task', 'task-linear-linked', 'issue', 'ENG-125', 'https://linear.app/workspace/issue/ENG-125', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z'), + ('backend-mapping-linear-typo', ?, 'linear', 'task', 'task-linear-typo', 'issue', 'ENG-126', 'https://linear.app/workspace/issue/ENG-126', 'lnked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID, projectID); err != nil { + t.Fatalf("insert backend mapping fixtures error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q for unknown sync status warning", status.Mode, ModeSQLiteReady) + } + diagnostic := findDiagnostic(t, status.Diagnostics, "backend-mapping-sync-status-unknown") + if !strings.Contains(diagnostic.Message, "lnked") { + t.Fatalf("diagnostic Message = %q, want unknown status value", diagnostic.Message) + } + assertDiagnosticPolicy(t, status.Diagnostics, "backend-mapping-sync-status-unknown", RepairCategoryBackendMapping, DiagnosticPolicyWarningDrift, false) + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-sync-status-unknown", "sync_status", "lnked") + assertDiagnosticDetail(t, status.Diagnostics, "backend-mapping-sync-status-unknown", "row_count", 1) + + action := findRepairAction(t, RepairPlanForStatus(status), "audit-backend-mappings") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual backend mapping audit") + } + if action.Category != RepairCategoryBackendMapping || action.RequiresExternalSync { + t.Fatalf("repair action = %#v, want local backend mapping audit", action) + } +} + +func TestInspectWarnsOnUnmappedLocalTasksWhenLinearEnabled(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if err := os.MkdirAll(filepath.Join(root.Path(), ".agents"), 0o755); err != nil { + t.Fatalf("MkdirAll(.agents) error = %v", err) + } + if err := os.WriteFile(filepath.Join(root.Path(), ".agents", "loaf.json"), []byte(`{"integrations":{"linear":{"enabled":true}}}`+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile(loaf.json) error = %v", err) + } + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + projectID := projectIDForTest(t, store, root) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO tasks (id, project_id, spec_id, title, status, priority, body_source_id, created_at, updated_at) +VALUES + ('task-active-unmapped', ?, NULL, 'Active unmapped task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z'), + ('task-archived-unmapped', ?, NULL, 'Archived unmapped task', 'archived', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z'), + ('task-active-mapped', ?, NULL, 'Active mapped task', 'todo', 'P2', NULL, '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID, projectID, projectID); err != nil { + t.Fatalf("insert task fixtures error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO backend_mappings (id, project_id, backend, entity_kind, entity_id, external_kind, external_id, external_url, sync_status, created_at, updated_at) +VALUES ('backend-mapping-linear-task', ?, 'linear', 'task', 'task-active-mapped', 'issue', 'ENG-125', 'https://linear.app/workspace/issue/ENG-125', 'linked', '2026-06-13T10:00:00Z', '2026-06-13T10:00:00Z') +`, projectID); err != nil { + t.Fatalf("insert mapped task backend fixture error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q for Linear-mode local task warning", status.Mode, ModeSQLiteReady) + } + diagnostic := findDiagnostic(t, status.Diagnostics, "linear-mode-local-task-unmapped") + if !strings.Contains(diagnostic.Message, "1 active local task row") { + t.Fatalf("diagnostic Message = %q, want count of only active unmapped tasks", diagnostic.Message) + } + assertDiagnosticPolicy(t, status.Diagnostics, "linear-mode-local-task-unmapped", RepairCategoryExternalSync, DiagnosticPolicyExternalSyncGap, true) + assertDiagnosticDetail(t, status.Diagnostics, "linear-mode-local-task-unmapped", "backend", "linear") + assertDiagnosticDetail(t, status.Diagnostics, "linear-mode-local-task-unmapped", "entity_kind", "task") + assertDiagnosticDetail(t, status.Diagnostics, "linear-mode-local-task-unmapped", "unmapped_task_count", 1) + + action := findRepairAction(t, RepairPlanForStatus(status), "reconcile-linear-task-mappings") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual Linear mapping reconciliation") + } + if action.Command != "loaf state export all --format json" { + t.Fatalf("repair action Command = %q, want export all JSON", action.Command) + } + if action.Category != RepairCategoryExternalSync || !action.RequiresExternalSync { + t.Fatalf("repair action = %#v, want external Linear sync requirement", action) + } +} + +func TestInspectReportsInvalidWhenOperationalInvariantsAreUnreadable(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store := openTestStore(t, root, stateHome) + defer store.Close() + + if _, err := store.db.ExecContext(context.Background(), `DROP TABLE relationships`); err != nil { + t.Fatalf("drop relationships error = %v", err) + } + + status, err := Inspect(root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeInvalid { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeInvalid) + } + assertDiagnostic(t, status.Diagnostics, "state-invariants-unreadable") + + action := findRepairAction(t, RepairPlanForStatus(status), "inspect-state-invariants") + if action.Safe { + t.Fatalf("repair action Safe = true, want manual invariant inspection") + } + if action.Command != "loaf state doctor --json" { + t.Fatalf("repair action Command = %q, want state doctor JSON inspection", action.Command) + } } func TestInspectReportsInvalidEmptyFileWithoutOpeningSQLite(t *testing.T) { @@ -295,12 +1086,77 @@ func openTestStore(t *testing.T, root project.Root, stateHome string) *Store { return store } +func projectIDForTest(t *testing.T, store *Store, root project.Root) string { + t.Helper() + identity, err := store.ProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("ProjectIdentityForRoot() error = %v", err) + } + return identity.ID +} + func assertDiagnostic(t *testing.T, diagnostics []Diagnostic, code string) { + t.Helper() + _ = findDiagnostic(t, diagnostics, code) +} + +func assertDiagnosticPolicy(t *testing.T, diagnostics []Diagnostic, code string, category string, policy string, requiresExternalSync bool) { + t.Helper() + diagnostic := findDiagnostic(t, diagnostics, code) + if diagnostic.Category != category || diagnostic.Policy != policy || diagnostic.RequiresExternalSync != requiresExternalSync { + t.Fatalf("diagnostic %q = %#v, want category %q policy %q requiresExternalSync %v", code, diagnostic, category, policy, requiresExternalSync) + } +} + +func assertDiagnosticDetail(t *testing.T, diagnostics []Diagnostic, code string, key string, want any) { + t.Helper() + diagnostic := findDiagnostic(t, diagnostics, code) + got, ok := diagnostic.Details[key] + if !ok { + t.Fatalf("diagnostic %q details = %#v, want key %q", code, diagnostic.Details, key) + } + if got != want { + t.Fatalf("diagnostic %q details[%q] = %#v, want %#v", code, key, got, want) + } +} + +func findDiagnostic(t *testing.T, diagnostics []Diagnostic, code string) Diagnostic { t.Helper() for _, diagnostic := range diagnostics { if diagnostic.Code == code { - return + return diagnostic } } t.Fatalf("diagnostic %q not found in %#v", code, diagnostics) + return Diagnostic{} +} + +func assertNoDiagnostic(t *testing.T, diagnostics []Diagnostic, code string) { + t.Helper() + for _, diagnostic := range diagnostics { + if diagnostic.Code == code { + t.Fatalf("diagnostic %q found in %#v", code, diagnostics) + } + } +} + +func removeSQLiteSidecars(t *testing.T, path string) { + t.Helper() + for _, suffix := range []string{"-wal", "-shm"} { + sidecar := path + suffix + if err := os.Remove(sidecar); err != nil && !os.IsNotExist(err) { + t.Fatalf("remove SQLite sidecar %s error = %v", sidecar, err) + } + } +} + +func findRepairAction(t *testing.T, actions []RepairAction, code string) RepairAction { + t.Helper() + for _, action := range actions { + if action.Code == code { + return action + } + } + t.Fatalf("repair action %q not found in %#v", code, actions) + return RepairAction{} } diff --git a/internal/state/storage_home_migration.go b/internal/state/storage_home_migration.go index e03841e5..002f9ec4 100644 --- a/internal/state/storage_home_migration.go +++ b/internal/state/storage_home_migration.go @@ -2,23 +2,56 @@ package state import ( "context" + "database/sql" "fmt" "os" "path/filepath" + "strings" "github.com/levifig/loaf/internal/project" ) const ( StorageHomeActionCopy = "copy-legacy-to-data" + StorageHomeActionMerge = "merge-legacy-to-data" StorageHomeActionAlreadyMigrated = "already-migrated" StorageHomeActionNoLegacyState = "no-legacy-state" ) +var projectScopedMergeTables = []string{ + "sources", + "specs", + "tasks", + "ideas", + "sparks", + "brainstorms", + "shaping_drafts", + "sessions", + "reports", + "journal_entries", + "events", + "relationships", + "tags", + "entity_tags", + "bundles", + "bundle_members", + "aliases", + "backend_mappings", + "hook_events", + "exports", + "session_state_snapshots", +} + // StorageHomeMigrationPlan describes the XDG_STATE_HOME to XDG_DATA_HOME move. type StorageHomeMigrationPlan struct { + ContractVersion int `json:"contract_version"` Version int `json:"version"` + DatabaseScope string `json:"database_scope"` + MigrationScope string `json:"migration_scope"` ProjectRoot string `json:"project_root"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` DatabasePath string `json:"database_path"` LegacyDatabasePath string `json:"legacy_database_path"` DatabaseExists bool `json:"database_exists"` @@ -35,13 +68,16 @@ func PreviewStorageHomeMigration(root project.Root, resolver PathResolver) (Stor if err != nil { return StorageHomeMigrationPlan{}, err } - legacyPath, err := resolver.LegacyDatabasePath(root) + legacyPath, err := migrationSourceDatabasePath(root, resolver) if err != nil { return StorageHomeMigrationPlan{}, err } plan := StorageHomeMigrationPlan{ + ContractVersion: StateJSONContractVersion, Version: 1, + DatabaseScope: "global", + MigrationScope: "project", ProjectRoot: root.Path(), DatabasePath: databasePath, LegacyDatabasePath: legacyPath, @@ -53,12 +89,21 @@ func PreviewStorageHomeMigration(root project.Root, resolver PathResolver) (Stor plan.DatabaseExists = regularFileExists(databasePath) plan.LegacyDatabaseExists = regularFileExists(legacyPath) + if plan.DatabaseExists { + if status, err := Inspect(root, resolver); err == nil && status.Mode == ModeSQLiteReady { + plan.recordVerifiedProject(status) + } + } switch { + case plan.DatabaseExists && plan.LegacyDatabaseExists: + if databaseContainsRootProject(databasePath, root) { + plan.Action = StorageHomeActionAlreadyMigrated + plan.Warnings = append(plan.Warnings, "legacy project database remains after migration; leaving it untouched") + } else { + plan.Action = StorageHomeActionMerge + } case plan.DatabaseExists: plan.Action = StorageHomeActionAlreadyMigrated - if plan.LegacyDatabaseExists { - plan.Warnings = append(plan.Warnings, "legacy state database remains in the old state home; leaving it untouched") - } case plan.LegacyDatabaseExists: plan.Action = StorageHomeActionCopy default: @@ -75,6 +120,9 @@ func ApplyStorageHomeMigration(ctx context.Context, root project.Root, resolver return StorageHomeMigrationPlan{}, err } if plan.Action != StorageHomeActionCopy { + if plan.Action == StorageHomeActionMerge { + return ApplyProjectDatabaseMerge(ctx, root, resolver, plan) + } return plan, nil } @@ -104,6 +152,32 @@ func ApplyStorageHomeMigration(ctx context.Context, root project.Root, resolver return StorageHomeMigrationPlan{}, fmt.Errorf("close legacy state database: %w", err) } + copiedReady := false + defer func() { + if !copiedReady { + _ = os.Remove(plan.DatabasePath) + } + }() + copiedStore, err := OpenStore(plan.DatabasePath) + if err != nil { + return StorageHomeMigrationPlan{}, fmt.Errorf("open copied state database: %w", err) + } + if err := copiedStore.ApplyMigrations(ctx); err != nil { + if closeErr := copiedStore.Close(); closeErr != nil { + return StorageHomeMigrationPlan{}, fmt.Errorf("upgrade copied state database: %w; close copied state database: %v", err, closeErr) + } + return StorageHomeMigrationPlan{}, fmt.Errorf("upgrade copied state database: %w", err) + } + if err := copiedStore.UpsertProject(ctx, root); err != nil { + if closeErr := copiedStore.Close(); closeErr != nil { + return StorageHomeMigrationPlan{}, fmt.Errorf("record copied state project: %w; close copied state database: %v", err, closeErr) + } + return StorageHomeMigrationPlan{}, fmt.Errorf("record copied state project: %w", err) + } + if err := copiedStore.Close(); err != nil { + return StorageHomeMigrationPlan{}, fmt.Errorf("close copied state database: %w", err) + } + status, err := Inspect(root, resolver) if err != nil { return StorageHomeMigrationPlan{}, err @@ -111,14 +185,231 @@ func ApplyStorageHomeMigration(ctx context.Context, root project.Root, resolver if status.Mode != ModeSQLiteReady { return StorageHomeMigrationPlan{}, fmt.Errorf("copied state database is not ready: %s", status.Mode) } + copiedReady = true + plan.recordVerifiedProject(status) plan.Applied = true plan.DatabaseExists = true plan.LegacyDatabaseExists = true plan.Action = StorageHomeActionAlreadyMigrated - plan.Warnings = append(plan.Warnings, "legacy state database left untouched; remove it manually after verifying the data-home database") + plan.Warnings = append(plan.Warnings, "legacy project database left untouched; remove it manually after verifying the global database") return plan, nil } +// ApplyProjectDatabaseMerge copies the current project's rows from a legacy +// project-sharded database into the global state database. +func ApplyProjectDatabaseMerge(ctx context.Context, root project.Root, resolver PathResolver, plan StorageHomeMigrationPlan) (StorageHomeMigrationPlan, error) { + store, err := OpenStore(plan.DatabasePath) + if err != nil { + return StorageHomeMigrationPlan{}, fmt.Errorf("open global state database: %w", err) + } + defer store.Close() + if err := store.ApplyMigrations(ctx); err != nil { + return StorageHomeMigrationPlan{}, fmt.Errorf("upgrade global state database: %w", err) + } + if err := store.mergeProjectDatabase(ctx, plan.LegacyDatabasePath, ProjectID(root)); err != nil { + return StorageHomeMigrationPlan{}, err + } + if err := store.UpsertProject(ctx, root); err != nil { + return StorageHomeMigrationPlan{}, fmt.Errorf("record global state project: %w", err) + } + + status, err := Inspect(root, resolver) + if err != nil { + return StorageHomeMigrationPlan{}, err + } + if status.Mode != ModeSQLiteReady { + return StorageHomeMigrationPlan{}, fmt.Errorf("global state database is not ready: %s", status.Mode) + } + plan.recordVerifiedProject(status) + plan.Applied = true + plan.DatabaseExists = true + plan.LegacyDatabaseExists = true + plan.Action = StorageHomeActionAlreadyMigrated + plan.Warnings = append(plan.Warnings, "legacy project database left untouched; remove it manually after verifying the global database") + return plan, nil +} + +func (p *StorageHomeMigrationPlan) recordVerifiedProject(status Status) { + p.ProjectID = status.ProjectID + p.ProjectName = status.ProjectName + p.ProjectCurrentPath = status.ProjectCurrentPath +} + +func (s *Store) mergeProjectDatabase(ctx context.Context, sourcePath string, projectID string) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin project database merge: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, `ATTACH DATABASE ? AS legacy`, sourcePath); err != nil { + return fmt.Errorf("attach legacy project database: %w", err) + } + + if err := copyProjectRow(ctx, tx, projectID); err != nil { + return err + } + for _, table := range projectScopedMergeTables { + if err := copyProjectScopedRows(ctx, tx, table, projectID); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit project database merge: %w", err) + } + return nil +} + +type mergeExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} + +func copyProjectRow(ctx context.Context, tx mergeExecer, projectID string) error { + if !legacyTableExists(ctx, tx, "projects") { + return nil + } + columns := sharedColumns(ctx, tx, "projects") + if len(columns) == 0 { + return nil + } + columnList := quotedColumnList(columns) + _, err := tx.ExecContext(ctx, fmt.Sprintf(` +INSERT OR REPLACE INTO projects (%s) +SELECT %s FROM legacy.projects WHERE id = ? +`, columnList, columnList), projectID) + if err != nil { + return fmt.Errorf("merge project row: %w", err) + } + return nil +} + +func copyProjectScopedRows(ctx context.Context, tx mergeExecer, table string, projectID string) error { + if !legacyTableExists(ctx, tx, table) { + return nil + } + columns := sharedColumns(ctx, tx, table) + if len(columns) == 0 { + return nil + } + columnList := quotedColumnList(columns) + quotedTable := quoteSQLiteIdentifier(table) + _, err := tx.ExecContext(ctx, fmt.Sprintf(` +INSERT OR REPLACE INTO %s (%s) +SELECT %s FROM legacy.%s WHERE project_id = ? +`, quotedTable, columnList, columnList, quotedTable), projectID) + if err != nil { + return fmt.Errorf("merge %s rows: %w", table, err) + } + return nil +} + +func legacyTableExists(ctx context.Context, tx mergeExecer, table string) bool { + rows, err := tx.QueryContext(ctx, `SELECT 1 FROM legacy.sqlite_schema WHERE type = 'table' AND name = ? LIMIT 1`, table) + if err != nil { + return false + } + defer rows.Close() + return rows.Next() +} + +func sharedColumns(ctx context.Context, tx mergeExecer, table string) []string { + mainColumns := tableColumns(ctx, tx, "main", table) + if len(mainColumns) == 0 { + return nil + } + legacyColumns := tableColumns(ctx, tx, "legacy", table) + legacySet := map[string]bool{} + for _, column := range legacyColumns { + legacySet[column] = true + } + var shared []string + for _, column := range mainColumns { + if legacySet[column] { + shared = append(shared, column) + } + } + return shared +} + +func tableColumns(ctx context.Context, tx mergeExecer, schema string, table string) []string { + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`PRAGMA %s.table_info(%s)`, quoteSQLiteIdentifier(schema), quoteSQLiteIdentifier(table))) + if err != nil { + return nil + } + defer rows.Close() + var columns []string + for rows.Next() { + var cid int + var name string + var typ string + var notNull int + var defaultValue any + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + return nil + } + columns = append(columns, name) + } + return columns +} + +func quotedColumnList(columns []string) string { + quoted := make([]string, 0, len(columns)) + for _, column := range columns { + quoted = append(quoted, quoteSQLiteIdentifier(column)) + } + return strings.Join(quoted, ", ") +} + +func quoteSQLiteIdentifier(identifier string) string { + return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"` +} + +func migrationSourceDatabasePath(root project.Root, resolver PathResolver) (string, error) { + projectPath, err := resolver.ProjectDatabasePath(root) + if err != nil { + return "", err + } + if regularFileExists(projectPath) { + return projectPath, nil + } + legacyPath, err := resolver.LegacyDatabasePath(root) + if err != nil { + return "", err + } + return legacyPath, nil +} + +func databaseContainsProject(path string, projectID string) bool { + store, err := OpenStore(path) + if err != nil { + return false + } + defer store.Close() + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM projects WHERE id = ?`, projectID).Scan(&count); err != nil { + return false + } + return count > 0 +} + +func databaseContainsRootProject(path string, root project.Root) bool { + store, err := OpenStore(path) + if err != nil { + return false + } + defer store.Close() + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM project_paths WHERE path = ?`, root.Path()).Scan(&count); err == nil && count > 0 { + return true + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM projects WHERE id = ?`, ProjectID(root)).Scan(&count); err != nil { + return false + } + return count > 0 +} + func regularFileExists(path string) bool { info, err := os.Stat(path) return err == nil && !info.IsDir() diff --git a/internal/state/storage_home_migration_test.go b/internal/state/storage_home_migration_test.go index 9a4812ec..bdfffc4b 100644 --- a/internal/state/storage_home_migration_test.go +++ b/internal/state/storage_home_migration_test.go @@ -3,8 +3,11 @@ package state import ( "context" "os" + "path/filepath" "strings" "testing" + + "github.com/levifig/loaf/internal/project" ) func TestPreviewStorageHomeMigrationPlansLegacyCopy(t *testing.T) { @@ -14,16 +17,25 @@ func TestPreviewStorageHomeMigrationPlansLegacyCopy(t *testing.T) { t.Setenv("XDG_DATA_HOME", dataHome) t.Setenv("XDG_STATE_HOME", stateHome) - legacyStatus, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) - if err != nil { - t.Fatalf("Initialize(legacy) error = %v", err) - } + legacyPath := initializeLegacyStateDatabase(t, root, PathResolver{}) plan, err := PreviewStorageHomeMigration(root, PathResolver{}) if err != nil { t.Fatalf("PreviewStorageHomeMigration() error = %v", err) } + if plan.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", plan.ContractVersion, StateJSONContractVersion) + } + if plan.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", plan.DatabaseScope) + } + if plan.MigrationScope != "project" { + t.Fatalf("MigrationScope = %q, want project", plan.MigrationScope) + } + if plan.ProjectID != "" { + t.Fatalf("ProjectID = %q, want empty before apply", plan.ProjectID) + } if plan.Action != StorageHomeActionCopy { t.Fatalf("Action = %q, want %q", plan.Action, StorageHomeActionCopy) } @@ -36,8 +48,8 @@ func TestPreviewStorageHomeMigrationPlansLegacyCopy(t *testing.T) { if !plan.LegacyDatabaseExists { t.Fatal("LegacyDatabaseExists = false, want true") } - if plan.LegacyDatabasePath != legacyStatus.DatabasePath { - t.Fatalf("LegacyDatabasePath = %q, want %q", plan.LegacyDatabasePath, legacyStatus.DatabasePath) + if plan.LegacyDatabasePath != legacyPath { + t.Fatalf("LegacyDatabasePath = %q, want %q", plan.LegacyDatabasePath, legacyPath) } if !strings.HasPrefix(plan.DatabasePath, dataHome) { t.Fatalf("DatabasePath = %q, want under data home %q", plan.DatabasePath, dataHome) @@ -51,23 +63,38 @@ func TestApplyStorageHomeMigrationCopiesLegacyDatabaseWithoutDeletingIt(t *testi t.Setenv("XDG_DATA_HOME", dataHome) t.Setenv("XDG_STATE_HOME", stateHome) - legacyStatus, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) - if err != nil { - t.Fatalf("Initialize(legacy) error = %v", err) - } + legacyPath := initializeLegacyStateDatabase(t, root, PathResolver{}) plan, err := ApplyStorageHomeMigration(context.Background(), root, PathResolver{}) if err != nil { t.Fatalf("ApplyStorageHomeMigration() error = %v", err) } + if plan.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", plan.ContractVersion, StateJSONContractVersion) + } + if plan.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", plan.DatabaseScope) + } + if plan.MigrationScope != "project" { + t.Fatalf("MigrationScope = %q, want project", plan.MigrationScope) + } + if plan.ProjectID == "" { + t.Fatal("ProjectID is empty after apply") + } + if plan.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", plan.ProjectName, filepath.Base(root.Path())) + } + if plan.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", plan.ProjectCurrentPath, root.Path()) + } if !plan.Applied { t.Fatal("Applied = false, want true") } if plan.Action != StorageHomeActionAlreadyMigrated { t.Fatalf("Action = %q, want %q", plan.Action, StorageHomeActionAlreadyMigrated) } - if _, err := os.Stat(legacyStatus.DatabasePath); err != nil { + if _, err := os.Stat(legacyPath); err != nil { t.Fatalf("legacy database stat error = %v, want legacy file preserved", err) } if _, err := os.Stat(plan.DatabasePath); err != nil { @@ -95,18 +122,121 @@ func TestApplyStorageHomeMigrationCopiesLegacyDatabaseWithoutDeletingIt(t *testi if second.Action != StorageHomeActionAlreadyMigrated { t.Fatalf("second Action = %q, want %q", second.Action, StorageHomeActionAlreadyMigrated) } + if second.DatabaseScope != "global" || second.MigrationScope != "project" { + t.Fatalf("second scopes = %q/%q, want global/project", second.DatabaseScope, second.MigrationScope) + } } -func TestApplyStorageHomeMigrationDoesNotOverwriteExistingDataHomeDatabase(t *testing.T) { +func TestStorageHomeMigrationUsesProjectDataDatabaseBeforeStateHome(t *testing.T) { + root := projectRoot(t) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + projectPath := initializeProjectDataDatabase(t, root, PathResolver{}) + initializeLegacyStateDatabase(t, root, PathResolver{}) + + plan, err := PreviewStorageHomeMigration(root, PathResolver{}) + if err != nil { + t.Fatalf("PreviewStorageHomeMigration() error = %v", err) + } + if plan.Action != StorageHomeActionCopy { + t.Fatalf("Action = %q, want %q", plan.Action, StorageHomeActionCopy) + } + if plan.DatabaseScope != "global" || plan.MigrationScope != "project" { + t.Fatalf("scopes = %q/%q, want global/project", plan.DatabaseScope, plan.MigrationScope) + } + if plan.LegacyDatabasePath != projectPath { + t.Fatalf("LegacyDatabasePath = %q, want project data path %q", plan.LegacyDatabasePath, projectPath) + } + + applied, err := ApplyStorageHomeMigration(context.Background(), root, PathResolver{}) + if err != nil { + t.Fatalf("ApplyStorageHomeMigration() error = %v", err) + } + if !applied.Applied { + t.Fatal("Applied = false, want true") + } + if applied.ProjectID == "" { + t.Fatal("ProjectID is empty after project database migration") + } + if applied.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", applied.ProjectCurrentPath, root.Path()) + } + if applied.DatabasePath == projectPath { + t.Fatalf("DatabasePath = %q, want global path distinct from project path", applied.DatabasePath) + } + status, err := Inspect(root, PathResolver{}) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeSQLiteReady) + } +} + +func TestApplyStorageHomeMigrationUpgradesCopiedLegacySchema(t *testing.T) { + ctx := context.Background() root := projectRoot(t) dataHome := t.TempDir() stateHome := t.TempDir() t.Setenv("XDG_DATA_HOME", dataHome) t.Setenv("XDG_STATE_HOME", stateHome) - if _, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { - t.Fatalf("Initialize(legacy) error = %v", err) + resolver := PathResolver{} + legacyPath, err := resolver.LegacyDatabasePath(root) + if err != nil { + t.Fatalf("LegacyDatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil { + t.Fatalf("create legacy database dir error = %v", err) + } + legacyStore, err := OpenStore(legacyPath) + if err != nil { + t.Fatalf("OpenStore(legacy) error = %v", err) + } + if err := ApplyMigrations(ctx, legacyStore.db, SchemaMigrations()[:1]); err != nil { + t.Fatalf("apply legacy schema migration error = %v", err) + } + if err := legacyStore.UpsertProject(ctx, root); err != nil { + t.Fatalf("UpsertProject(legacy) error = %v", err) } + if err := legacyStore.Close(); err != nil { + t.Fatalf("close legacy store error = %v", err) + } + + plan, err := ApplyStorageHomeMigration(ctx, root, resolver) + if err != nil { + t.Fatalf("ApplyStorageHomeMigration() error = %v", err) + } + if !plan.Applied { + t.Fatal("Applied = false, want true") + } + if _, err := os.Stat(legacyPath); err != nil { + t.Fatalf("legacy database stat error = %v, want legacy file preserved", err) + } + + status, err := Inspect(root, resolver) + if err != nil { + t.Fatalf("Inspect() error = %v", err) + } + if status.Mode != ModeSQLiteReady { + t.Fatalf("Mode = %q, want %q", status.Mode, ModeSQLiteReady) + } + if status.SchemaVersion != CurrentSchemaVersion() { + t.Fatalf("SchemaVersion = %d, want %d", status.SchemaVersion, CurrentSchemaVersion()) + } +} + +func TestApplyStorageHomeMigrationDoesNotOverwriteExistingDataHomeDatabase(t *testing.T) { + root := projectRoot(t) + dataHome := t.TempDir() + stateHome := t.TempDir() + t.Setenv("XDG_DATA_HOME", dataHome) + t.Setenv("XDG_STATE_HOME", stateHome) + + initializeLegacyStateDatabase(t, root, PathResolver{}) dataStatus, err := Initialize(context.Background(), root, PathResolver{}) if err != nil { t.Fatalf("Initialize(data) error = %v", err) @@ -117,9 +247,6 @@ func TestApplyStorageHomeMigrationDoesNotOverwriteExistingDataHomeDatabase(t *te t.Fatalf("ApplyStorageHomeMigration() error = %v", err) } - if plan.Applied { - t.Fatal("Applied = true, want no overwrite") - } if plan.Action != StorageHomeActionAlreadyMigrated { t.Fatalf("Action = %q, want %q", plan.Action, StorageHomeActionAlreadyMigrated) } @@ -139,11 +266,8 @@ func TestApplyStorageHomeMigrationIncludesPendingWALFrames(t *testing.T) { t.Setenv("XDG_DATA_HOME", dataHome) t.Setenv("XDG_STATE_HOME", stateHome) - legacyStatus, err := Initialize(ctx, root, PathResolver{StateHome: stateHome}) - if err != nil { - t.Fatalf("Initialize(legacy) error = %v", err) - } - legacyStore, err := OpenStore(legacyStatus.DatabasePath) + legacyPath := initializeLegacyStateDatabase(t, root, PathResolver{}) + legacyStore, err := OpenStore(legacyPath) if err != nil { t.Fatalf("OpenStore(legacy) error = %v", err) } @@ -153,13 +277,20 @@ func TestApplyStorageHomeMigrationIncludesPendingWALFrames(t *testing.T) { t.Fatalf("wal checkpoint error = %v", err) } wantProjectID := "pending-wal-project" + wantProjectPath := filepath.Join(root.Path(), "pending-wal-project") if _, err := legacyStore.db.ExecContext(ctx, ` -INSERT INTO projects (id, identity_hash, created_at, updated_at) -VALUES (?, ?, ?, ?) -`, wantProjectID, wantProjectID, "2026-06-12T00:00:00Z", "2026-06-12T00:00:00Z"); err != nil { +INSERT INTO projects (id, identity_hash, friendly_name, current_path, last_seen_at, created_at, updated_at) +VALUES (?, ?, ?, ?, ?, ?, ?) +`, wantProjectID, wantProjectID, "Pending WAL Project", wantProjectPath, "2026-06-12T00:00:00Z", "2026-06-12T00:00:00Z", "2026-06-12T00:00:00Z"); err != nil { t.Fatalf("insert WAL-backed project error = %v", err) } - if info, err := os.Stat(legacyStatus.DatabasePath + "-wal"); err != nil { + if _, err := legacyStore.db.ExecContext(ctx, ` +INSERT INTO project_paths (id, project_id, path, is_current, first_seen_at, last_seen_at, created_at, updated_at) +VALUES (?, ?, ?, 1, ?, ?, ?, ?) +`, "pending-wal-project-path", wantProjectID, wantProjectPath, "2026-06-12T00:00:00Z", "2026-06-12T00:00:00Z", "2026-06-12T00:00:00Z", "2026-06-12T00:00:00Z"); err != nil { + t.Fatalf("insert WAL-backed project path error = %v", err) + } + if info, err := os.Stat(legacyPath + "-wal"); err != nil { t.Fatalf("legacy WAL stat error = %v", err) } else if info.Size() == 0 { t.Fatal("legacy WAL is empty, want pending frames before migration") @@ -183,3 +314,60 @@ VALUES (?, ?, ?, ?) t.Fatalf("migrated project count = %d, want 1", count) } } + +func initializeLegacyStateDatabase(t *testing.T, root project.Root, resolver PathResolver) string { + t.Helper() + ctx := context.Background() + legacyPath, err := resolver.LegacyDatabasePath(root) + if err != nil { + t.Fatalf("LegacyDatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(legacyPath), 0o700); err != nil { + t.Fatalf("create legacy database dir error = %v", err) + } + store, err := OpenStore(legacyPath) + if err != nil { + t.Fatalf("OpenStore(legacy) error = %v", err) + } + if err := store.ApplyMigrations(ctx); err != nil { + t.Fatalf("ApplyMigrations(legacy) error = %v", err) + } + if err := store.UpsertProject(ctx, root); err != nil { + t.Fatalf("UpsertProject(legacy) error = %v", err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close(legacy) error = %v", err) + } + return legacyPath +} + +func initializeProjectDataDatabase(t *testing.T, root project.Root, resolver PathResolver) string { + t.Helper() + projectPath, err := resolver.ProjectDatabasePath(root) + if err != nil { + t.Fatalf("ProjectDatabasePath() error = %v", err) + } + initializeDatabaseAtPath(t, root, projectPath) + return projectPath +} + +func initializeDatabaseAtPath(t *testing.T, root project.Root, path string) { + t.Helper() + ctx := context.Background() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("create database dir error = %v", err) + } + store, err := OpenStore(path) + if err != nil { + t.Fatalf("OpenStore(%s) error = %v", path, err) + } + if err := store.ApplyMigrations(ctx); err != nil { + t.Fatalf("ApplyMigrations(%s) error = %v", path, err) + } + if err := store.UpsertProject(ctx, root); err != nil { + t.Fatalf("UpsertProject(%s) error = %v", path, err) + } + if err := store.Close(); err != nil { + t.Fatalf("Close(%s) error = %v", path, err) + } +} diff --git a/internal/state/store.go b/internal/state/store.go index cb76db6f..d3f01103 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -20,8 +20,9 @@ const sqliteDriverName = "sqlite3" // Store owns a SQLite connection for Loaf operational state. type Store struct { - db *sql.DB - path string + db *sql.DB + path string + readOnly bool } // OpenStore opens an existing SQLite database path. @@ -38,6 +39,25 @@ func OpenStore(path string) (*Store, error) { return &Store{db: db, path: path}, nil } +// OpenStoreReadOnly opens an existing SQLite database without creating or mutating it. +func OpenStoreReadOnly(path string) (*Store, error) { + db, err := sql.Open(sqliteDriverName, sqliteReadOnlyDSN(path)) + if err != nil { + return nil, fmt.Errorf("open state database read-only: %w", err) + } + db.SetMaxOpenConns(1) + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("ping state database read-only: %w", err) + } + var schemaVersion int + if err := db.QueryRow(`PRAGMA schema_version`).Scan(&schemaVersion); err != nil { + db.Close() + return nil, fmt.Errorf("validate state database read-only: %w", err) + } + return &Store{db: db, path: path, readOnly: true}, nil +} + func sqliteDSN(path string) string { values := url.Values{} values.Add("_pragma", "busy_timeout(5000)") @@ -50,6 +70,18 @@ func sqliteDSN(path string) string { }).String() } +func sqliteReadOnlyDSN(path string) string { + values := url.Values{} + values.Add("mode", "ro") + values.Add("_pragma", "busy_timeout(5000)") + values.Add("_pragma", "foreign_keys(on)") + return (&url.URL{ + Scheme: "file", + Path: filepath.ToSlash(path), + RawQuery: values.Encode(), + }).String() +} + // Close closes the database connection. func (s *Store) Close() error { if s == nil || s.db == nil { @@ -63,7 +95,7 @@ func (s *Store) Path() string { return s.path } -// Initialize creates the project database, applies migrations, and records the project row. +// Initialize creates the global database, applies migrations, and records the project row. func Initialize(ctx context.Context, root project.Root, resolver PathResolver) (Status, error) { path, err := resolver.DatabasePath(root) if err != nil { @@ -93,23 +125,6 @@ func (s *Store) ApplyMigrations(ctx context.Context) error { return ApplyMigrations(ctx, s.db, SchemaMigrations()) } -// UpsertProject records the project identity row after migrations are applied. -func (s *Store) UpsertProject(ctx context.Context, root project.Root) error { - now := time.Now().UTC().Format(time.RFC3339) - projectID := ProjectID(root) - _, err := s.db.ExecContext(ctx, ` -INSERT INTO projects (id, identity_hash, created_at, updated_at) -VALUES (?, ?, ?, ?) -ON CONFLICT(id) DO UPDATE SET - identity_hash = excluded.identity_hash, - updated_at = excluded.updated_at -`, projectID, projectID, now, now) - if err != nil { - return fmt.Errorf("upsert project: %w", err) - } - return nil -} - // SchemaVersion returns the highest applied migration version. func (s *Store) SchemaVersion(ctx context.Context) (int, error) { var version sql.NullInt64 @@ -123,6 +138,51 @@ func (s *Store) SchemaVersion(ctx context.Context) (int, error) { return int(version.Int64), nil } +// DatabasePath returns the SQLite path backing this store. +func (s *Store) DatabasePath() string { + return s.path +} + +// ValidateCurrentSchema rejects version drift, missing migrations, and checksum drift. +func (s *Store) ValidateCurrentSchema(ctx context.Context) (int, error) { + version, err := s.SchemaVersion(ctx) + if err != nil { + return 0, err + } + current := CurrentSchemaVersion() + if version != current { + return version, fmt.Errorf("schema version %d does not match expected version %d", version, current) + } + for _, migration := range SchemaMigrations() { + var checksum string + err := s.db.QueryRowContext(ctx, `SELECT checksum FROM schema_migrations WHERE version = ?`, migration.Version).Scan(&checksum) + switch { + case err == nil && checksum != migration.Checksum(): + return version, fmt.Errorf("schema migration %d checksum does not match Go-owned migration", migration.Version) + case errors.Is(err, sql.ErrNoRows): + return version, fmt.Errorf("schema migration %d is missing", migration.Version) + case err != nil: + return version, fmt.Errorf("read schema migration %d: %w", migration.Version, err) + } + } + return version, nil +} + +// ValidateProjectPathInvariants rejects inconsistent durable project path metadata. +func (s *Store) ValidateProjectPathInvariants(ctx context.Context) error { + diagnostics, valid, err := inspectProjectPathInvariants(ctx, s) + if err != nil { + return err + } + if valid { + return nil + } + if len(diagnostics) == 0 { + return fmt.Errorf("project path invariants are invalid") + } + return fmt.Errorf("%s", diagnostics[0].Message) +} + // AppliedMigrationCount returns the number of applied migrations. func (s *Store) AppliedMigrationCount(ctx context.Context) (int, error) { var count int diff --git a/internal/state/store_test.go b/internal/state/store_test.go index cd4b3107..dd5765d6 100644 --- a/internal/state/store_test.go +++ b/internal/state/store_test.go @@ -8,8 +8,11 @@ import ( "path/filepath" "strings" "testing" + "time" _ "github.com/ncruces/go-sqlite3/driver" + + "github.com/levifig/loaf/internal/project" ) func TestInitializeAppliesMigrationsAndRecordsProject(t *testing.T) { @@ -52,7 +55,7 @@ func TestInitializeAppliesMigrationsAndRecordsProject(t *testing.T) { } var projectID string - err = store.db.QueryRowContext(context.Background(), `SELECT id FROM projects WHERE id = ?`, ProjectID(root)).Scan(&projectID) + err = store.db.QueryRowContext(context.Background(), `SELECT id FROM projects WHERE id = ?`, projectIDForTest(t, store, root)).Scan(&projectID) if err != nil { t.Fatalf("project row missing: %v", err) } @@ -79,6 +82,455 @@ func TestInitializeIsIdempotent(t *testing.T) { } } +func TestOpenStoreReadOnlyDoesNotCreateMissingDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.sqlite") + if _, err := OpenStoreReadOnly(path); err == nil { + t.Fatal("OpenStoreReadOnly() error = nil, want missing database error") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("Stat(%s) error = %v, want missing database to stay missing", path, err) + } +} + +func TestOpenStoreReadOnlyRejectsInvalidDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid.sqlite") + if err := os.WriteFile(path, []byte("not sqlite"), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + if _, err := OpenStoreReadOnly(path); err == nil { + t.Fatal("OpenStoreReadOnly() error = nil, want invalid database error") + } else if !strings.Contains(err.Error(), "validate state database read-only") { + t.Fatalf("OpenStoreReadOnly() error = %v, want validation context", err) + } +} + +func TestProjectIdentityIsStableAcrossRenameAndMove(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + identity, err := store.ProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("ProjectIdentityForRoot() error = %v", err) + } + if identity.ID == ProjectID(root) { + t.Fatalf("project ID = legacy path hash %q, want path-independent generated ID", identity.ID) + } + if identity.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", identity.ContractVersion, StateJSONContractVersion) + } + if identity.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", identity.DatabaseScope) + } + if identity.FriendlyName != filepath.Base(root.Path()) { + t.Fatalf("FriendlyName = %q, want folder name", identity.FriendlyName) + } + + renamed, err := store.RenameProject(context.Background(), root, "Friendly Loaf") + if err != nil { + t.Fatalf("RenameProject() error = %v", err) + } + if renamed.ID != identity.ID { + t.Fatalf("rename changed project ID: %q -> %q", identity.ID, renamed.ID) + } + if renamed.DatabaseScope != "global" { + t.Fatalf("renamed DatabaseScope = %q, want global", renamed.DatabaseScope) + } + if renamed.FriendlyName != "Friendly Loaf" { + t.Fatalf("FriendlyName = %q, want Friendly Loaf", renamed.FriendlyName) + } + + newRoot, err := project.ResolveRoot(t.TempDir()) + if err != nil { + t.Fatalf("ResolveRoot(new) error = %v", err) + } + moved, err := store.MoveProject(context.Background(), newRoot, root.Path(), newRoot.Path()) + if err != nil { + t.Fatalf("MoveProject() error = %v", err) + } + if moved.Project.ID != identity.ID { + t.Fatalf("move changed project ID: %q -> %q", identity.ID, moved.Project.ID) + } + if moved.DatabaseScope != "global" || moved.Project.DatabaseScope != "global" { + t.Fatalf("moved scopes = %q/%q, want global/global", moved.DatabaseScope, moved.Project.DatabaseScope) + } + if moved.Project.CurrentPath != newRoot.Path() { + t.Fatalf("CurrentPath = %q, want %q", moved.Project.CurrentPath, newRoot.Path()) + } +} + +func TestPreviewMoveProjectValidatesWithoutWriting(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + identity, err := store.ProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("ProjectIdentityForRoot() error = %v", err) + } + newRoot, err := project.ResolveRoot(t.TempDir()) + if err != nil { + t.Fatalf("ResolveRoot(new) error = %v", err) + } + preview, err := store.PreviewMoveProject(context.Background(), newRoot, root.Path(), newRoot.Path()) + if err != nil { + t.Fatalf("PreviewMoveProject() error = %v", err) + } + if preview.Action != "dry-run" { + t.Fatalf("Action = %q, want dry-run", preview.Action) + } + if preview.DatabaseScope != "global" || preview.Project.DatabaseScope != "global" { + t.Fatalf("preview scopes = %q/%q, want global/global", preview.DatabaseScope, preview.Project.DatabaseScope) + } + if preview.Project.ID != identity.ID || preview.Project.CurrentPath != newRoot.Path() { + t.Fatalf("preview project = %#v, want same ID %q previewing %s", preview.Project, identity.ID, newRoot.Path()) + } + after, err := store.LookupProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("LookupProjectIdentityForRoot(root) error = %v", err) + } + if after.CurrentPath != root.Path() { + t.Fatalf("CurrentPath after preview = %q, want original %q", after.CurrentPath, root.Path()) + } + if got := countCurrentProjectPaths(t, store, identity.ID); got != 1 { + t.Fatalf("current project paths after preview = %d, want 1", got) + } +} + +func TestPreviewRenameProjectValidatesWithoutWriting(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + identity, err := store.ProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("ProjectIdentityForRoot() error = %v", err) + } + preview, err := store.PreviewRenameProject(context.Background(), root, "Preview Loaf") + if err != nil { + t.Fatalf("PreviewRenameProject() error = %v", err) + } + if preview.Action != "dry-run" || preview.FromName != identity.FriendlyName || preview.ToName != "Preview Loaf" { + t.Fatalf("preview = %#v, want dry-run from %q to Preview Loaf", preview, identity.FriendlyName) + } + if preview.DatabaseScope != "global" || preview.Project.DatabaseScope != "global" { + t.Fatalf("preview scopes = %q/%q, want global/global", preview.DatabaseScope, preview.Project.DatabaseScope) + } + if preview.Project.ID != identity.ID || preview.Project.FriendlyName != "Preview Loaf" { + t.Fatalf("preview project = %#v, want same ID %q with preview name", preview.Project, identity.ID) + } + after, err := store.LookupProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("LookupProjectIdentityForRoot(root) error = %v", err) + } + if after.FriendlyName != identity.FriendlyName { + t.Fatalf("FriendlyName after preview = %q, want original %q", after.FriendlyName, identity.FriendlyName) + } +} + +func TestRenameProjectRequiresRegisteredIdentity(t *testing.T) { + root := projectRoot(t) + unknownRoot, err := project.ResolveRoot(t.TempDir()) + if err != nil { + t.Fatalf("ResolveRoot(unknown) error = %v", err) + } + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + if _, err := store.RenameProject(context.Background(), unknownRoot, "Unknown"); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("RenameProject(unknown) error = %v, want sql.ErrNoRows", err) + } + if got := countRows(t, store, `SELECT COUNT(*) FROM projects`); got != 1 { + t.Fatalf("projects = %d, want only initialized project row", got) + } +} + +func TestListProjectsReturnsRegisteredIdentities(t *testing.T) { + root := projectRoot(t) + otherRoot, err := project.ResolveRoot(t.TempDir()) + if err != nil { + t.Fatalf("ResolveRoot(other) error = %v", err) + } + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + loafIdentity, err := store.RenameProject(context.Background(), root, "Loaf") + if err != nil { + t.Fatalf("RenameProject() error = %v", err) + } + otherIdentity, err := store.EnsureProject(context.Background(), otherRoot) + if err != nil { + t.Fatalf("EnsureProject(other) error = %v", err) + } + + projects, err := store.ListProjects(context.Background()) + if err != nil { + t.Fatalf("ListProjects() error = %v", err) + } + if projects.DatabasePath != status.DatabasePath { + t.Fatalf("DatabasePath = %q, want %q", projects.DatabasePath, status.DatabasePath) + } + if projects.ContractVersion != StateJSONContractVersion { + t.Fatalf("projects ContractVersion = %d, want %d", projects.ContractVersion, StateJSONContractVersion) + } + if projects.DatabaseScope != "global" { + t.Fatalf("projects DatabaseScope = %q, want global", projects.DatabaseScope) + } + if len(projects.Projects) != 2 { + t.Fatalf("projects = %#v, want two registered identities", projects.Projects) + } + byID := map[string]ProjectIdentity{} + for _, project := range projects.Projects { + if project.DatabaseScope != "global" { + t.Fatalf("listed project DatabaseScope = %q, want global", project.DatabaseScope) + } + byID[project.ID] = project + } + if byID[loafIdentity.ID].FriendlyName != "Loaf" || byID[loafIdentity.ID].CurrentPath != root.Path() { + t.Fatalf("loaf project = %#v, want renamed identity at %s", byID[loafIdentity.ID], root.Path()) + } + if byID[otherIdentity.ID].CurrentPath != otherRoot.Path() { + t.Fatalf("other project = %#v, want path %s", byID[otherIdentity.ID], otherRoot.Path()) + } +} + +func TestProjectPathsAllowOnlyOneCurrentPath(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + projectID := projectIDForTest(t, store, root) + now := time.Now().UTC().Format(time.RFC3339) + _, err = store.db.ExecContext(context.Background(), ` +INSERT INTO project_paths (id, project_id, path, is_current, first_seen_at, last_seen_at, created_at, updated_at) +VALUES ('duplicate-current-path', ?, ?, 1, ?, ?, ?, ?) +`, projectID, filepath.Join(root.Path(), "other"), now, now, now, now) + if err == nil { + t.Fatal("insert duplicate current project path error = nil, want unique constraint failure") + } +} + +func TestStateCommandsFailWhenProjectIdentityMappingIsMissing(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + if _, err := store.db.ExecContext(context.Background(), `DROP TABLE project_paths`); err != nil { + t.Fatalf("drop project_paths error = %v", err) + } + + if _, err := store.ListTasks(context.Background(), root, TaskListOptions{}); err == nil { + t.Fatal("ListTasks() error = nil, want project identity mapping error") + } else if !strings.Contains(err.Error(), "read project path mapping") { + t.Fatalf("ListTasks() error = %q, want project identity mapping error", err) + } +} + +func TestMoveProjectUnknownFromPathDoesNotCreateProject(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + databasePath, err := (PathResolver{StateHome: stateHome}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(databasePath), 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + store, err := OpenStore(databasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations() error = %v", err) + } + + _, err = store.MoveProject(context.Background(), root, filepath.Join(t.TempDir(), "missing"), root.Path()) + if err == nil { + t.Fatal("MoveProject() error = nil, want unknown --from rejection") + } + var count int + if err := store.db.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM projects`).Scan(&count); err != nil { + t.Fatalf("count projects error = %v", err) + } + if count != 0 { + t.Fatalf("projects = %d, want no row after rejected move", count) + } +} + +func TestMoveProjectRequiresExistingTargetDirectory(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + status, err := Initialize(context.Background(), root, PathResolver{StateHome: stateHome}) + if err != nil { + t.Fatalf("Initialize() error = %v", err) + } + store, err := OpenStore(status.DatabasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + + missingTarget := filepath.Join(t.TempDir(), "missing-target") + if _, err := store.PreviewMoveProject(context.Background(), root, root.Path(), missingTarget); err == nil { + t.Fatal("PreviewMoveProject() error = nil, want missing target rejection") + } else if !strings.Contains(err.Error(), "target path does not exist") { + t.Fatalf("PreviewMoveProject() error = %q, want missing target path rejection", err) + } + if _, err := store.MoveProject(context.Background(), root, root.Path(), missingTarget); err == nil { + t.Fatal("MoveProject() error = nil, want missing target rejection") + } else if !strings.Contains(err.Error(), "target path does not exist") { + t.Fatalf("MoveProject() error = %q, want missing target path rejection", err) + } + + identity, err := store.LookupProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("LookupProjectIdentityForRoot() error = %v", err) + } + if identity.CurrentPath != root.Path() { + t.Fatalf("CurrentPath = %q, want unchanged %q", identity.CurrentPath, root.Path()) + } +} + +func TestProjectIdentityRekeysLegacyPathHashRows(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + databasePath, err := (PathResolver{StateHome: stateHome}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(databasePath), 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + store, err := OpenStore(databasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations() error = %v", err) + } + + legacyID := ProjectID(root) + now := time.Now().UTC().Format(time.RFC3339) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO projects (id, identity_hash, created_at, updated_at) +VALUES (?, ?, ?, ?) +`, legacyID, legacyID, now, now); err != nil { + t.Fatalf("insert legacy project error = %v", err) + } + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO ideas (id, project_id, title, status, created_at, updated_at) +VALUES ('idea-legacy', ?, 'Legacy Idea', 'open', ?, ?) +`, legacyID, now, now); err != nil { + t.Fatalf("insert legacy idea error = %v", err) + } + + identity, err := store.ProjectIdentityForRoot(context.Background(), root) + if err != nil { + t.Fatalf("ProjectIdentityForRoot() error = %v", err) + } + if identity.ID == legacyID { + t.Fatalf("identity ID = legacy path hash %q, want generated ID", identity.ID) + } + var ideaProjectID string + if err := store.db.QueryRowContext(context.Background(), `SELECT project_id FROM ideas WHERE id = 'idea-legacy'`).Scan(&ideaProjectID); err != nil { + t.Fatalf("read rekeyed idea error = %v", err) + } + if ideaProjectID != identity.ID { + t.Fatalf("idea project_id = %q, want %q", ideaProjectID, identity.ID) + } +} + +func TestLookupProjectIdentityDoesNotFallBackToLegacyPathHash(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + databasePath, err := (PathResolver{StateHome: stateHome}).DatabasePath(root) + if err != nil { + t.Fatalf("DatabasePath() error = %v", err) + } + if err := os.MkdirAll(filepath.Dir(databasePath), 0o700); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + store, err := OpenStore(databasePath) + if err != nil { + t.Fatalf("OpenStore() error = %v", err) + } + defer store.Close() + if err := store.ApplyMigrations(context.Background()); err != nil { + t.Fatalf("ApplyMigrations() error = %v", err) + } + + legacyID := ProjectID(root) + now := time.Now().UTC().Format(time.RFC3339) + if _, err := store.db.ExecContext(context.Background(), ` +INSERT INTO projects (id, identity_hash, created_at, updated_at) +VALUES (?, ?, ?, ?) +`, legacyID, legacyID, now, now); err != nil { + t.Fatalf("insert legacy project error = %v", err) + } + + if _, err := store.LookupProjectIdentityForRoot(context.Background(), root); !errors.Is(err, sql.ErrNoRows) { + t.Fatalf("LookupProjectIdentityForRoot() error = %v, want sql.ErrNoRows", err) + } +} + func TestOpenStoreAppliesConnectionPragmas(t *testing.T) { root := projectRoot(t) status, err := Initialize(context.Background(), root, PathResolver{StateHome: t.TempDir()}) @@ -216,3 +668,12 @@ func TestApplyMigrationsRollsBackFailedMigrationBatch(t *testing.T) { t.Fatalf("schema_migrations lookup error = %v, want no table after rollback", err) } } + +func countCurrentProjectPaths(t *testing.T, store *Store, projectID string) int { + t.Helper() + var count int + if err := store.db.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM project_paths WHERE project_id = ? AND is_current = 1`, projectID).Scan(&count); err != nil { + t.Fatalf("count current project paths error = %v", err) + } + return count +} diff --git a/internal/state/tag.go b/internal/state/tag.go index 2f200242..2ac1203c 100644 --- a/internal/state/tag.go +++ b/internal/state/tag.go @@ -13,8 +13,14 @@ import ( // TagList is the state-backed tag-list read model. type TagList struct { - Version int `json:"version"` - Tags map[string]TagItem `json:"tags"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Version int `json:"version"` + Tags map[string]TagItem `json:"tags"` } // TagItem is a tag entry returned by the state-backed tag list. @@ -24,14 +30,26 @@ type TagItem struct { // TagShowResult describes a tag and its classified rows. type TagShowResult struct { - Name string `json:"name"` - Members []TraceEntity `json:"members"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Name string `json:"name"` + Members []TraceEntity `json:"members"` } // TagMutationResult describes an add/remove tag mutation. type TagMutationResult struct { - Name string `json:"name"` - Entity TraceEntity `json:"entity"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Name string `json:"name"` + Entity TraceEntity `json:"entity"` } // ListTags returns tags from initialized SQLite state. @@ -76,7 +94,14 @@ func RemoveTag(ctx context.Context, root project.Root, resolver PathResolver, re // ListTags returns tags from an open store. func (s *Store) ListTags(ctx context.Context, root project.Root) (TagList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TagList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TagList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT tags.name, COUNT(entity_tags.id) FROM tags @@ -92,7 +117,16 @@ ORDER BY tags.name } defer rows.Close() - result := TagList{Version: 1, Tags: map[string]TagItem{}} + result := TagList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Tags: map[string]TagItem{}, + } for rows.Next() { var name string var count int @@ -109,7 +143,14 @@ ORDER BY tags.name // ShowTag returns members for one tag from an open store. func (s *Store) ShowTag(ctx context.Context, root project.Root, name string) (TagShowResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TagShowResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TagShowResult{}, err + } tagName, err := normalizeTagName(name) if err != nil { return TagShowResult{}, err @@ -162,12 +203,28 @@ ORDER BY entity_tags.entity_kind, entity_tags.entity_id right := members[j].Kind + "\x00" + firstNonEmpty(members[j].Alias, members[j].ID) return left < right }) - return TagShowResult{Name: tagName, Members: members}, nil + return TagShowResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Name: tagName, + Members: members, + }, nil } // AddTag adds a tag membership in an open store. func (s *Store) AddTag(ctx context.Context, root project.Root, ref string, name string) (TagMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TagMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TagMutationResult{}, err + } tagName, err := normalizeTagName(name) if err != nil { return TagMutationResult{}, err @@ -204,12 +261,28 @@ func (s *Store) AddTag(ctx context.Context, root project.Root, ref string, name if err := tx.Commit(); err != nil { return TagMutationResult{}, fmt.Errorf("commit tag transaction: %w", err) } - return TagMutationResult{Name: tagName, Entity: entity}, nil + return TagMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Name: tagName, + Entity: entity, + }, nil } // RemoveTag removes a tag membership in an open store. func (s *Store) RemoveTag(ctx context.Context, root project.Root, ref string, name string) (TagMutationResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TagMutationResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TagMutationResult{}, err + } tagName, err := normalizeTagName(name) if err != nil { return TagMutationResult{}, err @@ -233,7 +306,16 @@ WHERE project_id = ? AND tag_id = ? AND entity_kind = ? AND entity_id = ? if rows == 0 { return TagMutationResult{}, fmt.Errorf("tag %q is not attached to %s %q", tagName, entity.Kind, firstNonEmpty(entity.Alias, entity.ID)) } - return TagMutationResult{Name: tagName, Entity: entity}, nil + return TagMutationResult{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Name: tagName, + Entity: entity, + }, nil } func openInitializedStore(root project.Root, resolver PathResolver) (*Store, error) { @@ -250,6 +332,14 @@ func openInitializedStore(root project.Root, resolver PathResolver) (*Store, err if err != nil { return nil, err } + if err := store.ApplyMigrations(context.Background()); err != nil { + store.Close() + return nil, err + } + if err := store.UpsertProject(context.Background(), root); err != nil { + store.Close() + return nil, err + } return store, nil } diff --git a/internal/state/tag_test.go b/internal/state/tag_test.go index f866f6cc..798bb268 100644 --- a/internal/state/tag_test.go +++ b/internal/state/tag_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" "github.com/levifig/loaf/internal/project" @@ -40,9 +41,11 @@ func TestTagsClassifyRequiredEntityKindsThroughManyToManyTable(t *testing.T) { "journal_entry": journal.ID, } for kind, ref := range refs { - if _, err := AddTag(context.Background(), root, PathResolver{StateHome: stateHome}, ref, "SQLite"); err != nil { + added, err := AddTag(context.Background(), root, PathResolver{StateHome: stateHome}, ref, "SQLite") + if err != nil { t.Fatalf("AddTag(%s %s) error = %v", kind, ref, err) } + assertTagMutationContext(t, added, root) } if _, err := AddTag(context.Background(), root, PathResolver{StateHome: stateHome}, "SPEC-001", "sqlite"); err != nil { t.Fatalf("idempotent AddTag() error = %v", err) @@ -55,11 +58,13 @@ func TestTagsClassifyRequiredEntityKindsThroughManyToManyTable(t *testing.T) { if tags.Tags["sqlite"].Count != len(refs) { t.Fatalf("sqlite count = %d, want %d", tags.Tags["sqlite"].Count, len(refs)) } + assertTagListContext(t, tags, root) show, err := ShowTag(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite") if err != nil { t.Fatalf("ShowTag() error = %v", err) } + assertTagShowContext(t, show, root) gotKinds := map[string]bool{} for _, member := range show.Members { gotKinds[member.Kind] = true @@ -81,7 +86,7 @@ SELECT COUNT(*) FROM entity_tags JOIN tags ON tags.id = entity_tags.tag_id AND tags.project_id = entity_tags.project_id WHERE entity_tags.project_id = ? AND tags.name = 'sqlite' -`, ProjectID(root)).Scan(&memberships) +`, projectIDForTest(t, store, root)).Scan(&memberships) if err != nil { t.Fatalf("count memberships error = %v", err) } @@ -89,9 +94,11 @@ WHERE entity_tags.project_id = ? AND tags.name = 'sqlite' t.Fatalf("memberships = %d, want %d after idempotent add", memberships, len(refs)) } - if _, err := RemoveTag(context.Background(), root, PathResolver{StateHome: stateHome}, "TASK-001", "sqlite"); err != nil { + removed, err := RemoveTag(context.Background(), root, PathResolver{StateHome: stateHome}, "TASK-001", "sqlite") + if err != nil { t.Fatalf("RemoveTag() error = %v", err) } + assertTagMutationContext(t, removed, root) show, err = ShowTag(context.Background(), root, PathResolver{StateHome: stateHome}, "sqlite") if err != nil { t.Fatalf("ShowTag() after remove error = %v", err) @@ -106,6 +113,72 @@ WHERE entity_tags.project_id = ? AND tags.name = 'sqlite' } } +func assertTagMutationContext(t *testing.T, result TagMutationResult, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + +func assertTagListContext(t *testing.T, result TagList, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + +func assertTagShowContext(t *testing.T, result TagShowResult, root project.Root) { + t.Helper() + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } +} + func TestRemoveTagRejectsMissingMembership(t *testing.T) { repo := initGitRepo(t) root, err := project.ResolveRoot(repo) diff --git a/internal/state/task_archive.go b/internal/state/task_archive.go index fb83a901..91c7995e 100644 --- a/internal/state/task_archive.go +++ b/internal/state/task_archive.go @@ -18,9 +18,15 @@ type TaskArchiveOptions struct { // TaskArchiveResult describes a state-backed task archive mutation. type TaskArchiveResult struct { - Spec *TraceEntity `json:"spec,omitempty"` - Archived []TaskArchiveItem `json:"archived"` - Skipped []TaskArchiveItem `json:"skipped"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Spec *TraceEntity `json:"spec,omitempty"` + Archived []TaskArchiveItem `json:"archived"` + Skipped []TaskArchiveItem `json:"skipped"` } // TaskArchiveItem describes one requested task archive outcome. @@ -45,7 +51,14 @@ func ArchiveTasks(ctx context.Context, root project.Root, resolver PathResolver, // ArchiveTasks archives done tasks in an open store. func (s *Store) ArchiveTasks(ctx context.Context, root project.Root, options TaskArchiveOptions) (TaskArchiveResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TaskArchiveResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TaskArchiveResult{}, err + } if options.Spec != "" && len(options.Refs) > 0 { return TaskArchiveResult{}, fmt.Errorf("task archive accepts task ids or --spec, not both") } @@ -54,6 +67,12 @@ func (s *Store) ArchiveTasks(ctx context.Context, root project.Root, options Tas } result := TaskArchiveResult{Archived: []TaskArchiveItem{}, Skipped: []TaskArchiveItem{}} + result.ContractVersion = StateJSONContractVersion + result.DatabaseScope = identity.DatabaseScope + result.DatabasePath = identity.DatabasePath + result.ProjectID = identity.ID + result.ProjectName = identity.FriendlyName + result.ProjectCurrentPath = identity.CurrentPath refs := options.Refs if options.Spec != "" { spec, err := s.resolveTraceEntity(ctx, projectID, options.Spec) diff --git a/internal/state/task_archive_test.go b/internal/state/task_archive_test.go index 1e0aae04..79574788 100644 --- a/internal/state/task_archive_test.go +++ b/internal/state/task_archive_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" ) @@ -26,6 +27,24 @@ func TestArchiveTasksArchivesDoneTasksAndRecordsEvents(t *testing.T) { if len(result.Archived) != 1 || result.Archived[0].Task == nil || result.Archived[0].Task.Alias != "TASK-001" || result.Archived[0].Previous != "done" || result.Archived[0].Status != "archived" || result.Archived[0].EventID == "" { t.Fatalf("Archived = %#v, want TASK-001 archived with event", result.Archived) } + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } if len(result.Skipped) != 3 { t.Fatalf("Skipped = %#v, want todo, wrong-kind, and missing refs", result.Skipped) } diff --git a/internal/state/task_create.go b/internal/state/task_create.go index d837188a..30143590 100644 --- a/internal/state/task_create.go +++ b/internal/state/task_create.go @@ -14,6 +14,7 @@ import ( ) var taskAliasPattern = regexp.MustCompile(`^TASK-(\d+)$`) +var taskPriorityOrder = []string{"P0", "P1", "P2", "P3"} // TaskCreateOptions describes a SQLite-backed task creation request. type TaskCreateOptions struct { @@ -25,11 +26,17 @@ type TaskCreateOptions struct { // TaskCreateResult describes a created SQLite-backed task. type TaskCreateResult struct { - Task TraceEntity `json:"task"` - Priority string `json:"priority"` - Spec TraceEntity `json:"spec,omitempty"` - Depends []TraceEntity `json:"depends_on"` - EventID string `json:"event_id"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Task TraceEntity `json:"task"` + Priority string `json:"priority"` + Spec *TraceEntity `json:"spec,omitempty"` + Depends []TraceEntity `json:"depends_on"` + EventID string `json:"event_id"` } // CreateTask creates a task in initialized SQLite state. @@ -44,7 +51,14 @@ func CreateTask(ctx context.Context, root project.Root, resolver PathResolver, o // CreateTask creates a task in an open store. func (s *Store) CreateTask(ctx context.Context, root project.Root, options TaskCreateOptions) (TaskCreateResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TaskCreateResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TaskCreateResult{}, err + } title := strings.TrimSpace(options.Title) if title == "" { return TaskCreateResult{}, fmt.Errorf("task create requires --title") @@ -54,10 +68,10 @@ func (s *Store) CreateTask(ctx context.Context, root project.Root, options TaskC priority = "P2" } if !ValidTaskPriority(priority) { - return TaskCreateResult{}, fmt.Errorf("invalid priority %q", priority) + return TaskCreateResult{}, fmt.Errorf("invalid priority %q (valid: %s)", priority, taskPriorityText()) } - var spec TraceEntity + var spec *TraceEntity var specID any if strings.TrimSpace(options.Spec) != "" { resolved, err := s.resolveTraceEntity(ctx, projectID, options.Spec) @@ -67,7 +81,7 @@ func (s *Store) CreateTask(ctx context.Context, root project.Root, options TaskC if resolved.Kind != "spec" { return TaskCreateResult{}, fmt.Errorf("%q resolves to %s, not spec", options.Spec, resolved.Kind) } - spec = resolved + spec = &resolved specID = resolved.ID } @@ -111,11 +125,11 @@ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?) return TaskCreateResult{}, err } - if spec.ID != "" { + if spec != nil { relationshipID := stableMigrationID("relationship", projectID, "task", taskID, "implements", "spec", spec.ID) if _, err := tx.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, 'task', ?, 'spec', ?, 'implements', 'recorded by task create', ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, 'task', ?, 'spec', ?, 'implements', 'recorded by task create', 'command', ?, ?) `, relationshipID, projectID, taskID, spec.ID, now, now); err != nil { return TaskCreateResult{}, fmt.Errorf("record task spec relationship: %w", err) } @@ -123,8 +137,8 @@ VALUES (?, ?, 'task', ?, 'spec', ?, 'implements', 'recorded by task create', ?, for _, dependency := range dependencies { relationshipID := stableMigrationID("relationship", projectID, "task", taskID, "blocked_by", "task", dependency.ID) if _, err := tx.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, 'task', ?, 'task', ?, 'blocked_by', 'recorded by task create', ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, 'task', ?, 'task', ?, 'blocked_by', 'recorded by task create', 'command', ?, ?) `, relationshipID, projectID, taskID, dependency.ID, now, now); err != nil { return TaskCreateResult{}, fmt.Errorf("record task dependency relationship: %w", err) } @@ -144,11 +158,17 @@ VALUES (?, ?, 'task', ?, 'status_changed', NULL, 'todo', 'recorded by task creat task := TraceEntity{Kind: "task", ID: taskID, Alias: alias, Title: title, Status: "todo"} return TaskCreateResult{ - Task: task, - Priority: priority, - Spec: spec, - Depends: dependencies, - EventID: eventID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Task: task, + Priority: priority, + Spec: spec, + Depends: dependencies, + EventID: eventID, }, nil } @@ -212,10 +232,19 @@ VALUES (?, ?, ?, ?, ?, ?, ?, ?) // ValidTaskPriority reports whether priority is a known task priority. func ValidTaskPriority(priority string) bool { - switch priority { - case "P0", "P1", "P2", "P3": - return true - default: - return false + for _, valid := range taskPriorityOrder { + if priority == valid { + return true + } } + return false +} + +// TaskPriorities returns valid task priorities in display order. +func TaskPriorities() []string { + return append([]string(nil), taskPriorityOrder...) +} + +func taskPriorityText() string { + return strings.Join(TaskPriorities(), ", ") } diff --git a/internal/state/task_create_test.go b/internal/state/task_create_test.go index 91f6c5fd..b6cc7822 100644 --- a/internal/state/task_create_test.go +++ b/internal/state/task_create_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "strings" "testing" ) @@ -20,8 +21,26 @@ func TestCreateTaskDefaultsAndIntegratesWithReads(t *testing.T) { if result.Task.Alias != "TASK-001" || result.Task.Title != "New Task" || result.Task.Status != "todo" || result.Priority != "P2" || result.EventID == "" { t.Fatalf("result = %#v, want default TASK-001 todo task", result) } - if result.Spec.ID != "" { - t.Fatalf("Spec = %#v, want empty spec", result.Spec) + if result.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", result.ContractVersion, StateJSONContractVersion) + } + if result.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", result.DatabaseScope) + } + if result.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if result.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if result.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", result.ProjectName, filepath.Base(root.Path())) + } + if result.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", result.ProjectCurrentPath, root.Path()) + } + if result.Spec != nil { + t.Fatalf("Spec = %#v, want nil spec", result.Spec) } if len(result.Depends) != 0 { t.Fatalf("Depends = %#v, want none", result.Depends) @@ -73,7 +92,7 @@ func TestCreateTaskWithSpecAndDependencies(t *testing.T) { if result.Task.Alias != "TASK-002" || result.Task.Title != "Follow-up Task" || result.Priority != "P1" { t.Fatalf("result.Task = %#v, want TASK-002 follow-up", result.Task) } - if result.Spec.Alias != "SPEC-001" { + if result.Spec == nil || result.Spec.Alias != "SPEC-001" { t.Fatalf("Spec = %#v, want SPEC-001", result.Spec) } if len(result.Depends) != 1 || result.Depends[0].Alias != "TASK-001" { diff --git a/internal/state/task_list.go b/internal/state/task_list.go index df211303..fab0ecfe 100644 --- a/internal/state/task_list.go +++ b/internal/state/task_list.go @@ -14,8 +14,15 @@ var taskListStatusOrder = []string{"in_progress", "blocked", "todo", "review", " // TaskList is the state-backed task-list read model. type TaskList struct { - Version int `json:"version"` - Tasks map[string]TaskItem `json:"tasks"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Diagnostics []Diagnostic `json:"diagnostics,omitempty"` + Version int `json:"version"` + Tasks map[string]TaskItem `json:"tasks"` } // TaskItem is a task entry returned by the state-backed task list. @@ -55,7 +62,14 @@ func ListTasks(ctx context.Context, root project.Root, resolver PathResolver, op // ListTasks returns imported tasks from an open store. func (s *Store) ListTasks(ctx context.Context, root project.Root, options TaskListOptions) (TaskList, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TaskList{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TaskList{}, err + } rows, err := s.db.QueryContext(ctx, ` SELECT task_alias.alias, @@ -83,7 +97,16 @@ ORDER BY task_alias.alias return TaskList{}, fmt.Errorf("query tasks: %w", err) } - taskList := TaskList{Version: 1, Tasks: map[string]TaskItem{}} + taskList := TaskList{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Version: 1, + Tasks: map[string]TaskItem{}, + } for rows.Next() { var alias, title, status, priority, specAlias, sourcePath string if err := rows.Scan(&alias, &title, &status, &priority, &specAlias, &sourcePath); err != nil { @@ -181,6 +204,11 @@ func ValidTaskStatus(status string) bool { return false } +// TaskStatuses returns valid direct task statuses in display order. +func TaskStatuses() []string { + return append([]string(nil), taskStatusOrder...) +} + // ValidTaskListStatus reports whether status is a known task-list filter status. func ValidTaskListStatus(status string) bool { for _, valid := range taskListStatusOrder { @@ -190,3 +218,8 @@ func ValidTaskListStatus(status string) bool { } return false } + +// TaskListStatuses returns valid task-list filter statuses in display order. +func TaskListStatuses() []string { + return append([]string(nil), taskListStatusOrder...) +} diff --git a/internal/state/task_list_test.go b/internal/state/task_list_test.go index 1b87c433..b8ade932 100644 --- a/internal/state/task_list_test.go +++ b/internal/state/task_list_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" ) @@ -37,6 +38,7 @@ func TestListTasksReadsImportedSQLiteTasks(t *testing.T) { if err != nil { t.Fatalf("ListTasks() error = %v", err) } + assertTaskProjectContext(t, root.Path(), tasks.ContractVersion, tasks.DatabaseScope, tasks.DatabasePath, tasks.ProjectID, tasks.ProjectName, tasks.ProjectCurrentPath) task := tasks.Tasks["TASK-001"] if task.Title != "Example Task" || task.Status != "todo" || task.Priority != "P1" || task.Spec != "SPEC-001" { @@ -57,6 +59,70 @@ func TestListTasksReadsImportedSQLiteTasks(t *testing.T) { } } +func TestListTasksIgnoresEmptyFrontmatterDependencyList(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + writeAgentsFile(t, root.Path(), "TASKS.json", `{"tasks":{}}`) + writeAgentsFile(t, root.Path(), "tasks/TASK-001-empty-deps.md", `--- +id: TASK-001 +title: Empty Dependencies +depends_on: [] +--- +# Empty Dependencies +`) + + if _, err := ApplyMarkdownMigration(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("ApplyMarkdownMigration() error = %v", err) + } + + tasks, err := ListTasks(context.Background(), root, PathResolver{StateHome: stateHome}, TaskListOptions{}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + task := tasks.Tasks["TASK-001"] + if task.DependsOn == nil || len(task.DependsOn) != 0 { + t.Fatalf("DependsOn = %#v, want empty dependency list", task.DependsOn) + } +} + +func TestApplyMarkdownMigrationPrunesStaleImportedDependencies(t *testing.T) { + root := projectRoot(t) + stateHome := t.TempDir() + writeAgentsFile(t, root.Path(), "TASKS.json", `{"tasks":{}}`) + writeAgentsFile(t, root.Path(), "tasks/TASK-001-changing-deps.md", `--- +id: TASK-001 +title: Changing Dependencies +depends_on: + - TASK-002 +--- +# Changing Dependencies +`) + + if _, err := ApplyMarkdownMigration(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("ApplyMarkdownMigration() error = %v", err) + } + + writeAgentsFile(t, root.Path(), "tasks/TASK-001-changing-deps.md", `--- +id: TASK-001 +title: Changing Dependencies +depends_on: [] +--- +# Changing Dependencies +`) + if _, err := ApplyMarkdownMigration(context.Background(), root, PathResolver{StateHome: stateHome}); err != nil { + t.Fatalf("second ApplyMarkdownMigration() error = %v", err) + } + + tasks, err := ListTasks(context.Background(), root, PathResolver{StateHome: stateHome}, TaskListOptions{}) + if err != nil { + t.Fatalf("ListTasks() error = %v", err) + } + task := tasks.Tasks["TASK-001"] + if task.DependsOn == nil || len(task.DependsOn) != 0 { + t.Fatalf("DependsOn = %#v, want stale dependency pruned", task.DependsOn) + } +} + func TestListTasksFiltersActiveAndStatus(t *testing.T) { root := projectRoot(t) stateHome := t.TempDir() @@ -97,3 +163,25 @@ func TestListTasksFiltersActiveAndStatus(t *testing.T) { t.Fatalf("active done list = %#v, want empty", activeDone.Tasks) } } + +func assertTaskProjectContext(t *testing.T, rootPath string, contractVersion int, databaseScope string, databasePath string, projectID string, projectName string, projectCurrentPath string) { + t.Helper() + if contractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", contractVersion, StateJSONContractVersion) + } + if databaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", databaseScope) + } + if databasePath == "" { + t.Fatal("DatabasePath is empty") + } + if projectID == "" { + t.Fatal("ProjectID is empty") + } + if projectName != filepath.Base(rootPath) { + t.Fatalf("ProjectName = %q, want %q", projectName, filepath.Base(rootPath)) + } + if projectCurrentPath != rootPath { + t.Fatalf("ProjectCurrentPath = %q, want %q", projectCurrentPath, rootPath) + } +} diff --git a/internal/state/task_show.go b/internal/state/task_show.go index 350c1353..1350d710 100644 --- a/internal/state/task_show.go +++ b/internal/state/task_show.go @@ -14,8 +14,14 @@ import ( // TaskShow is the state-backed single-task read model. type TaskShow struct { - Query string `json:"query"` - Task TaskDetail `json:"task"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Task TaskDetail `json:"task"` } // TaskDetail contains operational task metadata plus imported source context. @@ -55,7 +61,14 @@ func ShowTask(ctx context.Context, root project.Root, resolver PathResolver, ref // ShowTask returns one imported task from an open store. func (s *Store) ShowTask(ctx context.Context, root project.Root, ref string) (TaskShow, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TaskShow{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TaskShow{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return TaskShow{}, err @@ -68,7 +81,16 @@ func (s *Store) ShowTask(ctx context.Context, root project.Root, ref string) (Ta if err != nil { return TaskShow{}, err } - return TaskShow{Query: ref, Task: task}, nil + return TaskShow{ + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Task: task, + }, nil } func (s *Store) taskDetail(ctx context.Context, root project.Root, projectID string, entity TraceEntity) (TaskDetail, error) { diff --git a/internal/state/task_show_test.go b/internal/state/task_show_test.go index db981111..32137641 100644 --- a/internal/state/task_show_test.go +++ b/internal/state/task_show_test.go @@ -26,6 +26,7 @@ Imported task prose. if err != nil { t.Fatalf("ShowTask() error = %v", err) } + assertTaskProjectContext(t, root.Path(), result.ContractVersion, result.DatabaseScope, result.DatabasePath, result.ProjectID, result.ProjectName, result.ProjectCurrentPath) task := result.Task if result.Query != "TASK-001" { diff --git a/internal/state/task_update.go b/internal/state/task_update.go index 70bde92b..b2bffe94 100644 --- a/internal/state/task_update.go +++ b/internal/state/task_update.go @@ -28,14 +28,20 @@ type TaskUpdateOptions struct { // TaskStatusUpdateResult describes a task status mutation. type TaskStatusUpdateResult struct { - Task TraceEntity `json:"task"` - Previous string `json:"previous_status"` - Status string `json:"status"` - Priority string `json:"priority,omitempty"` - Spec *TraceEntity `json:"spec,omitempty"` - Depends []TraceEntity `json:"depends_on,omitempty"` - Session *TraceEntity `json:"session,omitempty"` - EventID string `json:"event_id,omitempty"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Task TraceEntity `json:"task"` + Previous string `json:"previous_status"` + Status string `json:"status"` + Priority string `json:"priority,omitempty"` + Spec *TraceEntity `json:"spec,omitempty"` + Depends []TraceEntity `json:"depends_on,omitempty"` + Session *TraceEntity `json:"session,omitempty"` + EventID string `json:"event_id,omitempty"` } // UpdateTaskStatus updates a task's status in initialized SQLite state. @@ -60,7 +66,14 @@ func (s *Store) UpdateTaskStatus(ctx context.Context, root project.Root, ref str // UpdateTask updates a task in an open store. func (s *Store) UpdateTask(ctx context.Context, root project.Root, options TaskUpdateOptions) (TaskStatusUpdateResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TaskStatusUpdateResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TaskStatusUpdateResult{}, err + } if !options.SetStatus && !options.SetPriority && !options.SetSpec && !options.SetDependsOn && !options.SetSession { return TaskStatusUpdateResult{}, fmt.Errorf("task update requires at least one update") } @@ -68,7 +81,7 @@ func (s *Store) UpdateTask(ctx context.Context, root project.Root, options TaskU return TaskStatusUpdateResult{}, fmt.Errorf("invalid task status %q", options.Status) } if options.SetPriority && !ValidTaskPriority(options.Priority) { - return TaskStatusUpdateResult{}, fmt.Errorf("invalid priority %q", options.Priority) + return TaskStatusUpdateResult{}, fmt.Errorf("invalid priority %q (valid: %s)", options.Priority, taskPriorityText()) } task, err := s.resolveTraceEntity(ctx, projectID, options.Ref) if err != nil { @@ -217,14 +230,20 @@ ON CONFLICT(id) DO NOTHING } resultSession := session return TaskStatusUpdateResult{ - Task: task, - Previous: previousStatus, - Status: finalStatus, - Priority: finalPriority, - Spec: resultSpec, - Depends: resultDepends, - Session: resultSession, - EventID: eventID, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Task: task, + Previous: previousStatus, + Status: finalStatus, + Priority: finalPriority, + Spec: resultSpec, + Depends: resultDepends, + Session: resultSession, + EventID: eventID, }, nil } @@ -250,10 +269,11 @@ WHERE project_id = ? func insertTaskRelationship(ctx context.Context, tx *sql.Tx, projectID string, taskID string, relationshipType string, targetKind string, targetID string, reason string, now string) error { relationshipID := stableMigrationID("relationship", projectID, "task", taskID, relationshipType, targetKind, targetID) _, err := tx.ExecContext(ctx, ` -INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, created_at, updated_at) -VALUES (?, ?, 'task', ?, ?, ?, ?, ?, ?, ?) +INSERT INTO relationships (id, project_id, from_entity_kind, from_entity_id, to_entity_kind, to_entity_id, relationship_type, reason, origin, created_at, updated_at) +VALUES (?, ?, 'task', ?, ?, ?, ?, ?, 'command', ?, ?) ON CONFLICT(id) DO UPDATE SET reason = excluded.reason, + origin = excluded.origin, updated_at = excluded.updated_at `, relationshipID, projectID, taskID, targetKind, targetID, relationshipType, reason, now, now) if err != nil { diff --git a/internal/state/task_update_test.go b/internal/state/task_update_test.go index ec4df093..088a3cf4 100644 --- a/internal/state/task_update_test.go +++ b/internal/state/task_update_test.go @@ -2,6 +2,7 @@ package state import ( "context" + "path/filepath" "testing" "github.com/levifig/loaf/internal/project" @@ -27,6 +28,24 @@ func TestUpdateTaskStatusMutatesTaskAndRecordsEvent(t *testing.T) { if updated.Task.Alias != "TASK-001" || updated.Previous != "todo" || updated.Status != "in_progress" || updated.EventID == "" { t.Fatalf("updated = %#v, want TASK-001 todo -> in_progress with event", updated) } + if updated.ContractVersion != StateJSONContractVersion { + t.Fatalf("ContractVersion = %d, want %d", updated.ContractVersion, StateJSONContractVersion) + } + if updated.DatabaseScope != "global" { + t.Fatalf("DatabaseScope = %q, want global", updated.DatabaseScope) + } + if updated.DatabasePath == "" { + t.Fatal("DatabasePath is empty") + } + if updated.ProjectID == "" { + t.Fatal("ProjectID is empty") + } + if updated.ProjectName != filepath.Base(root.Path()) { + t.Fatalf("ProjectName = %q, want %q", updated.ProjectName, filepath.Base(root.Path())) + } + if updated.ProjectCurrentPath != root.Path() { + t.Fatalf("ProjectCurrentPath = %q, want %q", updated.ProjectCurrentPath, root.Path()) + } tasks, err := ListTasks(context.Background(), root, PathResolver{StateHome: stateHome}, TaskListOptions{}) if err != nil { @@ -62,7 +81,7 @@ func TestUpdateTaskStatusMutatesTaskAndRecordsEvent(t *testing.T) { SELECT COUNT(*) FROM events WHERE project_id = ? AND entity_kind = 'task' AND event_type = 'status_changed' AND from_status = 'todo' AND to_status = 'in_progress' -`, ProjectID(root)).Scan(&events); err != nil { +`, projectIDForTest(t, store, root)).Scan(&events); err != nil { t.Fatalf("count task status events error = %v", err) } if events != 1 { diff --git a/internal/state/trace.go b/internal/state/trace.go index e2cc5d24..bb8a626d 100644 --- a/internal/state/trace.go +++ b/internal/state/trace.go @@ -13,10 +13,16 @@ import ( // TraceResult describes a state-backed entity and its immediate provenance graph. type TraceResult struct { - Query string `json:"query"` - Entity TraceEntity `json:"entity"` - Sources []TraceSource `json:"sources"` - Relationships []TraceRelationship `json:"relationships"` + ContractVersion int `json:"contract_version,omitempty"` + DatabaseScope string `json:"database_scope,omitempty"` + DatabasePath string `json:"database_path,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ProjectName string `json:"project_name,omitempty"` + ProjectCurrentPath string `json:"project_current_path,omitempty"` + Query string `json:"query"` + Entity TraceEntity `json:"entity"` + Sources []TraceSource `json:"sources"` + Relationships []TraceRelationship `json:"relationships"` } // TraceEntity is a compact representation of a traced row. @@ -73,7 +79,14 @@ func Trace(ctx context.Context, root project.Root, resolver PathResolver, ref st // Trace resolves a human-facing alias or internal row ID from an open store. func (s *Store) Trace(ctx context.Context, root project.Root, ref string) (TraceResult, error) { - projectID := ProjectID(root) + projectID, err := s.projectID(ctx, root) + if err != nil { + return TraceResult{}, err + } + identity, err := s.projectIdentity(ctx, projectID) + if err != nil { + return TraceResult{}, err + } entity, err := s.resolveTraceEntity(ctx, projectID, ref) if err != nil { return TraceResult{}, err @@ -87,10 +100,16 @@ func (s *Store) Trace(ctx context.Context, root project.Root, ref string) (Trace return TraceResult{}, err } return TraceResult{ - Query: ref, - Entity: entity, - Sources: sources, - Relationships: relationships, + ContractVersion: StateJSONContractVersion, + DatabaseScope: identity.DatabaseScope, + DatabasePath: identity.DatabasePath, + ProjectID: identity.ID, + ProjectName: identity.FriendlyName, + ProjectCurrentPath: identity.CurrentPath, + Query: ref, + Entity: entity, + Sources: sources, + Relationships: relationships, }, nil } diff --git a/internal/state/trace_test.go b/internal/state/trace_test.go index 4cbe4d0e..b7ee1a4d 100644 --- a/internal/state/trace_test.go +++ b/internal/state/trace_test.go @@ -27,6 +27,7 @@ func TestTraceImportedTaskShowsSourcesAndRelationships(t *testing.T) { t.Fatalf("Trace(TASK-001) error = %v", err) } + assertTaskProjectContext(t, root.Path(), trace.ContractVersion, trace.DatabaseScope, trace.DatabasePath, trace.ProjectID, trace.ProjectName, trace.ProjectCurrentPath) if trace.Entity.Kind != "task" || trace.Entity.Alias != "TASK-001" || trace.Entity.Title != "Example Task" || trace.Entity.Status != "todo" { t.Fatalf("Entity = %#v, want imported task metadata", trace.Entity) } diff --git a/package.json b/package.json index 9605ade8..df5cfff5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "loaf", - "version": "2.0.0-dev.49", + "version": "2.0.0-pre.20260614235428", "description": "Loaf - An Opinionated Agentic Framework for Claude Code, OpenCode, Cursor, Codex, and Gemini", "type": "module", "bin": { @@ -14,10 +14,11 @@ "scripts": { "build:go": "node cli/scripts/build-go.mjs", "build:release": "node cli/scripts/build-release.mjs", + "package:release": "node cli/scripts/package-release.mjs", "verify:go-artifacts": "node cli/scripts/verify-go-artifacts.mjs", "build:cli-ref": "bin/loaf __generate-cli-ref", "build:content": "bin/loaf build", - "build": "npm run build:cli-ref && npm run build:go && npm run build:content && npm run verify:go-artifacts", + "build": "npm run build:go && npm run build:cli-ref && npm run build:content && npm run verify:go-artifacts", "typecheck": "go test ./... -run=^$", "test": "go test ./...", "test:smoke": "node cli/scripts/smoke-test.js", diff --git a/plugins/loaf/.claude-plugin/plugin.json b/plugins/loaf/.claude-plugin/plugin.json index 8bfc8297..0272ee0d 100644 --- a/plugins/loaf/.claude-plugin/plugin.json +++ b/plugins/loaf/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "loaf", - "version": "2.0.0-dev.49", + "version": "2.0.0-pre.20260614235428", "description": "Loaf - An Opinionated Agentic Framework", "repository": "https://github.com/levifig/loaf", "license": "MIT" diff --git a/plugins/loaf/agents/background-runner.md b/plugins/loaf/agents/background-runner.md index 319f161c..32944633 100644 --- a/plugins/loaf/agents/background-runner.md +++ b/plugins/loaf/agents/background-runner.md @@ -168,4 +168,4 @@ Background Agent ID: bg-20260123-143000-auth-security 4. Update `.agents/sessions/20260123-140000-auth-feature.md` frontmatter --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/plugins/loaf/agents/implementer.md b/plugins/loaf/agents/implementer.md index 20e62517..b5410bdb 100644 --- a/plugins/loaf/agents/implementer.md +++ b/plugins/loaf/agents/implementer.md @@ -31,4 +31,4 @@ You are an implementer. You have full write access to the codebase: code, tests, - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/plugins/loaf/agents/librarian.md b/plugins/loaf/agents/librarian.md index 8abb3b82..7b6a0014 100644 --- a/plugins/loaf/agents/librarian.md +++ b/plugins/loaf/agents/librarian.md @@ -47,4 +47,4 @@ You are a librarian. You shepherd session files through their lifecycle and tend - Scope all file operations to `.agents/` paths. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/plugins/loaf/agents/researcher.md b/plugins/loaf/agents/researcher.md index 8f7d2f39..9036c04f 100644 --- a/plugins/loaf/agents/researcher.md +++ b/plugins/loaf/agents/researcher.md @@ -29,4 +29,4 @@ You are a researcher. You have read access to the codebase and web access to the - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/plugins/loaf/agents/reviewer.md b/plugins/loaf/agents/reviewer.md index 7da39c01..6acba720 100644 --- a/plugins/loaf/agents/reviewer.md +++ b/plugins/loaf/agents/reviewer.md @@ -27,4 +27,4 @@ You are a reviewer. You have read-only access to the codebase. This is not a lim - Do not orchestrate other agents — that is the orchestrator's role. --- -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 diff --git a/plugins/loaf/bin/native/darwin-arm64/loaf b/plugins/loaf/bin/native/darwin-arm64/loaf index 9ca94204..8810d1f8 100755 Binary files a/plugins/loaf/bin/native/darwin-arm64/loaf and b/plugins/loaf/bin/native/darwin-arm64/loaf differ diff --git a/plugins/loaf/bin/native/darwin-x64/loaf b/plugins/loaf/bin/native/darwin-x64/loaf index 6ac29ce1..d6931b09 100755 Binary files a/plugins/loaf/bin/native/darwin-x64/loaf and b/plugins/loaf/bin/native/darwin-x64/loaf differ diff --git a/plugins/loaf/bin/native/linux-arm64/loaf b/plugins/loaf/bin/native/linux-arm64/loaf index 3755b476..c324c988 100755 Binary files a/plugins/loaf/bin/native/linux-arm64/loaf and b/plugins/loaf/bin/native/linux-arm64/loaf differ diff --git a/plugins/loaf/bin/native/linux-x64/loaf b/plugins/loaf/bin/native/linux-x64/loaf index cb673af3..f24fcb03 100755 Binary files a/plugins/loaf/bin/native/linux-x64/loaf and b/plugins/loaf/bin/native/linux-x64/loaf differ diff --git a/plugins/loaf/bin/native/win32-arm64/loaf.exe b/plugins/loaf/bin/native/win32-arm64/loaf.exe index e636b1c5..93414828 100755 Binary files a/plugins/loaf/bin/native/win32-arm64/loaf.exe and b/plugins/loaf/bin/native/win32-arm64/loaf.exe differ diff --git a/plugins/loaf/bin/native/win32-x64/loaf.exe b/plugins/loaf/bin/native/win32-x64/loaf.exe index a896a810..271e9729 100755 Binary files a/plugins/loaf/bin/native/win32-x64/loaf.exe and b/plugins/loaf/bin/native/win32-x64/loaf.exe differ diff --git a/plugins/loaf/package.json b/plugins/loaf/package.json index 89d6f3e7..b180adda 100644 --- a/plugins/loaf/package.json +++ b/plugins/loaf/package.json @@ -1,4 +1,4 @@ { "name": "loaf", - "version": "2.0.0-dev.49" + "version": "2.0.0-pre.20260614235428" } \ No newline at end of file diff --git a/plugins/loaf/skills/architecture/SKILL.md b/plugins/loaf/skills/architecture/SKILL.md index 25eecafb..dda9592b 100644 --- a/plugins/loaf/skills/architecture/SKILL.md +++ b/plugins/loaf/skills/architecture/SKILL.md @@ -7,7 +7,7 @@ description: >- difficult to reverse. Captures ... user-invocable: true argument-hint: '[topic or decision]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Architecture diff --git a/plugins/loaf/skills/bootstrap/SKILL.md b/plugins/loaf/skills/bootstrap/SKILL.md index 803009c9..37901719 100644 --- a/plugins/loaf/skills/bootstrap/SKILL.md +++ b/plugins/loaf/skills/bootstrap/SKILL.md @@ -8,7 +8,7 @@ description: >- user-invocable: true argument-hint: '[brief or path]' allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep, AskUserQuestion' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Bootstrap diff --git a/plugins/loaf/skills/brainstorm/SKILL.md b/plugins/loaf/skills/brainstorm/SKILL.md index 7c68c851..ceaefeee 100644 --- a/plugins/loaf/skills/brainstorm/SKILL.md +++ b/plugins/loaf/skills/brainstorm/SKILL.md @@ -7,7 +7,7 @@ description: >- ideas or shaping. user-invocable: true argument-hint: '[idea or problem]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Brainstorm diff --git a/plugins/loaf/skills/breakdown/SKILL.md b/plugins/loaf/skills/breakdown/SKILL.md index 4390d51e..623d93dd 100644 --- a/plugins/loaf/skills/breakdown/SKILL.md +++ b/plugins/loaf/skills/breakdown/SKILL.md @@ -7,7 +7,7 @@ description: >- for shaping idea... user-invocable: true argument-hint: '[spec-file or topic]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Breakdown diff --git a/plugins/loaf/skills/cli-reference/SKILL.md b/plugins/loaf/skills/cli-reference/SKILL.md index 82f5ecd4..e038486a 100644 --- a/plugins/loaf/skills/cli-reference/SKILL.md +++ b/plugins/loaf/skills/cli-reference/SKILL.md @@ -6,7 +6,7 @@ description: >- which CLI command to invoke. Not for skill documentation (use the skill's own SKILL.md) or for understa... user-invocable: false -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Loaf CLI Reference @@ -46,6 +46,10 @@ Coordinates multi-agent work: agent delegation, session management, Linear integ ### `loaf build` Build skill distributions for agent harnesses +**Options:** + +- `-t, --target <name>` - Build a specific target only + **Usage:** ```bash loaf build @@ -58,6 +62,13 @@ loaf build ### `loaf install` Install Loaf to detected AI tool configurations +**Options:** + +- `--to <target>` - Target to install to (or "all") +- `--upgrade` - Update only already-installed targets +- `-y, --yes` - Assume 'yes' to safe migrations (merge content, back up, and replace real files with symlinks) +- `--no-yes` - Force interactive prompts even when stdin is not a TTY (testing) + **Usage:** ```bash loaf install @@ -70,6 +81,10 @@ loaf install ### `loaf init` Initialize a project with Loaf structure +**Options:** + +- `--no-symlinks` - Skip symlink creation prompts + **Usage:** ```bash loaf init @@ -82,6 +97,20 @@ loaf init ### `loaf release` Create a new release with changelog, version bump, and tag +**Options:** + +- `--dry-run` - Preview release without making changes +- `--bump <type>` - Skip interactive bump choice (prerelease, release, major, minor, patch) +- `--base <ref>` - Use commits since <ref> instead of last tag (e.g. main) +- `--tag` - Force git tag creation (overrides --pre-merge default) +- `--no-tag` - Skip git tag creation +- `--gh` - Force GitHub release draft (overrides --pre-merge default) +- `--no-gh` - Skip GitHub release draft +- `--pre-merge` - Shortcut for --no-tag --no-gh --base <auto-detected> +- `--post-merge` - Finalize release after squash-merge +- `--version-file <path>` - Override version file path (repeatable). Replaces configured version files and root auto-detection. +- `-y, --yes` - Skip confirmation prompt + **Usage:** ```bash loaf release @@ -99,6 +128,14 @@ markdown-only compatibility mode until SQLite is initialized. Use `loaf state migrate markdown --apply` to import `.agents/` Markdown into SQLite without rewriting the source Markdown files. +Manual restore from a backup is explicit until a guarded restore command exists: +verify the backup with `loaf state backup verify <backup>`, preserve the current +`$(loaf state path)` file, copy the verified backup to that path, then run +`loaf state doctor` and `loaf state status`. +For agents, `loaf state backup verify <backup> --json` also returns +`restore_database_path`, `restore_preserve_path`, and +`restore_validation_commands` for the current checkout. + **Subcommands:** | Subcommand | Purpose | @@ -107,47 +144,150 @@ without rewriting the source Markdown files. | `loaf state status` | Show SQLite readiness and markdown-only compatibility status | | `loaf state init` | Initialize an empty SQLite state database | | `loaf state doctor` | Diagnose SQLite state health | +| `loaf state repair legacy-project-database` | Archive migrated per-project SQLite leftovers | +| `loaf state repair relationship-origin` | Preview or apply guarded relationship provenance backfills | | `loaf state migrate markdown` | Import existing .agents Markdown artifacts into SQLite | | `loaf state migrate storage-home` | Copy legacy XDG_STATE_HOME SQLite state into XDG_DATA_HOME | -| `loaf state backup` | Create a SQLite database backup | +| `loaf state backup` | Create a SQLite database backup under the global data-home backups directory | +| `loaf state backup verify` | Verify an existing SQLite database backup | | `loaf state export` | Export SQLite state for review or migration | +| `loaf state export all` | Export a complete project-scoped SQLite snapshot | +| `loaf state export triage` | Export a triage summary from SQLite state | +| `loaf state export session` | Export one session from SQLite state | +| `loaf state export spec` | Export one spec from SQLite state | +| `loaf state export release-readiness` | Export a release-readiness report from SQLite state | **Options:** +- `loaf state path`: + - `--json` - Output contract version, database path, scope, and project root as JSON + - `--verbose` - Output command, scope, project root, and database path + - `loaf state status`: - - `--json` - Output status as JSON + - `--json` - Output readiness mode, diagnostics, global database scope, and project identity as JSON - `loaf state init`: - - `--json` - Output initialized status as JSON + - `--json` - Output initialized status, global database scope, and project identity as JSON - `loaf state doctor`: - `--fix` - Initialize missing SQLite state when safe - - `--json` - Output diagnostics as JSON + - `--dry-run` - Show the repair plan without applying fixes + - `--json` - Output diagnostics, repair plan, global database scope, and project identity as JSON + +- `loaf state repair legacy-project-database`: + - `--dry-run` - Preview archive paths without writing + - `--apply` - Move legacy SQLite files into the archive directory + - `--json` - Output archive plan/result, global database scope, and project identity as JSON + +- `loaf state repair relationship-origin`: + - `--origin <imported|manual>` - Provenance value to backfill + - `--dry-run` - Preview affected rows without writing + - `--apply` - Backfill missing origins after creating a SQLite backup + - `--json` - Output repair plan/result, global database scope, and project identity as JSON - `loaf state migrate markdown`: - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf state migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available - `loaf state backup`: - - `--json` - Output backup details as JSON + - `--json` - Output backup verification, checksum, schema version, project count, and current project identity as JSON + +- `loaf state backup verify`: + - `--json` - Output backup verification, restore guidance, schema version, and captured project identities as JSON + +- `loaf state export`: + - `--format <format>` - Output format for the selected export kind + +- `loaf state export all`: + - `--format <format>` - Output format: json + - `--json` - Alias for --format json + +- `loaf state export triage`: + - `--format <format>` - Output format: markdown + +- `loaf state export session`: + - `--format <format>` - Output format: markdown + +- `loaf state export spec`: + - `--format <format>` - Output format: markdown + +- `loaf state export release-readiness`: + - `--format <format>` - Output format: markdown **Usage:** ```bash loaf state status loaf state migrate markdown --dry-run loaf state migrate markdown --apply +loaf state backup +loaf state backup verify /path/to/backup.sqlite loaf state status ``` --- +## Project Management + +### `loaf project` +Manage durable project identity + +Project IDs are stable SQLite identities, not path or name hashes. Use +`loaf project rename --dry-run` for display-name previews and +`loaf project move --dry-run` before recording checkout path moves. + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf project list` | List registered projects in the global SQLite database | +| `loaf project show` | Show the current project identity | +| `loaf project identity` | Alias for project show | +| `loaf project rename` | Rename the friendly project name | +| `loaf project move` | Record a checkout path move | + +**Options:** + +- `loaf project list`: + - `--json` - Output database path, project IDs, friendly names, and current paths as JSON + +- `loaf project show`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project identity`: + - `--json` - Output project ID, friendly name, current path, and database path as JSON + +- `loaf project rename`: + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +- `loaf project move`: + - `<from> [to]` - Previous and optional new absolute project paths + - `--from <path>` - Previous absolute project path + - `--to <path>` - New absolute project path; defaults to the current project root + - `--dry-run` - Validate and preview without writing + - `--json` - Output project ID, friendly name, current path, database path, and applied status as JSON + +**Usage:** +```bash +loaf project show +loaf project identity --json +loaf project rename "Loaf" --dry-run +loaf project rename "Loaf" +loaf project move /old/path/to/loaf /new/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf --dry-run +loaf project move --from /old/path/to/loaf +loaf project show --json +``` + +--- + ## Migrate Management ### `loaf migrate` @@ -171,12 +311,17 @@ when the artifact counts and skipped files look right. - `--dry-run` - Preview import counts without creating a database - `--apply` - Initialize SQLite and import Markdown artifacts - `--resume` - Resume the Markdown import after an interrupted attempt - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, scope, project context, and counts as JSON - `loaf migrate storage-home`: - `--dry-run` - Preview the storage-home migration - `--apply` - Copy the legacy database without deleting it - - `--json` - Output migration details as JSON + - `--json` - Output migration contract, global database paths, action, and project identity when available + +- `loaf migrate worktree-storage`: + - `--apply` - Perform the migration; dry-run is the default + - `--force-from-worktree` - On conflict, keep the worktree-local copy + - `--force-from-main` - On conflict, keep the main-worktree copy **Usage:** ```bash @@ -212,32 +357,39 @@ artifacts during migration; do not edit them directly for lifecycle changes. **Options:** - `loaf task list`: - - `--json` - Output raw JSON + - `--json` - Output tasks, diagnostics, global database scope, and project identity as JSON - `--active` - Hide completed tasks - - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done + - `--status <status>` - Only show tasks with status: in_progress, blocked, todo, review, done, archived - `loaf task show`: - - `--json` - Output task entry as JSON + - `--json` - Output task details, relationships, global database scope, and project identity as JSON - `loaf task create`: - `--title <title>` - Task title - `--spec <id>` - Associated spec ID (e.g., SPEC-010) - - `--priority <level>` - Priority level (P0/P1/P2/P3) + - `--priority <level>` - Priority level: P0, P1, P2, P3 - `--depends-on <ids>` - Comma-separated task IDs + - `--json` - Output created task, event, global database scope, and project identity as JSON - `loaf task update`: - - `--status <status>` - New status: todo, in_progress, blocked, review, done + - `--status <status>` - New status: in_progress, blocked, todo, review, done - `--priority <level>` - New priority: P0, P1, P2, P3 - `--depends-on <ids>` - Replace depends_on (comma-separated task IDs) - `--session <file>` - Set or clear session reference (use "none" to clear) - `--spec <id>` - Set or change associated spec + - `--json` - Output updated task, event, global database scope, and project identity as JSON - `loaf task archive`: - `--spec <id>` - Archive all done tasks for a spec + - `--json` - Output archive result, archived tasks, global database scope, and project identity as JSON + +- `loaf task refresh`: + - `--json` - Output compatibility summary as JSON - `loaf task sync`: - `--import` - Import orphan .md files not in the index - `--push` - Push compatibility index metadata into .md frontmatter + - `--json` - Output compatibility summary as JSON **Usage:** ```bash @@ -268,13 +420,13 @@ status and relationship data when initialized. **Options:** - `loaf spec list`: - - `--json` - Output raw JSON + - `--json` - Output specs, diagnostics, task counts, global database scope, and project identity as JSON - `loaf spec show`: - - `--json` - Output raw JSON + - `--json` - Output spec details, task counts, relationships, global database scope, and project identity as JSON - `loaf spec archive`: - - `--json` - Output raw JSON + - `--json` - Output archive result, archived specs, global database scope, and project identity as JSON **Usage:** ```bash @@ -308,22 +460,23 @@ only when a durable prose artifact is explicitly needed. - `loaf report list`: - `--type <type>` - Filter by report type - - `--status <status>` - Filter by status - - `--json` - Output as JSON + - `--status <status>` - Filter by status; Loaf lifecycle statuses: draft, final, archived + - `--json` - Output reports, diagnostics, global database scope, and project identity as JSON - `loaf report generate`: - - `--format <format>` - Output format + - `--format <format>` - Output format: markdown + - `--json` - Output contract, command, project context, and markdown content as JSON - `loaf report create`: - `--type <type>` - Report type - `--source <source>` - Report source - - `--json` - Output as JSON + - `--json` - Output created report, event, global database scope, and project identity as JSON - `loaf report finalize`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON - `loaf report archive`: - - `--json` - Output as JSON + - `--json` - Output report status transition, event, global database scope, and project identity as JSON **Usage:** ```bash @@ -356,24 +509,24 @@ Knowledge base management **Options:** - `loaf kb validate`: - - `--json` - Output results as JSON + - `--json` - Output per-file frontmatter errors and warnings as JSON - `loaf kb status`: - - `--json` - Output status as JSON + - `--json` - Output knowledge file totals, coverage counts, stale count, review age, and directories as JSON - `loaf kb check`: - `--file <path>` - Reverse lookup: find knowledge files covering this path - - `--json` - Output results as JSON + - `--json` - Output per-file staleness, coverage, commit, and review metadata as JSON - `loaf kb review`: - - `--json` - Output updated frontmatter as JSON + - `--json` - Output updated knowledge frontmatter as JSON - `loaf kb init`: - - `--json` - Output results as JSON + - `--json` - Output directory actions, config status, and QMD collections as JSON - `loaf kb import`: - `--path <path>` - Path to the external project's knowledge directory - - `--json` - Output results as JSON + - `--json` - Output QMD import collection status or import error as JSON **Usage:** ```bash @@ -413,6 +566,16 @@ loaf version ### `loaf housekeeping` Scan project artifacts and recommend housekeeping actions +**Options:** + +- `--dry-run` - Show recommendations without prompting for actions +- `--json` - Output housekeeping sections, cleanup candidates, signals, and SQLite-backed project identity when available as JSON +- `--sessions` - Only review sessions +- `--specs` - Only review specs +- `--plans` - Only review plans +- `--drafts` - Only review drafts +- `--handoffs` - Only review handoffs + **Usage:** ```bash loaf housekeeping @@ -420,11 +583,297 @@ loaf housekeeping --- +## Trace Management + +### `loaf trace` +Trace relationships for one state entity + +**Options:** + +- `--json` - Output traced entity, sources, relationships, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf trace +``` + +--- + +## Brainstorm Management + +### `loaf brainstorm` +Manage brainstorms in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf brainstorm list` | List brainstorms from SQLite state | +| `loaf brainstorm show` | Show one brainstorm from SQLite state | +| `loaf brainstorm promote` | Record brainstorm-to-idea promotion | +| `loaf brainstorm archive` | Archive one or more brainstorms | + +**Options:** + +- `loaf brainstorm list`: + - `--all` - Include archived brainstorms + - `--status <status>` - Filter by status + - `--json` - Output brainstorms, global database scope, and project identity as JSON + +- `loaf brainstorm show`: + - `--json` - Output brainstorm details, relationships, global database scope, and project identity as JSON + +- `loaf brainstorm promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf brainstorm archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived brainstorms, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf brainstorm list +loaf brainstorm show +loaf brainstorm promote +``` + +--- + +## Idea Management + +### `loaf idea` +Manage ideas in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf idea list` | List ideas from SQLite state | +| `loaf idea show` | Show one idea from SQLite state | +| `loaf idea capture` | Capture an idea in SQLite state | +| `loaf idea promote` | Record idea-to-spec promotion | +| `loaf idea resolve` | Resolve an idea by linking it to another entity | +| `loaf idea archive` | Archive one or more ideas | + +**Options:** + +- `loaf idea list`: + - `--all` - Include resolved and archived ideas + - `--status <status>` - Filter by status + - `--json` - Output ideas, global database scope, and project identity as JSON + +- `loaf idea show`: + - `--json` - Output idea details, relationships, global database scope, and project identity as JSON + +- `loaf idea capture`: + - `--title <title>` - Idea title + - `--json` - Output created idea, event, global database scope, and project identity as JSON + +- `loaf idea promote`: + - `--to-spec <spec>` - Target spec + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +- `loaf idea resolve`: + - `--by <entity>` - Resolving entity + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf idea archive`: + - `--reason <text>` - Archive reason + - `--json` - Output archive result, archived ideas, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf idea list +loaf idea show +loaf idea capture +``` + +--- + +## Spark Management + +### `loaf spark` +Manage sparks in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf spark list` | List sparks from SQLite state | +| `loaf spark show` | Show one spark from SQLite state | +| `loaf spark capture` | Capture a spark in SQLite state | +| `loaf spark resolve` | Resolve a spark | +| `loaf spark promote` | Record spark-to-idea promotion | + +**Options:** + +- `loaf spark list`: + - `--all` - Include resolved sparks + - `--status <status>` - Filter by status + - `--json` - Output sparks, global database scope, and project identity as JSON + +- `loaf spark show`: + - `--json` - Output spark details, relationships, global database scope, and project identity as JSON + +- `loaf spark capture`: + - `--scope <scope>` - Spark scope + - `--text <text>` - Spark text + - `--json` - Output created spark, event, global database scope, and project identity as JSON + +- `loaf spark resolve`: + - `--reason <text>` - Resolution reason + - `--json` - Output resolution relationship, event, global database scope, and project identity as JSON + +- `loaf spark promote`: + - `--to-idea <idea>` - Target idea + - `--json` - Output promotion relationship, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf spark list +loaf spark show +loaf spark capture +``` + +--- + +## Tag Management + +### `loaf tag` +Manage tags in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf tag list` | List tags from SQLite state | +| `loaf tag show` | Show entities with a tag | +| `loaf tag add` | Add a tag to an entity | +| `loaf tag remove` | Remove a tag from an entity | + +**Options:** + +- `loaf tag list`: + - `--json` - Output tags, global database scope, and project identity as JSON + +- `loaf tag show`: + - `--json` - Output tagged entities, global database scope, and project identity as JSON + +- `loaf tag add`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +- `loaf tag remove`: + - `--json` - Output tag mutation, entity, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf tag list +loaf tag show +loaf tag add +``` + +--- + +## Bundle Management + +### `loaf bundle` +Manage bundles in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf bundle list` | List bundles from SQLite state | +| `loaf bundle create` | Create a bundle | +| `loaf bundle update` | Update a bundle | +| `loaf bundle show` | Show one bundle | +| `loaf bundle add` | Add an entity to a bundle | +| `loaf bundle remove` | Remove an entity from a bundle | + +**Options:** + +- `loaf bundle list`: + - `--json` - Output bundles, global database scope, and project identity as JSON + +- `loaf bundle create`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output created bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle update`: + - `--title <title>` - Bundle title + - `--tags <tags>` - Comma-separated tag query + - `--json` - Output updated bundle, tags, global database scope, and project identity as JSON + +- `loaf bundle show`: + - `--json` - Output bundle details, members, global database scope, and project identity as JSON + +- `loaf bundle add`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +- `loaf bundle remove`: + - `--json` - Output bundle membership result, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf bundle list +loaf bundle create +loaf bundle update +``` + +--- + +## Link Management + +### `loaf link` +Manage explicit relationships in native SQLite state + +**Subcommands:** + +| Subcommand | Purpose | +|------------|---------| +| `loaf link create` | Create an explicit relationship | +| `loaf link list` | List relationships for one entity | +| `loaf link remove` | Remove an explicit relationship | + +**Options:** + +- `loaf link create`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--reason <text>` - Relationship reason + - `--json` - Output relationship ID, source/target, global database scope, and project identity as JSON + +- `loaf link list`: + - `--json` - Output relationships, global database scope, and project identity as JSON + +- `loaf link remove`: + - `--from <entity>` - Source entity + - `--to <entity>` - Target entity + - `--type <type>` - Relationship type + - `--json` - Output removed relationship ID, global database scope, and project identity as JSON + +**Usage:** +```bash +loaf link create +loaf link list +loaf link remove +``` + +--- + ## Check Management ### `loaf check` Run enforcement hook checks +**Options:** + +- `--hook <id>` - Registered hook ID to run +- `--json` - Output hook result, pass/block status, exit code, warnings, errors, and findings as JSON + **Usage:** ```bash loaf check diff --git a/plugins/loaf/skills/council/SKILL.md b/plugins/loaf/skills/council/SKILL.md index 75326a13..d33acc6a 100644 --- a/plugins/loaf/skills/council/SKILL.md +++ b/plugins/loaf/skills/council/SKILL.md @@ -7,7 +7,7 @@ description: >- the user wants a st... user-invocable: true argument-hint: '[topic]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Council diff --git a/plugins/loaf/skills/database-design/SKILL.md b/plugins/loaf/skills/database-design/SKILL.md index dfa50ee2..82ee4c66 100644 --- a/plugins/loaf/skills/database-design/SKILL.md +++ b/plugins/loaf/skills/database-design/SKILL.md @@ -7,7 +7,7 @@ description: >- database administration ... user-invocable: false allowed-tools: 'Read, Write, Edit, Glob, Grep, Bash(psql:*, sqlite3:*, mysql:*)' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Database Skill diff --git a/plugins/loaf/skills/debugging/SKILL.md b/plugins/loaf/skills/debugging/SKILL.md index ba26889b..1ce6e3b0 100644 --- a/plugins/loaf/skills/debugging/SKILL.md +++ b/plugins/loaf/skills/debugging/SKILL.md @@ -8,7 +8,7 @@ description: >- user-invocable: true argument-hint: '[issue or error]' allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Debugging diff --git a/plugins/loaf/skills/documentation-standards/SKILL.md b/plugins/loaf/skills/documentation-standards/SKILL.md index 49763053..5ad98ea2 100644 --- a/plugins/loaf/skills/documentation-standards/SKILL.md +++ b/plugins/loaf/skills/documentation-standards/SKILL.md @@ -7,7 +7,7 @@ description: >- inline code commen... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Documentation Standards diff --git a/plugins/loaf/skills/foundations/SKILL.md b/plugins/loaf/skills/foundations/SKILL.md index 3b36c50a..6c3eb253 100644 --- a/plugins/loaf/skills/foundations/SKILL.md +++ b/plugins/loaf/skills/foundations/SKILL.md @@ -7,7 +7,7 @@ description: >- workflows. Not for git ... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Code Standards diff --git a/plugins/loaf/skills/git-workflow/SKILL.md b/plugins/loaf/skills/git-workflow/SKILL.md index c6493e26..89d8ea5b 100644 --- a/plugins/loaf/skills/git-workflow/SKILL.md +++ b/plugins/loaf/skills/git-workflow/SKILL.md @@ -7,7 +7,7 @@ description: >- workflows. Not for code... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Git Workflow diff --git a/plugins/loaf/skills/go-development/SKILL.md b/plugins/loaf/skills/go-development/SKILL.md index ec6e2960..18772e44 100644 --- a/plugins/loaf/skills/go-development/SKILL.md +++ b/plugins/loaf/skills/go-development/SKILL.md @@ -7,7 +7,7 @@ description: >- schema design (use... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Go Skill diff --git a/plugins/loaf/skills/handoff/SKILL.md b/plugins/loaf/skills/handoff/SKILL.md index 23039cb6..799aa959 100644 --- a/plugins/loaf/skills/handoff/SKILL.md +++ b/plugins/loaf/skills/handoff/SKILL.md @@ -7,7 +7,7 @@ description: >- parked for later.... user-invocable: true argument-hint: '[next-session focus]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Handoff diff --git a/plugins/loaf/skills/housekeeping/SKILL.md b/plugins/loaf/skills/housekeeping/SKILL.md index c0106bc7..222e7059 100644 --- a/plugins/loaf/skills/housekeeping/SKILL.md +++ b/plugins/loaf/skills/housekeeping/SKILL.md @@ -7,7 +7,7 @@ description: >- hygiene recommendations, arc... user-invocable: true argument-hint: '[sessions|specs|plans|drafts|handoffs]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Housekeeping diff --git a/plugins/loaf/skills/idea/SKILL.md b/plugins/loaf/skills/idea/SKILL.md index 5fd457e3..8586c7f4 100644 --- a/plugins/loaf/skills/idea/SKILL.md +++ b/plugins/loaf/skills/idea/SKILL.md @@ -7,7 +7,7 @@ description: >- processing the intake qu... user-invocable: true argument-hint: '[idea description]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Idea diff --git a/plugins/loaf/skills/implement/SKILL.md b/plugins/loaf/skills/implement/SKILL.md index 9e68a173..3338b743 100644 --- a/plugins/loaf/skills/implement/SKILL.md +++ b/plugins/loaf/skills/implement/SKILL.md @@ -7,7 +7,7 @@ description: >- tracking. Not for shapin... user-invocable: true argument-hint: '[TASK-XXX | SPEC-XXX | TASK-XXX..YYY | TASK-XXX,YYY | description]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Implement diff --git a/plugins/loaf/skills/infrastructure-management/SKILL.md b/plugins/loaf/skills/infrastructure-management/SKILL.md index 05b0e338..3b9356e3 100644 --- a/plugins/loaf/skills/infrastructure-management/SKILL.md +++ b/plugins/loaf/skills/infrastructure-management/SKILL.md @@ -7,7 +7,7 @@ description: >- application code ... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Infrastructure diff --git a/plugins/loaf/skills/interface-design/SKILL.md b/plugins/loaf/skills/interface-design/SKILL.md index 379c9846..53ce2284 100644 --- a/plugins/loaf/skills/interface-design/SKILL.md +++ b/plugins/loaf/skills/interface-design/SKILL.md @@ -7,7 +7,7 @@ description: >- typescript-development) or AP... user-invocable: false allowed-tools: 'Read, Write, Edit, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Design Principles diff --git a/plugins/loaf/skills/knowledge-base/SKILL.md b/plugins/loaf/skills/knowledge-base/SKILL.md index 7c1f36df..9c647c7a 100644 --- a/plugins/loaf/skills/knowledge-base/SKILL.md +++ b/plugins/loaf/skills/knowledge-base/SKILL.md @@ -7,7 +7,7 @@ description: >- directly), archite... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Knowledge Base diff --git a/plugins/loaf/skills/orchestration/SKILL.md b/plugins/loaf/skills/orchestration/SKILL.md index 7a71072e..e3f44ffb 100644 --- a/plugins/loaf/skills/orchestration/SKILL.md +++ b/plugins/loaf/skills/orchestration/SKILL.md @@ -7,7 +7,7 @@ description: >- single-task impleme... user-invocable: false allowed-tools: 'Read, Write, Edit, Glob, Grep, TodoWrite, TodoRead' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Orchestration diff --git a/plugins/loaf/skills/power-systems-modeling/SKILL.md b/plugins/loaf/skills/power-systems-modeling/SKILL.md index 07f41b78..70c68990 100644 --- a/plugins/loaf/skills/power-systems-modeling/SKILL.md +++ b/plugins/loaf/skills/power-systems-modeling/SKILL.md @@ -7,7 +7,7 @@ description: >- Not for infras... user-invocable: false allowed-tools: 'Read, Write, Edit, Glob, Grep, Bash(python:*)' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Power Systems Reference diff --git a/plugins/loaf/skills/python-development/SKILL.md b/plugins/loaf/skills/python-development/SKILL.md index c3ea1c27..cd02a107 100644 --- a/plugins/loaf/skills/python-development/SKILL.md +++ b/plugins/loaf/skills/python-development/SKILL.md @@ -7,7 +7,7 @@ description: >- schema design (use database-... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Python Development diff --git a/plugins/loaf/skills/refactor-deepen/SKILL.md b/plugins/loaf/skills/refactor-deepen/SKILL.md index 44df6023..af6d555f 100644 --- a/plugins/loaf/skills/refactor-deepen/SKILL.md +++ b/plugins/loaf/skills/refactor-deepen/SKILL.md @@ -7,7 +7,7 @@ description: >- improvements, or when t... user-invocable: true argument-hint: '[module or area]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Refactor-Deepen diff --git a/plugins/loaf/skills/reflect/SKILL.md b/plugins/loaf/skills/reflect/SKILL.md index 00eed802..f83c3010 100644 --- a/plugins/loaf/skills/reflect/SKILL.md +++ b/plugins/loaf/skills/reflect/SKILL.md @@ -7,7 +7,7 @@ description: >- experience. Not for pre-i... user-invocable: true argument-hint: '[session-file]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Reflect diff --git a/plugins/loaf/skills/release/SKILL.md b/plugins/loaf/skills/release/SKILL.md index 3c544ef2..edd9c94f 100644 --- a/plugins/loaf/skills/release/SKILL.md +++ b/plugins/loaf/skills/release/SKILL.md @@ -7,7 +7,7 @@ description: >- changelog updates, and... user-invocable: true argument-hint: '[PR number or URL]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Release diff --git a/plugins/loaf/skills/research/SKILL.md b/plugins/loaf/skills/research/SKILL.md index be508f65..56b09c61 100644 --- a/plugins/loaf/skills/research/SKILL.md +++ b/plugins/loaf/skills/research/SKILL.md @@ -7,7 +7,7 @@ description: >- change proposa... user-invocable: true argument-hint: '[topic]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Research diff --git a/plugins/loaf/skills/ruby-development/SKILL.md b/plugins/loaf/skills/ruby-development/SKILL.md index 0138fce1..0b1a186a 100644 --- a/plugins/loaf/skills/ruby-development/SKILL.md +++ b/plugins/loaf/skills/ruby-development/SKILL.md @@ -7,7 +7,7 @@ description: >- schema design (u... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Ruby Development diff --git a/plugins/loaf/skills/security-compliance/SKILL.md b/plugins/loaf/skills/security-compliance/SKILL.md index f9482c96..10598fcd 100644 --- a/plugins/loaf/skills/security-compliance/SKILL.md +++ b/plugins/loaf/skills/security-compliance/SKILL.md @@ -7,7 +7,7 @@ description: >- (use debugging) or gener... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Security & Compliance diff --git a/plugins/loaf/skills/shape/SKILL.md b/plugins/loaf/skills/shape/SKILL.md index f9676b52..2549439e 100644 --- a/plugins/loaf/skills/shape/SKILL.md +++ b/plugins/loaf/skills/shape/SKILL.md @@ -7,7 +7,7 @@ description: >- acceptance criteria. Not for b... user-invocable: true argument-hint: '[idea or requirement]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Shape diff --git a/plugins/loaf/skills/strategy/SKILL.md b/plugins/loaf/skills/strategy/SKILL.md index 5a75599c..5fd74781 100644 --- a/plugins/loaf/skills/strategy/SKILL.md +++ b/plugins/loaf/skills/strategy/SKILL.md @@ -7,7 +7,7 @@ description: >- architecture (use arc... user-invocable: true argument-hint: '[topic]' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Strategy diff --git a/plugins/loaf/skills/triage/SKILL.md b/plugins/loaf/skills/triage/SKILL.md index 5cf9b96c..9f1661c1 100644 --- a/plugins/loaf/skills/triage/SKILL.md +++ b/plugins/loaf/skills/triage/SKILL.md @@ -6,7 +6,7 @@ description: >- when the user asks "what sparks do I have?", "review my ideas", "triage", or "what's in my backlog?"... user-invocable: true -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Triage diff --git a/plugins/loaf/skills/typescript-development/SKILL.md b/plugins/loaf/skills/typescript-development/SKILL.md index 4be7452f..4bfdc704 100644 --- a/plugins/loaf/skills/typescript-development/SKILL.md +++ b/plugins/loaf/skills/typescript-development/SKILL.md @@ -7,7 +7,7 @@ description: >- database schema (use ... user-invocable: false allowed-tools: 'Read, Write, Edit, Bash, Glob, Grep' -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # TypeScript Development diff --git a/plugins/loaf/skills/wrap/SKILL.md b/plugins/loaf/skills/wrap/SKILL.md index 2727dcd3..b47f9e0a 100644 --- a/plugins/loaf/skills/wrap/SKILL.md +++ b/plugins/loaf/skills/wrap/SKILL.md @@ -6,7 +6,7 @@ description: >- summary that replaces Current State. Use at the end of a work session or when the user asks "wr... user-invocable: true -version: 2.0.0-dev.49 +version: 2.0.0-pre.20260614235428 --- # Wrap