textus 0.54.2 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +8 -1
  4. data/SPEC.md +27 -0
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +1 -1
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +74 -78
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +19 -26
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +7 -15
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -63
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -91
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/ports/raw_index.rb +0 -61
  174. data/lib/textus/role.rb +0 -36
  175. data/lib/textus/session.rb +0 -35
  176. data/lib/textus/types.rb +0 -15
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 53f01828833f9c5e6cb0249fe513b92d6afbb210d90cfca10f51c560c8b2e2ca
4
- data.tar.gz: 1956232435c941479290225e20168979beb270a6e2f5874df37c302e170a43e7
3
+ metadata.gz: 27ac7fc2922b98c97c7873939781a13330e0765b7f0521449aa4871c414fc201
4
+ data.tar.gz: 055ed0684590d9ca8cf5e2ad49fc17ee05c70b8cbaa5f8b589b9bb9a3de99f0e
5
5
  SHA512:
6
- metadata.gz: c086755b90faab7ecebdeebd722e9458dbede54c5efa9be212d6d861c999655093166a1f61826aa9e09ebee8cd1cf57996d958bd410c3b682763d911ed44abe5
7
- data.tar.gz: aad04f9470c3de178ad6c762ef3102528cbdc94922fedb79d121ffbadc23acc5dd3f52871f6b3c0c75664afdb968c12fa814b88fbedcbd0d914161e4e1f337ab
6
+ metadata.gz: 437568a688e1ff1b6f9786d1965633e9dfa69be5b6f7450ffeeaecb3c0be5a6729e94851f2066a26e2d313f8d23335d2ff12b83ad5e73718b27e784887c9ceb4
7
+ data.tar.gz: a8f4167af7c3ce315301996d3d3fedc13139f8eae4804b445ec4ed930da0d284ec96cab3467af1169ce8f6a64288d6f1d805bbfad60c0acb949f62e03ce1322d
data/CHANGELOG.md CHANGED
@@ -9,6 +9,43 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
9
9
  bump is a breaking change that requires a store migration; the gem version
10
10
  tracks both additive improvements and breaking protocol bumps independently.
11
11
 
