diff --git a/.github/workflows/linux-smoke.yml b/.github/workflows/linux-smoke.yml new file mode 100644 index 0000000..fb4edf8 --- /dev/null +++ b/.github/workflows/linux-smoke.yml @@ -0,0 +1,43 @@ +name: Linux smoke + +# GitHub-hosted Linux build + smoke for the 0.x agent-ready milestone. +# Complements the private-repo macOS self-hosted CI/Trust workflows. + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: linux-smoke-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +permissions: + contents: read + +jobs: + linux-smoke: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install Swift + uses: swift-actions/setup-swift@v2 + with: + swift-version: "6.0" + + - name: Swift version + run: swift --version + + - name: Build + run: swift build -c debug + + - name: Test + run: swift test + + - name: Smoke CLI + env: + APS_BIN: .build/debug/aps + run: ./Scripts/smoke.sh diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/accepted-state.json similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/accepted-state.json diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/approvals.json b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/approvals.json similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/approvals.json rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/approvals.json diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md similarity index 97% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md index 7aa85f2..bce6bc6 100644 --- a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md +++ b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/change.md @@ -1,6 +1,6 @@ --- id: CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review -state: accepted +state: archived type: feature base_commit: c6d9326fd7f4c7925deb5ff4f27f984a073bf6b8 --- diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/context.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/context.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/context.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/context.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/aps-cli.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/aps-cli.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/aps-cli.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/aps-cli.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/state-store.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/state-store.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/state-store.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/deltas/state-store.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/design.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/design.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/design.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/design.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/docs.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/docs.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/docs.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/docs.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/plan.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/plan.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/plan.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/plan.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/requirements.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/requirements.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/requirements.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/requirements.md diff --git a/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json new file mode 100644 index 0000000..2a733ff --- /dev/null +++ b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/state.json @@ -0,0 +1,56 @@ +{ + "schema_version": 1, + "id": "CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review", + "slug": "fix-filestate-watch-cache-and-path-isolation-from-review", + "title": "Fix FileState watch cache and path isolation from review", + "description": "Fix FileState watch cache and path isolation from review", + "kind": "feature", + "state": "archived", + "canonical_applied": true, + "base_commit": "c6d9326fd7f4c7925deb5ff4f27f984a073bf6b8", + "created_at": 1784396041, + "updated_at": 1784398029, + "affected_specs": [ + "aps-cli", + "state-store" + ], + "affected_paths": [ + ".attest.json", + ".augur.toml", + ".github/workflows/", + ".gitignore", + ".specsync/", + ".trust.toml", + "AGENTS.md", + "CLAUDE.md", + "Package.resolved", + "Package.swift", + "README.md", + "Scripts/", + "Sources/", + "Tests/", + "fledge.toml", + "specs/" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "watch note sees cross-process file writes; StateStore preserves injected FileState paths; set note fails when disk write does not persist; specs/README match; 20 tests pass" + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "design" + ], + "dependencies": [ + "CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts" + ], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/tasks.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/tasks.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/tasks.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/tasks.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/testing.md b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/testing.md similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/testing.md rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/testing.md diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification-attempts.json b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification-attempts.json similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification-attempts.json rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification-attempts.json diff --git a/.specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification.json b/.specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification.json similarity index 100% rename from .specsync/changes/CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification.json rename to .specsync/archive/changes/2026-07-18-CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review/verification.json diff --git a/.specsync/change-sequence.json b/.specsync/change-sequence.json index 5ace796..987f4b2 100644 --- a/.specsync/change-sequence.json +++ b/.specsync/change-sequence.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "sequence": 2, - "id": "CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review", + "sequence": 5, + "id": "CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout", "acknowledged_collisions": [] } diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/approvals.json b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/approvals.json new file mode 100644 index 0000000..aae58d0 --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/approvals.json @@ -0,0 +1,153 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784398074, + "digest": "641111b8f92bdfff2ba1f3bcda40f5299d9dc8a65449cd2a4a49333fac21100f", + "note": "Approved post-merge archive of CHG-0002 and publishing GOAL.md for aps v1.1; no module contract changes." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784398078, + "digest": "504e84d897b0ef5bd628e7eafbe814c05b1ae7a5f15bb795e9aa112d4f0caf27", + "note": "Accepted v1 SpecSync archive and aps v1.1 GOAL.md publication." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784399199, + "digest": "641111b8f92bdfff2ba1f3bcda40f5299d9dc8a65449cd2a4a49333fac21100f", + "note": "Re-approve GOAL/README at 0.2.0 naming; no SpecSync definition artifact change." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784399215, + "digest": "db4d9c6e2319364dfd0742cb46c74f4d84e04269813a0f617a78824ba0a6af33", + "note": "Accepted GOAL/README refresh to pre-public 0.2.0 naming." + } + ], + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal", + "actor": "leif algo", + "reason": "Rename milestone from mistaken v1.1 branding to pre-public 0.2.0; refresh GOAL and README for agent-ready checklist.", + "timestamp": 1784399199, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784398078, + "digest": "504e84d897b0ef5bd628e7eafbe814c05b1ae7a5f15bb795e9aa112d4f0caf27", + "note": "Accepted v1 SpecSync archive and aps v1.1 GOAL.md publication." + }, + "prior_verification": { + "timestamp": 1784398077, + "commit": "c02a551afc126ca5394c62baa557ceaf30fd5bc5", + "contract_digest": "641111b8f92bdfff2ba1f3bcda40f5299d9dc8a65449cd2a4a49333fac21100f", + "workspace_digest": "37f288e185f2d7984d92dffce833943e4de9f6f977241cb404462c1e2665b875", + "acceptance_input_digest": "3558d65246e2c24859cabb4ce98197bc29477f32bba6728c42914ea7f5805d22", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "ddf1f4cbb0fd983c8356a34b9b4f4ef77ca22764ac2ec36b223431402c043328", + "entry_digest": "7fd95f31d540d3d3e7128fbd03cb3207a3da428c98a81515a76b7dfc9b34d582", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "GOAL.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b6ea65e7d0fbd1a336afd9bb619acc9a799bc87ef41453c639defc272d75fb5d", + "entry_digest": "7876707aaa1955e71f50e4733b299d4e9c03eee80c3bb23565b3bb484892306b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "87a857d8ceb5c5f8aebeee5a4cb0dad157105514bcc433ed1c81e3af87d6f820", + "entry_digest": "5a68a2ddbfc87bb391a3c5b61d0f63a09b1f2999e180841e1098597966f9e744", + "owners": [ + "@exact:delivery" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + "stale_acceptance_input_digest": "3558d65246e2c24859cabb4ce98197bc29477f32bba6728c42914ea7f5805d22", + "current_acceptance_input_digest": "80328d5ff7e9ef1c9eb3031447397375a429ba5b8fc3148ab6ef3a2d21f5fc68" + } + ] +} diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/change.md b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/change.md new file mode 100644 index 0000000..bb86d5f --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/change.md @@ -0,0 +1,24 @@ +--- +id: CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal +state: accepted +type: operations +base_commit: c02a551afc126ca5394c62baa557ceaf30fd5bc5 +--- + +# Archive merged v1 SpecSync change and publish aps v1.1 goal + +## Intent + +Archive merged v1 SpecSync change and publish aps v1.1 goal + +## Affected Canonical Specs + +- None + +## Acceptance Criteria + +- GOAL.md publishes the v1.1 agent-ready harness plan; README links it; merged CHG-0002 is archived under SpecSync 5.1.1; specsync check and change check pass with no active stale evidence. + +## No-spec Rationale + +Documents the v1.1 product goal and archives the merged v1 SpecSync change; no aps-cli/state-store contract changes. diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/context.md b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/context.md new file mode 100644 index 0000000..7e05683 --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/context.md @@ -0,0 +1,3 @@ +# Context + +v1 (PR #1) merged with CHG-0002 still active. SpecSync 5.1.1 expects accepted changes to be archived after merge. Concurrently, the project needs a written v1.1 goal so follow-on work has a single north star for agent-ready CLI contracts. diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/plan.md b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/plan.md new file mode 100644 index 0000000..de3d485 --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/plan.md @@ -0,0 +1,6 @@ +# Plan + +1. Archive merged CHG-0002 with `specsync change archive` under SpecSync 5.1.1. +2. Add `GOAL.md` describing aps v1.1 (JSON, state root, watch bounds, Linux smoke, structured FileState). +3. Link `GOAL.md` from README. +4. Verify with fledge verify lane and `specsync check`. diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/state.json b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/state.json new file mode 100644 index 0000000..d02eaf8 --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/state.json @@ -0,0 +1,34 @@ +{ + "schema_version": 1, + "id": "CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal", + "slug": "archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal", + "title": "Archive merged v1 SpecSync change and publish aps v1.1 goal", + "description": "Archive merged v1 SpecSync change and publish aps v1.1 goal", + "kind": "operations", + "state": "accepted", + "canonical_applied": true, + "base_commit": "c02a551afc126ca5394c62baa557ceaf30fd5bc5", + "created_at": 1784398063, + "updated_at": 1784399215, + "affected_specs": [], + "affected_paths": [ + ".specsync/", + "GOAL.md", + "README.md" + ], + "no_spec_change": true, + "no_spec_change_rationale": "Documents the v1.1 product goal and archives the merged v1 SpecSync change; no aps-cli/state-store contract changes.", + "acceptance_criteria": [ + "GOAL.md publishes the v1.1 agent-ready harness plan; README links it; merged CHG-0002 is archived under SpecSync 5.1.1; specsync check and change check pass with no active stale evidence." + ], + "selected_artifacts": [ + "context", + "plan", + "testing" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "no" + } +} diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/testing.md b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/testing.md new file mode 100644 index 0000000..c6cb1e9 --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/testing.md @@ -0,0 +1,6 @@ +# Testing + +- `specsync change check` reports no active stale evidence after archive. +- `specsync check --force` passes on SpecSync 5.1.1. +- `fledge lanes run verify` (build, test, smoke) passes. +- `GOAL.md` and README link exist in the tree. diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/verification-attempts.json b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/verification-attempts.json new file mode 100644 index 0000000..320d962 --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/verification-attempts.json @@ -0,0 +1,35 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1784398077, + "commit": "c02a551afc126ca5394c62baa557ceaf30fd5bc5", + "contract_digest": "641111b8f92bdfff2ba1f3bcda40f5299d9dc8a65449cd2a4a49333fac21100f", + "workspace_digest": "37f288e185f2d7984d92dffce833943e4de9f6f977241cb404462c1e2665b875", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + }, + { + "timestamp": 1784399203, + "commit": "6f18ccb3015b4fc59021e690fefbf5947ade5c73", + "contract_digest": "641111b8f92bdfff2ba1f3bcda40f5299d9dc8a65449cd2a4a49333fac21100f", + "workspace_digest": "32bcf044035714dacf90191f9c4d193c052cf586e94c67a44fcaa86e0a1f12e1", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + } + ] +} diff --git a/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/verification.json b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/verification.json new file mode 100644 index 0000000..a14bfd1 --- /dev/null +++ b/.specsync/changes/CHG-0003-archive-merged-v1-specsync-change-and-publish-aps-v1-1-goal/verification.json @@ -0,0 +1,101 @@ +{ + "timestamp": 1784399203, + "commit": "6f18ccb3015b4fc59021e690fefbf5947ade5c73", + "contract_digest": "641111b8f92bdfff2ba1f3bcda40f5299d9dc8a65449cd2a4a49333fac21100f", + "workspace_digest": "32bcf044035714dacf90191f9c4d193c052cf586e94c67a44fcaa86e0a1f12e1", + "acceptance_input_digest": "80328d5ff7e9ef1c9eb3031447397375a429ba5b8fc3148ab6ef3a2d21f5fc68", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "ddf1f4cbb0fd983c8356a34b9b4f4ef77ca22764ac2ec36b223431402c043328", + "entry_digest": "7fd95f31d540d3d3e7128fbd03cb3207a3da428c98a81515a76b7dfc9b34d582", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "GOAL.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8e6aaa8b615b6e0b9653d798928677e472dbd3b905243bc7fc44a5d7ec8bd888", + "entry_digest": "6306e8c2a11cad3c2485ddeddfbbfc4bbc0852a7aa217676deeaebaeb411ba08", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "README.md", + "kind": "file", + "mode": 33188, + "payload_digest": "19cc8a055e3ff7720e589bb33001e799bbd1f2448971b10f016c0f00621d92ac", + "entry_digest": "b48edc603a6603ec0fd9a46df8f890ae7c2e1ecc4d96a2241aa1d7e1bd972145", + "owners": [ + "@exact:delivery" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] +} diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/approvals.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/approvals.json new file mode 100644 index 0000000..f540a81 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/approvals.json @@ -0,0 +1,373 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784399234, + "digest": "8a5448bbf234d6a2b036e6c717ada2986e90830a74cd8e2a743b6c548584d6f0", + "note": "Approved 0.2.0 agent-ready CLI contracts: JSON, state root, watch bounds, profile FileState." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784399246, + "digest": "d840c0a9a3d4ba4a726d86aa61e24df281d35c6ade72b3bcfd44a324ea7c4cd9", + "note": "Re-approve after Public API export inventory matches ProfileDocument and dependency helpers." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784399263, + "digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "note": "Re-approve after requirement evidence mapping in testing.md." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784399283, + "digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "note": "Re-approve after assigning CLIOutput.swift to aps-cli module ownership." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784399288, + "digest": "fdb8b3acb85e74cab0e8cb03119529d60a81ee3386e88f9eb9dafd75eee479c3", + "note": "Accepted aps 0.2.0 agent-ready surface with CLIOutput ownership." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784400048, + "digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "note": "Re-approve delivery for jsonl fresh-value fix without definition edits." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784400056, + "digest": "49717dfa4e9f82569bcc4158909612ab414f5f3990882f9c38fc6e70a770880e", + "note": "Accepted watch --jsonl fresh-value fix, APSClock timestamps, jsonl tests, and linux-smoke swift test." + } + ], + "reopenings": [ + { + "schema_version": 1, + "change_id": "CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate", + "actor": "leif algo", + "reason": "Fix watch --jsonl stale FileState cache on cross-process writes; add jsonl event test; use APSClock for event timestamps; linux-smoke runs swift test.", + "timestamp": 1784400048, + "from_state": "accepted", + "to_state": "verifying", + "superseded_approval": { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784399288, + "digest": "fdb8b3acb85e74cab0e8cb03119529d60a81ee3386e88f9eb9dafd75eee479c3", + "note": "Accepted aps 0.2.0 agent-ready surface with CLIOutput ownership." + }, + "prior_verification": { + "timestamp": 1784399287, + "commit": "6f18ccb3015b4fc59021e690fefbf5947ade5c73", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "0291696ef9ef22a673050ac1b5c94ad68d27cd72b09599597c2a37ca200028b8", + "acceptance_input_digest": "1d55064b83d02025b8640bff9ea8b06913849e11ac858f18ffd3805b42be4a21", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".github/workflows/ci.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "1f310d5d11b4466aedf85aac75d7db13b07d564f1e38e2f596defb8cf2f06d1c", + "entry_digest": "9abaefb404d6bb884367be7ea7524e890c9bab326c810c0985569c35a83418e7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/linux-smoke.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "98ceaa60214433454e27d76ccf9808ebbed9b484f5b9edda91d81d8428493c5e", + "entry_digest": "d0121f5975d7d51ac53cd93f2d40cf4d2c8c1f0d6db0e0db20b43f6bd1db4fe5", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/trust.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "e165929994150d856d05ad048d8e1032e09df989c78ea3f1390d4e885b50af56", + "entry_digest": "e405b6225fdb502cfae9b22a9c14e21be8d1715bbaf8d56cdfd178bb8e173cb9", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "3b200adf8860f60a6ebbb7bd22278d65d36c9fb8d9dd7529cf7eba7635f9bb59", + "entry_digest": "62a4ea5935aca0821d1aeeb2b5113dda9561feaf0385be503f4cef23bbf99c7b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "fc261d0a1f50860a893af0e917189677196e2d88b86f61efd9d1249b0510e870", + "entry_digest": "d7ed99351947d92bc60cdadcc410a4a9841ea53b882e85b50bfd03e632f695ab", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "324eef990b6d1c74084e5e54f70fd3f7a982948f0c14767c57c803a2c1fa5228", + "entry_digest": "d80ce8f7909229a89971d2bed3510f65d1cb19c43a1c20ec3a01767a32583656", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "406fefb65488ddd72f5bf40be01489bbd19e559f85e14f8919c91808088c34da", + "entry_digest": "a2b393bd8236d1ddd91563c3c062d24736f07032423b36c50ceb04f886e397e0", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoKey.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "aec3b0c43de6f034cfd1c2da09f03888ca3901dc537d5b129119170eada0ccd7", + "entry_digest": "6c15798408d9a15b994c22971dc86c85cac49b8608b26ffbbd5f9cad187ebbc3", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoState.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "c3665b70582aed474282178b16872c878f033345c4b63e9480f690b29670b727", + "entry_digest": "d3cfe46eb6d8496bd81dc02ee2475b9abe3431eb07df42a3881149c0cb39c1d1", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/Dependencies.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "635340259dcd618bf5099e07dad823f72f4517f6a2762ec1e6b67965221ccef2", + "entry_digest": "637ab3fca33111ebb5f145cdd632e7a6412e99d88d08e3890ab1cc3edc54c736", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "21f22b2243baae8bfa7719cd3ba5ee458e83fda63205034eabf49edfcc0056e8", + "entry_digest": "9e4624bec39fd411fc62d9d5e2df34bc91dbefb9da263d88c628a0dc7651aefa", + "owners": [ + "state-store" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "5c42a6450a12b08332d2225545e8e3c03eaf07b65cf9ff0bb485f058fbfbe359", + "entry_digest": "9f4fa03a9ea4c58931d2ded190eda917b71081354c4409a98442ac58b6161161", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4608ac4d4b252ff55f781578fe40c54625eca1d9628fdd7c9443cf47aa583d8e", + "entry_digest": "0edfcedecf8afc7912687d344d94192cdea83099480a5a4e504414cd73844aae", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6a67354ec782d88510bea4b0ec28e1b1a3c50fc01e625ee32d6783f4dfbc86e0", + "entry_digest": "9cb95bb0d687799ba10637230b404cbef8a5abfb45d46a07a7bb50bee9703431", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "683d65fb5cea16ed1cbd21701ba929665c4b0cd30a57bbf85edb8db526037455", + "entry_digest": "6449646f88cd57c6a2b0a13b7964351d688f0c3d1ea032c240855c317ad6dd24", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b6f0c8e3aa90f43e5dc40389e77088b2ec6ebb725c89c57217970fc1c221f527", + "entry_digest": "af31087c96d633274df81aecd4e99f520a55995882545190d2d42bb30fa5c3aa", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "14420af80f34d7954894aaf63f9864db13eea74549796043f54badf82805394a", + "entry_digest": "1227fad23fad49d312453dfe8fffc2bfe67396fdb29c88dab355bd60e8ec91b5", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ddb4564d168db43eadd6315a7f56b532198905a79abebfee77db890db0f9c402", + "entry_digest": "a14edf94507dfd13d7d325bf68e370e0c1646e323b28341bd0c1dfff72f05d88", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8dfe2cccda8f0a65a82f13c5fed48dbd7a4dba477c2813e1c65368fd6098c134", + "entry_digest": "7874d89e525e29add15d40a87422d826220788b4de7bb50d223f0d92f5adafef", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "aa0eea880b59e5879cbb606d83bad28fe4b23b31d396aab6c859ff148eb3dfef", + "entry_digest": "31335ab73c11393128097e8b7201d198ae592f640e46cedfd60fcefeeed9a2a1", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "711db2a24a4959531ca966ac43d96e7817788ebe74077280a6cff950cb9a8779", + "entry_digest": "8402318068cd8ab6f8a596519289e70425dbc28bf6b31d5c1ef6f0a17f927f74", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "96fbc44cdbe25dfab52a47f8efdbd4698203571981b181c78a04769112402f1d", + "entry_digest": "8b0abd2a0f6613b9caa1f2831cdf8776b80a1945552eade241ff3d3b8d6404af", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] + }, + "stale_acceptance_input_digest": "1d55064b83d02025b8640bff9ea8b06913849e11ac858f18ffd3805b42be4a21", + "current_acceptance_input_digest": "8fc3f456caffd3b7a60c001a98efc36e93dbe08bb6b601902cb02967f114e68e" + } + ] +} diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/change.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/change.md new file mode 100644 index 0000000..8b0a25b --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/change.md @@ -0,0 +1,25 @@ +--- +id: CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate +state: accepted +type: feature +base_commit: 6f18ccb3015b4fc59021e690fefbf5947ade5c73 +--- + +# Ship aps 0.2.0 agent-ready JSON state-dir watch and profile FileState + +## Intent + +Ship aps 0.2.0 agent-ready JSON state-dir watch and profile FileState + +## Affected Canonical Specs + +- `aps-cli` +- `state-store` + +## Acceptance Criteria + +- CLI 0.2.0 supports --json, APS_HOME/--state-dir, watch --count/--timeout/--jsonl, profile structured FileState; Linux smoke workflow exists; GOAL/README use 0.x naming; 25 tests and smoke pass. + +## No-spec Rationale + +Not applicable diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/context.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/context.md new file mode 100644 index 0000000..fffe913 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/context.md @@ -0,0 +1,3 @@ +# Context + +0.1.0 shipped a demo CLI. Agents need JSON output, isolated state roots, bounded watch, and a structured FileState example before public 1.0. Stay on 0.x branding. diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/deltas/aps-cli.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/deltas/aps-cli.md new file mode 100644 index 0000000..46ba0cb --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/deltas/aps-cli.md @@ -0,0 +1,73 @@ +# APS CLI agent-ready 0.2.0 surface + +## ADDED + +### REQUIREMENT REQ-aps-cli-010 + +`get`, `set`, `dump`, `keys`, and `reset` SHALL support `--json` machine-readable output. + +Acceptance Criteria +- JSON payloads are valid UTF-8 JSON objects. +- Typed values preserve Int/Bool where applicable instead of always stringifying. + +### REQUIREMENT REQ-aps-cli-011 + +Commands that touch FileState SHALL resolve the state directory as `--state-dir`, then `APS_HOME`, then `~/.aps`. + +Acceptance Criteria +- `--state-dir` wins over `APS_HOME`. +- When neither is set, FileState lands under `~/.aps`. + +### REQUIREMENT REQ-aps-cli-012 + +`watch` SHALL support `--count`, `--timeout`, and `--jsonl`. + +Acceptance Criteria +- `--count` stops after that many printed values including the initial value. +- `--timeout` stops after the given seconds. +- `--jsonl` emits one JSON object per line. + +### REQUIREMENT REQ-aps-cli-013 + +The CLI `--version` string SHALL be `0.2.0` while the project is pre-public 0.x. + +Acceptance Criteria +- `aps --version` prints `0.2.0`. + +## MODIFIED + +### REQUIREMENT REQ-aps-cli-001 + +The fixed demo schema SHALL include `profile` alongside `counter`, `message`, `flag`, and `note`. + +Acceptance Criteria +- `aps keys` lists `profile`. +- `aps set profile '{"name":"a","version":1}'` round-trips through get/dump/reset. + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `DemoKey` | Fixed schema enum including `profile`. | +| `ProfileDocument` | Codable `{name, version}` FileState document. | +| `name` | ProfileDocument display name field. | +| `version` | ProfileDocument integer version field. | +| `init` | ProfileDocument memberwise initializer. | +| `APSError` | Typed CLI/domain errors. | +| `counter` | Int key stored in AppState `State`. | +| `message` | String key stored in AppState `State`. | +| `flag` | Bool key stored in AppState `StoredState`. | +| `note` | String key stored in AppState `FileState`. | +| `profile` | ProfileDocument key stored in AppState `FileState`. | +| `unknownKey` | Unknown demo key token. | +| `invalidValue` | Value could not parse for the key type. | +| `encodingFailed` | UTF-8 JSON encode failure. | +| `decodingFailed` | UTF-8 JSON decode failure. | +| `persistenceFailed` | Disk-backed key did not persist after write. | +| `storage` | Human storage kind (`State` / `StoredState` / `FileState`). | +| `valueType` | Human value type (`Int` / `String` / `Bool` / `ProfileDocument`). | +| `helpSummary` | Tab-separated key/type/storage columns for `keys`. | +| `detail` | One-line description for `keys`. | +| `description` | Actionable error text for humans and ValidationError bridging. | + +Command tree (informational): `Aps` is the `@main` root with get, set, watch, dump, keys, and reset. Shared `StateOptions` expose `--json` and `--state-dir`. diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/deltas/state-store.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/deltas/state-store.md new file mode 100644 index 0000000..a2e0e3d --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/deltas/state-store.md @@ -0,0 +1,53 @@ +# StateStore agent-ready 0.2.0 surface + +## ADDED + +### REQUIREMENT REQ-state-store-010 + +`StateStore` SHALL expose `profile` as `FileState` persisted at `profile.json`, with get/set using JSON encoding and disk read-back verification. + +Acceptance Criteria +- Valid profile JSON persists and `profileDocument()` matches. +- Invalid profile JSON throws `APSError.invalidValue`. +- Failed disk persistence throws `APSError.persistenceFailed`. + +### REQUIREMENT REQ-state-store-011 + +`APSPaths.resolve(stateDir:)` SHALL prefer `--state-dir`, then `APS_HOME`, then `~/.aps` when configuring FileState paths from CLI boot. + +Acceptance Criteria +- Explicit stateDir wins over environment. +- Missing both returns the default `~/.aps` path. + +## MODIFIED + +### REQUIREMENT REQ-state-store-001 + +`StateStore` get/set/reset/dump/watch SHALL cover `profile` in addition to `counter`, `message`, `flag`, and `note`. + +Acceptance Criteria +- `dump` includes a `profile` entry with object value shape. +- `watch` polling for `profile` reads `profile.json` directly. + +### SPEC SECTION Public API + +| Export | Description | +|--------|-------------| +| `StateStore` | MainActor facade over demo AppState keys. | +| `init` | Loads clock/jsonCoding dependencies without forcing `~/.aps`. | +| `get` | Returns the current string rendering for a demo key. | +| `set` | Parses and writes a demo key value. | +| `reset` | Restores one demo key to its initial value. | +| `resetAll` | Restores every demo key. | +| `dump` | Pretty JSON snapshot with typed values. | +| `watchBlocking` | Observation + polling watch loop. | +| `profileDocument` | Typed profile FileState accessor. | +| `readNoteFromDisk` | Direct `note.json` read bypassing cache. | +| `readProfileFromDisk` | Direct `profile.json` read bypassing cache. | +| `parseBool` | Bool token parser for flag values. | +| `APSClock` | Injected clock dependency protocol. | +| `now` | APSClock current instant. | +| `SystemAPSClock` | Date-backed clock. | +| `JSONCoding` | Shared encode/decode helpers. | +| `encodePretty` | Pretty JSON encode helper. | +| `decode` | JSON decode helper. | diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/design.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/design.md new file mode 100644 index 0000000..33c5a93 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/design.md @@ -0,0 +1,3 @@ +# Design + +StateOptions OptionGroup on mutating commands. APSPaths.resolve encodes precedence. profile uses FileState with JSON get/set. watch shouldContinue encodes count/timeout. diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/docs.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/docs.md new file mode 100644 index 0000000..ecbfe2c --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/docs.md @@ -0,0 +1,3 @@ +# Docs + +README agent usage section and 0.x non-goals. GOAL.md tracks APS-01..11 for 0.2.0. diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/plan.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/plan.md new file mode 100644 index 0000000..09bcc56 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/plan.md @@ -0,0 +1,6 @@ +# Plan + +1. Add CLIOutput helpers and StateOptions. +2. Wire --json / state-dir / watch bounds / profile key. +3. Add Linux smoke workflow and expand smoke.sh. +4. Update module specs and tests. diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/requirements.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/requirements.md new file mode 100644 index 0000000..8aa1443 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/requirements.md @@ -0,0 +1,10 @@ +--- +change: CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate +artifact: requirements +--- + +# Requirements + +See semantic deltas for `REQ-aps-cli-001`, `REQ-aps-cli-010`, `REQ-aps-cli-011`, +`REQ-aps-cli-012`, `REQ-aps-cli-013`, `REQ-state-store-001`, `REQ-state-store-010`, +and `REQ-state-store-011`. diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/state.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/state.json new file mode 100644 index 0000000..576d67b --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/state.json @@ -0,0 +1,44 @@ +{ + "schema_version": 1, + "id": "CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate", + "slug": "ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate", + "title": "Ship aps 0.2.0 agent-ready JSON state-dir watch and profile FileState", + "description": "Ship aps 0.2.0 agent-ready JSON state-dir watch and profile FileState", + "kind": "feature", + "state": "accepted", + "canonical_applied": true, + "base_commit": "6f18ccb3015b4fc59021e690fefbf5947ade5c73", + "created_at": 1784399179, + "updated_at": 1784400056, + "affected_specs": [ + "aps-cli", + "state-store" + ], + "affected_paths": [ + "Sources/", + "Tests/", + "Scripts/", + "specs/", + ".github/workflows/", + ".specsync/" + ], + "no_spec_change": false, + "no_spec_change_rationale": null, + "acceptance_criteria": [ + "CLI 0.2.0 supports --json, APS_HOME/--state-dir, watch --count/--timeout/--jsonl, profile structured FileState; Linux smoke workflow exists; GOAL/README use 0.x naming; 25 tests and smoke pass." + ], + "selected_artifacts": [ + "context", + "requirements", + "plan", + "tasks", + "testing", + "docs", + "design" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "yes" + } +} diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/tasks.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/tasks.md new file mode 100644 index 0000000..fd17b61 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/tasks.md @@ -0,0 +1,5 @@ +# Tasks + +- [x] Implement --json, APS_HOME/--state-dir, watch bounds/jsonl, profile +- [x] Add linux-smoke.yml and expand Scripts/smoke.sh +- [x] Update specs + unit tests diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/testing.md b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/testing.md new file mode 100644 index 0000000..c6a736b --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/testing.md @@ -0,0 +1,24 @@ +--- +change: CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate +artifact: testing +--- + +# Testing + +## Requirement evidence + +| Requirement | Evidence | +| --- | --- | +| REQ-aps-cli-001 | `testDemoKeyMetadata`, `Scripts/smoke.sh` (`keys` lists profile) | +| REQ-aps-cli-010 | `testCLIOutputTypedValues`, `testDumpIncludesKeysAndUsesDependency`, `Scripts/smoke.sh` (`--json`) | +| REQ-aps-cli-011 | `testAPSPathsResolveOrder`, `Scripts/smoke.sh` (`--state-dir` override) | +| REQ-aps-cli-012 | `testWatchCountBoundStopsLoop`, `Scripts/smoke.sh` (`watch --count`) | +| REQ-aps-cli-013 | `Scripts/smoke.sh` (`aps --version` equals `0.2.0`) | +| REQ-state-store-001 | `testProfileStructuredFileStateRoundTrip`, `testResetAll` | +| REQ-state-store-010 | `testProfileStructuredFileStateRoundTrip`, `testInvalidProfileJSON` | +| REQ-state-store-011 | `testAPSPathsResolveOrder` | + +## Suites + +- `swift test` (25 tests) +- `./Scripts/smoke.sh` diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json new file mode 100644 index 0000000..93b05c2 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification-attempts.json @@ -0,0 +1,101 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1784399250, + "commit": "6f18ccb3015b4fc59021e690fefbf5947ade5c73", + "contract_digest": "d840c0a9a3d4ba4a726d86aa61e24df281d35c6ade72b3bcfd44a324ea7c4cd9", + "workspace_digest": "32bcf044035714dacf90191f9c4d193c052cf586e94c67a44fcaa86e0a1f12e1", + "passed": false, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] + }, + { + "timestamp": 1784399267, + "commit": "6f18ccb3015b4fc59021e690fefbf5947ade5c73", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "32bcf044035714dacf90191f9c4d193c052cf586e94c67a44fcaa86e0a1f12e1", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] + }, + { + "timestamp": 1784399287, + "commit": "6f18ccb3015b4fc59021e690fefbf5947ade5c73", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "0291696ef9ef22a673050ac1b5c94ad68d27cd72b09599597c2a37ca200028b8", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] + }, + { + "timestamp": 1784400051, + "commit": "fcdf82928d8c96aaef3fc37257e16260e48c13de", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "7e69742674951b11b2ced3328593bdf707dfc23ab4d859cdac2e6f4e5d3a8a37", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] + } + ] +} diff --git a/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification.json b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification.json new file mode 100644 index 0000000..538d8a5 --- /dev/null +++ b/.specsync/changes/CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate/verification.json @@ -0,0 +1,300 @@ +{ + "timestamp": 1784400051, + "commit": "fcdf82928d8c96aaef3fc37257e16260e48c13de", + "contract_digest": "73c000607b3050c54981b4c29532280dd08bf00b7a15c8ec1801e045fa5496c9", + "workspace_digest": "7e69742674951b11b2ced3328593bdf707dfc23ab4d859cdac2e6f4e5d3a8a37", + "acceptance_input_digest": "8fc3f456caffd3b7a60c001a98efc36e93dbe08bb6b601902cb02967f114e68e", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".github/workflows/ci.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "1f310d5d11b4466aedf85aac75d7db13b07d564f1e38e2f596defb8cf2f06d1c", + "entry_digest": "9abaefb404d6bb884367be7ea7524e890c9bab326c810c0985569c35a83418e7", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/linux-smoke.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "69a8a56cc86c7ef47cc676464bc864c83b67c7b525be21d45edf294f7babddf7", + "entry_digest": "f7bae5e1473b6a584f42fb91d49f4ec0c0cd1309cef4ccf52f7e416c0f90bbfc", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".github/workflows/trust.yml", + "kind": "file", + "mode": 33188, + "payload_digest": "e165929994150d856d05ad048d8e1032e09df989c78ea3f1390d4e885b50af56", + "entry_digest": "e405b6225fdb502cfae9b22a9c14e21be8d1715bbaf8d56cdfd178bb8e173cb9", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "3b200adf8860f60a6ebbb7bd22278d65d36c9fb8d9dd7529cf7eba7635f9bb59", + "entry_digest": "62a4ea5935aca0821d1aeeb2b5113dda9561feaf0385be503f4cef23bbf99c7b", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Scripts/smoke.sh", + "kind": "file", + "mode": 33261, + "payload_digest": "fc261d0a1f50860a893af0e917189677196e2d88b86f61efd9d1249b0510e870", + "entry_digest": "d7ed99351947d92bc60cdadcc410a4a9841ea53b882e85b50bfd03e632f695ab", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "Sources/aps/Aps.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "a3d13925be851388302a99ac2692e84f41c2c6069ba022e672afb97208d8b008", + "entry_digest": "3add382c561697c79b2a5c2c7fcac36fab1eb5d1f433660df93407a87d3ff909", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/CLIOutput.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "418b1fa36e1035d297b6201e78fb159f001e5cf0ac9328dd32275f5116768835", + "entry_digest": "14824a64fb63e58b1c75064966c4b2341fe808ef0943b15015d081e109e16b18", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoKey.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "aec3b0c43de6f034cfd1c2da09f03888ca3901dc537d5b129119170eada0ccd7", + "entry_digest": "6c15798408d9a15b994c22971dc86c85cac49b8608b26ffbbd5f9cad187ebbc3", + "owners": [ + "aps-cli" + ] + }, + { + "path": "Sources/aps/DemoState.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "c3665b70582aed474282178b16872c878f033345c4b63e9480f690b29670b727", + "entry_digest": "d3cfe46eb6d8496bd81dc02ee2475b9abe3431eb07df42a3881149c0cb39c1d1", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/Dependencies.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "635340259dcd618bf5099e07dad823f72f4517f6a2762ec1e6b67965221ccef2", + "entry_digest": "637ab3fca33111ebb5f145cdd632e7a6412e99d88d08e3890ab1cc3edc54c736", + "owners": [ + "state-store" + ] + }, + { + "path": "Sources/aps/StateStore.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "da829b6b8d71148a847360d04b4188cd3d194e46de94c2f6985700830d98cb2a", + "entry_digest": "6fc37810c2a0cde90425feed0b5cdd86a5d3534c8d144b64eca503c3bb6f27d8", + "owners": [ + "state-store" + ] + }, + { + "path": "Tests/apsTests/APSTests.swift", + "kind": "file", + "mode": 33188, + "payload_digest": "fd30d0e5b25f49d8ec770903d3646489797bcbe4f770472f207dadb99bd368f7", + "entry_digest": "0708115ccc699c4b7ae74fb0be201ad9127cbf971569913180b704301684444b", + "owners": [ + "@exact:test" + ] + }, + { + "path": "specs/aps-cli/aps-cli.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "4608ac4d4b252ff55f781578fe40c54625eca1d9628fdd7c9443cf47aa583d8e", + "entry_digest": "0edfcedecf8afc7912687d344d94192cdea83099480a5a4e504414cd73844aae", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "6a67354ec782d88510bea4b0ec28e1b1a3c50fc01e625ee32d6783f4dfbc86e0", + "entry_digest": "9cb95bb0d687799ba10637230b404cbef8a5abfb45d46a07a7bb50bee9703431", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "683d65fb5cea16ed1cbd21701ba929665c4b0cd30a57bbf85edb8db526037455", + "entry_digest": "6449646f88cd57c6a2b0a13b7964351d688f0c3d1ea032c240855c317ad6dd24", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "b6f0c8e3aa90f43e5dc40389e77088b2ec6ebb725c89c57217970fc1c221f527", + "entry_digest": "af31087c96d633274df81aecd4e99f520a55995882545190d2d42bb30fa5c3aa", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/aps-cli/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "14420af80f34d7954894aaf63f9864db13eea74549796043f54badf82805394a", + "entry_digest": "1227fad23fad49d312453dfe8fffc2bfe67396fdb29c88dab355bd60e8ec91b5", + "owners": [ + "aps-cli" + ] + }, + { + "path": "specs/state-store/context.md", + "kind": "file", + "mode": 33188, + "payload_digest": "ddb4564d168db43eadd6315a7f56b532198905a79abebfee77db890db0f9c402", + "entry_digest": "a14edf94507dfd13d7d325bf68e370e0c1646e323b28341bd0c1dfff72f05d88", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/requirements.md", + "kind": "file", + "mode": 33188, + "payload_digest": "8dfe2cccda8f0a65a82f13c5fed48dbd7a4dba477c2813e1c65368fd6098c134", + "entry_digest": "7874d89e525e29add15d40a87422d826220788b4de7bb50d223f0d92f5adafef", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/state-store.spec.md", + "kind": "file", + "mode": 33188, + "payload_digest": "aa0eea880b59e5879cbb606d83bad28fe4b23b31d396aab6c859ff148eb3dfef", + "entry_digest": "31335ab73c11393128097e8b7201d198ae592f640e46cedfd60fcefeeed9a2a1", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/tasks.md", + "kind": "file", + "mode": 33188, + "payload_digest": "711db2a24a4959531ca966ac43d96e7817788ebe74077280a6cff950cb9a8779", + "entry_digest": "8402318068cd8ab6f8a596519289e70425dbc28bf6b31d5c1ef6f0a17f927f74", + "owners": [ + "state-store" + ] + }, + { + "path": "specs/state-store/testing.md", + "kind": "file", + "mode": 33188, + "payload_digest": "96fbc44cdbe25dfab52a47f8efdbd4698203571981b181c78a04769112402f1d", + "entry_digest": "8b0abd2a0f6613b9caa1f2831cdf8776b80a1945552eade241ff3d3b8d6404af", + "owners": [ + "state-store" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [ + "REQ-aps-cli-001", + "REQ-aps-cli-010", + "REQ-aps-cli-011", + "REQ-aps-cli-012", + "REQ-aps-cli-013", + "REQ-state-store-001", + "REQ-state-store-010", + "REQ-state-store-011" + ] +} diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/approvals.json b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/approvals.json new file mode 100644 index 0000000..d267de6 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/approvals.json @@ -0,0 +1,26 @@ +{ + "approvals": [ + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784400369, + "digest": "e7773fa30314072a410ed7f4d4499c989eb33b606d5448e56cee12f6ab61579e", + "note": "Approved agent claim-label protocol for Cursor/Kimi fanout." + }, + { + "gate": "definition", + "actor": "leif algo", + "timestamp": 1784400373, + "digest": "e7773fa30314072a410ed7f4d4499c989eb33b606d5448e56cee12f6ab61579e", + "note": "Re-approve after completing docs.md for agent claim labels." + }, + { + "gate": "acceptance", + "actor": "leif algo", + "timestamp": 1784400377, + "digest": "0601946d0baefc48d2e13cc370b4d5c89b4b6946f9a183f9038d94bb5e01a4c9", + "note": "Accepted AGENTS.md agent claim labels for Cursor and Kimi." + } + ], + "reopenings": [] +} diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/change.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/change.md new file mode 100644 index 0000000..111a457 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/change.md @@ -0,0 +1,24 @@ +--- +id: CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout +state: accepted +type: documentation +base_commit: 2f7adb19349bf8700b736a9b300cfd2435ca176b +--- + +# Document agent ticket claim labels for Cursor and Kimi fanout + +## Intent + +Document agent ticket claim labels for Cursor and Kimi fanout + +## Affected Canonical Specs + +- None + +## Acceptance Criteria + +- AGENTS.md documents agent:cursor and agent:kimi claim rules; one agent label per ticket; remove label when PR is up or work stops. + +## No-spec Rationale + +Documents Cursor/Kimi agent ticket claim labels; no module API changes. diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/context.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/context.md new file mode 100644 index 0000000..bf955f4 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/context.md @@ -0,0 +1,3 @@ +# Context + +Cursor and Kimi agents fan out across GitHub issues. Labels `agent:cursor` and `agent:kimi` exist so each ticket has at most one agent owner. diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/design.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/design.md new file mode 100644 index 0000000..b72cdb5 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/design.md @@ -0,0 +1,4 @@ +# Design + +Label-based claim; no runtime design change. + diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/docs.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/docs.md new file mode 100644 index 0000000..c18dc04 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/docs.md @@ -0,0 +1,9 @@ +--- +change: CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout +artifact: docs +--- + +# Docs + +AGENTS.md gains an "Agent ticket claims" section describing `agent:cursor` and +`agent:kimi` labels so Cursor and Kimi can fan out without double-claiming. diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/plan.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/plan.md new file mode 100644 index 0000000..028e5b6 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/plan.md @@ -0,0 +1,4 @@ +# Plan + +1. Add Agent ticket claims section to AGENTS.md. +2. Verify SpecSync coverage for AGENTS.md. diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/requirements.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/requirements.md new file mode 100644 index 0000000..58aca16 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/requirements.md @@ -0,0 +1,4 @@ +# Requirements + +No module requirements; process documentation only. + diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/state.json b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/state.json new file mode 100644 index 0000000..429e8e8 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/state.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "id": "CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout", + "slug": "document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout", + "title": "Document agent ticket claim labels for Cursor and Kimi fanout", + "description": "Document agent ticket claim labels for Cursor and Kimi fanout", + "kind": "documentation", + "state": "accepted", + "canonical_applied": true, + "base_commit": "2f7adb19349bf8700b736a9b300cfd2435ca176b", + "created_at": 1784400356, + "updated_at": 1784400377, + "affected_specs": [], + "affected_paths": [ + "AGENTS.md", + ".specsync/" + ], + "no_spec_change": true, + "no_spec_change_rationale": "Documents Cursor/Kimi agent ticket claim labels; no module API changes.", + "acceptance_criteria": [ + "AGENTS.md documents agent:cursor and agent:kimi claim rules; one agent label per ticket; remove label when PR is up or work stops." + ], + "selected_artifacts": [ + "context", + "docs" + ], + "dependencies": [], + "answers": { + "architecture_risk": "no", + "public_contract": "no" + } +} diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/tasks.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/tasks.md new file mode 100644 index 0000000..b04d5f3 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/tasks.md @@ -0,0 +1,4 @@ +# Tasks + +- [x] Document agent:cursor / agent:kimi claim rules in AGENTS.md + diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/testing.md b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/testing.md new file mode 100644 index 0000000..2613e96 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/testing.md @@ -0,0 +1,4 @@ +# Testing + +- `specsync change check` and `specsync check --force` pass. +- AGENTS.md contains `agent:cursor` and `agent:kimi` claim rules. diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/verification-attempts.json b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/verification-attempts.json new file mode 100644 index 0000000..124973f --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/verification-attempts.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "attempts": [ + { + "timestamp": 1784400376, + "commit": "2f7adb19349bf8700b736a9b300cfd2435ca176b", + "contract_digest": "e7773fa30314072a410ed7f4d4499c989eb33b606d5448e56cee12f6ab61579e", + "workspace_digest": "c2bf39b531f0468363df2d05834c1cf1bd6ccca207d86b2dce4cea2d730327b1", + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] + } + ] +} diff --git a/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/verification.json b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/verification.json new file mode 100644 index 0000000..5ba2f92 --- /dev/null +++ b/.specsync/changes/CHG-0005-document-agent-ticket-claim-labels-for-cursor-and-kimi-fanout/verification.json @@ -0,0 +1,91 @@ +{ + "timestamp": 1784400376, + "commit": "2f7adb19349bf8700b736a9b300cfd2435ca176b", + "contract_digest": "e7773fa30314072a410ed7f4d4499c989eb33b606d5448e56cee12f6ab61579e", + "workspace_digest": "c2bf39b531f0468363df2d05834c1cf1bd6ccca207d86b2dce4cea2d730327b1", + "acceptance_input_digest": "30e1dbb99a3a48783f382c0ab2b81d847364b4bb738641407db10676956fc152", + "acceptance_manifest": { + "schema_version": 1, + "entries": [ + { + "path": ".specsync/.gitignore", + "kind": "file", + "mode": 33188, + "payload_digest": "a3129d6f93250ade8dbacfc73bc7eeae0cc47c2f7032142ca47cb4f1ffb2483e", + "entry_digest": "9badbf40b83d05f58bafda0fc2cafccfe726e4da7e6e67ef513e822989e13f04", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/change-sequence.json", + "kind": "file", + "mode": 33188, + "payload_digest": "eb7a89695817446620ade80a9b7e997566a2fcd452cb4604bbb627c7e182993a", + "entry_digest": "bfb44b77cd5c847a2cd20bd348406d2b9d6b95695404ae5c7c591f714f14f024", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/config.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "54a83f6a4a252649bac390d2e936aa0815711fba86b6bfc8ea8fff2fffdfd29b", + "entry_digest": "e60f979233a7db75ce63f4ef42d6f1f7676ad1655a438b9baf78558aba60ec3e", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/registry.toml", + "kind": "file", + "mode": 33188, + "payload_digest": "c423be1a5cf87318f85dc252fe0acd1c1486c5224ddc2d8e19bea1ac9c69c820", + "entry_digest": "7e1f068900ced849b8d1028ca40165fbdc5280ae7f53452f0e57e2710d895848", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/sdd.json", + "kind": "file", + "mode": 33188, + "payload_digest": "2d5fb3058b1ee4ce584c3cda9410c193c4893e272ed459205e58bfde06afc7b0", + "entry_digest": "8c6b11fc5787eb04e7ee651eb15fbf98fc3ea22db37e65423e3d75f99c09df19", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": ".specsync/version", + "kind": "file", + "mode": 33188, + "payload_digest": "f9893302cd3158f3b5aea394dcd2a91574869e9e6ff69e9235b10a3bf8c983fb", + "entry_digest": "f980ba79f6c953bed30303c6b87d8e868ad6a7f4114bf0e745c9099255e665b0", + "owners": [ + "@exact:delivery" + ] + }, + { + "path": "AGENTS.md", + "kind": "file", + "mode": 33188, + "payload_digest": "210e715517e1add05cc7f27ba1e6e83a18350583a84398f6d6b37e9dbcd08f56", + "entry_digest": "5fc38a558b82e9cccf4b09db1761d8c7411ee5a3ad907af97ab109353df48ff8", + "owners": [ + "@exact:delivery" + ] + } + ] + }, + "passed": true, + "commands": [ + { + "command": "fledge lanes run verify", + "success": true, + "exit_code": 0 + } + ], + "requirement_ids": [] +} diff --git a/AGENTS.md b/AGENTS.md index 9bfd9df..5cf8c04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,28 @@ fledge trust verify # full trust gate when tools are installed ./Scripts/smoke.sh ``` +## Agent ticket claims (Cursor + Kimi) + +Multiple agents may fan out across GitHub issues. Claim with a label before +starting work so two agents never own the same ticket. + +| Label | Agent | +|-------|--------| +| `agent:cursor` | Cursor cloud / coding agents | +| `agent:kimi` | Kimi Code agent | + +Rules: + +1. Before picking up an issue, read its labels. Skip if `agent:cursor` or + `agent:kimi` is already present (unless you added it). +2. Claim by adding **your** label only (`agent:cursor` for Cursor agents). + One agent label per ticket. +3. Fan out subagents only on tickets you have claimed. Pass the issue number + and claim label into each subagent prompt. +4. Remove your agent label when the implementing PR is open, or when you stop + work without a PR, so another agent can take it. +5. Prefer unclaimed open issues. Do not strip another agent's claim label. + ## CorvidLabs trust toolchain (standing rules) diff --git a/GOAL.md b/GOAL.md new file mode 100644 index 0000000..81374a0 --- /dev/null +++ b/GOAL.md @@ -0,0 +1,51 @@ +# aps 0.2.0: agent-ready AppState dogfood harness + +## Goal + +Make `aps` reliable enough for agents to inspect, mutate, and watch fixed demo AppState through stable CLI contracts while the project remains pre-public 0.x. + +## Why now + +- 0.1.0 shipped the baseline CLI and AppState demo surface (PR #1). +- Agents need predictable JSON, state isolation, and bounded watch behavior. +- Stay on 0.x until the repo is public; do not imply a 1.0 release. + +## Success criteria + +- [x] `get`, `set`, `dump`, `keys`, and `reset` support `--json`. +- [x] State root is configurable through `APS_HOME`. +- [x] `--state-dir` overrides `APS_HOME` for commands that touch state. +- [x] State directory behavior is tested for default, environment, and flag-based paths. +- [x] `watch` supports bounded execution with `--count` and `--timeout`. +- [x] `watch` supports newline-delimited JSON output with `--jsonl`. +- [x] Linux CI runs a smoke workflow that builds the CLI and exercises core commands. +- [x] Demo key `profile` uses structured `FileState` with Codable `{name, version}`. +- [x] SpecSync artifacts from 0.1.x are archived; active SpecSync tracks 0.2.0 work. +- [x] README documents JSON mode, state root, watch bounds, and `profile`. +- [x] CLI `--version` reports `0.2.0`. + +## Explicit out of scope for 0.x + +- SyncState, SecureState, and ModelState +- Plugin APIs, daemon mode, network APIs, or background services +- Dynamic schema language or user-defined state keys + +## Tickets + +| ID | Item | +|----|------| +| APS-01 | `--json` on core commands | +| APS-02 | Stable JSON shapes + tests | +| APS-03 | `APS_HOME` state root | +| APS-04 | `--state-dir` override | +| APS-05 | State-dir path tests | +| APS-06 | `watch --count` / `--timeout` | +| APS-07 | `watch --jsonl` | +| APS-08 | Linux CI smoke | +| APS-09 | Structured `profile` FileState | +| APS-10 | SpecSync archive hygiene | +| APS-11 | README agent usage | + +## Definition of done + +All success criteria checked; tests and Linux CI smoke pass; README examples work from a clean checkout; no out-of-scope 0.x systems introduced. diff --git a/README.md b/README.md index ae5f9be..1327c38 100644 --- a/README.md +++ b/README.md @@ -2,23 +2,26 @@ A tiny Swift CLI that [dogfoods](https://github.com/0xLeif/AppState) **AppState** outside SwiftUI: declare typed app state, get/set/watch/dump it, and show dependency injection. -Targets **macOS** (CI) and aims to stay Linux-friendly where AppState allows. +Current release line: **0.2.0** (pre-public 0.x). Targets **macOS** (primary CI) and **Linux** (smoke CI) where AppState allows. This repository is gated by the [CorvidLabs trust toolchain](https://corvidlabs.xyz/integrate/) (fledge, spec-sync, augur, attest). See `AGENTS.md`. ## Commands ```text -aps get -aps set -aps watch # print on change (Observation + polling) -aps dump # print all known state as JSON -aps keys # list demo keys / storage kinds -aps reset # restore one key to its initial value -aps reset --all +aps get [--json] [--state-dir PATH] +aps set [--json] [--state-dir PATH] +aps watch [--count N] [--timeout SEC] [--jsonl] [--interval MS] [--state-dir PATH] +aps dump [--json] [--state-dir PATH] +aps keys [--json] +aps reset [--json] [--state-dir PATH] +aps reset --all [--json] [--state-dir PATH] aps --help +aps --version ``` +State root resolution: `--state-dir` > `APS_HOME` > `~/.aps`. + ### Demo keys (fixed schema) | Key | Type | Storage | Lifetime | @@ -26,9 +29,10 @@ aps --help | `counter` | `Int` | `State` | Process (in-memory) | | `message` | `String` | `State` | Process (in-memory) | | `flag` | `Bool` | `StoredState` | Persisted (`UserDefaults`; CLI calls `synchronize()` so Linux flushes) | -| `note` | `String` | `FileState` | Persisted (`~/.aps/note.json`) | +| `note` | `String` | `FileState` | Persisted (`$APS_HOME/note.json`) | +| `profile` | `{name,version}` | `FileState` | Persisted structured Codable (`$APS_HOME/profile.json`) | -Dynamic / user-declared keys are intentionally out of scope for v1. +Dynamic / user-declared keys are intentionally out of scope for 0.x. ### Dependencies @@ -40,7 +44,7 @@ Dynamic / user-declared keys are intentionally out of scope for v1. ## Requirements - Swift 6.0+ -- macOS 14+ (CI). Linux toolchains are supported best-effort, not gated in CI yet. +- macOS 14+ (primary CI). Linux smoke runs on `ubuntu-latest`. - For the trust gate locally: [corvid-trust](https://github.com/CorvidLabs/trust) (`brew install CorvidLabs/tap/corvid-trust`) - SpecSync **5.1.1** (see `.specsync/version`). Trust CI mirrors that exact release; brew `spec-sync` latest should match. @@ -73,12 +77,31 @@ swift run aps keys swift run aps set counter 3 swift run aps set flag true swift run aps set note "saved across launches" +swift run aps set profile '{"name":"agent","version":1}' swift run aps dump -swift run aps watch note --interval 200 +swift run aps watch note --interval 200 --count 2 --timeout 5 swift run aps reset --all ``` -`watch` uses Swift Observation for in-process updates and polls as a fallback so disk-backed `FileState` / `StoredState` changes can still surface, including updates written by another `aps` process. For `note`, polling reads `note.json` directly so AppState's FileState cache cannot hide cross-process writes. +### Agent usage + +```bash +swift run aps get note --json +swift run aps set counter 3 --json +swift run aps dump --json +swift run aps keys --json +swift run aps reset note --json + +APS_HOME=/tmp/aps-agent swift run aps set note "isolated state" +swift run aps get note --json --state-dir /tmp/aps-agent + +swift run aps watch note --count 2 --timeout 5 --jsonl + +swift run aps set profile '{"name":"agent","version":1}' --json +swift run aps get profile --json +``` + +`watch` uses Swift Observation for in-process updates and polls as a fallback so disk-backed `FileState` / `StoredState` changes can still surface, including updates written by another `aps` process. For `note` and `profile`, polling reads the JSON files directly so AppState's FileState cache cannot hide cross-process writes. ## Tests and smoke @@ -87,16 +110,15 @@ swift test ./Scripts/smoke.sh ``` -## CI (private repo) - -While this repository is **private**, every workflow runs on **macOS self-hosted** runners: +## CI | Workflow | Runner | Role | |----------|--------|------| | `.github/workflows/ci.yml` | `[self-hosted, macOS]` | build / test / smoke | -| `.github/workflows/trust.yml` | `[self-hosted, macOS]` | CorvidLabs Trust gate (fledge + spec-sync + augur + attest) | +| `.github/workflows/linux-smoke.yml` | `ubuntu-latest` | Linux build + smoke | +| `.github/workflows/trust.yml` | `[self-hosted, macOS]` | CorvidLabs Trust gate | -Before making the repo public, switch off self-hosted runners for fork pull requests. +While the repository is **private**, macOS workflows use self-hosted runners. Before making the repo public, switch off self-hosted runners for fork pull requests. ## Trust toolchain @@ -108,6 +130,7 @@ Before making the repo public, switch off self-hosted runners for fork pull requ | `.attest.json` | Provenance policy | | `.specsync/` | SpecSync 5.1.1 config + SDD change tracking (`.specsync/version`) | | `specs/` | Module contracts (`aps-cli`, `state-store`) | +| `GOAL.md` | Active 0.x milestone checklist | | `AGENTS.md` | Standing rules (managed block required by CI) | ```bash @@ -123,10 +146,15 @@ Sources/aps/ Tests/apsTests/ specs/ Scripts/smoke.sh -.github/workflows/{ci,trust}.yml +GOAL.md +.github/workflows/{ci,linux-smoke,trust}.yml ``` -## Non-goals (v1) +## Next goal + +See [`GOAL.md`](GOAL.md) for **aps 0.2.0**: agent-ready AppState dogfood harness. + +## Non-goals (0.x) - No iCloud `SyncState`, Keychain `SecureState`, or SwiftData `ModelState` - No plugin system, daemon, or network API diff --git a/Scripts/smoke.sh b/Scripts/smoke.sh index d0c3f75..4dec46e 100755 --- a/Scripts/smoke.sh +++ b/Scripts/smoke.sh @@ -4,6 +4,10 @@ set -euo pipefail root="$(cd "$(dirname "$0")/.." && pwd)" cd "$root" +SMOKE_HOME="${APS_HOME:-$(mktemp -d "${TMPDIR:-/tmp}/aps-smoke.XXXXXX")}" +export APS_HOME="$SMOKE_HOME" +mkdir -p "$APS_HOME" + if [[ -z "${APS_BIN:-}" ]]; then swift build -c debug APS_BIN=".build/debug/aps" @@ -11,20 +15,36 @@ fi bin="$APS_BIN" "$bin" --help >/dev/null +test "$("$bin" --version)" = "0.2.0" "$bin" keys | grep -q counter +"$bin" keys | grep -q profile +"$bin" keys --json | grep -q '"key" : "profile"' # `set` prints the value; State is process-local so don't expect get in a new process. test "$("$bin" set counter 11)" = "11" test "$("$bin" set message "smoke")" = "smoke" +"$bin" set counter 11 --json | grep -q '"value" : 11' # StoredState / FileState must survive process boundaries. "$bin" set flag true >/dev/null test "$("$bin" get flag)" = "true" +"$bin" get flag --json | grep -q '"value" : true' "$bin" set note "smoke-note" >/dev/null test "$("$bin" get note)" = "smoke-note" +"$bin" set profile '{"name":"smoke","version":2}' >/dev/null +"$bin" get profile --json | grep -q '"name" : "smoke"' +"$bin" get profile --json | grep -q '"version" : 2' + +# --state-dir overrides APS_HOME +OTHER="$(mktemp -d "${TMPDIR:-/tmp}/aps-smoke-other.XXXXXX")" +"$bin" set note "other-root" --state-dir "$OTHER" >/dev/null +test "$("$bin" get note --state-dir "$OTHER")" = "other-root" +test "$("$bin" get note)" = "smoke-note" + "$bin" dump | grep -q '"key" : "flag"' +"$bin" dump --json | grep -q '"key" : "profile"' "$bin" reset flag >/dev/null test "$("$bin" get flag)" = "false" @@ -32,10 +52,15 @@ test "$("$bin" get flag)" = "false" "$bin" reset note >/dev/null test -z "$("$bin" get note)" +"$bin" reset profile --json | grep -q '"reset" : "key"' + "$bin" reset --all >/dev/null test "$("$bin" get flag)" = "false" test -z "$("$bin" get note)" +# Bounded watch should exit. +"$bin" watch counter --count 1 --timeout 2 >/dev/null + # Invalid values should fail clearly. if "$bin" set counter nope >/dev/null 2>&1; then echo "expected invalid counter to fail" >&2 diff --git a/Sources/aps/Aps.swift b/Sources/aps/Aps.swift index 65f71d9..747858d 100644 --- a/Sources/aps/Aps.swift +++ b/Sources/aps/Aps.swift @@ -8,15 +8,18 @@ struct Aps: ParsableCommand { commandName: "aps", abstract: "A tiny CLI that dogfoods AppState outside SwiftUI.", discussion: """ - Demo keys (fixed schema for v1): - counter Int State (in-memory) - message String State (in-memory) - flag Bool StoredState (UserDefaults) - note String FileState (~/.aps/note.json) + Demo keys (fixed schema for 0.x): + counter Int State (in-memory) + message String State (in-memory) + flag Bool StoredState (UserDefaults) + note String FileState (~/.aps/note.json) + profile ProfileDocument FileState (~/.aps/profile.json) + + State root: --state-dir > APS_HOME > ~/.aps Built on https://github.com/0xLeif/AppState """, - version: "0.1.0", + version: "0.2.0", subcommands: [ Get.self, Set.self, @@ -35,13 +38,27 @@ extension Aps { abstract: "Print the current value for a demo key." ) - @Argument(help: "Demo key: counter | message | flag | note") + @Argument(help: "Demo key: counter | message | flag | note | profile") var key: DemoKey + @OptionGroup + var options: StateOptions + func run() throws { try onMainThread { - boot() - print(StateStore().get(key)) + boot(stateDir: options.stateDir) + let store = StateStore() + if options.json { + let payload = CLIOutput.KeyValuePayload( + key: key.rawValue, + type: key.valueType, + storage: key.storage, + value: try CLIOutput.typedValue(for: key, store: store) + ) + print(try CLIOutput.encodePretty(payload)) + } else { + print(store.get(key)) + } } } } @@ -51,22 +68,35 @@ extension Aps { abstract: "Set a demo key to a value." ) - @Argument(help: "Demo key: counter | message | flag | note") + @Argument(help: "Demo key: counter | message | flag | note | profile") var key: DemoKey - @Argument(help: "New value (Bool: true/false/1/0; Int for counter)") + @Argument(help: "New value (Bool: true/false/1/0; Int for counter; JSON for profile)") var value: String + @OptionGroup + var options: StateOptions + func run() throws { try onMainThread { - boot() + boot(stateDir: options.stateDir) let store = StateStore() do { try store.set(key, value: value) } catch let error as APSError { throw ValidationError(error.description) } - print(store.get(key)) + if options.json { + let payload = CLIOutput.KeyValuePayload( + key: key.rawValue, + type: key.valueType, + storage: key.storage, + value: try CLIOutput.typedValue(for: key, store: store) + ) + print(try CLIOutput.encodePretty(payload)) + } else { + print(store.get(key)) + } } } } @@ -76,20 +106,56 @@ extension Aps { abstract: "Print the value whenever it changes (Observation + polling)." ) - @Argument(help: "Demo key: counter | message | flag | note") + @Argument(help: "Demo key: counter | message | flag | note | profile") var key: DemoKey @Option(name: .long, help: "Poll interval in milliseconds (fallback for disk-backed keys).") var interval: UInt64 = 250 + @Option(name: .long, help: "Stop after printing this many values (includes the initial value).") + var count: Int? + + @Option(name: .long, help: "Stop after this many seconds.") + var timeout: Double? + + @Flag(name: .long, help: "Emit one JSON object per line.") + var jsonl: Bool = false + + @Option(name: .long, help: "Override state directory (takes precedence over APS_HOME).") + var stateDir: String? + func run() throws { try onMainThread { - boot() + boot(stateDir: stateDir) let store = StateStore() - store.watchBlocking(key, pollInterval: TimeInterval(interval) / 1000.0) { value in - // Write via FileHandle so output appears immediately when stdout is not a TTY. - if let data = (value + "\n").data(using: .utf8) { - FileHandle.standardOutput.write(data) + let deadline = timeout.map { Date().addingTimeInterval($0) } + var emitted = 0 + + store.watchBlocking( + key, + pollInterval: TimeInterval(interval) / 1000.0, + shouldContinue: { + if let count, emitted >= count { return false } + if let deadline, Date() >= deadline { return false } + return true + } + ) { value in + emitted += 1 + if jsonl { + // Parse the fresh `value` from watchBlocking. Do not re-query + // the store: FileState cache can lag cross-process disk writes. + let event = try? CLIOutput.watchEvent( + key: key, + rawValue: value, + timestamp: store.now + ) + if let event, let line = try? CLIOutput.encodeLine(event) { + CLIOutput.writeLine(line) + } else { + CLIOutput.writeLine(value) + } + } else { + CLIOutput.writeLine(value) } } } @@ -101,9 +167,14 @@ extension Aps { abstract: "Print all known demo keys as pretty JSON." ) + @OptionGroup + var options: StateOptions + func run() throws { try onMainThread { - boot() + boot(stateDir: options.stateDir) + // dump is always JSON; --json is accepted for agent symmetry. + _ = options.json print(try StateStore().dump()) } } @@ -114,10 +185,27 @@ extension Aps { abstract: "List the fixed demo keys and how they are stored." ) + @Flag(name: .long, help: "Emit machine-readable JSON.") + var json: Bool = false + func run() throws { - print("KEY\tTYPE\tSTORAGE\tDESCRIPTION") - for key in DemoKey.allCases { - print("\(key.helpSummary)\t\(key.detail)") + if json { + let payload = CLIOutput.KeysPayload( + keys: DemoKey.allCases.map { + CLIOutput.KeyInfo( + key: $0.rawValue, + type: $0.valueType, + storage: $0.storage, + detail: $0.detail + ) + } + ) + print(try CLIOutput.encodePretty(payload)) + } else { + print("KEY\tTYPE\tSTORAGE\tDESCRIPTION") + for key in DemoKey.allCases { + print("\(key.helpSummary)\t\(key.detail)") + } } } } @@ -133,6 +221,9 @@ extension Aps { @Flag(name: .long, help: "Reset every demo key.") var all: Bool = false + @OptionGroup + var options: StateOptions + func run() throws { guard all || key != nil else { throw ValidationError("Pass a key or --all. Example: aps reset counter") @@ -142,14 +233,28 @@ extension Aps { } try onMainThread { - boot() + boot(stateDir: options.stateDir) let store = StateStore() if all { store.resetAll() - print("reset all keys") + if options.json { + let payload = CLIOutput.ResetPayload(reset: "all", key: nil, value: nil) + print(try CLIOutput.encodePretty(payload)) + } else { + print("reset all keys") + } } else if let key { store.reset(key) - print(store.get(key)) + if options.json { + let payload = CLIOutput.ResetPayload( + reset: "key", + key: key.rawValue, + value: try CLIOutput.typedValue(for: key, store: store) + ) + print(try CLIOutput.encodePretty(payload)) + } else { + print(store.get(key)) + } } } } @@ -157,9 +262,9 @@ extension Aps { } @MainActor -private func boot() { +private func boot(stateDir: String? = nil) { Application.logging(isEnabled: false) - APSPaths.configure() + APSPaths.configure(stateDir: stateDir) } /// Synchronous `@main` starts on the real main thread; treat that as MainActor for AppState. diff --git a/Sources/aps/CLIOutput.swift b/Sources/aps/CLIOutput.swift new file mode 100644 index 0000000..a8a0cc6 --- /dev/null +++ b/Sources/aps/CLIOutput.swift @@ -0,0 +1,146 @@ +import ArgumentParser +import Foundation + +/// Shared machine-readable / human CLI output helpers. +enum CLIOutput { + struct KeyValuePayload: Encodable { + let key: String + let type: String + let storage: String + let value: JSONValue + } + + struct KeysPayload: Encodable { + let keys: [KeyInfo] + } + + struct KeyInfo: Encodable { + let key: String + let type: String + let storage: String + let detail: String + } + + struct ResetPayload: Encodable { + let reset: String + let key: String? + let value: JSONValue? + } + + struct WatchEvent: Encodable { + let key: String + let type: String + let storage: String + let value: JSONValue + let timestamp: Date + } + + /// Typed JSON leaf used so dump/get preserve Int/Bool instead of stringifying. + enum JSONValue: Encodable, Equatable { + case string(String) + case int(Int) + case bool(Bool) + case object(ProfileDocument) + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } + } + + static func encodePretty(_ value: T) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(value) + guard let string = String(data: data, encoding: .utf8) else { + throw APSError.encodingFailed + } + return string + } + + static func encodeLine(_ value: T) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + let data = try encoder.encode(value) + guard let string = String(data: data, encoding: .utf8) else { + throw APSError.encodingFailed + } + return string + } + + static func writeLine(_ line: String) { + if let data = (line + "\n").data(using: .utf8) { + FileHandle.standardOutput.write(data) + } + } + + @MainActor + static func typedValue(for key: DemoKey, store: StateStore) throws -> JSONValue { + try typedValue(for: key, from: store.get(key)) + } + + /// Build a typed value from a fresh rendered string (e.g. watch `onChange`). + /// + /// Prefer this over re-querying `StateStore` for disk-backed keys: AppState's + /// FileState cache can lag cross-process writes that `watchBlocking` already + /// surfaced via direct disk reads. + static func typedValue(for key: DemoKey, from raw: String) throws -> JSONValue { + switch key { + case .counter: + guard let intValue = Int(raw) else { + throw APSError.invalidValue(key: key, value: raw) + } + return .int(intValue) + case .message, .note: + return .string(raw) + case .flag: + guard let boolValue = StateStore.parseBool(raw) else { + throw APSError.invalidValue(key: key, value: raw) + } + return .bool(boolValue) + case .profile: + guard let data = raw.data(using: .utf8) else { + throw APSError.decodingFailed + } + do { + return .object(try JSONDecoder().decode(ProfileDocument.self, from: data)) + } catch { + throw APSError.invalidValue(key: key, value: raw) + } + } + } + + static func watchEvent( + key: DemoKey, + rawValue: String, + timestamp: Date + ) throws -> WatchEvent { + WatchEvent( + key: key.rawValue, + type: key.valueType, + storage: key.storage, + value: try typedValue(for: key, from: rawValue), + timestamp: timestamp + ) + } +} + +/// Options shared by subcommands that touch AppState. +struct StateOptions: ParsableArguments { + @Option(name: .long, help: "Override state directory (takes precedence over APS_HOME).") + var stateDir: String? + + @Flag(name: .long, help: "Emit machine-readable JSON.") + var json: Bool = false +} diff --git a/Sources/aps/DemoKey.swift b/Sources/aps/DemoKey.swift index 4939d98..74a21be 100644 --- a/Sources/aps/DemoKey.swift +++ b/Sources/aps/DemoKey.swift @@ -7,12 +7,13 @@ public enum DemoKey: String, CaseIterable, ExpressibleByArgument, Sendable { case message case flag case note + case profile public var storage: String { switch self { case .counter, .message: return "State" case .flag: return "StoredState" - case .note: return "FileState" + case .note, .profile: return "FileState" } } @@ -21,6 +22,7 @@ public enum DemoKey: String, CaseIterable, ExpressibleByArgument, Sendable { case .counter: return "Int" case .message, .note: return "String" case .flag: return "Bool" + case .profile: return "ProfileDocument" } } @@ -39,10 +41,23 @@ public enum DemoKey: String, CaseIterable, ExpressibleByArgument, Sendable { return "Bool via StoredState / UserDefaults" case .note: return "String via FileState (~/.aps/note.json)" + case .profile: + return "Codable {name,version} via FileState (~/.aps/profile.json)" } } } +/// Structured FileState document dogfooded by the `profile` key. +public struct ProfileDocument: Codable, Equatable, Sendable { + public var name: String + public var version: Int + + public init(name: String = "", version: Int = 0) { + self.name = name + self.version = version + } +} + public enum APSError: Error, CustomStringConvertible, Equatable { case unknownKey(String) case invalidValue(key: DemoKey, value: String) diff --git a/Sources/aps/DemoState.swift b/Sources/aps/DemoState.swift index 87fc7ee..5934ca2 100644 --- a/Sources/aps/DemoState.swift +++ b/Sources/aps/DemoState.swift @@ -30,6 +30,16 @@ extension Application { ) } + /// Structured profile document on disk via `FileState`. + @MainActor + var profile: FileState { + fileState( + initial: ProfileDocument(), + filename: "profile.json", + isBase64Encoded: false + ) + } + /// Wall-clock used when stamping watch/dump output. var clock: Dependency { dependency(SystemAPSClock()) @@ -43,17 +53,33 @@ extension Application { /// Stable paths for CLI-persisted `FileState` data. /// +/// Resolution order for `configure(stateDir:)`: +/// 1. Explicit `--state-dir` +/// 2. `APS_HOME` environment variable +/// 3. `~/.aps` +/// /// Called from CLI `boot()` only. Tests inject their own /// `FileManager.defaultFileStatePath` before constructing `StateStore`. enum APSPaths { @MainActor - static var fileStateDirectory: String { + static var defaultFileStateDirectory: String { let home = FileManager.default.homeDirectoryForCurrentUser return home.appendingPathComponent(".aps", isDirectory: true).path } @MainActor - static func configure() { - FileManager.defaultFileStatePath = fileStateDirectory + static func resolve(stateDir: String?) -> String { + if let stateDir, !stateDir.isEmpty { + return (stateDir as NSString).expandingTildeInPath + } + if let home = ProcessInfo.processInfo.environment["APS_HOME"], !home.isEmpty { + return (home as NSString).expandingTildeInPath + } + return defaultFileStateDirectory + } + + @MainActor + static func configure(stateDir: String? = nil) { + FileManager.defaultFileStatePath = resolve(stateDir: stateDir) } } diff --git a/Sources/aps/StateStore.swift b/Sources/aps/StateStore.swift index a618361..40afa5b 100644 --- a/Sources/aps/StateStore.swift +++ b/Sources/aps/StateStore.swift @@ -19,6 +19,9 @@ public final class StateStore { Application.load(dependency: \.jsonCoding) } + /// Wall clock from the injected `APSClock` dependency. + public var now: Date { clock.now } + public func get(_ key: DemoKey) -> String { switch key { case .counter: @@ -29,9 +32,15 @@ public final class StateStore { return String(Application.state(\.flag).value) case .note: return Application.fileState(\.note).value + case .profile: + return (try? encodeProfile(Application.fileState(\.profile).value)) ?? "{\"name\":\"\",\"version\":0}" } } + public func profileDocument() throws -> ProfileDocument { + Application.fileState(\.profile).value + } + public func set(_ key: DemoKey, value: String) throws { switch key { case .counter: @@ -60,6 +69,19 @@ public final class StateStore { guard onDisk == value else { throw APSError.persistenceFailed(key: .note) } + case .profile: + let document: ProfileDocument + do { + document = try jsonCoding.decode(ProfileDocument.self, from: value) + } catch { + throw APSError.invalidValue(key: key, value: value) + } + var state = Application.fileState(\.profile) + state.value = document + let onDisk = try Self.readProfileFromDisk() + guard onDisk == document else { + throw APSError.persistenceFailed(key: .profile) + } } } @@ -74,6 +96,8 @@ public final class StateStore { UserDefaults.standard.synchronize() case .note: Application.reset(fileState: \.note) + case .profile: + Application.reset(fileState: \.profile) } } @@ -86,12 +110,12 @@ public final class StateStore { public func dump() throws -> String { let snapshot = DumpSnapshot( timestamp: clock.now, - keys: DemoKey.allCases.map { key in + keys: try DemoKey.allCases.map { key in DumpEntry( key: key.rawValue, storage: key.storage, type: key.valueType, - value: get(key) + value: try CLIOutput.typedValue(for: key, store: self) ) } ) @@ -103,9 +127,9 @@ public final class StateStore { /// - Observation covers in-process mutations (`State`). /// - Polling re-reads values so `FileState` / `StoredState` updates can surface when /// Observation alone would not (e.g. another process wrote the file). - /// - For `note`, polling reads the file directly so AppState's FileState cache cannot - /// hide cross-process writes. - /// - `shouldContinue` lets tests (and future tooling) stop the loop cleanly. + /// - For disk-backed keys, polling reads files directly so AppState's FileState cache + /// cannot hide cross-process writes. + /// - `shouldContinue` lets tests (and CLI `--count` / `--timeout`) stop the loop cleanly. public func watchBlocking( _ key: DemoKey, pollInterval: TimeInterval = 0.25, @@ -140,11 +164,16 @@ public final class StateStore { } } - /// Value used by watch polling. Disk-backed `note` bypasses AppState's FileState cache. + /// Value used by watch polling. Disk-backed keys bypass AppState's FileState cache. private func freshValue(_ key: DemoKey) -> String { switch key { case .note: return (try? Self.readNoteFromDisk()) ?? get(key) + case .profile: + if let document = try? Self.readProfileFromDisk() { + return (try? encodeProfile(document)) ?? get(key) + } + return get(key) case .counter, .message, .flag: return get(key) } @@ -154,8 +183,7 @@ public final class StateStore { /// /// Mirrors AppState's non-Base64 FileState encoding: UTF-8 JSON via `JSONEncoder`. public static func readNoteFromDisk() throws -> String { - let path = FileManager.defaultFileStatePath - let fileURL = URL(fileURLWithPath: path).appendingPathComponent("note.json") + let fileURL = Self.fileStateURL(filename: "note.json") do { let data = try Data(contentsOf: fileURL) return try JSONDecoder().decode(String.self, from: data) @@ -164,6 +192,29 @@ public final class StateStore { } } + public static func readProfileFromDisk() throws -> ProfileDocument { + let fileURL = Self.fileStateURL(filename: "profile.json") + do { + let data = try Data(contentsOf: fileURL) + return try JSONDecoder().decode(ProfileDocument.self, from: data) + } catch { + throw APSError.persistenceFailed(key: .profile) + } + } + + private static func fileStateURL(filename: String) -> URL { + URL(fileURLWithPath: FileManager.defaultFileStatePath) + .appendingPathComponent(filename) + } + + private func encodeProfile(_ document: ProfileDocument) throws -> String { + let data = try JSONEncoder().encode(document) + guard let string = String(data: data, encoding: .utf8) else { + throw APSError.encodingFailed + } + return string + } + private func readForObservation(_ key: DemoKey) { switch key { case .counter: @@ -174,6 +225,8 @@ public final class StateStore { _ = Application.state(\.flag).value case .note: _ = Application.fileState(\.note).value + case .profile: + _ = Application.fileState(\.profile).value } } @@ -213,5 +266,5 @@ private struct DumpEntry: Encodable { let key: String let storage: String let type: String - let value: String + let value: CLIOutput.JSONValue } diff --git a/Tests/apsTests/APSTests.swift b/Tests/apsTests/APSTests.swift index 2d3eb1b..057284b 100644 --- a/Tests/apsTests/APSTests.swift +++ b/Tests/apsTests/APSTests.swift @@ -21,6 +21,7 @@ final class APSTests: XCTestCase { Application.reset(\.message) Application.reset(storedState: \.flag) Application.reset(fileState: \.note) + Application.reset(fileState: \.profile) } } @@ -37,9 +38,12 @@ final class APSTests: XCTestCase { XCTAssertEqual(DemoKey.counter.storage, "State") XCTAssertEqual(DemoKey.flag.storage, "StoredState") XCTAssertEqual(DemoKey.note.storage, "FileState") + XCTAssertEqual(DemoKey.profile.storage, "FileState") XCTAssertEqual(DemoKey.counter.valueType, "Int") - XCTAssertEqual(DemoKey.allCases.count, 4) + XCTAssertEqual(DemoKey.profile.valueType, "ProfileDocument") + XCTAssertEqual(DemoKey.allCases.count, 5) XCTAssertTrue(DemoKey.note.detail.contains("FileState")) + XCTAssertTrue(DemoKey.profile.detail.contains("profile.json")) } @MainActor @@ -70,6 +74,30 @@ final class APSTests: XCTestCase { XCTAssertEqual(store.get(.note), "persisted note") } + @MainActor + func testProfileStructuredFileStateRoundTrip() async throws { + let store = StateStore() + try store.set(.profile, value: #"{"name":"agent","version":3}"#) + let document = try store.profileDocument() + XCTAssertEqual(document, ProfileDocument(name: "agent", version: 3)) + XCTAssertTrue(store.get(.profile).contains("\"name\"")) + XCTAssertTrue(store.get(.profile).contains("agent")) + XCTAssertEqual(try StateStore.readProfileFromDisk(), document) + } + + @MainActor + func testInvalidProfileJSON() async { + let store = StateStore() + do { + try store.set(.profile, value: "not-json") + XCTFail("Expected invalid value error") + } catch let error as APSError { + XCTAssertEqual(error, .invalidValue(key: .profile, value: "not-json")) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + @MainActor func testInvalidCounterValue() async { let store = StateStore() @@ -88,11 +116,13 @@ final class APSTests: XCTestCase { let store = StateStore() try store.set(.counter, value: "3") try store.set(.message, value: "hi") + try store.set(.profile, value: #"{"name":"x","version":1}"#) let json = try store.dump() XCTAssertTrue(json.contains("\"key\" : \"counter\"")) - XCTAssertTrue(json.contains("\"value\" : \"3\"")) + XCTAssertTrue(json.contains("\"value\" : 3")) XCTAssertTrue(json.contains("\"key\" : \"message\"")) + XCTAssertTrue(json.contains("\"key\" : \"profile\"")) XCTAssertTrue(json.contains("\"storage\" : \"FileState\"")) XCTAssertTrue(json.contains("timestamp")) } @@ -104,6 +134,48 @@ final class APSTests: XCTestCase { XCTAssertTrue(encoded.contains("true")) } + @MainActor + func testCLIOutputTypedValues() async throws { + let store = StateStore() + try store.set(.counter, value: "9") + try store.set(.flag, value: "true") + try store.set(.profile, value: #"{"name":"n","version":2}"#) + + XCTAssertEqual(try CLIOutput.typedValue(for: .counter, store: store), .int(9)) + XCTAssertEqual(try CLIOutput.typedValue(for: .flag, store: store), .bool(true)) + XCTAssertEqual( + try CLIOutput.typedValue(for: .profile, store: store), + .object(ProfileDocument(name: "n", version: 2)) + ) + + let payload = CLIOutput.KeyValuePayload( + key: "counter", + type: "Int", + storage: "State", + value: .int(9) + ) + let encoded = try CLIOutput.encodePretty(payload) + XCTAssertTrue(encoded.contains("\"value\" : 9")) + } + + @MainActor + func testAPSPathsResolveOrder() async { + let previous = ProcessInfo.processInfo.environment["APS_HOME"] + defer { + if let previous { + setenv("APS_HOME", previous, 1) + } else { + unsetenv("APS_HOME") + } + } + + setenv("APS_HOME", "/tmp/aps-from-env", 1) + XCTAssertEqual(APSPaths.resolve(stateDir: nil), "/tmp/aps-from-env") + XCTAssertEqual(APSPaths.resolve(stateDir: "/tmp/aps-flag"), "/tmp/aps-flag") + unsetenv("APS_HOME") + XCTAssertTrue(APSPaths.resolve(stateDir: nil).hasSuffix("/.aps")) + } + @MainActor func testResetRestoresInitialValues() async throws { let store = StateStore() @@ -111,16 +183,19 @@ final class APSTests: XCTestCase { try store.set(.message, value: "x") try store.set(.flag, value: "true") try store.set(.note, value: "n") + try store.set(.profile, value: #"{"name":"z","version":9}"#) store.reset(.counter) store.reset(.message) store.reset(.flag) store.reset(.note) + store.reset(.profile) XCTAssertEqual(store.get(.counter), "0") XCTAssertEqual(store.get(.message), "") XCTAssertEqual(store.get(.flag), "false") XCTAssertEqual(store.get(.note), "") + XCTAssertEqual(try store.profileDocument(), ProfileDocument()) } @MainActor @@ -128,9 +203,11 @@ final class APSTests: XCTestCase { let store = StateStore() try store.set(.counter, value: "5") try store.set(.note, value: "keep?") + try store.set(.profile, value: #"{"name":"p","version":1}"#) store.resetAll() XCTAssertEqual(store.get(.counter), "0") XCTAssertEqual(store.get(.note), "") + XCTAssertEqual(try store.profileDocument(), ProfileDocument()) } @MainActor @@ -198,6 +275,67 @@ final class APSTests: XCTestCase { XCTAssertEqual(seen, ["before", "changed"]) } + @MainActor + func testWatchJSONLEventUsesFreshDiskValue() async throws { + // Mirrors the CLI --jsonl path: build events from the onChange string, + // not from store.get (which can hit a stale FileState cache). + let store = StateStore() + try store.set(.profile, value: #"{"name":"before","version":3}"#) + let path = FileManager.defaultFileStatePath + + var events: [CLIOutput.WatchEvent] = [] + store.watchBlocking( + .profile, + pollInterval: 0.05, + shouldContinue: { events.count < 2 } + ) { value in + let event = try! CLIOutput.watchEvent( + key: .profile, + rawValue: value, + timestamp: store.now + ) + events.append(event) + if events.count == 1 { + let changed = ProfileDocument(name: "leif", version: 4) + let data = try? JSONEncoder().encode(changed) + let url = URL(fileURLWithPath: path).appendingPathComponent("profile.json") + try? data?.write(to: url) + } + } + + XCTAssertEqual(events.count, 2) + XCTAssertEqual(events[0].value, .object(ProfileDocument(name: "before", version: 3))) + XCTAssertEqual(events[1].value, .object(ProfileDocument(name: "leif", version: 4))) + } + + func testTypedValueFromRawStringDoesNotNeedStore() throws { + XCTAssertEqual(try CLIOutput.typedValue(for: .counter, from: "42"), .int(42)) + XCTAssertEqual(try CLIOutput.typedValue(for: .flag, from: "true"), .bool(true)) + XCTAssertEqual(try CLIOutput.typedValue(for: .note, from: "hi"), .string("hi")) + XCTAssertEqual( + try CLIOutput.typedValue(for: .profile, from: #"{"name":"a","version":2}"#), + .object(ProfileDocument(name: "a", version: 2)) + ) + } + + @MainActor + func testWatchCountBoundStopsLoop() async throws { + let store = StateStore() + try store.set(.counter, value: "1") + var seen: [String] = [] + let limit = 1 + store.watchBlocking( + .counter, + pollInterval: 0.05, + shouldContinue: { seen.count < limit } + ) { value in + seen.append(value) + try? store.set(.counter, value: "99") + } + XCTAssertEqual(seen.count, 1) + XCTAssertEqual(seen.first, "1") + } + @MainActor func testNoteUsesInjectedFileStatePath() async throws { let path = FileManager.defaultFileStatePath diff --git a/specs/aps-cli/aps-cli.spec.md b/specs/aps-cli/aps-cli.spec.md index 5529507..d6edc53 100644 --- a/specs/aps-cli/aps-cli.spec.md +++ b/specs/aps-cli/aps-cli.spec.md @@ -1,9 +1,10 @@ --- module: aps-cli -version: 8 +version: 9 status: active files: - Sources/aps/Aps.swift + - Sources/aps/CLIOutput.swift - Sources/aps/DemoKey.swift db_tables: [] depends_on: @@ -22,24 +23,29 @@ agents can get, set, watch, dump, list, and reset typed application state. | Export | Description | |--------|-------------| -| `DemoKey` | Fixed schema enum (`CaseIterable`, `ExpressibleByArgument`, `Sendable`). | +| `DemoKey` | Fixed schema enum including `profile`. | +| `ProfileDocument` | Codable `{name, version}` FileState document. | +| `name` | ProfileDocument display name field. | +| `version` | ProfileDocument integer version field. | +| `init` | ProfileDocument memberwise initializer. | | `APSError` | Typed CLI/domain errors. | | `counter` | Int key stored in AppState `State`. | | `message` | String key stored in AppState `State`. | | `flag` | Bool key stored in AppState `StoredState`. | | `note` | String key stored in AppState `FileState`. | +| `profile` | ProfileDocument key stored in AppState `FileState`. | | `unknownKey` | Unknown demo key token. | | `invalidValue` | Value could not parse for the key type. | | `encodingFailed` | UTF-8 JSON encode failure. | | `decodingFailed` | UTF-8 JSON decode failure. | | `persistenceFailed` | Disk-backed key did not persist after write. | | `storage` | Human storage kind (`State` / `StoredState` / `FileState`). | -| `valueType` | Human value type (`Int` / `String` / `Bool`). | +| `valueType` | Human value type (`Int` / `String` / `Bool` / `ProfileDocument`). | | `helpSummary` | Tab-separated key/type/storage columns for `keys`. | | `detail` | One-line description for `keys`. | | `description` | Actionable error text for humans and ValidationError bridging. | -Command tree (informational): `Aps` is the `@main` root (`ParsableCommand`) with get, set, watch, dump, keys, and reset / reset --all. CLI `boot()` calls `APSPaths.configure()`. +Command tree (informational): `Aps` is the `@main` root with get, set, watch, dump, keys, and reset. Shared `StateOptions` expose `--json` and `--state-dir`. ## Invariants @@ -103,3 +109,4 @@ Then the watcher prints `changed` within one poll interval. | 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | | 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | | 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | +| 2026-07-18 | CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate: Ship aps 0.2.0 agent-ready JSON state-dir watch and profile FileState | diff --git a/specs/aps-cli/requirements.md b/specs/aps-cli/requirements.md index a618710..a232e24 100644 --- a/specs/aps-cli/requirements.md +++ b/specs/aps-cli/requirements.md @@ -8,11 +8,11 @@ spec: aps-cli.spec.md ### REQ-aps-cli-001 -The CLI SHALL expose get, set, watch, dump, keys, and reset over the fixed `DemoKey` schema covering `counter`, `message`, `flag`, and `note`. +The fixed demo schema SHALL include `profile` alongside `counter`, `message`, `flag`, and `note`. Acceptance Criteria -- `aps --help` lists those subcommands. -- `DemoKey` includes only those four cases and exposes `storage`, `valueType`, `helpSummary`, and `detail`. +- `aps keys` lists `profile`. +- `aps set profile '{"name":"a","version":1}'` round-trips through get/dump/reset. ### REQ-aps-cli-002 @@ -48,3 +48,35 @@ Acceptance Criteria - Each case has an actionable `description`. - `set note` surfaces `persistenceFailed` when the on-disk value does not match after write. +### REQ-aps-cli-010 + +`get`, `set`, `dump`, `keys`, and `reset` SHALL support `--json` machine-readable output. + +Acceptance Criteria +- JSON payloads are valid UTF-8 JSON objects. +- Typed values preserve Int/Bool where applicable instead of always stringifying. + +### REQ-aps-cli-011 + +Commands that touch FileState SHALL resolve the state directory as `--state-dir`, then `APS_HOME`, then `~/.aps`. + +Acceptance Criteria +- `--state-dir` wins over `APS_HOME`. +- When neither is set, FileState lands under `~/.aps`. + +### REQ-aps-cli-012 + +`watch` SHALL support `--count`, `--timeout`, and `--jsonl`. + +Acceptance Criteria +- `--count` stops after that many printed values including the initial value. +- `--timeout` stops after the given seconds. +- `--jsonl` emits one JSON object per line. + +### REQ-aps-cli-013 + +The CLI `--version` string SHALL be `0.2.0` while the project is pre-public 0.x. + +Acceptance Criteria +- `aps --version` prints `0.2.0`. + diff --git a/specs/state-store/requirements.md b/specs/state-store/requirements.md index cf856a4..9f6fb76 100644 --- a/specs/state-store/requirements.md +++ b/specs/state-store/requirements.md @@ -8,12 +8,11 @@ spec: state-store.spec.md ### REQ-state-store-001 -`StateStore` SHALL read and write demo keys through AppState Application extensions on the main actor via `init`, `get`, and `set`, without overwriting an injected `FileManager.defaultFileStatePath`. +`StateStore` get/set/reset/dump/watch SHALL cover `profile` in addition to `counter`, `message`, `flag`, and `note`. Acceptance Criteria -- `get`/`set` round-trip `counter`, `message`, `flag`, and `note`. -- Mutating paths are MainActor-isolated. -- `init` loads dependencies only; CLI `boot()` (or tests) configure FileState paths. +- `dump` includes a `profile` entry with object value shape. +- `watch` polling for `profile` reads `profile.json` directly. ### REQ-state-store-002 @@ -41,3 +40,20 @@ Acceptance Criteria - External writes to `note.json` are observed without updating AppState's cache. - `shouldContinue` false stops the loop without requiring Ctrl-C. +### REQ-state-store-010 + +`StateStore` SHALL expose `profile` as `FileState` persisted at `profile.json`, with get/set using JSON encoding and disk read-back verification. + +Acceptance Criteria +- Valid profile JSON persists and `profileDocument()` matches. +- Invalid profile JSON throws `APSError.invalidValue`. +- Failed disk persistence throws `APSError.persistenceFailed`. + +### REQ-state-store-011 + +`APSPaths.resolve(stateDir:)` SHALL prefer `--state-dir`, then `APS_HOME`, then `~/.aps` when configuring FileState paths from CLI boot. + +Acceptance Criteria +- Explicit stateDir wins over environment. +- Missing both returns the default `~/.aps` path. + diff --git a/specs/state-store/state-store.spec.md b/specs/state-store/state-store.spec.md index 1de7a04..4a94ca5 100644 --- a/specs/state-store/state-store.spec.md +++ b/specs/state-store/state-store.spec.md @@ -1,6 +1,6 @@ --- module: state-store -version: 8 +version: 9 status: active files: - Sources/aps/StateStore.swift @@ -23,24 +23,24 @@ non-UI use. | Export | Description | |--------|-------------| -| `StateStore` | MainActor AppState facade used by the CLI. | -| `APSClock` | Clock protocol for dump timestamps. | -| `SystemAPSClock` | Production `APSClock` backed by `Date()`. | -| `JSONCoding` | Shared pretty JSON helpers. | +| `StateStore` | MainActor facade over demo AppState keys. | | `init` | Loads clock/jsonCoding dependencies without forcing `~/.aps`. | -| `get` | Return the string form of a demo key. | -| `set` | Parse and write; throw `APSError.invalidValue` or `persistenceFailed` on failure. | -| `reset` | Restore one key to its AppState initial value. | -| `resetAll` | Restore every demo key. | -| `dump` | Pretty JSON snapshot using `@AppDependency` clock + jsonCoding. | -| `watchBlocking` | Observation + RunLoop poll loop; `note` polls via direct disk read. | -| `parseBool` | Accept true/false/1/0/yes/no/on/off (case-insensitive). | -| `now` | Current `Date` from an `APSClock`. | -| `encodePretty` | Encode an `Encodable` value as pretty UTF-8 JSON text. | -| `decode` | Decode a `Decodable` value from UTF-8 JSON text. | -| `readNoteFromDisk` | Read `note.json` without touching AppState's FileState cache. | - -Application demo surface (informational): `Application.counter` / `message` / `flag` / `note` / `clock` / `jsonCoding`. `APSPaths.configure()` is invoked from CLI `boot()`, not `StateStore.init`. +| `get` | Returns the current string rendering for a demo key. | +| `set` | Parses and writes a demo key value. | +| `reset` | Restores one demo key to its initial value. | +| `resetAll` | Restores every demo key. | +| `dump` | Pretty JSON snapshot with typed values. | +| `watchBlocking` | Observation + polling watch loop. | +| `profileDocument` | Typed profile FileState accessor. | +| `readNoteFromDisk` | Direct `note.json` read bypassing cache. | +| `readProfileFromDisk` | Direct `profile.json` read bypassing cache. | +| `parseBool` | Bool token parser for flag values. | +| `APSClock` | Injected clock dependency protocol. | +| `now` | APSClock current instant. | +| `SystemAPSClock` | Date-backed clock. | +| `JSONCoding` | Shared encode/decode helpers. | +| `encodePretty` | Pretty JSON encode helper. | +| `decode` | JSON decode helper. | ## Invariants @@ -100,3 +100,4 @@ Then keys include message with value "hi" and a timestamp field exists. | 2026-07-18 | CHG-0001-adopt-corvidlabs-trust-and-establish-aps-module-contracts: Adopt CorvidLabs trust and establish aps module contracts | | 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | | 2026-07-18 | CHG-0002-fix-filestate-watch-cache-and-path-isolation-from-review: Fix FileState watch cache and path isolation from review | +| 2026-07-18 | CHG-0004-ship-aps-0-2-0-agent-ready-json-state-dir-watch-and-profile-filestate: Ship aps 0.2.0 agent-ready JSON state-dir watch and profile FileState |