From 5439ac6dbcc059b0ccc19faef663e85f879b8a33 Mon Sep 17 00:00:00 2001 From: Rene Zander Date: Thu, 1 Oct 2026 13:21:44 +0000 Subject: [PATCH] feat: harden reviewed writes and prepare 0.15.0 --- .gitignore | 3 + CHANGELOG.md | 15 +++ README.md | 14 ++ docs/releasing.md | 41 ++++++ docs/state-integrity.md | 15 ++- package-lock.json | 60 ++++----- package.json | 5 +- packages/adapter-airtable/package.json | 4 +- packages/adapter-beads/package.json | 4 +- packages/adapter-composite/package.json | 4 +- packages/adapter-github/package.json | 4 +- packages/adapter-google/package.json | 4 +- packages/adapter-notion/package.json | 4 +- packages/adapter-obsidian/package.json | 4 +- packages/adapter-okf/package.json | 4 +- packages/adapter-taskmaster/package.json | 4 +- packages/adapter-ticktick-cache/package.json | 4 +- packages/adapter-ticktick/package.json | 4 +- packages/cli/bin/ats.js | 92 ++++++++++--- packages/cli/doctor.js | 36 +++-- packages/cli/package.json | 4 +- packages/cli/parser.js | 63 +++++++-- packages/cli/reliability.js | 2 +- packages/cli/test/doctor.test.js | 31 +++++ packages/cli/test/find-stale.test.js | 4 + packages/cli/test/parser.test.js | 20 +++ packages/cli/test/release-hardening.test.js | 134 +++++++++++++++++++ packages/core/corpus-cache.d.ts | 11 +- packages/core/corpus-cache.js | 21 ++- packages/core/fs-lock.js | 38 +++++- packages/core/index.d.ts | 3 +- packages/core/index.js | 3 +- packages/core/package.json | 2 +- packages/core/review-queue.js | 44 ++++-- packages/core/state-bundle.js | 57 ++++++-- packages/core/test/corpus-scope.test.js | 24 ++++ packages/core/test/fs-lock.test.js | 20 +++ packages/core/test/review-claim.test.js | 65 +++++++++ packages/core/test/state-bundle.test.js | 33 ++++- packages/core/test/write-guard.test.js | 4 +- packages/core/write-guard.d.ts | 1 + packages/core/write-guard.js | 15 ++- packages/mcp/package.json | 6 +- scripts/check-release.mjs | 28 ++++ server.json | 4 +- 45 files changed, 811 insertions(+), 151 deletions(-) create mode 100644 docs/releasing.md create mode 100644 packages/cli/test/doctor.test.js create mode 100644 packages/cli/test/release-hardening.test.js create mode 100644 packages/core/test/corpus-scope.test.js create mode 100644 packages/core/test/review-claim.test.js create mode 100644 scripts/check-release.mjs diff --git a/.gitignore b/.gitignore index 98caa1b..85f6626 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,6 @@ scripts/.pii-denylist .beads-credential-key .beads/proxieddb/ .beads-ats.env + +# MCP publisher credentials (some publisher versions store login in CWD) +.mcpregistry_* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4d285..965721c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ # Changelog +## 0.15.0 - Reviewed writes and CLI state integrity + +Pending release. + +- **Restore preflight.** State exports carry per-file SHA-256 checksums. Import validates all recognized files, JSON/JSONL shapes, schema versions and supplied checksums before its first write; dry-run performs the same validation. Valid older bundles remain compatible. +- **Reviewed target checks.** Staged task writes capture a revision of the target's logical fields. Apply re-reads the target and refuses changed or unreadable state with exit 3, including title, body, tags, status and relationships. The check is optimistic: backend writes without native conditional-update support can still race after the read. +- **Exclusive review apply.** One process durably claims an approved task write before calling its adapter. Other apply processes cannot execute that item. A crashed claim stays `applying`; an uncertain failure stays `failed`, requiring backend inspection and a fresh proposal instead of automatic retry. +- **Argument contracts.** Known boolean flags preserve following positionals, `--flag=false` stays false, `--` preserves literal flag-shaped arguments, and known value flags reject missing values through the JSON error contract. +- **Approval payload binding.** Approval records a digest of the staged kind and payload. Modified payloads and older approvals without a digest cannot apply; stage and approve a fresh proposal. +- **Bounded diagnostics.** Doctor bounds import, auth, adapter-cache, vector and full retrieval probes with `--timeout-ms` (4 seconds per probe by default). Degraded retrieval reports warnings; timed-out commands flush their diagnostic document before exiting. +- **Strict read option.** `--require-complete` returns exit 2 for stale, degraded or explicitly incomplete read results while preserving stdout. Doctor uses the same option for warning-only reports; hard diagnostic failures remain exit 1. `--fresh` and `--no-cache` bypass corpus cache reads. +- **Source-scoped corpus cache.** The CLI tags its corpus cache with a digest of adapter identity, working directory and source configuration. Fresh, stale and delta paths reject other scopes. An untagged cache refreshes once; custom adapters can set `ATS_CACHE_NAMESPACE` for additional source identity. +- **Lock ownership.** State locks record PID and host. Age alone cannot reclaim a living local owner, and a holder's cleanup preserves a replacement lock. Old metadata-free locks retain stale recovery. + + ## 0.14.0 - Versioned context and reliable automation contracts Released 2026-09-17. diff --git a/README.md b/README.md index e83c278..0b7e23e 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,20 @@ npm run prove:beads npm run prove:progress ``` +Reviewed task writes bind approval to the exact staged payload and check the +current task revision before applying. Only one process can claim an item. +Inspect `ats review list --all` after an interrupted apply: `applying` or `failed` +items require checking the backend before staging a fresh proposal. Older +approvals without payload digests must also be staged and approved again. + +For automation that requires a complete read, use +`ats find QUERY --require-complete --fresh --json`. Stale, degraded or explicitly +incomplete results keep their JSON output and exit 2; ordinary reads preserve +their existing permissive exit behavior. Doctor supports the same strict option and bounds +each probe: `ats doctor --timeout-ms 1000 --require-complete --json`. +State import validates recognized file contents and any supplied checksums even +with `--dry-run`, before writing any file. + ## Use it from any MCP client (Claude Code, Claude Desktop, Cursor, Windsurf, OpenCode) [`@reneza/ats-mcp`](packages/mcp) exposes the active adapter as a tool set spanning retrieval, CRUD, and execution context (`find`, `get_task`, `create_task`, `set_task_intent`, `add_task_link`, `resolve_task_links`, `context_for_task`, `record_action`, `undo_write`, `poll_task_events`, and more). For Claude Code this provides persistent context between sessions without replacing the task system as the source of truth; optional caches and vector indexes remain derived retrieval state. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..306f2e3 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,41 @@ +# Releasing ATS + +ATS is a JavaScript npm-workspaces monorepo. Publish its public npm packages, +the existing MCP server manifest, and one matching GitHub Release. The private +workspace is excluded. There is no Python distribution. + +Before publishing, the release PR must be reviewed and accepted at its final +commit. Merge the reviewed commit, then use a clean checkout of that merged +revision. A changed PR needs another review. No registry publication belongs +in the PR CI job. + +```sh +npm ci +npm test +npm run check:publish +npm run check:release +mcp-publisher validate server.json +``` + +Check npm credentials with `npm whoami`. Publish core first, then the public +adapters, then CLI and MCP; consumers must not receive a package whose required +ATS dependency version is missing from npm. Use `npm publish --access public +--workspace PACKAGE_NAME` for each public workspace. Skip already-published +versions only after confirming the registry artifact matches the intended +release; npm package versions are immutable. + +After all public packages are available, install the released CLI in a clean +directory and smoke-test `ats --version`, adapter configuration, `find`, and +`doctor`. Initialize the published MCP server over stdio and list its tools. +Check that `server.json` pins the published MCP package version and that the +package's `mcpName` matches the registry namespace. + +Authenticate the MCP publisher with `mcp-publisher login github` if necessary, +then run `mcp-publisher publish server.json`. Verify the exact version in the +registry before creating the matching `vVERSION` GitHub Release from the merged +commit. Use the current changelog entry for its release notes. If publishing +stops partway, record the successful packages and resume from the remaining +ones; never mark the release complete while a required destination is missing. + +Authentication details are documented by [npm](https://docs.npmjs.com/trusted-publishers/) +and the [MCP registry](https://github.com/modelcontextprotocol/registry/blob/main/docs/reference/cli/commands.md). diff --git a/docs/state-integrity.md b/docs/state-integrity.md index 88b007f..4300f60 100644 --- a/docs/state-integrity.md +++ b/docs/state-integrity.md @@ -78,7 +78,20 @@ whether the state they received is complete. - `ats batch --journal` records each item outcome, so a stopped batch resumes by stable item id. Partial failure exits 5 and keeps successful item results. - `ats state doctor` checks local schemas and permissions without rewriting - files; `ats state import --dry-run` previews its local write set. + files; `ats state import --dry-run` validates its contents and previews its local write set. +- State imports validate every recognized file before writing; exported SHA-256 + checksums detect changed content. This is preflight validation, not a cross-file + transaction: a later filesystem failure may still leave a partial restore. +- Reviewed task writes bind approval to a payload digest and claim each item + before an external call. Target revisions are checked immediately before apply. + Backend-native compare-and-swap is required to eliminate the remaining race + between that read and the backend write. +- `--require-complete` keeps read output but exits 2 for stale, degraded or + explicitly incomplete results. Default read exits remain permissive. +- CLI corpus caches are scoped to adapter, working directory and configuration. + Library callers using the cache directly can supply a `scope`; custom CLI + adapters can set `ATS_CACHE_NAMESPACE` when their source identity is otherwise + outside the standard configuration. - JSON errors use stable categories (`validation`, `precondition`, `authentication`, `timeout`, `transport`, `internal`) and say whether a retry can help. Exit 3 is a failed write precondition, 5 a partial batch, and diff --git a/package-lock.json b/package-lock.json index e0265df..b64afe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agentic-task-system", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agentic-task-system", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "workspaces": [ "packages/*" @@ -2127,98 +2127,98 @@ }, "packages/adapter-airtable": { "name": "@reneza/ats-adapter-airtable", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-beads": { "name": "@reneza/ats-adapter-beads", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-composite": { "name": "@reneza/ats-adapter-composite", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-github": { "name": "@reneza/ats-adapter-github", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-google": { "name": "@reneza/ats-adapter-google", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-notion": { "name": "@reneza/ats-adapter-notion", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-obsidian": { "name": "@reneza/ats-adapter-obsidian", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-okf": { "name": "@reneza/ats-adapter-okf", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-taskmaster": { "name": "@reneza/ats-adapter-taskmaster", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-ticktick": { "name": "@reneza/ats-adapter-ticktick", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/adapter-ticktick-cache": { "name": "@reneza/ats-adapter-ticktick-cache", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } }, "packages/cli": { "name": "@reneza/ats-cli", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "dependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "bin": { "ats": "bin/ats.js" @@ -2226,23 +2226,23 @@ }, "packages/core": { "name": "@reneza/ats-core", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT" }, "packages/mcp": { "name": "@reneza/ats-mcp", - "version": "0.14.0", + "version": "0.15.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.1", - "@reneza/ats-core": "^0.14.0", + "@reneza/ats-core": "^0.15.0", "zod": "^4.6.5" }, "bin": { "ats-mcp": "server.js" }, "peerDependencies": { - "@reneza/ats-adapter-ticktick": "^0.14.0" + "@reneza/ats-adapter-ticktick": "^0.15.0" }, "peerDependenciesMeta": { "@reneza/ats-adapter-ticktick": { diff --git a/package.json b/package.json index 6f212e8..dd3ce61 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agentic-task-system", - "version": "0.14.0", + "version": "0.15.0", "private": true, "type": "module", "description": "Your task manager is the best agent memory you're not using. Agent-native context layer over your existing task app, with hybrid retrieval (RRF) and pluggable storage adapters.", @@ -18,7 +18,8 @@ "prove:beads": "node examples/beads/prove.mjs --json", "prove:progress": "node packages/core/bench/progress.js --episodes=packages/core/bench/data/progress-episodes.jsonl --format=json", "test:fast": "node --test --test-timeout=30000 packages/core/test/*.test.js packages/mcp/test/*.test.js packages/cli/test/*.test.js packages/adapter-airtable/test/*.test.js packages/adapter-beads/test/*.test.js packages/adapter-composite/test/*.test.js packages/adapter-github/test/*.test.js packages/adapter-google/test/*.test.js packages/adapter-notion/test/*.test.js packages/adapter-obsidian/test/*.test.js packages/adapter-okf/test/*.test.js packages/adapter-taskmaster/test/*.test.js packages/adapter-ticktick/test/*.test.js packages/adapter-ticktick-cache/test/*.test.js", - "test": "npm run lint && npm run check:pii && npm run check:claims && npm run test:fast && npm run prove:intent -- --json && npm run prove:taskmaster && npm run prove:beads && npm run prove:progress" + "test": "npm run lint && npm run check:pii && npm run check:claims && npm run test:fast && npm run prove:intent -- --json && npm run prove:taskmaster && npm run prove:beads && npm run prove:progress", + "check:release": "node scripts/check-release.mjs" }, "license": "MIT", "repository": { diff --git a/packages/adapter-airtable/package.json b/packages/adapter-airtable/package.json index f63bbc5..e5e5d33 100644 --- a/packages/adapter-airtable/package.json +++ b/packages/adapter-airtable/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-airtable", - "version": "0.14.0", + "version": "0.15.0", "description": "Airtable adapter for Agentic Task System. Expose any Airtable base as agent-queryable records through ATS retrieval, RRF fusion, and MCP — a table is a project, a record is a task. Adapter, not migration.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "test": "node --test" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-beads/package.json b/packages/adapter-beads/package.json index b01ce27..6415596 100644 --- a/packages/adapter-beads/package.json +++ b/packages/adapter-beads/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-beads", - "version": "0.14.0", + "version": "0.15.0", "description": "Beads adapter for Agentic Task System using the official bd JSON CLI over repository-local Dolt state.", "type": "module", "main": "index.js", @@ -13,7 +13,7 @@ "prepublishOnly": "node ../../scripts/check-no-pii.mjs --self" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "repository": { "type": "git", diff --git a/packages/adapter-composite/package.json b/packages/adapter-composite/package.json index 6ad85f0..0cff042 100644 --- a/packages/adapter-composite/package.json +++ b/packages/adapter-composite/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-composite", - "version": "0.14.0", + "version": "0.15.0", "description": "Cross-source adapter for the Agentic Task System. Query GitHub + Notion + TickTick + any ATS backends as ONE fused corpus: a single `ats find` returns one RRF-ranked list across all of them, each result tagged with its backend. The thing a single-vendor MCP server can't do.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "test": "node --test" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-github/package.json b/packages/adapter-github/package.json index 3f5df9c..9a6d97d 100644 --- a/packages/adapter-github/package.json +++ b/packages/adapter-github/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-github", - "version": "0.14.0", + "version": "0.15.0", "description": "GitHub Issues adapter for Agentic Task System. Expose any repository's issues as agent-queryable tasks through ATS retrieval, RRF fusion, and MCP — a repo is a project, an issue is a task. Adapter, not migration.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "test": "node --test" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-google/package.json b/packages/adapter-google/package.json index da144af..4b78bcc 100644 --- a/packages/adapter-google/package.json +++ b/packages/adapter-google/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-google", - "version": "0.14.0", + "version": "0.15.0", "description": "Google Workspace adapter for Agentic Task System. Pull Google Sheets, Docs, and Slides into ATS retrieval and MCP as a read-only corpus, authed as a dedicated share-scoped user. Adapter, not migration.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "test": "node --test" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-notion/package.json b/packages/adapter-notion/package.json index 92d4e28..b07941d 100644 --- a/packages/adapter-notion/package.json +++ b/packages/adapter-notion/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-notion", - "version": "0.14.0", + "version": "0.15.0", "description": "Notion adapter for Agentic Task System. Expose any Notion database as agent-queryable pages through ATS retrieval, RRF fusion, and MCP — a database is a project, a page is a task. Adapter, not migration.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "test": "node --test" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-obsidian/package.json b/packages/adapter-obsidian/package.json index 48e87b4..5e1f9d8 100644 --- a/packages/adapter-obsidian/package.json +++ b/packages/adapter-obsidian/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-obsidian", - "version": "0.14.0", + "version": "0.15.0", "description": "Obsidian vault adapter for Agentic Task System. Hybrid + RRF retrieval, the wiki layer, and the MCP server over the folder of markdown you already keep in Obsidian — adapter, not migration.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "prepublishOnly": "node ../../scripts/check-no-pii.mjs --self" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-okf/package.json b/packages/adapter-okf/package.json index f3dce73..38c05cd 100644 --- a/packages/adapter-okf/package.json +++ b/packages/adapter-okf/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-okf", - "version": "0.14.0", + "version": "0.15.0", "description": "OKF bundle adapter for Agentic Task System. Expose Open Knowledge Format markdown bundles through ATS retrieval, graph context, and MCP.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "prepublishOnly": "node ../../scripts/check-no-pii.mjs --self" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-taskmaster/package.json b/packages/adapter-taskmaster/package.json index 6b929e0..fad2476 100644 --- a/packages/adapter-taskmaster/package.json +++ b/packages/adapter-taskmaster/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-taskmaster", - "version": "0.14.0", + "version": "0.15.0", "description": "Local Taskmaster adapter for Agentic Task System: cross-tag search, task context, and field-preserving writes over tasks.json.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "prepublishOnly": "node ../../scripts/check-no-pii.mjs --self" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/adapter-ticktick-cache/package.json b/packages/adapter-ticktick-cache/package.json index fcbd829..881f7e8 100644 --- a/packages/adapter-ticktick-cache/package.json +++ b/packages/adapter-ticktick-cache/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-ticktick-cache", - "version": "0.14.0", + "version": "0.15.0", "private": true, "description": "Local-first ATS adapter over a centralized TickTick JSON cache, with OpenAPI-backed synchronization and writes.", "type": "module", @@ -10,6 +10,6 @@ ], "license": "MIT", "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" } } diff --git a/packages/adapter-ticktick/package.json b/packages/adapter-ticktick/package.json index cded7a6..b6a07c4 100644 --- a/packages/adapter-ticktick/package.json +++ b/packages/adapter-ticktick/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-adapter-ticktick", - "version": "0.14.0", + "version": "0.15.0", "description": "Reference TickTick adapter for Agentic Task System. Wraps TickTick OpenAPI v1 + qdrant + ollama (nomic-embed) into the ATS adapter contract.", "type": "module", "main": "index.js", @@ -12,7 +12,7 @@ "prepublishOnly": "node ../../scripts/check-no-pii.mjs --self" }, "peerDependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "dependencies": {}, "repository": { diff --git a/packages/cli/bin/ats.js b/packages/cli/bin/ats.js index 5fc1ca8..2bc3e6c 100755 --- a/packages/cli/bin/ats.js +++ b/packages/cli/bin/ats.js @@ -13,6 +13,7 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; +import { createHash } from 'node:crypto'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { spawn, spawnSync } from 'node:child_process'; import { @@ -91,6 +92,8 @@ import { findReviewItem, decideReviewItem, markReviewItemApplied, + claimReviewItem, + reviewTargetRevision, exportState, importState, inspectState, @@ -129,7 +132,7 @@ import { mutationReceipt, } from '../reliability.js'; -const args = parseArgs(process.argv.slice(2)); +let args = { options: { format: process.argv.includes('--json') || process.argv.includes('--format=json') || process.argv.some((arg, i) => arg === '--format' && process.argv[i + 1] === 'json') ? 'json' : 'text' } }; // Resolve config dir: prefer ~/.config/ats; fall back to legacy ~/.config/akb if it exists (akb→ats rename migration). function atsConfigDir() { @@ -215,8 +218,31 @@ async function importAdapterTarget(target) { return mod.default || mod; } +function configureCorpusScope() { + const source = resolveAdapterPkg(); + const configDir = atsConfigDir(); + const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => + /^(ATS_(GITHUB|NOTION|AIRTABLE|GOOGLE|OBSIDIAN|COMPOSITE|BEADS|TASKMASTER|OKF|TICKTICK|CACHE_NAMESPACE)(_|$)|TICKTICK_)/.test(key) + ).sort(([a], [b]) => a.localeCompare(b))); + const files = new Set(); + for (const base of [configDir, path.join(os.homedir(), '.config', 'ats')]) { + for (const name of ['config.json', 'tokens.json', 'github.json', 'notion.json', 'airtable.json', 'google.json', 'composite.json', 'obsidian-vault']) files.add(path.join(base, name)); + } + for (const [key, value] of Object.entries(environment)) { + if (/^ATS_.*_CONFIG$/.test(key) && value) files.add(path.resolve(value)); + } + const configs = [...files].sort().map((file) => { + try { return [file, fs.readFileSync(file, 'utf8')]; } + catch (error) { if (error.code === 'ENOENT') return [file, null]; throw error; } + }); + // Configuration can include credentials. Only the digest is stored, never + // source configuration. CWD separates adapters that discover a local root. + process.env.ATS_CORPUS_SCOPE = createHash('sha256').update(JSON.stringify({ adapter: source.pkg, cwd: process.cwd(), configDir, environment, configs })).digest('hex'); +} + async function main() { try { + args = parseArgs(process.argv.slice(2)); if (args.options.version) { const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')); console.log(pkg.version); @@ -232,6 +258,7 @@ async function main() { return; } + configureCorpusScope(); let result; switch (args.command) { case 'help': @@ -400,6 +427,13 @@ async function main() { process.exit(1); } + if (args.options['require-complete'] && result && typeof result === 'object') { + const incomplete = result.degraded === true || result.error || result.corpus?.stale === true + || result.completeness?.complete === false || result.complete === false || result.truncated === true + || result.retrieval?.degraded === true || result.retrieval?.branches?.some((branch) => branch.ok === false) + || result.sourcesFailed?.length > 0 || result.corpus?.sourcesFailed?.length > 0; + if (incomplete) process.exitCode = 2; + } if (result !== undefined) { if (result && typeof result === 'object' && result.__raw !== undefined) { const v = result.__raw; @@ -423,7 +457,7 @@ async function main() { const classified = classifyError(err); if (args.options.format === 'json') console.error(JSON.stringify(errorEnvelope(err), null, 2)); else console.error(`Error [${classified.kind}]: ${err.message}`); - process.exit(Number.isInteger(err?.exitCode) ? err.exitCode : classified.exitCode); + process.exitCode = Number.isInteger(err?.exitCode) ? err.exitCode : classified.exitCode; } } @@ -653,13 +687,15 @@ async function handleDoctor() { adapterSource: { pkg: source.pkg, origin: source.origin }, configPath: source.configPath, nodeVersion: process.version, + probeMs: args.options['timeout-ms'] === undefined ? 4000 : Number(args.options['timeout-ms']), }); - if (args.options.format === 'json') { - console.log(JSON.stringify(report, null, 2)); - } else { - console.log(formatDoctor(report)); - } - if (!report.ok) process.exit(1); + const output = args.options.format === 'json' ? JSON.stringify(report, null, 2) : formatDoctor(report); + const status = !report.ok ? 1 : args.options['require-complete'] && report.degraded ? 2 : 0; + await new Promise((resolve, reject) => process.stdout.write(output + '\n', (error) => error ? reject(error) : resolve())); + process.exitCode = status; + // A timed-out adapter may leave sockets or timers alive. Once the diagnostic + // document is flushed, end the CLI so the probe deadline bounds the command. + if (report.checks.some((check) => /timed out after/.test(check.detail))) process.exit(status); } async function handleAdapter() { @@ -1024,6 +1060,20 @@ const summarizeReviewItem = (i) => ({ async function applyReviewedWrite(item, adapter, t) { const p = item.payload; const approvals = [item.decidedBy].filter(Boolean); + if (p.expectedRevision !== undefined) { + let actualRevision; + try { + const current = t?.get + ? await t.get(p.projectId, p.taskId, { live: true }) + : await adapter.getTask(p.projectId, p.taskId); + actualRevision = reviewTargetRevision(current); + } catch (error) { + throw withExitCode(new Error(`Precondition failed: cannot read reviewed target ${p.projectId}/${p.taskId}; ${error.message}`, { cause: error }), 3); + } + if (actualRevision !== p.expectedRevision) { + throw withExitCode(new Error(`Precondition failed: ${p.projectId}/${p.taskId} changed since review staging; stage a fresh proposal.`), 3); + } + } switch (p.action) { case 'task.updated': { let before; @@ -1041,7 +1091,7 @@ async function applyReviewedWrite(item, adapter, t) { } } const result = t?.update - ? await t.update(p.projectId, p.taskId, p.patch) + ? await t.update(p.projectId, p.taskId, p.patch, { live: true }) : await adapter.updateTask(p.projectId, p.taskId, { ...p.patch, tags: tagsToArray(p.patch?.tags) }); auditCliWrite('task.updated', result, { projectId: p.projectId, taskId: p.taskId }, { fields: Object.keys(p.patch || {}), reviewId: item.id }, false, before, approvals); return result; @@ -1497,12 +1547,16 @@ async function handleReview() { } const applied = []; for (const item of targets) { + let claimed; try { - const result = await applyReviewedWrite(item, adapter, t); - markReviewItemApplied(item.id, { result: taskRefFromResult(result, item.payload) }); + if (item.kind !== 'task.write') throw new Error('Only task.write items can be applied here; use ats kg ratify for facts.'); + claimed = claimReviewItem(item.id); + const result = await applyReviewedWrite(claimed, adapter, t); + markReviewItemApplied(item.id, { result: taskRefFromResult(result, claimed.payload), applyToken: claimed.applyToken }); applied.push({ id: item.id.slice(0, 8), ok: true }); } catch (err) { - markReviewItemApplied(item.id, { error: err.message }); + if (claimed) markReviewItemApplied(item.id, { error: err.message, applyToken: claimed.applyToken }); + process.exitCode = err.exitCode || 1; applied.push({ id: item.id.slice(0, 8), ok: false, error: err.message }); } } @@ -1628,8 +1682,8 @@ function spawnCacheRefresh() { } function corpusFreshness() { - const fresh = args.options.fresh === true; - return { staleOk: !fresh, revalidate: fresh ? false : spawnCacheRefresh }; + const fresh = args.options.fresh === true || args.options['no-cache'] === true; + return { cache: !fresh, staleOk: !fresh, revalidate: fresh ? false : spawnCacheRefresh }; } async function handleTasks() { @@ -1817,7 +1871,7 @@ async function handleTasks() { let before; let current = null; try { - current = t?.get ? await t.get(up, uid) : await adapter.getTask(up, uid); + current = t?.get ? await t.get(up, uid, { live: true }) : await adapter.getTask(up, uid); before = snapshotTask(current?.task || current); } catch { before = undefined; } const currentTask = current?.task || current; @@ -1885,8 +1939,8 @@ async function handleTasks() { case 'complete': { if (!args.positional[0] || !args.positional[1]) { console.error('Usage: ats tasks complete PROJECT_ID TASK_ID'); process.exit(1); } let current = null; - if (process.env.ATS_REVIEW_ALL !== '1' || args.options['dry-run'] === true) { - try { current = t?.get ? await t.get(args.positional[0], args.positional[1]) : await adapter.getTask(args.positional[0], args.positional[1]); } catch { current = null; } + try { current = t?.get ? await t.get(args.positional[0], args.positional[1], { live: true }) : await adapter.getTask(args.positional[0], args.positional[1]); } catch (error) { + if (process.env.ATS_REVIEW_ALL === '1') throw error; } if (args.options['dry-run'] === true) return { dryRun: true, operation: 'complete', target: { projectId: args.positional[0], taskId: args.positional[1] }, before: snapshotTask(current) }; const gate = reviewGate('task.completed', current, { projectId: args.positional[0], taskId: args.positional[1] }); @@ -1898,8 +1952,8 @@ async function handleTasks() { case 'delete': { if (!args.positional[0] || !args.positional[1]) { console.error('Usage: ats tasks delete PROJECT_ID TASK_ID'); process.exit(1); } let current = null; - if (process.env.ATS_REVIEW_ALL !== '1' || args.options['dry-run'] === true) { - try { current = t?.get ? await t.get(args.positional[0], args.positional[1]) : await adapter.getTask(args.positional[0], args.positional[1]); } catch { current = null; } + try { current = t?.get ? await t.get(args.positional[0], args.positional[1], { live: true }) : await adapter.getTask(args.positional[0], args.positional[1]); } catch (error) { + if (process.env.ATS_REVIEW_ALL === '1') throw error; } if (args.options['dry-run'] === true) return { dryRun: true, operation: 'delete', target: { projectId: args.positional[0], taskId: args.positional[1] }, before: snapshotTask(current) }; const gate = reviewGate('task.deleted', current, { projectId: args.positional[0], taskId: args.positional[1] }); diff --git a/packages/cli/doctor.js b/packages/cli/doctor.js index c388234..4fefe1f 100644 --- a/packages/cli/doctor.js +++ b/packages/cli/doctor.js @@ -15,6 +15,16 @@ const warn = (detail) => ({ status: 'warn', detail }); const fail = (detail) => ({ status: 'fail', detail }); const info = (detail) => ({ status: 'info', detail }); +async function probe(run, ms, label) { + let timer; + try { + return await Promise.race([ + Promise.resolve().then(run), + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms); }), + ]); + } finally { clearTimeout(timer); } +} + /** * @param {object} args * @param {() => Promise} args.loadAdapter - resolves + imports the active adapter @@ -23,7 +33,8 @@ const info = (detail) => ({ status: 'info', detail }); * @param {string} args.nodeVersion * @returns {Promise<{ ok: boolean, checks: Array<{ id, label, status, detail }> }>} */ -export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVersion }) { +export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVersion, probeMs = 4000 }) { + if (!Number.isFinite(probeMs) || probeMs <= 0) throw new Error('Doctor probe timeout must be positive.'); const checks = []; const add = (id, label, result) => checks.push({ id, label, ...result }); @@ -37,7 +48,7 @@ export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVe let adapter = null; try { - adapter = await loadAdapter(); + adapter = await probe(loadAdapter, probeMs, 'Adapter import'); add('adapter-load', 'Adapter import', pass(`imported ${adapterSource.pkg}`)); } catch (e) { add('adapter-load', 'Adapter import', fail(e?.message || String(e))); @@ -53,7 +64,7 @@ export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVe // Auth. try { - const status = await adapter.authStatus(); + const status = await probe(() => adapter.authStatus(), probeMs, 'Authentication'); if (status?.authenticated) { add('auth', 'Authentication', pass(status.expiresIn ? `valid (expires ${status.expiresIn})` : 'authenticated')); } else { @@ -72,7 +83,7 @@ export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVe const vectorStatus = adapter.__ext?.tasks?.vectorStatus; if (typeof vectorStatus === 'function') { try { - const vs = await vectorStatus(); + const vs = await probe(() => vectorStatus(), probeMs, 'Vector index'); const n = vs?.vectorCount ?? vs?.count ?? vs?.points ?? vs?.indexed; add('vector-index', 'Vector index', n != null ? pass(`${n} embedded item(s)`) : info(JSON.stringify(vs))); } catch (e) { @@ -83,7 +94,7 @@ export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVe const cacheStatus = adapter.__ext?.cache?.status; if (typeof cacheStatus === 'function') { try { - const cs = await cacheStatus(); + const cs = await probe(() => cacheStatus(), probeMs, 'Adapter cache'); const ageS = cs.ageMs == null ? '?' : Math.round(cs.ageMs / 1000); add('adapter-cache', 'Adapter cache', pass(`${cs.tasks} task(s), ${cs.projects} project(s), ${ageS}s old via ${cs.syncMethod || 'unknown'}`)); } catch (e) { @@ -93,8 +104,11 @@ export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVe // Core retrieval reachability — a cheap, short-budget query. try { - const res = await coreFind('the', { adapter, limit: 1, budgetMs: 4000, cache: false }); - add('retrieval', 'Core retrieval', res?.mode === 'find' ? pass(`fan-out OK (${res.branches.map((b) => b.name).join('+')})`) : warn(`mode=${res?.mode}`)); + const res = await probe(() => coreFind('the', { adapter, limit: 1, budgetMs: probeMs, cache: false }), probeMs, 'Core retrieval'); + const healthy = res?.mode === 'find' && !res.degraded; + add('retrieval', 'Core retrieval', healthy + ? pass(`fan-out OK (${res.branches.map((b) => b.name).join('+')})`) + : warn(res.error || `partial retrieval: ${JSON.stringify(res.warnings || res.branches || [])}`)); } catch (e) { add('retrieval', 'Core retrieval', warn(e?.message || String(e))); } @@ -121,15 +135,15 @@ export async function runDoctor({ loadAdapter, adapterSource, configPath, nodeVe } const ok = !checks.some((c) => c.status === 'fail'); - return { ok, checks }; + const degraded = checks.some((c) => c.status === 'warn' || c.status === 'fail'); + return { ok, degraded, checks }; } /** Render a doctor report as a readable string. */ export function formatDoctor(report) { const mark = { pass: '✔', warn: '!', fail: '✗', info: '·' }; const lines = report.checks.map((c) => ` ${mark[c.status] || '·'} ${c.label}: ${c.detail}`); - const verdict = report.ok - ? 'All systems go.' - : 'Problems found — see ✗ above.'; + const verdict = !report.ok ? 'Problems found — see ✗ above.' + : report.degraded ? 'Checks completed with warnings — see ! above.' : 'All systems go.'; return ['ats doctor', ...lines, '', verdict].join('\n'); } diff --git a/packages/cli/package.json b/packages/cli/package.json index f45db6f..b18dfc6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-cli", - "version": "0.14.0", + "version": "0.15.0", "description": "Command-line interface for Agentic Task System. Routes to the configured storage adapter and exposes find / get / url / links / hybrid / similar / create / update / bench.", "type": "module", "bin": { @@ -15,7 +15,7 @@ "prepublishOnly": "node ../../scripts/check-no-pii.mjs --self" }, "dependencies": { - "@reneza/ats-core": "^0.14.0" + "@reneza/ats-core": "^0.15.0" }, "repository": { "type": "git", diff --git a/packages/cli/parser.js b/packages/cli/parser.js index f6be9c9..75fa339 100644 --- a/packages/cli/parser.js +++ b/packages/cli/parser.js @@ -18,6 +18,28 @@ function looksLikeFlag(token) { return /^--?[A-Za-z]/.test(token); } +const BOOLEAN_OPTIONS = new Set([ + 'help', 'version', 'json', 'explain', 'rerank', 'include-completed', 'fresh', + 'no-cache', 'no-format', 'no-triage', 'raw', 'print', 'exact', 'all', 'full', + 'force', 'write', 'dry-run', 'once', 'close', 'relevance', 'no-relevance', + 'no-facts', 'semantic', 'lexical', 'include-retracted', 'cypher', 'graphiti', + 'additive', 'clear-parent', 'allow-missing', 'live', 'require-complete', + 'if-absent', 'non-interactive', 'n', +]); +const VALUE_OPTIONS = new Set([ + 'format', 'content', 'append', 'prepend', 'title', 'project', 'projects', + 'limit', 'budget-ms', 'timeout-ms', 'input', 'output', 'file', 'out', 'journal', + 'if-match', 'idempotency-key', 'due', 'priority', 'tags', 'reminder', + 'domain', 'source', 'subject', 'predicate', 'confidence', 'task', 'by', 'agent', + 'reason', 'url', 'type', 'desc', 'display', 'extract', 'folder', 'from', 'to', + 'days', 'since', 'state', 'spool', 'interval', 'due-within-hours', 'max', + 'threshold', 'max-corpus', 'rerank-depth', 'min-sources', 'facts-limit', + 'depth', 'max-depth', 'max-nodes', 'restore', 'dir', 'keep', 'dupes', + 'status', 'stale-days', 'as-of', 'acknowledge-rejected', 'supersedes', 'object', 'dialect', 'center', 'outcome', 'done-when', 'parent-project', + 'parent-task', 'approval-required', 'valid-from', 'valid-until', 'allow-actions', + 'allow-resources', 'deny-resources', 'approval-actions', +]); + export function parseArgs(args) { const result = { command: null, @@ -31,15 +53,22 @@ export function parseArgs(args) { }; let i = 0; + let positionalOnly = false; while (i < args.length) { const arg = args[i]; - - if (arg === '--help' || arg === '-h') { + if (!positionalOnly && arg === '--') { + positionalOnly = true; + i++; + continue; + } + if (positionalOnly) { + if (!result.command) result.command = arg; + else if (!result.subcommand) result.subcommand = arg; + else result.positional.push(arg); + } else if (arg === '--help' || arg === '-h') { result.options.help = true; } else if (arg === '--version' || arg === '-v') { result.options.version = true; - } else if (arg === '--format' && args[i + 1]) { - result.options.format = args[++i]; } else if (arg === '--json') { // Ergonomic shorthand for `--format json`. Makes every read command emit // machine-readable output for piping into jq / agents. @@ -47,8 +76,21 @@ export function parseArgs(args) { } else if (arg.startsWith('--') && arg.includes('=')) { // `--key=value` binds the whole remainder, whatever it starts with. const eq = arg.indexOf('='); - result.options[arg.slice(2, eq)] = arg.slice(eq + 1); - } else if (arg.startsWith('--') && args[i + 1] !== undefined && !looksLikeFlag(args[i + 1])) { + const key = arg.slice(2, eq); + const value = arg.slice(eq + 1); + if (BOOLEAN_OPTIONS.has(key)) { + if (!['true', 'false'].includes(value)) throw new Error(`--${key} must be true or false.`); + if (key === 'json') result.options.format = value === 'true' ? 'json' : 'text'; + else result.options[key] = value === 'true'; + } else result.options[key] = value; + } else if (arg.startsWith('--') && BOOLEAN_OPTIONS.has(arg.slice(2))) { + result.options[arg.slice(2)] = true; + } else if (arg.startsWith('--') && VALUE_OPTIONS.has(arg.slice(2))) { + if (args[i + 1] === undefined || args[i + 1] === '--' || looksLikeFlag(args[i + 1])) { + throw new Error(`--${arg.slice(2)} requires a value (use --${arg.slice(2)}=VALUE for a flag-shaped value).`); + } + result.options[arg.slice(2)] = args[++i]; + } else if (arg.startsWith('--') && args[i + 1] !== undefined && args[i + 1] !== '--' && !looksLikeFlag(args[i + 1])) { // Generic option with value. A value may start with a dash when it is not // flag-shaped: a log bullet ("- 2026-09-05: shipped") or a negative number. const key = arg.slice(2); @@ -433,7 +475,7 @@ Commands: undo [id] Reverse the last write (or a named one) from the ledger before-image security Define task trust/resource scope and audit access decisions events Snapshot, poll, or watch observation-only task state changes - doctor Diagnose adapter, auth, capabilities, cache, retrieval + doctor Diagnose services (--timeout-ms N per probe, --require-complete) status Alias for doctor cache Inspect or refresh the adapter's centralized cache sync vector Synchronize the vector index @@ -452,6 +494,8 @@ Global options: --version, -v Show version --format Output format: text (default) or json --json Shorthand for --format json (machine-readable, pipe to jq) + --require-complete Exit 2 for stale, degraded or explicitly incomplete reads + -- Treat remaining arguments as literal positionals Run 'ats --help' for command-specific help. @@ -728,8 +772,9 @@ Usage: ats review reject ID... [--by NAME] Reject pending items ats review apply Execute approved writes -Ids may be unambiguous prefixes. A failed apply keeps the item approved -with its error recorded, ready to retry or reject.`; +Ids may be unambiguous prefixes. Task writes claim approved items before applying. +Interrupted items stay applying; failed writes stay failed. Inspect the backend +before staging a fresh proposal. Changed payloads or target revisions are refused.`; } export function getKgHelp() { diff --git a/packages/cli/reliability.js b/packages/cli/reliability.js index 3ccf500..832eb3a 100644 --- a/packages/cli/reliability.js +++ b/packages/cli/reliability.js @@ -97,7 +97,7 @@ export function classifyError(error) { if (/ECONN|ENOTFOUND|network|socket|fetch failed|transport/i.test(message)) { return { kind: 'transport', code: 'ATS_TRANSPORT', retryable: true, exitCode: 7 }; } - if (/required|unknown field|invalid|usage|must be|cannot read structured input/i.test(message)) { + if (/requires? (?:a value|an?|JSON)|required|unknown field|invalid|usage|must be|cannot read structured input/i.test(message)) { return { kind: 'validation', code: 'ATS_VALIDATION', retryable: false, exitCode: 2 }; } return { kind: 'internal', code, retryable: false, exitCode: Number.isInteger(error?.exitCode) ? error.exitCode : 1 }; diff --git a/packages/cli/test/doctor.test.js b/packages/cli/test/doctor.test.js new file mode 100644 index 0000000..4ec019d --- /dev/null +++ b/packages/cli/test/doctor.test.js @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { runDoctor, formatDoctor } from '../doctor.js'; + +const adapter = { + listProjects: async () => [{ id: 'p1', name: 'Inbox' }], + listTasksInProject: async () => [], getTask: async () => ({}), + createTask: async () => ({}), updateTask: async () => ({}), urlFor: () => '', + authStatus: async () => ({ authenticated: true }), authLogin: async () => ({}), +}; +const options = { adapterSource: { pkg: 'test-adapter', origin: 'test' }, configPath: 'test-config', nodeVersion: process.version, probeMs: 15 }; + +test('doctor warns for partial retrieval and cannot label it healthy', async () => { + const report = await runDoctor({ ...options, loadAdapter: async () => ({ ...adapter, listTasksInProject: async () => { throw new Error('unavailable'); } }) }); + assert.equal(report.degraded, true); + assert.equal(report.checks.find(c => c.id === 'retrieval').status, 'warn'); + assert.doesNotMatch(formatDoctor(report), /All systems go/); +}); + +test('doctor bounds hanging import, auth and full-corpus probes', async () => { + const never = () => new Promise(() => {}); + const start = Date.now(); + const imported = await runDoctor({ ...options, loadAdapter: never }); + assert.equal(imported.ok, false); + assert.match(imported.checks.find(c => c.id === 'adapter-load').detail, /timed out/); + const report = await runDoctor({ ...options, loadAdapter: async () => ({ ...adapter, authStatus: never, listProjects: never }) }); + assert.equal(report.degraded, true); + assert.match(report.checks.find(c => c.id === 'auth').detail, /timed out/); + assert.match(report.checks.find(c => c.id === 'retrieval').detail, /timed out/); + assert.ok(Date.now() - start < 1000); +}); diff --git a/packages/cli/test/find-stale.test.js b/packages/cli/test/find-stale.test.js index 30f6bc8..cbe7ce8 100644 --- a/packages/cli/test/find-stale.test.js +++ b/packages/cli/test/find-stale.test.js @@ -41,7 +41,10 @@ after(() => fs.rmSync(tempDir, { recursive: true, force: true })); function seedStaleCache() { // A cache older than the 1ms TTL holding a copy that differs from the adapter. + run('cache', 'sync'); + const { scope } = JSON.parse(fs.readFileSync(cachePath, 'utf8')); fs.writeFileSync(cachePath, JSON.stringify({ + scope, timestamp: Date.now() - 60_000, count: 1, tasks: [{ id: 'old1', title: 'Release checklist (stale copy)', content: 'deployment', projectId: 'p1', projectName: 'Inbox', tags: [] }], @@ -61,6 +64,7 @@ function run(...argv) { }, }); assert.equal(proc.status, 0, proc.stderr); + assert.ok(proc.stdout, JSON.stringify({ argv, status: proc.status, stderr: proc.stderr })); return JSON.parse(proc.stdout); } diff --git a/packages/cli/test/parser.test.js b/packages/cli/test/parser.test.js index 95e24e4..c6ea20c 100644 --- a/packages/cli/test/parser.test.js +++ b/packages/cli/test/parser.test.js @@ -144,3 +144,23 @@ test('formatOutput find handles zero matches', () => { assert.match(out, /find "nothing" — 0 results in 5ms/); assert.match(out, /\(no matches\)/); }); + +test('known boolean flags preserve positionals anywhere and false stays false', () => { + const parsed = parseArgs(['tasks', '--dry-run', 'delete', '--live=false', 'p1', '--force', 't1']); + assert.equal(parsed.subcommand, 'delete'); + assert.deepEqual(parsed.positional, ['p1', 't1']); + assert.equal(parsed.options['dry-run'], true); + assert.equal(parsed.options.live, false); + assert.equal(parseArgs(['--if-absent', 'create', 'title']).subcommand, 'title'); + assert.throws(() => parseArgs(['--force=yes']), /must be true or false/); +}); + +test('terminator preserves literal flag-shaped positionals; value flags fail early', () => { + const parsed = parseArgs(['tasks', 'find', '--', '--deployment', '--json']); + assert.deepEqual(parsed.positional, ['--deployment', '--json']); + assert.equal(parsed.options.format, 'text'); + for (const flag of ['--title', '--limit', '--format', '--input']) { + assert.throws(() => parseArgs(['find', 'x', flag]), /requires a value/); + assert.throws(() => parseArgs(['find', 'x', flag, '--json']), /requires a value/); + } +}); diff --git a/packages/cli/test/release-hardening.test.js b/packages/cli/test/release-hardening.test.js new file mode 100644 index 0000000..f4994d2 --- /dev/null +++ b/packages/cli/test/release-hardening.test.js @@ -0,0 +1,134 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; + +const cli = new URL('../bin/ats.js', import.meta.url).pathname; +function fixture(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-release-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + const state = path.join(dir, 'backend.json'); + const initial = { id: 't1', projectId: 'p1', title: 'Release checklist', content: 'Original', tags: [], modifiedTime: '2026-09-01T00:00:00Z' }; + fs.writeFileSync(state, JSON.stringify(initial)); + const adapterFile = path.join(dir, 'adapter.mjs'); + fs.writeFileSync(adapterFile, ` + import fs from 'node:fs'; + const file = ${JSON.stringify(state)}; + const get = () => JSON.parse(fs.readFileSync(file)); + export default { + listProjects: async () => [{ id: 'p1', name: 'Inbox' }], + listTasksInProject: async () => { if (process.env.TEST_PARTIAL) throw new Error('Project unavailable'); return [get()]; }, + getTask: async () => process.env.TEST_MISSING ? null : get(), + createTask: async input => ({ id: 'new', ...input }), + updateTask: async (_p, _id, patch) => { const task = {...get(), ...patch}; fs.writeFileSync(file, JSON.stringify(task)); return task; }, + urlFor: () => 'test://task', authStatus: async () => { if (process.env.TEST_HANG) { setInterval(() => {}, 1000); return new Promise(() => {}); } return { authenticated: true }; }, authLogin: async () => ({}), + }; + `); + const env = { ...process.env, ATS_ADAPTER: pathToFileURL(adapterFile).href, XDG_CONFIG_HOME: path.join(dir, 'config'), ATS_CORPUS_CACHE: path.join(dir, 'cache.json'), ATS_REVIEW_QUEUE: path.join(dir, 'review.json'), ATS_ACTION_LOG: path.join(dir, 'actions.jsonl'), ATS_USAGE_DISABLE: '1', ATS_GET_NOFORMAT: '1', ATS_GET_NOTRIAGE: '1' }; + delete env.ATS_CORPUS_CACHE_DISABLE; + delete env.ATS_REVIEW_ALL; + const run = (argv, extra = {}) => { + const proc = spawnSync(process.execPath, [cli, ...argv, '--json'], { encoding: 'utf8', env: { ...env, ...extra }, timeout: 10_000 }); + assert.ifError(proc.error); + return { ...proc, data: proc.stdout.trim() ? JSON.parse(proc.stdout) : null }; + }; + return { dir, env, initial, state, adapterFile, run }; +} + +test('CLI parse errors retain the JSON stderr contract', (t) => { + const { run } = fixture(t); + const result = run(['find', 'Release', '--limit']); + assert.equal(result.status, 2); + assert.equal(result.stdout, ''); + assert.equal(JSON.parse(result.stderr).error.kind, 'validation'); +}); + +test('strict reads emit the full JSON result and fail on partial or stale corpus', (t) => { + const { run, env } = fixture(t); + assert.equal(run(['find', 'Release', '--require-complete']).status, 0); + const partial = run(['find', 'Release', '--no-cache', '--require-complete'], { TEST_PARTIAL: '1' }); + assert.equal(partial.status, 2, partial.stderr); + assert.equal(partial.data.degraded, true); + assert.equal(run(['find', 'Release', '--no-cache'], { TEST_PARTIAL: '1' }).status, 0); + const cache = JSON.parse(fs.readFileSync(env.ATS_CORPUS_CACHE)); + cache.timestamp -= 600_000; + fs.writeFileSync(env.ATS_CORPUS_CACHE, JSON.stringify(cache)); + const stale = run(['find', 'Release', '--require-complete']); + assert.equal(stale.status, 2, stale.stderr); + assert.equal(stale.data.corpus.stale, true); +}); + +test('CLI caches never reuse another adapter source or changed configuration', (t) => { + const { run, env, adapterFile, state } = fixture(t); + assert.equal(run(['find', 'Release']).data.tasks[0].id, 't1'); + const otherAdapter = path.join(path.dirname(adapterFile), 'other-adapter.mjs'); + fs.copyFileSync(adapterFile, otherAdapter); + fs.writeFileSync(state, JSON.stringify({ id: 't2', projectId: 'p1', title: 'Release B', content: '', tags: [] })); + const other = run(['find', 'Release'], { ATS_ADAPTER: pathToFileURL(otherAdapter).href }); + assert.equal(other.data.corpus.fromCache, false); + assert.equal(other.data.tasks[0].id, 't2'); + run(['cache', 'sync']); + fs.mkdirSync(path.join(env.XDG_CONFIG_HOME, 'ats'), { recursive: true }); + fs.writeFileSync(path.join(env.XDG_CONFIG_HOME, 'ats', 'config.json'), '{"source":"changed"}'); + fs.writeFileSync(state, JSON.stringify({ id: 't3', projectId: 'p1', title: 'Release C', content: '', tags: [] })); + const changed = run(['find', 'Release']); + assert.equal(changed.data.corpus.fromCache, false); + assert.equal(changed.data.tasks[0].id, 't3'); +}); + +test('reviewed update refuses a target changed after staging without writing it', (t) => { + const { run, state, initial } = fixture(t); + const staged = run(['tasks', 'update', 'p1', 't1', '--title', 'Approved title'], { ATS_REVIEW_ALL: '1' }); + assert.equal(staged.status, 0, staged.stderr); + const id = staged.data.reviewId; + assert.equal(run(['review', 'approve', id]).status, 0); + fs.writeFileSync(state, JSON.stringify({ ...initial, tags: ['human-edit'] })); + const applied = run(['review', 'apply', id]); + assert.equal(applied.status, 3, applied.stderr); + assert.equal(applied.data.applied[0].ok, false); + assert.match(applied.data.applied[0].error, /changed since review staging/); + assert.equal(JSON.parse(fs.readFileSync(state)).title, initial.title); + assert.equal(run(['review', 'show', id]).data.status, 'failed'); +}); + +test('an unchanged reviewed update applies once and preserves human fields', (t) => { + const { run, state } = fixture(t); + const id = run(['tasks', 'update', 'p1', 't1', '--title', 'Approved title'], { ATS_REVIEW_ALL: '1' }).data.reviewId; + run(['review', 'approve', id]); + assert.equal(run(['review', 'apply', id]).status, 0); + assert.equal(JSON.parse(fs.readFileSync(state)).title, 'Approved title'); + assert.equal(JSON.parse(fs.readFileSync(state)).content, 'Original'); + assert.notEqual(run(['review', 'apply', id]).status, 0); +}); + +test('doctor strict mode reports warnings as a nonzero result', (t) => { + const { run } = fixture(t); + const result = run(['doctor', '--require-complete', '--timeout-ms', '100'], { TEST_PARTIAL: '1' }); + assert.equal(result.status, 2); + assert.equal(result.data.degraded, true); +}); + + +test('doctor exits after flushing a timed-out probe even when the adapter keeps handles alive', (t) => { + const { run } = fixture(t); + const start = Date.now(); + const result = run(['doctor', '--require-complete', '--timeout-ms', '30'], { TEST_HANG: '1' }); + assert.equal(result.status, 2); + assert.equal(result.data.degraded, true); + assert.match(result.data.checks.find(check => check.id === 'auth').detail, /timed out/); + assert.ok(Date.now() - start < 2000); +}); + + +test('an unreadable reviewed target fails its precondition before any write', (t) => { + const { run, state, initial } = fixture(t); + const id = run(['tasks', 'update', 'p1', 't1', '--title', 'Approved title'], { ATS_REVIEW_ALL: '1' }).data.reviewId; + run(['review', 'approve', id]); + const result = run(['review', 'apply', id], { TEST_MISSING: '1' }); + assert.equal(result.status, 3); + assert.match(result.data.applied[0].error, /cannot read reviewed target/); + assert.deepEqual(JSON.parse(fs.readFileSync(state)), initial); +}); diff --git a/packages/core/corpus-cache.d.ts b/packages/core/corpus-cache.d.ts index 4e651cd..03fad56 100644 --- a/packages/core/corpus-cache.d.ts +++ b/packages/core/corpus-cache.d.ts @@ -2,19 +2,19 @@ import type { Task } from './adapter-interface.js'; /** Read the cached corpus if fresh enough, else null. */ -export function read(): Task[] | null; +export function read(opts?: { scope?: string }): Task[] | null; /** * Read a cache past its TTL but within the stale ceiling (ATS_CORPUS_STALE_MAX_MS, * default 24h). Null when missing, still fresh, or too old to serve. */ -export function readStale(): { tasks: Task[]; ageMs: number } | null; +export function readStale(opts?: { scope?: string }): { tasks: Task[]; ageMs: number } | null; /** Read the cache regardless of age (delta sync). Null when missing/corrupt. */ -export function readAny(): { tasks: Task[]; timestamp: number | null; cursor: unknown } | null; +export function readAny(opts?: { scope?: string }): { tasks: Task[]; timestamp: number | null; cursor: unknown } | null; /** Persist corpus + timestamp (+ optional delta-sync cursor). */ -export function write(tasks: Task[], opts?: { cursor?: unknown }): void; +export function write(tasks: Task[], opts?: { cursor?: unknown; scope?: string }): void; /** True while a background refresh holds the refresh lease. */ export function refreshing(): boolean; @@ -43,9 +43,10 @@ export interface CacheMeta { revalidating?: boolean; path?: string; error?: string; + scopeMismatch?: boolean; } -export function meta(): CacheMeta; +export function meta(opts?: { scope?: string }): CacheMeta; export function clear(): boolean; diff --git a/packages/core/corpus-cache.js b/packages/core/corpus-cache.js index 3e4017f..0a70af7 100644 --- a/packages/core/corpus-cache.js +++ b/packages/core/corpus-cache.js @@ -30,6 +30,10 @@ const STALE_MAX_MS = Number(process.env.ATS_CORPUS_STALE_MAX_MS) || 24 * 60 * 60 const REFRESH_MARKER = `${CACHE_PATH}.refreshing`; const REFRESH_LEASE_MS = Number(process.env.ATS_CORPUS_REFRESH_LEASE_MS) || 120_000; +function scopeMatches(parsed, scope) { + return (parsed.scope ?? null) === (scope ?? process.env.ATS_CORPUS_SCOPE ?? null); +} + function ensureDir() { try { fs.mkdirSync(path.dirname(CACHE_PATH), { recursive: true, mode: 0o700 }); @@ -41,12 +45,13 @@ function ensureDir() { * * @returns {Array|null} list of task objects, or null if cache missing/stale */ -export function read() { +export function read({ scope } = {}) { if (process.env.ATS_CORPUS_CACHE_DISABLE === '1') return null; try { if (!fs.existsSync(CACHE_PATH)) return null; const raw = fs.readFileSync(CACHE_PATH, 'utf8'); const parsed = JSON.parse(raw); + if (!scopeMatches(parsed, scope) || !Array.isArray(parsed.tasks) || !Number.isFinite(parsed.timestamp)) return null; const age = Date.now() - parsed.timestamp; if (age > TTL_MS) return null; return parsed.tasks; @@ -60,12 +65,12 @@ export function read() { * stale-while-revalidate window. Returns `{ tasks, ageMs }`, or null when the * cache is missing, still fresh (use {@link read}), or too old to serve. */ -export function readStale() { +export function readStale({ scope } = {}) { if (process.env.ATS_CORPUS_CACHE_DISABLE === '1') return null; try { if (!fs.existsSync(CACHE_PATH)) return null; const parsed = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); - if (!Array.isArray(parsed.tasks)) return null; + if (!scopeMatches(parsed, scope) || !Array.isArray(parsed.tasks) || !Number.isFinite(parsed.timestamp)) return null; const age = Date.now() - parsed.timestamp; if (age <= TTL_MS || age > STALE_MAX_MS) return null; return { tasks: parsed.tasks, ageMs: age }; @@ -130,7 +135,7 @@ export function beginRevalidate(run) { * Persist corpus + timestamp. An optional sync cursor (from an adapter's * `bulkFetchDelta`) rides along so the next delta sync can resume from it. */ -export function write(tasks, { cursor = null } = {}) { +export function write(tasks, { cursor = null, scope } = {}) { if (process.env.ATS_CORPUS_CACHE_DISABLE === '1') return; ensureDir(); try { @@ -140,6 +145,7 @@ export function write(tasks, { cursor = null } = {}) { writeFileAtomicSync( CACHE_PATH, JSON.stringify({ + scope: scope ?? process.env.ATS_CORPUS_SCOPE ?? null, timestamp: Date.now(), count: tasks.length, ...(cursor != null ? { cursor } : {}), @@ -154,22 +160,23 @@ export function write(tasks, { cursor = null } = {}) { * Read the cached corpus regardless of TTL — for delta sync, which updates a * stale cache instead of discarding it. Returns null when missing/corrupt. */ -export function readAny() { +export function readAny({ scope } = {}) { if (process.env.ATS_CORPUS_CACHE_DISABLE === '1') return null; try { if (!fs.existsSync(CACHE_PATH)) return null; const parsed = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); - if (!Array.isArray(parsed.tasks)) return null; + if (!scopeMatches(parsed, scope) || !Array.isArray(parsed.tasks)) return null; return { tasks: parsed.tasks, timestamp: parsed.timestamp ?? null, cursor: parsed.cursor ?? null }; } catch { return null; } } -export function meta() { +export function meta({ scope } = {}) { try { if (!fs.existsSync(CACHE_PATH)) return { exists: false }; const raw = JSON.parse(fs.readFileSync(CACHE_PATH, 'utf8')); + if (!scopeMatches(raw, scope)) return { exists: false, scopeMismatch: true, path: CACHE_PATH }; const ageMs = Date.now() - raw.timestamp; return { exists: true, diff --git a/packages/core/fs-lock.js b/packages/core/fs-lock.js index a731499..5520c93 100644 --- a/packages/core/fs-lock.js +++ b/packages/core/fs-lock.js @@ -8,8 +8,8 @@ * * - `withLockSync` / `withLock`: a mutual-exclusion lock around any * read-modify-write of a state file. Lock = `.lock` created - * with O_EXCL; a lock older than `staleMs` is treated as abandoned by - * a crashed process and stolen. + * with O_EXCL and PID/host ownership. Old locks are reclaimed only when + * their local owner is gone (or they predate owner metadata). * - `writeFileAtomicSync`: temp-file + rename in the target directory, * so readers only ever observe a complete file — never a torn write. * @@ -20,6 +20,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import os from 'node:os'; const DEFAULT_STALE_MS = 30_000; const DEFAULT_TIMEOUT_MS = 5_000; @@ -37,12 +38,32 @@ function ensureParentDir(targetPath) { function tryAcquire(lockPath, staleMs) { try { - return fs.openSync(lockPath, 'wx', 0o600); + const fd = fs.openSync(lockPath, 'wx', 0o600); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, host: os.hostname() })); + } catch (error) { + fs.closeSync(fd); + fs.unlinkSync(lockPath); + throw error; + } + return fd; } catch (error) { if (error.code !== 'EEXIST') throw error; - // A crashed holder leaves the lock behind; steal it once it is stale. + // Age alone does not mean abandonment: a slow adapter call may still own it. try { - if (Date.now() - fs.statSync(lockPath).mtimeMs > staleMs) fs.unlinkSync(lockPath); + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs <= staleMs) return undefined; + let owner; + try { owner = JSON.parse(fs.readFileSync(lockPath, 'utf8')); } catch {} + if (owner?.host && owner.host !== os.hostname()) return undefined; + if (Number.isInteger(owner?.pid) && owner.pid > 0) { + try { process.kill(owner.pid, 0); return undefined; } catch (probeError) { + if (probeError.code !== 'ESRCH') return undefined; + } + } + // Only remove the inode inspected above, not a replacement holder. + const current = fs.statSync(lockPath); + if (current.ino === stat.ino && current.dev === stat.dev) fs.unlinkSync(lockPath); } catch (statError) { if (statError.code !== 'ENOENT') throw statError; } @@ -52,13 +73,16 @@ function tryAcquire(lockPath, staleMs) { function release(lockFd, lockPath, runError) { let cleanupError; + let owned; + try { owned = fs.fstatSync(lockFd); } catch (error) { cleanupError = error; } try { fs.closeSync(lockFd); } catch (error) { cleanupError = error; } try { - fs.unlinkSync(lockPath); + const current = fs.statSync(lockPath); + if (owned && current.ino === owned.ino && current.dev === owned.dev) fs.unlinkSync(lockPath); } catch (error) { if (error.code !== 'ENOENT' && !cleanupError) cleanupError = error; } @@ -76,7 +100,7 @@ function timeoutError(label, lockPath) { * @param {string} targetPath - state file the lock protects * @param {() => any} run * @param {object} [opts] - * @param {number} [opts.staleMs=30000] - age after which a leftover lock is stolen + * @param {number} [opts.staleMs=30000] - age after which owner liveness is checked * @param {number} [opts.timeoutMs=5000] - how long to wait before giving up * @param {string} [opts.label] - human name for the timeout error */ diff --git a/packages/core/index.d.ts b/packages/core/index.d.ts index 6392c19..e8d85ab 100644 --- a/packages/core/index.d.ts +++ b/packages/core/index.d.ts @@ -36,7 +36,8 @@ export { withRetry, retryingFetch, retryPolicy, isTransientResponse, isTransient /** Short fingerprint of a task body for compare-and-swap writes (`ats update --if-match`). */ export function contentHash(content?: string): string; -export { guardWrite } from './write-guard.js'; +export { guardWrite, reviewTargetRevision } from './write-guard.js'; +export function claimReviewItem(id: string, opts?: { queuePath?: string }): Record; export type { GuardedWrite, StagedWrite } from './write-guard.js'; export type { RetryPolicy, RetryOptions } from './retry.js'; diff --git a/packages/core/index.js b/packages/core/index.js index 37d0cfc..c155497 100644 --- a/packages/core/index.js +++ b/packages/core/index.js @@ -9,10 +9,11 @@ export { listReviewItems, findReviewItem, decideReviewItem, + claimReviewItem, markReviewItemApplied, writeRequiresApproval, } from './review-queue.js'; -export { guardWrite } from './write-guard.js'; +export { guardWrite, reviewTargetRevision } from './write-guard.js'; export { STATE_BUNDLE_VERSION, stateFileRegistry, inspectState, exportState, importState } from './state-bundle.js'; export { kgFactsPath, diff --git a/packages/core/package.json b/packages/core/package.json index 4ee34c8..27ff37f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-core", - "version": "0.14.0", + "version": "0.15.0", "description": "Adapter-agnostic core for Agentic Task System — retrieval (RRF parallel fan-out), corpus cache, usage logging, conformance kit, adapter interface", "type": "module", "main": "index.js", diff --git a/packages/core/review-queue.js b/packages/core/review-queue.js index 9064566..37654e4 100644 --- a/packages/core/review-queue.js +++ b/packages/core/review-queue.js @@ -13,10 +13,10 @@ * `kg.fact`), so every propose → review → apply flow shares one store and one * set of mechanics: locked mutations, atomic writes, 0600 on disk. * - * Lifecycle: pending → approved → applied - * ↘ rejected - * A failed apply keeps the item approved and records `applyError`, so it can - * be retried or rejected — never silently lost. + * Task-write lifecycle: pending → approved → applying → applied (or failed). + * An applying claim is durable before the backend call. Uncertain failures + * require checking the backend and staging a fresh proposal, never auto-retry. + * Fact proposals retain their separate ratification path. */ import fs from 'node:fs'; @@ -25,6 +25,7 @@ import path from 'node:path'; import { randomUUID } from 'node:crypto'; import { withLockSync, writeFileAtomicSync } from './fs-lock.js'; import { taskMetadataForRead } from './task-context.js'; +import { stableDigest } from './reliability-snapshot.js'; export const REVIEW_QUEUE_VERSION = 1; @@ -101,6 +102,7 @@ export function decideReviewItem(idOrPrefix, decision, { by, note, queuePath = r const item = matchItem(queue.items, idOrPrefix); if (item.status !== 'pending') throw new Error(`Review item ${item.id} is ${item.status}, not pending.`); item.status = decision === 'approve' ? 'approved' : 'rejected'; + if (decision === 'approve') item.approvedDigest = stableDigest({ kind: item.kind, payload: item.payload }); item.decidedBy = by || process.env.ATS_REVIEWER || process.env.USER || 'reviewer'; item.decidedAt = new Date().toISOString(); if (note) item.decisionNote = note; @@ -109,16 +111,42 @@ export function decideReviewItem(idOrPrefix, decision, { by, note, queuePath = r }, { label: 'review queue' }); } +/** Claim the approved payload durably before any external side effect. */ +export function claimReviewItem(id, { queuePath = reviewQueuePath() } = {}) { + return withLockSync(queuePath, () => { + const queue = readReviewQueue({ queuePath }); + const item = matchItem(queue.items, id); + if (item.status !== 'approved') throw new Error(`Review item ${item.id} is ${item.status}, not approved.`); + const digest = stableDigest({ kind: item.kind, payload: item.payload }); + if (!item.approvedDigest || item.approvedDigest !== digest) { + throw new Error(`Review item ${item.id} has no matching payload approval; stage and approve it again.`); + } + item.status = 'applying'; + item.applyToken = randomUUID(); + item.applyStartedAt = new Date().toISOString(); + item.applyPid = process.pid; + writeQueue(queue, queuePath); + return item; + }, { label: 'review queue' }); +} + /** - * Record the outcome of executing an approved item. Success flips it to - * `applied`; failure keeps it `approved` with `applyError` for retry. + * Record the outcome with the applying claim token. Claimed failures become + * `failed`; legacy unclaimed callers retain their approved-error behavior. */ -export function markReviewItemApplied(id, { result, error, queuePath = reviewQueuePath() } = {}) { +export function markReviewItemApplied(id, { result, error, applyToken, queuePath = reviewQueuePath() } = {}) { return withLockSync(queuePath, () => { const queue = readReviewQueue({ queuePath }); const item = matchItem(queue.items, id); - if (item.status !== 'approved') throw new Error(`Review item ${item.id} is ${item.status}, not approved.`); + if (item.status === 'applying') { + if (!applyToken || applyToken !== item.applyToken) throw new Error(`Review item ${item.id} requires its applying claim token.`); + } else if (item.status !== 'approved' || applyToken) { + throw new Error(`Review item ${item.id} is ${item.status}, not approved.`); + } if (error) { + // The backend may have accepted the operation before its response failed. + // Never automatically retry a claimed write with an uncertain outcome. + if (applyToken) item.status = 'failed'; item.applyError = String(error); } else { item.status = 'applied'; diff --git a/packages/core/state-bundle.js b/packages/core/state-bundle.js index ad79833..8895727 100644 --- a/packages/core/state-bundle.js +++ b/packages/core/state-bundle.js @@ -18,6 +18,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createHash } from 'node:crypto'; import { actionLogPath } from './action-ledger.js'; import { reviewQueuePath } from './review-queue.js'; import { taskEventStatePath, taskEventSpoolPath } from './task-events.js'; @@ -57,19 +58,31 @@ function validateStateFile(entry, content) { if (entry.format === 'jsonl') { const lines = content.split('\n').filter((line) => line.trim()); lines.forEach((line, index) => { - try { JSON.parse(line); } catch (error) { + try { + const row = JSON.parse(line); + if (!row || typeof row !== 'object' || Array.isArray(row)) throw new Error('expected an object record'); + } catch (error) { throw new Error(`invalid JSONL at line ${index + 1}: ${error.message}`, { cause: error }); } }); return { records: lines.length }; } const parsed = JSON.parse(content); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('expected a state object'); if (entry.version !== undefined && parsed?.version !== entry.version) { throw new Error(`schema version ${parsed?.version ?? 'missing'}; expected ${entry.version}`); } + if (entry.name === 'review-queue' && !Array.isArray(parsed.items)) throw new Error('review queue requires items'); + if (entry.name === 'event-checkpoint' && (!parsed.tasks || typeof parsed.tasks !== 'object' || Array.isArray(parsed.tasks))) throw new Error('event checkpoint requires a task map'); + if (entry.name === 'event-spool' && (!Array.isArray(parsed.pending) || parsed.pending.some((item) => typeof item?.event?.id !== 'string' || typeof item.stagedAt !== 'string'))) throw new Error('event spool requires valid pending entries'); + if (entry.name === 'corpus-cache' && (!Number.isFinite(parsed.timestamp) || !Array.isArray(parsed.tasks))) throw new Error('corpus cache requires tasks'); return { version: parsed?.version ?? null }; } +function contentDigest(content) { + return createHash('sha256').update(content).digest('hex'); +} + /** Read-only compatibility and permissions report for every known state file. */ export function inspectState() { const files = stateFileRegistry().map((entry) => { @@ -108,7 +121,8 @@ export function exportState() { skipped.push(entry.name); continue; } - files[entry.name] = { path: entry.path, content: fs.readFileSync(entry.path, 'utf8') }; + const content = fs.readFileSync(entry.path, 'utf8'); + files[entry.name] = { path: entry.path, content, sha256: contentDigest(content) }; } catch (err) { skipped.push(`${entry.name} (${err.message})`); } @@ -123,31 +137,46 @@ export function exportState() { } export function importState(bundle, { force = false, dryRun = false } = {}) { - if (bundle?.version !== STATE_BUNDLE_VERSION || !bundle.files || typeof bundle.files !== 'object') { + if (bundle?.version !== STATE_BUNDLE_VERSION || !bundle.files || typeof bundle.files !== 'object' || Array.isArray(bundle.files)) { throw new Error('Unsupported state bundle.'); } - const registry = new Map(stateFileRegistry().map((e) => [e.name, e.path])); + const registry = new Map(stateFileRegistry().map((e) => [e.name, e])); const report = []; + const planned = []; + // Validate the entire bundle before the first write, including in dry-run. for (const [name, file] of Object.entries(bundle.files)) { - const target = registry.get(name); - if (!target) { + const entry = registry.get(name); + if (!entry) { report.push({ name, status: 'unknown name — skipped' }); continue; } if (typeof file?.content !== 'string') { - report.push({ name, status: 'invalid content — skipped' }); - continue; + throw new Error(`Invalid state file ${name}: content must be a string.`); } - if (fs.existsSync(target) && !force) { - report.push({ name, status: 'exists — rerun with --force to overwrite' }); - continue; + try { + if (file.sha256 !== undefined && file.sha256 !== contentDigest(file.content)) throw new Error('checksum mismatch'); + validateStateFile(entry, file.content); + } catch (error) { + throw new Error(`Invalid state file ${name}: ${error.message}`, { cause: error }); } + planned.push({ name, file, target: entry.path }); + } + for (const { name, file, target } of planned) { if (dryRun) { - report.push({ name, status: fs.existsSync(target) ? 'would overwrite' : 'would write', path: target }); + const exists = fs.existsSync(target); + report.push({ name, status: exists && !force ? 'exists — rerun with --force to overwrite' : exists ? 'would overwrite' : 'would write', path: target }); continue; } - withLockSync(target, () => writeFileAtomicSync(target, file.content), { label: `state file ${name}` }); - report.push({ name, status: 'written', path: target }); + // Check existence inside the same lock as the write; a concurrent creator + // must not be overwritten by an import that did not request --force. + withLockSync(target, () => { + if (fs.existsSync(target) && !force) { + report.push({ name, status: 'exists — rerun with --force to overwrite' }); + } else { + writeFileAtomicSync(target, file.content); + report.push({ name, status: 'written', path: target }); + } + }, { label: `state file ${name}` }); } return { dryRun, imported: report.filter((r) => r.status === 'written').length, report }; } diff --git a/packages/core/test/corpus-scope.test.js b/packages/core/test/corpus-scope.test.js new file mode 100644 index 0000000..d53ed90 --- /dev/null +++ b/packages/core/test/corpus-scope.test.js @@ -0,0 +1,24 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-scope-')); +process.env.ATS_CORPUS_CACHE = path.join(dir, 'cache.json'); +process.env.ATS_CORPUS_TTL_MS = '1000'; +const cache = await import('../corpus-cache.js'); +after(() => fs.rmSync(dir, { recursive: true, force: true })); + +test('fresh, stale and delta cache paths all reject a different source', () => { + cache.write([{ id: 'a' }], { scope: 'source-a', cursor: 'private-cursor' }); + assert.equal(cache.read({ scope: 'source-a' })[0].id, 'a'); + assert.equal(cache.read({ scope: 'source-b' }), null); + assert.equal(cache.readAny({ scope: 'source-b' }), null); + assert.equal(cache.meta({ scope: 'source-b' }).scopeMismatch, true); + const raw = JSON.parse(fs.readFileSync(cache.cachePath)); + raw.timestamp -= 5000; + fs.writeFileSync(cache.cachePath, JSON.stringify(raw)); + assert.equal(cache.readStale({ scope: 'source-b' }), null); + assert.equal(cache.readStale({ scope: 'source-a' }).tasks[0].id, 'a'); + assert.equal(cache.readAny({ scope: 'source-a' }).cursor, 'private-cursor'); +}); diff --git a/packages/core/test/fs-lock.test.js b/packages/core/test/fs-lock.test.js index 58a5e69..e0414d4 100644 --- a/packages/core/test/fs-lock.test.js +++ b/packages/core/test/fs-lock.test.js @@ -92,3 +92,23 @@ test('concurrent processes doing read-modify-write under the lock lose no update // 3 processes × 30 locked increments: any lost update makes this < 90. assert.equal(Number(fs.readFileSync(target, 'utf8')), 90); }); + +test('an aged lock held by a live local PID is never stolen', () => { + const target = tempTarget('state.json'); + const lock = `${target}.lock`; + fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, host: os.hostname() })); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(lock, old, old); + assert.throws(() => withLockSync(target, () => assert.fail('entered live lock'), { staleMs: 1, timeoutMs: 60 }), /Timed out/); + assert.equal(JSON.parse(fs.readFileSync(lock)).pid, process.pid); +}); + +test('releasing a holder does not unlink a replacement lock', () => { + const target = tempTarget('state.json'); + const lock = `${target}.lock`; + withLockSync(target, () => { + fs.renameSync(lock, `${lock}.old`); + fs.writeFileSync(lock, 'replacement'); + }); + assert.equal(fs.readFileSync(lock, 'utf8'), 'replacement'); +}); diff --git a/packages/core/test/review-claim.test.js b/packages/core/test/review-claim.test.js new file mode 100644 index 0000000..be549f2 --- /dev/null +++ b/packages/core/test/review-claim.test.js @@ -0,0 +1,65 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { stageReviewItem, decideReviewItem, claimReviewItem, markReviewItemApplied, findReviewItem } from '../review-queue.js'; + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ats-review-claim-')); +after(() => fs.rmSync(dir, { recursive: true, force: true })); +const moduleUrl = new URL('../review-queue.js', import.meta.url).href; +function approved(name) { + const queuePath = path.join(dir, `${name}.json`); + const item = stageReviewItem({ kind: 'task.write', payload: { action: 'task.created', title: 'Release' } }, { queuePath }); + decideReviewItem(item.id, 'approve', { queuePath }); + return { id: item.id, queuePath }; +} + +test('approval binds to kind and payload; edits and legacy approvals cannot claim', () => { + for (const mutation of [ + (item) => { item.payload.title = 'Edited'; }, + (item) => { item.kind = 'kg.fact'; }, + (item) => { delete item.approvedDigest; }, + ]) { + const { id, queuePath } = approved(`tampered-${Math.random()}`); + const queue = JSON.parse(fs.readFileSync(queuePath)); + mutation(queue.items[0]); + fs.writeFileSync(queuePath, JSON.stringify(queue)); + assert.throws(() => claimReviewItem(id, { queuePath }), /matching payload approval/); + assert.equal(findReviewItem(id, { queuePath }).status, 'approved'); + } +}); + +test('two processes applying one approval produce one external side effect', async () => { + const { id, queuePath } = approved('parallel'); + const effects = path.join(dir, 'side-effects.jsonl'); + const child = ` + import fs from 'node:fs'; + const { claimReviewItem, markReviewItemApplied } = await import(process.argv[1]); + try { + const item = claimReviewItem(process.argv[2], { queuePath: process.argv[3] }); + fs.appendFileSync(process.argv[4], JSON.stringify({ id: item.id }) + '\\n'); + await new Promise(resolve => setTimeout(resolve, 100)); + markReviewItemApplied(item.id, { queuePath: process.argv[3], applyToken: item.applyToken }); + } catch (error) { process.exitCode = 1; } + `; + const run = () => new Promise((resolve, reject) => { + const proc = spawn(process.execPath, ['--input-type=module', '-e', child, moduleUrl, id, queuePath, effects]); + proc.on('error', reject); + proc.on('exit', resolve); + }); + assert.deepEqual((await Promise.all([run(), run()])).sort(), [0, 1]); + assert.equal(fs.readFileSync(effects, 'utf8').trim().split('\n').length, 1); + assert.equal(findReviewItem(id, { queuePath }).status, 'applied'); +}); + +test('claim tokens are required and uncertain backend failures cannot auto-retry', () => { + const { id, queuePath } = approved('failure'); + const item = claimReviewItem(id, { queuePath }); + assert.throws(() => claimReviewItem(id, { queuePath }), /applying, not approved/); + assert.throws(() => markReviewItemApplied(id, { queuePath, applyToken: 'other' }), /claim token/); + markReviewItemApplied(id, { queuePath, applyToken: item.applyToken, error: 'response lost' }); + assert.equal(findReviewItem(id, { queuePath }).status, 'failed'); + assert.throws(() => claimReviewItem(id, { queuePath }), /failed, not approved/); +}); diff --git a/packages/core/test/state-bundle.test.js b/packages/core/test/state-bundle.test.js index eac5fbf..41e96b9 100644 --- a/packages/core/test/state-bundle.test.js +++ b/packages/core/test/state-bundle.test.js @@ -50,7 +50,7 @@ test('import ignores unknown names and bundle-supplied paths', () => { version: 1, files: { 'not-a-state-file': { path: path.join(tmp, 'evil.txt'), content: 'nope' }, - 'action-ledger': { path: path.join(tmp, 'elsewhere.txt'), content: 'redirected?\n' }, + 'action-ledger': { path: path.join(tmp, 'elsewhere.txt'), content: '{"action":"redirected"}\n' }, }, }; const res = importState(evil, { force: true }); @@ -58,7 +58,7 @@ test('import ignores unknown names and bundle-supplied paths', () => { assert.equal(fs.existsSync(path.join(tmp, 'evil.txt')), false); assert.equal(fs.existsSync(path.join(tmp, 'elsewhere.txt')), false); // Content landed at the LOCAL registry path, not the bundle's claimed path. - assert.equal(fs.readFileSync(process.env.ATS_ACTION_LOG, 'utf8'), 'redirected?\n'); + assert.equal(fs.readFileSync(process.env.ATS_ACTION_LOG, 'utf8'), '{"action":"redirected"}\n'); }); test('state doctor is read-only and import dry-run does not write', () => { @@ -79,3 +79,32 @@ test('the registry whitelists state only — no credential-bearing names', () => assert.ok(!names.includes(forbidden), `${forbidden} must never be bundled`); } }); + +test('invalid restore data is rejected before any file writes, including dry-run', () => { + fs.writeFileSync(process.env.ATS_ACTION_LOG, '{"action":"original"}\n'); + const bundle = { version: 1, files: { + 'action-ledger': { content: '{"action":"replacement"}\n' }, + 'review-queue': { content: '{"version":1,"items":{}}' }, + } }; + for (const dryRun of [false, true]) { + assert.throws(() => importState(bundle, { force: true, dryRun }), /review queue requires items/); + assert.equal(fs.readFileSync(process.env.ATS_ACTION_LOG, 'utf8'), '{"action":"original"}\n'); + } + bundle.files['review-queue'].content = '{"version":99,"items":[]}'; + assert.throws(() => importState(bundle, { force: true }), /schema version/); + bundle.files['review-queue'].content = '[]'; + assert.throws(() => importState(bundle), /state object/); + delete bundle.files['review-queue']; + bundle.files['action-ledger'].content = '{"action":"a"}\nBROKEN\n'; + assert.throws(() => importState(bundle), /JSONL at line 2/); +}); + +test('export checksums detect tampering while valid legacy bundles still import', () => { + fs.writeFileSync(process.env.ATS_REVIEW_QUEUE, '{"version":1,"items":[]}'); + const bundle = exportState(); + assert.match(bundle.files['action-ledger'].sha256, /^[a-f0-9]{64}$/); + bundle.files['action-ledger'].content = '{"action":"tampered"}\n'; + assert.throws(() => importState(bundle, { force: true }), /checksum mismatch/); + delete bundle.files['action-ledger'].sha256; + assert.ok(importState(bundle, { force: true }).imported > 0); +}); diff --git a/packages/core/test/write-guard.test.js b/packages/core/test/write-guard.test.js index 390cc03..101de94 100644 --- a/packages/core/test/write-guard.test.js +++ b/packages/core/test/write-guard.test.js @@ -4,7 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { guardWrite } from '../write-guard.js'; +import { guardWrite, reviewTargetRevision } from '../write-guard.js'; import { listReviewItems } from '../review-queue.js'; import { setTaskIntent } from '../task-context.js'; @@ -40,7 +40,7 @@ test('a target that declares approvalRequired stages the write with its payload' assert.equal(item.id, staged.reviewId); assert.equal(item.kind, 'task.write'); assert.equal(item.stagedBy, 'agent-a'); - assert.deepEqual(item.payload, { action: 'task.updated', projectId: 'p1', taskId: 't2', patch: { title: 'renamed' } }); + assert.deepEqual(item.payload, { action: 'task.updated', projectId: 'p1', taskId: 't2', patch: { title: 'renamed' }, expectedRevision: reviewTargetRevision(target) }); assert.equal(item.note, 'approvalRequired on target'); }); diff --git a/packages/core/write-guard.d.ts b/packages/core/write-guard.d.ts index ea5e387..dc6ed89 100644 --- a/packages/core/write-guard.d.ts +++ b/packages/core/write-guard.d.ts @@ -17,3 +17,4 @@ export interface StagedWrite { /** Null when the write may proceed; the staged-write response when it must wait for review. */ export function guardWrite(write: GuardedWrite, opts?: { queuePath?: string; env?: Record }): StagedWrite | null; +export function reviewTargetRevision(task: object): string; diff --git a/packages/core/write-guard.js b/packages/core/write-guard.js index 2290560..8cdc24f 100644 --- a/packages/core/write-guard.js +++ b/packages/core/write-guard.js @@ -12,6 +12,15 @@ * metadata — set `ATS_REVIEW_ALL` for a hard gate. */ import { stageReviewItem, writeRequiresApproval } from './review-queue.js'; +import { stableDigest } from './reliability-snapshot.js'; + +/** Logical target fields, excluding backend-specific raw and volatile read data. */ +export function reviewTargetRevision(task) { + const t = task?.task || task; + if (!t || typeof t !== 'object') throw new Error('Cannot fingerprint an unreadable review target.'); + const fields = ['id', 'projectId', 'title', 'content', 'tags', 'dueDate', 'priority', 'status', 'modifiedTime', 'parentId', 'childIds', 'links']; + return stableDigest(Object.fromEntries(fields.filter((key) => t[key] !== undefined).map((key) => [key, t[key]]))); +} /** * Decide whether a write must stage. Returns null when it may proceed, else @@ -27,7 +36,11 @@ export function guardWrite({ action, target = null, payload, by, note }, { queue const item = stageReviewItem( { kind: 'task.write', - payload: { action, ...(payload || {}) }, + payload: { + ...(payload || {}), + action, + ...(action === 'task.created' ? {} : { expectedRevision: target ? reviewTargetRevision(target) : null }), + }, by: by || env.ATS_AGENT_ID || 'unknown-agent', note: note || (forced ? 'staged by ATS_REVIEW_ALL' : 'approvalRequired on target'), }, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 36b8775..f1e1725 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@reneza/ats-mcp", - "version": "0.14.0", + "version": "0.15.0", "mcpName": "io.github.renezander030/agentic-task-system", "description": "Model Context Protocol server for Agentic Task System — exposes the task app you already use to any MCP client, backed by hybrid + RRF retrieval. Storage-agnostic over the ATS adapter contract.", "type": "module", @@ -20,11 +20,11 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.30.1", - "@reneza/ats-core": "^0.14.0", + "@reneza/ats-core": "^0.15.0", "zod": "^4.6.5" }, "peerDependencies": { - "@reneza/ats-adapter-ticktick": "^0.14.0" + "@reneza/ats-adapter-ticktick": "^0.15.0" }, "peerDependenciesMeta": { "@reneza/ats-adapter-ticktick": { diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..8d177c1 --- /dev/null +++ b/scripts/check-release.mjs @@ -0,0 +1,28 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import assert from 'node:assert/strict'; + +const root = path.resolve(new URL('..', import.meta.url).pathname); +const read = file => JSON.parse(fs.readFileSync(path.join(root, file), 'utf8')); +const manifest = read('package.json'); +const lock = read('package-lock.json'); +const server = read('server.json'); +assert.equal(lock.version, manifest.version, 'lockfile release version'); +const packages = fs.readdirSync(path.join(root, 'packages')).map(name => ({ dir: `packages/${name}`, pkg: read(`packages/${name}/package.json`) })); +const names = new Set(packages.map(({ pkg }) => pkg.name)); +for (const { dir, pkg } of packages) { + assert.equal(pkg.version, manifest.version, `${pkg.name} release version`); + assert.equal(lock.packages[dir].version, pkg.version, `${pkg.name} lockfile version`); + for (const section of ['dependencies', 'peerDependencies', 'optionalDependencies']) { + for (const [name, range] of Object.entries(pkg[section] || {})) { + if (names.has(name)) assert.equal(range, `^${manifest.version}`, `${pkg.name} dependency ${name}`); + } + assert.deepEqual(lock.packages[dir][section] || {}, pkg[section] || {}, `${pkg.name} ${section} lockfile`); + } +} +const mcp = packages.find(({ pkg }) => pkg.name === '@reneza/ats-mcp').pkg; +assert.equal(server.version, manifest.version, 'MCP registry release version'); +assert.equal(server.name, mcp.mcpName, 'MCP registry namespace'); +assert.equal(server.packages[0].identifier, mcp.name, 'MCP registry npm package'); +assert.equal(server.packages[0].version, mcp.version, 'MCP registry npm version'); +console.log(`Release ${manifest.version} aligned across ${packages.filter(({ pkg }) => !pkg.private).length} public npm packages and MCP registry manifest.`); diff --git a/server.json b/server.json index e0dffe3..ad7c0af 100644 --- a/server.json +++ b/server.json @@ -9,13 +9,13 @@ "source": "github", "subfolder": "packages/mcp" }, - "version": "0.9.0", + "version": "0.15.0", "packages": [ { "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@reneza/ats-mcp", - "version": "0.9.0", + "version": "0.15.0", "transport": { "type": "stdio" },