12
+ ## 0.55.1 — 2026-06-22 — CI fix: converge_now purges done jobs
13
+
14
+ ### Fixed
15
+
16
+ - **publish_tree prune spec** — `converge_now` now purges "done" jobs before reseeding. The SQLite-backed job queue (`INSERT OR IGNORE`) was silently skipping re-enqueues for already-done jobs, so the second materialize never ran and the source-removed file was never pruned from the published tree.
17
+
18
+ ## 0.55.0 — 2026-06-22 — Architecture Deepening Phase 2
19
+
20
+ **ADR-0119**: dry-monad actions, container split, geometry authority.
21
+
22
+ ### Added
23
+
24
+ - **dry-monad Success/Failure actions** (PR #234): all 30 actions now return `Success(result)` or `Failure(code:, message:)` via `Dry::Monads`. `Value::Result.unwrap` converts Failure → `ActionError`. `be_success`/`be_failure` RSpec matchers replace `spec/unit/` tests.
25
+ - **`Container::Infrastructure` / `Container::Coordination`** (PR #234): single `LazyContainer` replaced by two `Data.define` records — Infrastructure owns manifest, file_store, geometry; Coordination owns gate, compositor, workflows, audit_log. Wired at boot via `wire_gate!`.
26
+ - **`Geometry` as sole path authority** (PR #234): `root`, `schemas_dir`, `hooks_dir`, `store_db_path` delegated to `Geometry`. Removed `root` from Infrastructure. High-value `File.join` callers converted (data_mv, ingest, base, loader, etag, schema/tools, 6 doctor checks).
27
+ - **`bin/db` helper**: sqlite3 wrapper for `.textus/.state/store.db` — `bin/db "SELECT * FROM jobs"` or just `bin/db` for interactive shell.
28
+ - **Centralized SQLite index** (PR #228, #233): `Store::Index::Builder` manages entries FTS index in `store.db`.
29
+
30
+ ### Changed
31
+
32
+ - **Gate reads `Action::VERBS` directly** (PR #234): `Gate::VERB_ACTIONS` deleted. Gate dispatches via `Action::VERBS[cmd.verb]`.
33
+ - **Composite actions** (PR #232): `Accept`, `Propose`, `Reject` separated as composite verbs with declarative `chain` steps, never instantiating sibling action classes directly.
34
+ - **`knowledge.decisions` → keyless nested mode**: decision files no longer enumerate in `list`; accessible via `get` with full key.
35
+
36
+ ### Fixed
37
+
38
+ - **CLI `--args` JSON parsing** (PR #233): `Runner.coerce` now `JSON.parse(raw)` for `Hash`-typed args — fixes `textus enqueue --args='{"key":"..."}'` which previously passed the raw JSON string to `Queue::Job`.
39
+ - **adr-log workflow convention drift** (PR #233): `Get.new(key:).call(...)` → `Get.call(key: k, ...)` — the old pattern raised silently since actions became class-method-only.
40
+ - **RuboCop `Lint/NoReturnInBeginEndBlocks`**: enqueue action restructured with method-level `rescue`.
41
+
42
+ ### Removed
43
+
44
+ - `Gate::VERB_ACTIONS` constant (replaced by `Action::VERBS`)
45
+ - `LazyContainer` (replaced by `Container::Infrastructure`/`Coordination`)
46
+ - `spec/unit/` directory + `spec/support/spec_layout.rb` (378 examples removed)
47
+ - Orphaned `.textus/.state/jobs.sqlite3` database file
48
+
12
49
  ## 0.54.2 — 2026-06-18 — ingest dedup, .run → .state rename
13
50
 
14
51
  ### Added
data/README.md CHANGED
@@ -1,4 +1,5 @@
1
- <!-- Generated from .textus/data/knowledge/readme.md edit there, then run `textus drain`. Do not hand-edit README.md (it is clobbered on drain and flagged by doctor). ADR 0103. -->
1
+ <!-- Generated from knowledge.readme.* fragments by the artifacts.docs.readme workflow. Edit the fragments under .textus/data/knowledge/readme/ and run `textus drain`. -->
2
+ <!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
2
3
  <p align="center">
3
4
  <picture>
4
5
  <source media="(prefers-color-scheme: dark)" srcset="assets/branding/wordmark-dark.png">
@@ -18,6 +19,8 @@
18
19
 
19
20
  *textus* is Latin for "the fabric a text is woven from" — same root as *context*, from *con-texere*, "to weave together."
20
21
 
22
+
23
+ <!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
21
24
  ## The idea
22
25
 
23
26
  Three actors write to your repo today:
@@ -90,6 +93,8 @@ An agent that tries to write directly into `knowledge/` gets `write_forbidden`.
90
93
 
91
94
  That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
92
95
 
96
+
97
+ <!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
93
98
  ## See it in four commands
94
99
 
95
100
  ```sh
@@ -316,3 +321,5 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
316
321
  ## License
317
322
 
318
323
  [MIT](LICENSE)
324
+
325
+
data/SPEC.md CHANGED
@@ -438,6 +438,22 @@ textus ingest url agentskills-io-brainstorming \
438
438
 
439
439
  A `get` on a raw entry is a pure read — it returns the entry as stored and never re-fetches (ADR 0089).
440
440
 
441
+ **Raw entry shape.** Every raw-lane entry has a set of required fields
442
+ enforced by the format layer on write:
443
+
444
+ | Field | Required | Description |
445
+ |-------|----------|-------------|
446
+ | `ingested_at` | yes | ISO-8601 timestamp of ingestion |
447
+ | `content_hash` | yes | `sha256:<hex>` of the source content |
448
+ | `source.kind` | yes | One of `url`, `file`, `asset` |
449
+ | `source.url` | conditional | Required when `kind=url` |
450
+ | `source.label` | no | Human-readable label |
451
+ | `body` | no | Present only for `kind=file` |
452
+ | `asset` | no | Present only for `kind=asset` |
453
+
454
+ Tombstone entries (carrying `superseded_by:`) are exempt from shape
455
+ validation — they are stripped-down records pointing to the live entry.
456
+
441
457
  ### 5.5 Pending / accept workflow
442
458
 
443
459
  Proposal entries are full patches authored into the `proposals` queue lane (writable by `propose`-holders: `agent` and `human` by default) — `proposals` in the default scaffold (Setup-1) — typically by agents. The entry's frontmatter describes the patch it proposes against another lane:
@@ -646,6 +662,13 @@ The frontmatter `name:` field, when present, must match the file's basename (wit
646
662
  - Existing files without a uid continue to work. The envelope shows `"uid": null` until a put mints one.
647
663
  - `text` entries have no metadata channel and therefore no uid; their envelope always shows `"uid": null`.
648
664
 
665
+ **`sources:` (Source references).** Entries MAY carry a `sources` array in
666
+ their frontmatter to declare external provenance. Each element is a raw-lane
667
+ key string (starting with `raw.`). The array is preserved on write —
668
+ existing sources carry forward if no new `sources` are provided. The
669
+ envelope returns `sources` as a top-level array when non-empty; omitted
670
+ when absent.
671
+
649
672
  Entries in a `produced` lane SHOULD additionally carry the `generated:` block defined in §5.2. Implementations MUST treat unknown frontmatter fields as warnings, not errors, so build tooling can extend the metadata without breaking conformance.
650
673
 
651
674
  ## 8. Envelope (the wire format)
@@ -665,6 +688,9 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
665
688
  "etag": "sha256:8f3c…",
666
689
  "schema_ref": "person",
667
690
  "uid": "a1b2c3d4e5f60718",
691
+ "sources": [
692
+ "raw.2026.06.20.url-mcp-spec"
693
+ ],
668
694
  "stale": false,
669
695
  "stale_reason": null,
670
696
  "fetching": false
@@ -682,6 +708,7 @@ Every successful CLI response (`--output=json`) is a single JSON envelope:
682
708
  - `etag` MUST be `sha256:<hex>` of the raw file bytes, computed identically for every format.
683
709
  - `schema_ref` MAY be `null` for entries in subtrees with `schema: null`.
684
710
  - `uid` is the stable Textus UID (§7) if the entry carries one, else `null`. Always present in the envelope.
711
+ - `sources` is an array of raw-lane key strings. Present only when non-empty. Each string starts with `raw.`.
685
712
  - `stale` is `true` when the entry's `source.ttl` has elapsed and the entry has not yet been re-materialised; `false` otherwise. Only populated for produced entries with a declared `ttl`; always `false` for other entries.
686
713
  - `stale_reason` is a short human-readable string describing why the entry is stale (e.g. `"ttl_exceeded"`, `"never_fetched"`), or `null` when `stale` is `false`.
687
714
  - `fetching` is `true` when a background re-pull is in flight for this entry; `false` otherwise. Callers observing `stale: true, fetching: true` SHOULD retry after a short delay.
@@ -6,7 +6,8 @@
6
6
  ```mermaid
7
7
  flowchart TD
8
8
  surfaces["Surfaces — CLI · MCP · RoleScope"]
9
- contract["Contract — per-verb DSL (source of truth for public interfaces)"]
9
+ contract["Contract — per-verb DSL (Arg, Spec, DSL)"]
10
+ dispatch["Dispatch — Binder · Around · CommandBuilder · Dispatcher"]
10
11
  gate["Gate — routing (VERB_COMMAND) · Auth"]
11
12
  action["Actions — per-verb use cases (action/)"]
12
13
  jobs["Jobs — Worker · Planner · Materialize · Retention · Sweep"]
@@ -15,8 +16,10 @@ flowchart TD
15
16
  manifest["Manifest — declarative config, no IO (policy/, schema/, entry/)"]
16
17
  core["Core — pure value types (Freshness, Retention, Duration, Sentinel)"]
17
18
  ports["Ports — IO adapters (FileStore, AuditLog, JobStore, Publisher…)"]
18
- surfaces --> contract
19
- contract --> gate
19
+ surfaces --> dispatch
20
+ surfaces -.->|declare| contract
21
+ contract --> dispatch
22
+ dispatch --> gate
20
23
  gate --> action
21
24
  action --> jobs
22
25
  action --> produce
@@ -27,7 +30,7 @@ flowchart TD
27
30
  jobs --> produce
28
31
  ```
29
32
 
30
- *Dependency rule: inward only.* Surfaces → Contract → Gate → Actions → inner layers. Actions never reference surfaces.
33
+ *Dependency rule: inward only.* Surfaces → Dispatch → Gate → Actions → inner layers. Actions never reference surfaces.
31
34
 
32
35
  ### What lives in each layer
33
36
 
@@ -35,18 +38,27 @@ flowchart TD
35
38
 
36
39
  ```
37
40
  surfaces/cli/ CLI command generation from contracts
41
+ sources.rb CLI-only input acquisition (stdin parsing, file sourcing, coercion)
38
42
  surfaces/mcp/ MCP server — stdio JSON-RPC 2.0, tools derived from contracts
39
43
  surfaces/role_scope.rb
40
44
  (Store#as(role)) — holds (container, role, dry_run, correlation_id);
41
45
  all verb methods injected via define_method in textus.rb
42
46
  ```
43
47
 
44
- **Contract**
48
+ **Contract + Dispatch**
45
49
 
46
50
  ```
47
- contract/ Per-verb DSL — verb, summary, surfaces, arg, view.
48
- Contract::Binder.inputs_from_ordered splits the uniform inputs hash
49
- into positional/keyword args for every surface.
51
+ contract/ Per-verb DSL — verb, summary, surfaces, arg, view, around.
52
+ Pure declaration: Spec and DSL only.
53
+
54
+ dispatch/ Runtime machinery consumed by every surface.
55
+ binder.rb Binder — validates required args, resolves session/literal defaults.
56
+ around.rb Around — registry of stateful wrappers (cursor, build_lock).
57
+ view.rb View.render(spec, surface, result, inputs) — output shaping.
58
+ command_builder.rb
59
+ CommandBuilder — command construction + role injection.
60
+ dispatcher.rb Dispatcher — unified pipeline: Around → Binder → Command → Gate.
61
+ resources/ Around resource implementations (Cursor, BuildLock).
50
62
  ```
51
63
 
52
64
  **Gate + Auth**
@@ -126,7 +126,7 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
126
126
  The application layer is organised around three shapes — `Manifest` as a composition record, a single `Container` capability record handed to every use case, and a split envelope reader/writer. See [ADR 0018](../architecture/decisions/0018-manifest-carving.md), [ADR 0017](../architecture/decisions/0017-envelope-io-split.md), [ADR 0022](../architecture/decisions/0022-container-call-dispatcher.md), and [ADR 0023](../architecture/decisions/0023-uniform-use-case-shape.md).
127
127
 
128
128
  - **`Manifest` is a composition record** (`Data.define(:data, :resolver, :policy, :rules)`). Reach individual concerns through the field accessors: `manifest.data.entries`, `manifest.policy.permission_for(lane)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
129
- - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/{read,write,maintenance}/` with `def initialize(container:, call:)` and a `#call(...)` method; verbs are looked up in the static `Textus::Dispatcher::VERBS` table. `Container` is a `Data.define` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `events`, `rpc`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). A use case that emits events derives its `Hooks::Context` from `(container, call)` — nothing is injected. Use cases pull only what they need into ivars; nobody passes the raw `Store` around the application layer. `store.as(role)` returns a `RoleScope` that forwards verbs to the dispatcher.
129
+ - **Use cases are plain `(container:, call:)` classes.** Each is a single class under `lib/textus/action/` with `def initialize(...)` (args from contract) and a `#call(container:, call:)` method; verbs are looked up in the static `Textus::Action::VERBS` table. `Container` is a `Dry::Struct` record bundling the wired ports + manifest (`manifest`, `file_store`, `schemas`, `root`, `audit_log`, `workflows`, `gate`); `Call` is the immutable per-invocation value (`role`, `correlation_id`, `now`, `dry_run`). All dispatch routes through `Gate#dispatch(spec:, inputs:, role:, correlation_id:, session:)` — the single seam that binds inputs via `Gate::Binder`, checks auth, and invokes the action. `store.as(role)` returns a `RoleScope` that forwards verbs to `Gate::dispatch`.
130
130
  - **Write path is split**: `Envelope::IO::Reader` owns read/parse (existing-uid lookup, raw read, parse), and `Envelope::IO::Writer` owns put/delete/move + the audit-append invariant (every public method's final action is `@audit_log.append(...)`).
131
131
 
132
132
  The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/4`) are unchanged.
data/exe/textus CHANGED
@@ -1,4 +1,4 @@
1
1
  #!/usr/bin/env ruby
2
2
  $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
3
3
  require "textus"
4
- exit Textus::Surfaces::CLI.run(ARGV)
4
+ exit Textus::Surface::CLI.run(ARGV)
@@ -2,42 +2,44 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class Accept < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class Accept < Base
8
6
  verb :accept
9
7
  summary "apply a queued proposal to its target zone; requires the author capability"
10
8
  surfaces :cli, :mcp
11
9
  cli "accept"
12
10
  arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
13
11
 
14
- def initialize(pending_key:)
15
- super()
16
- @pending_key = pending_key
17
- end
12
+ def self.call(container:, call:, pending_key:)
13
+ env = container.compositor.read(pending_key)
14
+ parsed = proposal_from(env, key: pending_key)
15
+ return parsed if parsed.is_a?(Dry::Monads::Result::Failure)
18
16
 
19
- def call(container:, call:)
20
- env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
21
- proposal = env.meta["proposal"] or raise Textus::ProposalError.new("entry has no proposal block: #{@pending_key}")
22
- target = proposal["target_key"] or raise Textus::ProposalError.new("proposal missing target_key")
23
- action = proposal["action"] || "put"
17
+ target = parsed[:target_key]
18
+ action = parsed[:proposal]["action"] || "put"
24
19
 
25
20
  case action
26
21
  when "put"
27
- Textus::Action::Put.new(
28
- key: target,
29
- meta: env.meta["_meta"] || {},
30
- body: env.body,
31
- ).call(container: container, call: call)
22
+ mentry = container.manifest.resolver.resolve(target).entry
23
+ container.compositor.write(
24
+ target,
25
+ mentry: mentry,
26
+ payload: Textus::Store::Envelope::Writer::Payload.new(
27
+ meta: env.meta["_meta"] || {},
28
+ body: env.body,
29
+ content: nil,
30
+ ),
31
+ call: call,
32
+ )
32
33
  when "delete"
33
- Textus::Action::KeyDelete.new(key: target).call(container: container, call: call)
34
+ container.compositor.delete(target, call: call)
34
35
  else
35
- raise Textus::ProposalError.new("unknown action: #{action}")
36
+ return Failure(code: :proposal_error, message: "unknown action: #{action}")
36
37
  end
37
38
 
38
- Textus::Action::KeyDelete.new(key: @pending_key).call(container: container, call: call)
39
+ container.compositor.delete(pending_key, call: call)
39
40
 
40
- { "protocol" => Textus::PROTOCOL, "accepted" => @pending_key, "target_key" => target, "action" => action }
41
+ Success("protocol" => Textus::PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action,
42
+ "cascade_key" => target)
41
43
  end
42
44
  end
43
45
  end
@@ -6,8 +6,6 @@ require "time"
6
6
  module Textus
7
7
  module Action
8
8
  class Audit < Base
9
- extend Textus::Contract::DSL
10
-
11
9
  verb :audit
12
10
  summary "Query the audit log with optional filters."
13
11
  surfaces :cli
@@ -24,42 +22,26 @@ module Textus
24
22
  arg :limit, Integer, required: false, description: "maximum number of rows to return"
25
23
  view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
26
24
 
27
- def initialize(**kwargs)
28
- super()
29
- @query = Query.build(**kwargs.slice(:key, :lane, :role, :verb, :since, :seq_since, :correlation_id, :limit))
30
- end
31
-
32
- def args
33
- @query.to_h.compact
34
- end
35
-
36
- def call(container:, **)
37
- @manifest = container.manifest
38
- @root = container.root
39
- @log_path = Textus::Layout.audit_log(container.root)
40
- @audit_log = container.audit_log
41
-
42
- query = @query
43
- check_cursor_expiry!(query.seq_since)
25
+ def self.call(container:, key: nil, lane: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil, **) # rubocop:disable Metrics/ParameterLists
26
+ audit_log = container.audit_log
27
+ manifest = container.manifest
44
28
 
45
- files = all_log_files
46
- return [] if files.empty?
29
+ cursor_check = check_cursor_expiry(seq_since, audit_log)
30
+ return cursor_check if cursor_check.is_a?(Dry::Monads::Result::Failure)
47
31
 
48
- rows = []
49
- files.each do |file|
50
- File.foreach(file) do |line|
51
- parsed = parse_row(line.chomp)
52
- next unless parsed
53
- next unless query.matches?(parsed)
54
- next if query.lane && !key_in_lane?(parsed["key"], query.lane)
55
-
56
- rows << parsed
57
- break if limit_reached?(rows, query)
58
- end
59
- break if limit_reached?(rows, query)
60
- end
32
+ Success(audit_log.scan(
33
+ seq_since: seq_since,
34
+ key: key,
35
+ role: role,
36
+ verb: verb,
37
+ correlation_id: correlation_id,
38
+ limit: limit,
39
+ ).select do |row|
40
+ next false if lane && !key_in_lane?(row["key"], lane, manifest)
41
+ next false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
61
42
 
62
- rows
43
+ true
44
+ end)
63
45
  end
64
46
 
65
47
  def self.parse_since(str, now: Time.now.utc)
@@ -91,37 +73,18 @@ module Textus
91
73
  end
92
74
  end
93
75
 
94
- private
95
-
96
- def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
97
-
98
- def check_cursor_expiry!(seq_since)
76
+ def self.check_cursor_expiry(seq_since, audit_log)
99
77
  return unless seq_since
100
78
 
101
- log = @audit_log || Textus::Ports::AuditLog.new(@root)
102
- min = log.min_available_seq
103
- raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
104
- end
105
-
106
- def all_log_files
107
- rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
108
- .reject { |path| path.end_with?(".meta.json") }
109
- .sort_by { |path| -path.scan(/\d+$/).first.to_i }
110
- active = File.exist?(@log_path) ? [@log_path] : []
111
- rotated + active
112
- end
113
-
114
- def parse_row(line)
115
- return nil if line.empty?
116
- return nil unless line.start_with?("{")
79
+ min = audit_log.min_available_seq
80
+ return unless min && seq_since < min - 1
117
81
 
118
- JSON.parse(line)
119
- rescue JSON::ParserError
120
- nil
82
+ Failure(code: :cursor_expired, message: "requested seq #{seq_since} is below minimum available #{min}",
83
+ details: { requested: seq_since, min_available: min })
121
84
  end
122
85
 
123
- def key_in_lane?(key, lane)
124
- mentry = @manifest.resolver.resolve(key).entry
86
+ def self.key_in_lane?(key, lane, manifest)
87
+ mentry = manifest.resolver.resolve(key).entry
125
88
  mentry && mentry.lane == lane
126
89
  rescue Textus::Error
127
90
  false
@@ -20,22 +20,22 @@ module Textus
20
20
  end
21
21
 
22
22
  class Base
23
+ extend Contract::DSL
24
+ extend Dry::Monads[:result]
25
+
23
26
  def self.inherited(subclass)
24
27
  super
25
28
  Textus::Action.register(subclass) if subclass.name
26
29
  end
27
30
 
28
- def call(**)
29
- raise NotImplementedError.new("#{self.class}#call")
31
+ def self.call(**)
32
+ raise NotImplementedError.new("#{name}.call")
30
33
  end
31
34
 
32
- def args
33
- params = self.class.instance_method(:initialize).parameters
34
- names = params.select { |t,| %i[key keyreq].include?(t) }.map(&:last)
35
- names.each_with_object({}) do |name, h|
36
- val = instance_variable_get(:"@#{name}")
37
- h[name] = val unless val.nil?
38
- end
35
+ def self.proposal_from(env, key:)
36
+ proposal = env.meta&.dig("proposal") or return Failure(code: :proposal_error, message: "entry has no proposal block: #{key}")
37
+ target = proposal["target_key"] or return Failure(code: :proposal_error, message: "proposal missing target_key")
38
+ { proposal:, target_key: target }
39
39
  end
40
40
  end
41
41
  end
@@ -5,8 +5,6 @@ require "open3"
5
5
  module Textus
6
6
  module Action
7
7
  class Blame < Base
8
- extend Textus::Contract::DSL
9
-
10
8
  verb :blame
11
9
  summary "Annotate audit rows for a key with the git commit that introduced each file state."
12
10
  surfaces :cli
@@ -15,59 +13,43 @@ module Textus
15
13
  arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
16
14
  view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
17
15
 
18
- def initialize(key:, limit: nil)
19
- super()
20
- @key = key
21
- @limit = limit
22
- end
23
-
24
- def call(container:, **)
25
- @container = container
26
- @manifest = container.manifest
27
- @root = container.root
16
+ def self.call(container:, key:, limit: nil, **)
17
+ manifest = container.manifest
18
+ root = container.root
28
19
 
29
- audit_rows = Textus::Action::Audit.new(key: @key, limit: @limit).call(container: container)
30
- path = resolve_path(@key)
31
- return audit_rows.map { |row| row.merge("git" => nil) } unless git_tracked?(path)
20
+ audit_result = Textus::Action::Audit.call(container: container, key: key, limit: limit)
21
+ audit_rows = Value::Result.unwrap(audit_result)
22
+ path = resolve_path(key, manifest: manifest)
23
+ return Success(audit_rows.map { |row| row.merge("git" => nil) }) unless git_tracked?(path, root: root)
32
24
 
33
- audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"])) }
25
+ Success(audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"], root: root)) })
34
26
  end
35
27
 
36
- def self.new(*args, **kwargs)
37
- return super(**kwargs) unless args.any?
38
-
39
- positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
40
- mapped = positional.zip(args).to_h
41
- super(**mapped.merge(kwargs))
42
- end
43
-
44
- private
45
-
46
- def resolve_path(key)
47
- res = @manifest.resolver.resolve(key)
28
+ def self.resolve_path(key, manifest:)
29
+ res = manifest.resolver.resolve(key)
48
30
  mentry = res.entry
49
31
  path = res.path
50
- path || Textus::Key::Path.resolve(@manifest.data, mentry)
32
+ path || Textus::Key::Path.resolve(manifest.data, mentry)
51
33
  rescue Textus::Error
52
34
  nil
53
35
  end
54
36
 
55
- def git_tracked?(path)
37
+ def self.git_tracked?(path, root:)
56
38
  return false if path.nil?
57
39
  return false unless File.exist?(path)
58
- return false unless git_repo?
40
+ return false unless git_repo?(root)
59
41
 
60
42
  _out, _err, status = Open3.capture3(
61
43
  "git", "ls-files", "--error-unmatch", path,
62
- chdir: @root
44
+ chdir: root
63
45
  )
64
46
  status.success?
65
47
  rescue Errno::ENOENT
66
48
  false
67
49
  end
68
50
 
69
- def git_repo?
70
- dir = @root
51
+ def self.git_repo?(root)
52
+ dir = root
71
53
  loop do
72
54
  return true if File.directory?(File.join(dir, ".git"))
73
55
 
@@ -78,11 +60,11 @@ module Textus
78
60
  end
79
61
  end
80
62
 
81
- def git_commit_at(path, timestamp:)
63
+ def self.git_commit_at(path, timestamp:, root:)
82
64
  args = ["git", "log", "-1"]
83
65
  args << "--before=#{timestamp}" if timestamp
84
66
  args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
85
- out, _err, status = Open3.capture3(*args, chdir: @root)
67
+ out, _err, status = Open3.capture3(*args, chdir: root)
86
68
  return nil unless status.success?
87
69
 
88
70
  sha, author, date, subject = out.strip.split("\t", 4)
@@ -3,14 +3,12 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Boot < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :boot
9
7
  summary "Return the orientation contract: lanes, agent_quickstart, agent_protocol, and pre-computed artifacts."
10
8
  surfaces :cli, :mcp
11
9
 
12
- def call(container:, **)
13
- Textus::Boot.build(container: container)
10
+ def self.call(container:, **)
11
+ Success(Textus::Boot.build(container: container))
14
12
  end
15
13
  end
16
14
  end
@@ -5,8 +5,6 @@ require "yaml"
5
5
  module Textus
6
6
  module Action
7
7
  class DataMv < Base
8
- extend Textus::Contract::DSL
9
-
10
8
  verb :data_mv
11
9
  summary "Rename a data lane — manifest + files. Refuses if destination exists."
12
10
  surfaces :cli, :mcp
@@ -19,48 +17,39 @@ module Textus
19
17
  "defaults to false, so omitting it applies the move immediately"
20
18
  view { |v, _i| v.to_h }
21
19
 
22
- def initialize(from:, to:, dry_run: false)
23
- super()
24
- @from = from
25
- @to = to
26
- @dry_run = dry_run
27
- end
28
-
29
- def call(container:, **)
20
+ def self.call(container:, call:, from:, to:, dry_run: false, **) # rubocop:disable Lint/UnusedMethodArgument
30
21
  manifest = container.manifest
31
- root = container.root
22
+ geom = container.geometry
32
23
 
33
- raise UsageError.new("from and to required") if @from.nil? || @to.nil? || @from.empty? || @to.empty?
34
- raise UsageError.new("data lane '#{@from}' not declared") unless manifest.data.declared_lane_kinds.key?(@from)
24
+ return Failure(code: :usage_error, message: "from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
25
+ return Failure(code: :usage_error, message: "data lane '#{from}' not declared") unless manifest.data.declared_lane_kinds.key?(from)
35
26
 
36
- dest_dir = File.join(root, "data", @to)
37
- raise UsageError.new("destination 'data/#{@to}' already exists") if File.exist?(dest_dir)
27
+ dest_dir = geom.lane_path(to)
28
+ return Failure(code: :usage_error, message: "destination 'data/#{to}' already exists") if File.exist?(dest_dir)
38
29
 
39
- affected_keys = manifest.data.entries.select { |entry| entry.lane == @from }.map(&:key)
30
+ affected_keys = manifest.data.entries.select { |entry| entry.lane == from }.map(&:key)
40
31
 
41
- steps = [{ "op" => "rename_zone", "from" => @from, "to" => @to }]
32
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
42
33
  steps += affected_keys.map do |key|
43
- { "op" => "mv", "from" => key, "to" => "#{@to}#{key[@from.length..]}" }
34
+ { "op" => "mv", "from" => key, "to" => "#{to}#{key[from.length..]}" }
44
35
  end
45
36
 
46
- plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
47
- return plan if @dry_run
37
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: [])
38
+ return Success(plan) if dry_run
48
39
 
49
- rewrite_manifest!(root)
50
- FileUtils.mv(File.join(root, "data", @from), dest_dir)
51
- plan
40
+ rewrite_manifest!(geom, from:, to:)
41
+ FileUtils.mv(geom.lane_path(from), dest_dir)
42
+ Success(plan)
52
43
  end
53
44
 
54
- private
55
-
56
- def rewrite_manifest!(root)
57
- path = File.join(root, "manifest.yaml")
45
+ def self.rewrite_manifest!(geom, from:, to:)
46
+ path = geom.manifest_path
58
47
  raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
59
- raw["lanes"].each { |lane| lane["name"] = @to if lane["name"] == @from }
48
+ raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
60
49
  raw["entries"].each do |entry|
61
- entry["lane"] = @to if entry["lane"] == @from
62
- entry["key"] = entry["key"].sub(/\A#{Regexp.escape(@from)}(\.|\z)/, "#{@to}\\1")
63
- entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(@from)}(/|\z)}, "\\1#{@to}\\2")
50
+ entry["lane"] = to if entry["lane"] == from
51
+ entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
52
+ entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
64
53
  end
65
54
  File.write(path, YAML.dump(raw))
66
55
  end