textus 0.4.0 → 0.5.0

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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +64 -1
  3. data/README.md +13 -11
  4. data/SPEC.md +13 -9
  5. data/docs/architecture.md +63 -28
  6. data/lib/textus/audit_log.rb +46 -11
  7. data/lib/textus/builder.rb +3 -3
  8. data/lib/textus/builtin_actions.rb +5 -5
  9. data/lib/textus/cli/accept.rb +13 -0
  10. data/lib/textus/cli/action.rb +51 -0
  11. data/lib/textus/cli/build.rb +11 -0
  12. data/lib/textus/cli/delete.rb +14 -0
  13. data/lib/textus/cli/deprecated_alias.rb +31 -0
  14. data/lib/textus/cli/deps.rb +10 -0
  15. data/lib/textus/cli/doctor.rb +13 -0
  16. data/lib/textus/cli/extension_group.rb +9 -0
  17. data/lib/textus/cli/extensions.rb +49 -0
  18. data/lib/textus/cli/get.rb +10 -0
  19. data/lib/textus/cli/group.rb +51 -0
  20. data/lib/textus/cli/init.rb +12 -0
  21. data/lib/textus/cli/intro.rb +9 -0
  22. data/lib/textus/cli/key_group.rb +10 -0
  23. data/lib/textus/cli/list.rb +12 -0
  24. data/lib/textus/cli/migrate.rb +41 -0
  25. data/lib/textus/cli/migrate_keys.rb +19 -0
  26. data/lib/textus/cli/mv.rb +20 -0
  27. data/lib/textus/cli/published.rb +9 -0
  28. data/lib/textus/cli/put.rb +48 -0
  29. data/lib/textus/cli/rdeps.rb +10 -0
  30. data/lib/textus/cli/refresh.rb +13 -0
  31. data/lib/textus/cli/schema.rb +10 -0
  32. data/lib/textus/cli/schema_diff.rb +15 -0
  33. data/lib/textus/cli/schema_group.rb +33 -0
  34. data/lib/textus/cli/schema_init.rb +19 -0
  35. data/lib/textus/cli/schema_migrate.rb +19 -0
  36. data/lib/textus/cli/stale.rb +12 -0
  37. data/lib/textus/cli/uid.rb +15 -0
  38. data/lib/textus/cli/verb.rb +62 -0
  39. data/lib/textus/cli/where.rb +10 -0
  40. data/lib/textus/cli.rb +65 -387
  41. data/lib/textus/doctor.rb +64 -33
  42. data/lib/textus/entry/json.rb +6 -4
  43. data/lib/textus/entry/markdown.rb +4 -4
  44. data/lib/textus/entry/text.rb +3 -3
  45. data/lib/textus/entry/yaml.rb +6 -4
  46. data/lib/textus/entry.rb +2 -2
  47. data/lib/textus/errors.rb +2 -2
  48. data/lib/textus/init.rb +1 -1
  49. data/lib/textus/intro.rb +2 -2
  50. data/lib/textus/manifest.rb +11 -221
  51. data/lib/textus/manifest_entry.rb +185 -0
  52. data/lib/textus/migrate_v2.rb +27 -0
  53. data/lib/textus/projection.rb +1 -1
  54. data/lib/textus/proposal.rb +3 -3
  55. data/lib/textus/refresh.rb +7 -7
  56. data/lib/textus/schema_tools.rb +8 -8
  57. data/lib/textus/store/events.rb +31 -0
  58. data/lib/textus/store/mover.rb +118 -0
  59. data/lib/textus/store/staleness.rb +142 -0
  60. data/lib/textus/store/validator.rb +53 -0
  61. data/lib/textus/store.rb +49 -354
  62. data/lib/textus/version.rb +2 -2
  63. data/lib/textus.rb +38 -0
  64. metadata +38 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 98c2ce5525bbf9c05ebdb5eeaacde8e208253ee878d7d0722ff192435168f69f
4
- data.tar.gz: c16cd5657396884c646331912d988838e0f8ed2002fc76d47cc54383dc434f19
3
+ metadata.gz: 997772ea1cfaafc9f28bd57eda51cde19911ca527893c658aa6edb1329b1e2e6
4
+ data.tar.gz: a134c101fedfb2cd84c6cc21ab0522597ee1252cdaeaa84bc1b25bf977676538
5
5
  SHA512:
6
- metadata.gz: 7478459f9672474c32f37b59a7134309cf12f6a7455a808c24b6ac717820ff8c8aad5a5187a6dbe5e0c73f98c04296ce95eff2cac8ea75144f559df8cea3fe80
7
- data.tar.gz: '08a29e9090f6558a010009e3562a7ec8d6ddf73454bb09b10b5aeaf3def24d9d11905078d62807b8e1f0936b00889a66df8c55d68b7dfc411addd90e60ae1a93'
6
+ metadata.gz: 666ac21f47159341408b2399f9051d0dad0fab406d08f73a83aa468ea245aa211f16b9749ccf80668dbdd304c5125fbd87644c7e16301c890538749c4a1c0b5e
7
+ data.tar.gz: f15b77854967509b1c4918dd8452557949f5f42ce6e1ebcd928b3225596d36dff043bf47eca632c8f46b8ba29873cd26792a09109430903402a6b52e3ac51d66
data/CHANGELOG.md CHANGED
@@ -5,11 +5,74 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
- (currently `textus/1`, embedded in every envelope as `protocol`). The protocol
8
+ (currently `textus/2`, embedded in every envelope as `protocol`). The protocol
9
9
  is additive within a major; a new major would change the wire string.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## 0.5.0 — Wire protocol `textus/2`; CLI restructure; Store split (breaking)
14
+
15
+ This release reshapes the public surface ahead of 1.0. The wire protocol bumps to `textus/2`; the CLI grows nested subcommand groups; `Store` is decomposed into a thin facade plus four focused helpers; the audit log finally matches its documented NDJSON shape; and a pile of pre-0.4 cruft gets cut.
16
+
17
+ ### Wire protocol — `textus/1` → `textus/2`
18
+
19
+ - **Breaking:** every envelope now carries `"_meta"` instead of `"frontmatter"`. For json/yaml entries, envelope `content` no longer carries a duplicate `_meta` — the metadata lives only at the envelope's top level.
20
+ - **Breaking:** `Manifest.load` refuses `textus/1` manifests with a pointer at the new migration command. On-disk file shapes are unchanged — only the manifest version string changes.
21
+ - **New:** `textus migrate v2` flips `version: textus/1` to `version: textus/2` in `.textus/manifest.yaml`. One command, no file edits.
22
+ - **Internal cleanup:** `Store#extract_uid`, `enforce_name_match!`, `serialize_for_put`, `validate_all`, and `build_envelope` no longer format-switch — metadata access is uniform. Role-authority validation now works for json/yaml entries (was markdown-only).
23
+ - **API:** `Store#put` keyword renamed from `frontmatter:` to `meta:`. Action callbacks return `_meta:` (formerly `frontmatter:`).
24
+
25
+ ### CLI — nested subcommand groups
26
+
27
+ - **New:** `textus key {mv, uid, migrate}`, `textus schema {show, init, diff, migrate}`, `textus extension {list, run}`. Discoverable, groupable, scales.
28
+ - **Deprecated (removed in 0.6):** the flat verbs `mv`, `uid`, `migrate-keys`, `schema-init`, `schema-diff`, `schema-migrate`, `extensions`, `action` still work but emit a stderr deprecation warning. `textus schema KEY` (positional dotted-key form) keeps working via a back-compat fallback in `SchemaGroup`.
29
+ - **New:** `textus list`, `textus get`, etc. default to JSON output. `--format=json` is still accepted; non-json values still raise.
30
+ - **CLI refactor:** `lib/textus/cli.rb` shrank from 434 LOC to ~100 LOC. Every verb is now a small command-object file under `lib/textus/cli/`. Dispatch is a frozen `VERBS` hash.
31
+
32
+ ### Audit log — true NDJSON
33
+
34
+ - **Breaking:** `.textus/audit.log` rows are now one JSON object per line (`{"ts":..., "role":..., "verb":..., "key":..., "etag_before":..., "etag_after":...}`). Missing etags are `null`, not the string `"NULL"`.
35
+ - **Structural shape:** `from_key`, `to_key`, `uid` (mv rows) live at the top level; arbitrary contextual data goes into an `extras` sub-object that is omitted when empty.
36
+ - **Back-compat:** legacy TSV rows still parse during 0.5 — `AuditLog#last_writer_for` and `Doctor#check_audit_log` accept both formats. Legacy support removed in 0.6.
37
+
38
+ ### Doctor
39
+
40
+ - **New:** `textus doctor --check=schema_violations[,name,…]` runs only the named built-in checks. The 9 built-ins are `manifest_files`, `schemas`, `templates`, `extensions`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`. Extension checks always run.
41
+ - **Breaking:** the standalone `textus validate-all` verb is gone. Use `textus doctor --check=schema_violations` instead. The internal `Store#validate_all` Ruby method is unchanged.
42
+
43
+ ### Manifest / store cleanup
44
+
45
+ - **Breaking:** `LEGACY_ZONES` fallback removed. Manifests must declare a `zones:` block explicitly (init scaffold does this).
46
+ - **Breaking:** legacy syntax errors removed for `source.parse` / `source.from` / `source.fetcher` / top-level `hooks:`. Those names were rejected with helpful errors in 0.4; in 0.5 they get the generic "unknown key" error from YAML parsing.
47
+ - **Internal:** `ManifestEntry` moved to its own file (`lib/textus/manifest_entry.rb`).
48
+
49
+ ### Store split
50
+
51
+ - **Internal:** `lib/textus/store.rb` shrank from 617 LOC to ~312 LOC. Four focused helpers live under `lib/textus/store/`:
52
+ - `events.rb` (31 LOC) — `fire_event` hook plumbing
53
+ - `validator.rb` (53 LOC) — `validate_all` body
54
+ - `staleness.rb` (142 LOC) — `stale` body (was 5 rubocop disables)
55
+ - `mover.rb` (118 LOC) — `mv` body
56
+ - No public-API change. `Store` facade delegates to each helper one-line.
57
+
58
+ ### Migration cheat-sheet
59
+
60
+ ```sh
61
+ # 1. Upgrade the gem
62
+ gem update textus # ≥ 0.5.0
63
+
64
+ # 2. Upgrade the store
65
+ cd /path/to/your/store
66
+ textus migrate v2 # flips manifest version
67
+
68
+ # 3. Anything else?
69
+ # - Audit log: existing TSV rows still readable; new rows are NDJSON.
70
+ # - CLI scripts: replace `textus mv ...` with `textus key mv ...`
71
+ # (and 7 similar aliases). Old forms work through 0.5 with a stderr warning.
72
+ # - Ruby callers of Store#put: pass `meta:` instead of `frontmatter:`.
73
+ # - Anything reading envelope["frontmatter"]: read envelope["_meta"] instead.
74
+ ```
75
+
13
76
  ## 0.4.0 — Extension API redesign (breaking)
14
77
 
15
78
  - **Breaking:** `Textus.fetcher` removed. Use `Textus.action` instead. The block signature changes from `|config:, store:|` to `|config:, store:, args:|`.
data/README.md CHANGED
@@ -73,8 +73,8 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
73
73
 
74
74
  - **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/derived/marketplace.json | jq .` works without going through textus — the in-store file *is* the consumer-shaped artifact. Structured outputs carry `_meta` at the top level (`generated_at`, `from`, `template`, `reducer`).
75
75
  - **Per-leaf publishing.** Nested entries declare `publish_each: "skills/{basename}/SKILL.md"`. Every leaf byte-copies to its consumer location on `textus build`. No more hand-mirrored `agents/` / `skills/` / `commands/` directories.
76
- - **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
77
- - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus migrate-keys --dry-run|--write` rewrites existing stores with illegal segments deterministically.
76
+ - **Stable identity (`uid:`).** 16-char hex, auto-minted on first `put`, preserved across writes and moves. `textus key mv old.key new.key` renames in place — uid survives, audit row records `from_key`, `to_key`, `uid`. Reorganising a tree no longer breaks references.
77
+ - **Strict key grammar.** `/^[a-z0-9][a-z0-9-]*$/`, max 8 segments × 64 chars. `textus key migrate --dry-run|--write` rewrites existing stores with illegal segments deterministically.
78
78
  - **`textus intro`.** One-shot store orientation: zones with writers + purposes, entry families with schemas and publish targets, loaded extensions, write flows per role, the full CLI verb table. The boot signal for any agent — one tool call and it knows your store.
79
79
  - **`textus doctor`.** Health check across 8 categories: missing schemas/templates, broken extensions, illegal nested keys, sentinel drift, audit log readability. Returns `ok: true` only when nothing is wrong; warnings and info don't flip the bit.
80
80
  - **Actionable hints on every error.** `UnknownKey` carries ranked "did you mean" suggestions. `WriteForbidden` names the role that *would* be allowed. `BadFrontmatter` tells you exactly what to rename. Printed to stderr alongside the JSON envelope on stdout.
@@ -93,22 +93,22 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
93
93
  | `list [--prefix=K] [--zone=Z]` | Enumerate keys |
94
94
  | `where K` | Resolve a key to its filesystem path |
95
95
  | `get K` | Full envelope (frontmatter, body, uid, etag, format) |
96
- | `schema K` | Schema bound to an entry |
96
+ | `schema show K` | Schema bound to an entry |
97
97
  | `stale [--prefix=K] [--zone=Z]` | List stale derived/intake entries |
98
98
  | `deps K` / `rdeps K` | Forward / reverse projection dependencies |
99
99
  | `published` | List `publish_to:` targets and their backing keys |
100
- | `validate-all` | Validate every entry against its schema |
101
- | `extensions list [--kind=K]` | Registered actions, reducers, hooks, doctor_checks |
100
+ | `doctor --check=schema_violations` | Validate every entry against its schema |
101
+ | `extension list [--kind=K]` | Registered actions, reducers, hooks, doctor_checks |
102
102
 
103
103
  **Write:**
104
104
 
105
105
  | Verb | Role |
106
106
  |---|---|
107
107
  | `put K --stdin --as=R [--action=NAME]` | per zone |
108
- | `action NAME [--key=val] [--as=R]` | per zone written (invoke a registered action) |
108
+ | `extension run NAME [--key=val] [--as=R]` | per zone written (invoke a registered action) |
109
109
  | `delete K --if-etag=E --as=R` | per zone |
110
110
  | `refresh K --as=script` | per zone (typically `script`) |
111
- | `mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
111
+ | `key mv old new --as=R [--dry-run]` | per zone (same-zone moves; uid preserved) |
112
112
  | `build [--prefix=K] [--dry-run]` | `build` |
113
113
  | `accept K --as=human` | `human` only |
114
114
 
@@ -117,16 +117,18 @@ All verbs accept `--format=json` and return the envelope defined in SPEC §8. Wr
117
117
  | Verb | Purpose |
118
118
  |---|---|
119
119
  | `doctor` | 8 health checks; `ok: true` when clean |
120
- | `migrate-keys [--dry-run]` | Rename files whose basenames violate the strict key grammar |
120
+ | `key migrate [--dry-run]` | Rename files whose basenames violate the strict key grammar |
121
121
 
122
122
  **Scaffolding (human-only):**
123
123
 
124
124
  | Verb | Purpose |
125
125
  |---|---|
126
126
  | `init` | Scaffold a fresh `.textus/` |
127
- | `schema-init NAME` | Stub a schema |
128
- | `schema-diff NAME` | Compare a schema against entries that claim it |
129
- | `schema-migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
127
+ | `schema init NAME` | Stub a schema |
128
+ | `schema diff NAME` | Compare a schema against entries that claim it |
129
+ | `schema migrate NAME [--rename=OLD:NEW]` | Rewrite frontmatter keys across affected entries |
130
+
131
+ **Deprecated (removed in 0.6):** `mv`, `uid`, `migrate-keys`, `schema-init`, `schema-diff`, `schema-migrate`, `extensions`, `action`.
130
132
 
131
133
  ## Zones and roles
132
134
 
data/SPEC.md CHANGED
@@ -306,15 +306,19 @@ Proposed body content.
306
306
 
307
307
  ### 5.6 Audit log
308
308
 
309
- Every successful write appends one line to an append-only TSV file at `.textus/audit.log`. The file is opened with `flock(LOCK_EX)` for the duration of each append so concurrent writers serialize cleanly.
309
+ Every successful write appends one compact JSON object (NDJSON) to `.textus/audit.log`. The file is opened with `flock(LOCK_EX)` for the duration of each append so concurrent writers serialize cleanly.
310
310
 
311
- Schema (tab-separated, one record per line):
311
+ Schema (one JSON object per line, no interior whitespace):
312
312
 
313
+ ```json
314
+ {"ts":"<iso8601-utc>","role":"<role>","verb":"<verb>","key":"<key>","etag_before":<etag-or-null>,"etag_after":<etag-or-null>}
313
315
  ```
314
- <iso8601-utc>\t<role>\t<verb>\t<key>\t<etag-before-or-NULL>\t<etag-after-or-NULL>
315
- ```
316
316
 
317
- `<iso8601-utc>` is the wall-clock timestamp in UTC with second (or finer) precision. `<role>` is the resolved role for the invocation. `<verb>` is the CLI verb (`put`, `delete`, `accept`, `compute`, `migrate-keys`, `mv`, ...). `<key>` is the affected entry key. For `mv`, `<key>` is the **new** key, and an extras JSON column carries `from_key`, `to_key`, `from_path`, `to_path`, and `uid`. `<etag-before>` and `<etag-after>` are the entry etags before and after the write, or the literal string `NULL` when not applicable (e.g. create has no before-etag, delete has no after-etag). `migrate-keys --write` emits one line per renamed file using the new key as `<key>` and the file's pre- and post-rename etags.
317
+ `ts` is the wall-clock timestamp in UTC with second precision. `role` is the resolved role for the invocation. `verb` is the CLI verb (`put`, `delete`, `accept`, `compute`, `migrate-keys`, `mv`, ...). `key` is the affected entry key. `etag_before` and `etag_after` are the entry etags before and after the write, or JSON `null` when not applicable (e.g. create has no before-etag, delete has no after-etag). `migrate-keys --write` emits one line per renamed file using the new key as `key` and the file's pre- and post-rename etags.
318
+
319
+ For `mv`, the structural fields `from_key`, `to_key`, and `uid` appear at the top level of the JSON object. Remaining verb-specific data (e.g. `from_path`, `to_path`) is nested under an `extras` key. The `extras` key is omitted entirely when empty.
320
+
321
+ **Backward compatibility (v0.5):** files written by v0.4 and earlier contain TSV rows. Readers MUST accept mixed-format files: lines starting with `{` are parsed as JSON; other lines are treated as legacy TSV (`ts\trole\tverb\tkey\tetag_before\tetag_after[\tjson_extras]`). TSV write support is removed in v0.6.
318
322
 
319
323
  ### 5.7 Security bounds
320
324
 
@@ -355,7 +359,7 @@ evolution:
355
359
 
356
360
  **Backwards compat:** v1.0 schemas (no `fields:`, no `evolution:`) continue to parse and behave identically. `schema.maintained_by(field)` returns `nil` for every field; `schema.evolution` returns `{}`.
357
361
 
358
- **Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. This preserves human authority over AI/script-managed data — humans curating canon over AI-written embeddings is a feature, not a bug. All other role mismatches are reported by `validate-all` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
362
+ **Override rule:** the role `human` is permitted to write any `maintained_by` field, regardless of declared owner. This preserves human authority over AI/script-managed data — humans curating canon over AI-written embeddings is a feature, not a bug. All other role mismatches are reported by `doctor --check=schema_violations` with code `role_authority`, including fields `key`, `field`, `expected`, and `last_writer`.
359
363
 
360
364
  ### 5.9 Reducers (v1.2)
361
365
 
@@ -403,7 +407,7 @@ Lifecycle events fire in-process. Subscribers register via `Textus.hook(:event,
403
407
 
404
408
  `:refresh` with `change: :unchanged` does NOT fire — only `:created` and `:updated` are emitted. The `store:` kwarg is always a `Textus::StoreView` (§5.11).
405
409
 
406
- **Timeout and isolation.** Each hook runs under `Timeout.timeout(2)`. Hook errors and timeouts are recorded as `event_error` rows in `.textus/audit.log` (column 7, JSON-encoded extras with `event`, `hook`, `error`) but do NOT abort the triggering operation. The store write that fired the event is already committed by the time hooks run.
410
+ **Timeout and isolation.** Each hook runs under `Timeout.timeout(2)`. Hook errors and timeouts are recorded as `event_error` rows in `.textus/audit.log` (NDJSON with an `extras` object carrying `event`, `hook`, `error`) but do NOT abort the triggering operation. The store write that fired the event is already committed by the time hooks run.
407
411
 
408
412
  **Manifest declarations.** A manifest entry MAY declare external-runner hooks under an `events:` block, keyed by event name:
409
413
 
@@ -594,7 +598,7 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
594
598
  | `stale [--prefix=K] [--strict]` | read | any |
595
599
  | `deps K` / `rdeps K` | read | any |
596
600
  | `published` | read | any |
597
- | `validate-all` | read | any |
601
+ | `doctor --check=schema_violations` | read | any |
598
602
  | `put K --stdin --as=R [--action=NAME]` | write | per zone |
599
603
  | `delete K --if-etag=E --as=R` | write | per zone |
600
604
  | `refresh K --as=script` | write | per zone (typically `script`) |
@@ -606,7 +610,7 @@ All verbs accept `--format=json` and emit a canonical envelope (success or error
606
610
  | `mv OLD NEW [--as=R] [--dry-run]` | write | per zone (same-zone only) |
607
611
  | `uid K` | read | any |
608
612
  | `extensions list [--kind=action\|reducer\|hook]` | read | any |
609
- | `doctor [--format=json]` | read | any |
613
+ | `doctor [--check=NAME[,NAME]] [--format=json]` | read | any |
610
614
  | `intro [--format=json]` | read | any |
611
615
 
612
616
  **`put` input** (read from stdin when `--stdin` is given):
data/docs/architecture.md CHANGED
@@ -2,56 +2,91 @@
2
2
 
3
3
  How the reference Ruby implementation is organized. The wire protocol itself lives in [`../SPEC.md`](../SPEC.md); this document covers *how* the gem implements that spec.
4
4
 
5
- ## Layering
5
+ The codebase is a flat graph of small modules under one CLI dispatcher, not a strict pyramid. The clusters below describe what each module exists for and which other modules it talks to.
6
+
7
+ ## At a glance
6
8
 
7
9
  ```
8
- ┌──────────────────────────────────────────────┐
9
- │ exe/textus │ thin shim: load lib, call CLI.run
10
- ├──────────────────────────────────────────────┤
11
- │ Textus::CLI │ argv parsing, JSON I/O, exit codes
12
- ├──────────────────────────────────────────────┤
13
- Textus::Store │ verb implementations (get/put/list/…)
14
- ├───────────────┬───────────────┬──────────────┤
15
- │ Manifest │ Schema │ Entry │ parse/resolve, validate, (de)serialize
16
- ├───────────────┴───────────────┴──────────────┤
17
- Etag · Errors · version │ primitives
18
- └──────────────────────────────────────────────┘
10
+ exe/textus → Textus::CLI ──┬──► Store (verb impl: get/put/list/stale/refresh/accept/…)
11
+ ├──► Builder (build verb)
12
+ ├──► Refresh (refresh verb)
13
+ ├──► Doctor (doctor verb)
14
+ ├──► Init (init verb)
15
+ ├──► Intro (intro verb)
16
+ ├──► MigrateKeys (migrate-keys, mv verbs)
17
+ ├──► SchemaTools (schema-init/diff/migrate verbs)
18
+ ├──► StoreView (read-only projection over Store)
19
+ └──► Role (role gate)
19
20
  ```
20
21
 
21
- Each layer talks only to the layer below it. `Store` is the only class that touches the filesystem for read/write; `Manifest` and `Schema` read at load time and are otherwise pure.
22
+ CLI is the single entry point. It parses argv and dispatches each verb to whichever module owns that capability there is no single mediator below CLI.
23
+
24
+ ## Module clusters
25
+
26
+ ### 1. Request path — core read/write verbs
22
27
 
23
- ## Key resolution
28
+ `Store` (617 LOC) owns the `get`, `put`, `list`, `delete`, `stale`, and proposal-acceptance verbs. It is the largest module and the only one that touches the working-store filesystem for primary read/write. It uses:
24
29
 
25
- `Manifest#resolve(key)` does **longest-prefix match** against `entries[].key`. The matched entry's `path:` is the base; if `nested: true`, remaining dotted segments become `/`-joined subdirectories under that path and a `.md` is appended.
30
+ - **`Manifest`** parses `.textus/manifest.yaml`; resolves a dotted key to a path via longest-prefix match. `nested: true` entries treat unmatched suffix segments as `/`-joined subdirs, with `.md` appended. Resolution is path-only; existence is the verb's concern.
31
+ - **`Schema`** — loads YAML schema files; validates frontmatter shape and surfaces unknown-key warnings (the §6 forward-compat rule).
32
+ - **`Entry`** + format adapters (`entry/markdown.rb`, `entry/text.rb`, `entry/json.rb`, `entry/yaml.rb`) — splits raw bytes on `---\n`, feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). The frontmatter `name:` field is enforced against the file basename inside `Store` (on read and on write) — mismatch raises `bad_frontmatter`.
33
+ - **`Etag`** — `sha256:<hex>` over raw file bytes. `put` accepts optional `if_etag:`; mismatch raises `etag_mismatch`. No locking, no temp-file-and-rename — v1 leaves stronger guarantees to v1.x.
34
+ - **`Role`** — agent-vs-human gate. `Store#put` checks `ManifestEntry#agent_writable?` (true only for `state`) before doing anything else; otherwise raises `write_forbidden`.
35
+ - **`AuditLog`** — append-only NDJSON; every successful write emits one line.
36
+ - **`Proposal`** — `accept` verb flow for promoting a pending entry into its target zone.
37
+ - **`Dependencies`** — `deps`/`rdeps`/`published` verb backing; walks manifest declarations.
26
38
 
27
- Resolution is **path-only** — it does not check whether the file exists. Existence is the verb's concern: `get` raises `unknown_key` when the file is missing; `put` happily creates new files in nested entries.
39
+ ### 2. Build / publish pipeline
28
40
 
29
- ## Frontmatter parsing
41
+ Separate from the request path. Owns derived-entry materialization and byte-copy publish.
42
+
43
+ ```
44
+ Builder ──► Projection ──► Mustache ──► Entry ──► Publisher ──► (sentinel)
45
+ ```
30
46
 
31
- `Entry.parse` splits raw bytes on `---\n` boundaries and feeds the YAML chunk to `YAML.safe_load` (no aliases, restricted classes). Unknown top-level frontmatter keys are not rejected here they are surfaced as warnings by `Schema#validate!`. This is the forward-compat rule from §6 of the spec.
47
+ - **`Builder`** iterates `zone: derived` entries, materializes each by running its declared template + projection, parses the rendered output back through `Entry`, and hands the bytes to `Publisher`.
48
+ - **`Projection`** — collects rows from manifest-declared source keys, applies optional reducer, sorts and positions. Pure data shaping.
49
+ - **`Mustache`** — minimal mustache renderer for templates in `.textus/templates/`.
50
+ - **`Publisher`** — byte-copy from store path to external target path. Refuses to overwrite unmanaged targets; writes a sentinel in `.textus/sentinels/` to track managed targets.
32
51
 
33
- The frontmatter `name:` field is enforced against the file basename inside `Store` (both on read and on write) so a misnamed file or a mistyped `name:` in `put` payload fails fast with `bad_frontmatter`.
52
+ ### 3. Extension surface
34
53
 
35
- ## Zone enforcement
54
+ Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
36
55
 
37
- Three zones (`fixed`, `state`, `derived`) declared per-entry in the manifest. `Store#put` checks `ManifestEntry#agent_writable?` (true only for `state`) before doing anything else and raises `write_forbidden` otherwise. Zone semantics live in the manifest, not directory names — a project can rename `state/` to whatever it wants.
56
+ - **`Extensions`** declarative manifest schema for action/reducer/hook/doctor_check extensions.
57
+ - **`ExtensionRegistry`** — loads one `.rb` per extension from `.textus/extensions/`, registers callables under their declared names.
58
+ - **`BuiltinActions`** — ships built-in actions (e.g. json, csv, ical-events, rss) available without user extensions.
59
+ - **`Refresh`** — `refresh` verb: looks up the action for a key, invokes it, normalizes the result by declared format, writes through `Store` with an etag check.
38
60
 
39
- ## ETag and concurrency
61
+ ### 4. Operational tooling
40
62
 
41
- `Etag.for_bytes` returns `sha256:<hex>` over the raw file bytes. `put` accepts an optional `if_etag:` — if provided and the on-disk file's etag differs, `etag_mismatch` is raised. No locking, no temp-file-and-rename the v1 spec leaves stronger guarantees to v1.x (§14 open question).
63
+ First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; side modules off CLI.
42
64
 
43
- ## Staleness (the dataflow oracle)
65
+ - **`Doctor`** — `doctor` verb: validates manifest, schemas, extensions, and (via `MigrateKeys`) suggests key migrations. Talks to Manifest/Schema/Entry/ExtensionRegistry directly.
66
+ - **`MigrateKeys`** — `migrate-keys` and `mv` verbs; computes renames against the manifest.
67
+ - **`SchemaTools`** — `schema-init`, `schema-diff`, `schema-migrate` verbs.
68
+ - **`Init`** — `init` verb: scaffolds `.textus/` with the five zone directories, baseline schemas, empty audit log, starter manifest.
69
+ - **`Intro`** — `intro` verb: emits the human/agent-facing onboarding payload.
70
+ - **`StoreView`** — read-only projection over `Store` for code that should not mutate.
71
+ - **`KeyDistance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
44
72
 
45
- `Store#stale` walks every `zone: derived` entry that declares a `generator:` block, reads its `generated.at` frontmatter timestamp, and compares against each `generator.sources` entry's current mtime. Returns the offenders **and their declared `command`**; it does **not** execute anything. This is the core "dataflow oracle, not executor" boundary from §5.1 of the spec.
73
+ ### 5. Primitives
46
74
 
47
- Sources are heuristically classified: a string matching the textus key grammar with no `/` is treated as a textus key (and enumerated via the manifest); anything else is treated as a repo-relative path.
75
+ - **`Errors`** `Textus::Error` subclasses, each carrying a stable `code`, a `details` hash, and an `exit_code`. `CLI` catches them at the top level and emits the §8 error envelope on stdout. In `--format=json` mode, errors are **never** written to stderr — agents read stdout.
76
+ - **`version`** — gem semver string (independent of the wire protocol `textus/1`).
48
77
 
49
- ## Errors → envelopes
78
+ ## Invariants
50
79
 
51
- All `Textus::Error` subclasses carry a stable error `code`, a `details` hash, and an `exit_code`. `CLI` catches them at the top level and emits the §8 error envelope on stdout; the exit code matches the §8 table. Errors are never written to stderr in `--format=json` mode — agents read stdout.
80
+ - **CLI is the only entry point.** No public API surface guarantees outside the verbs CLI exposes.
81
+ - **Manifest is pure.** Reads at load, no mutation.
82
+ - **Store is the only module that writes to working-store entry files.** Init, MigrateKeys, Publisher, Builder, AuditLog write to **other** parts of `.textus/` (scaffolding, sentinels, audit log, derived targets) — they do not edit existing entry files behind Store's back.
83
+ - **`name:` frontmatter matches file basename.** Enforced on read and write.
84
+ - **Zone semantics live in the manifest, not in directory names.** A project may rename `state/` to anything; the manifest declares which zone each entry belongs to.
85
+ - **`stale` does not execute anything.** It walks `zone: derived` entries with a `generator:` block, compares `generated.at` against source mtimes, and returns offenders **plus their declared `command`**. Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
52
86
 
53
87
  ## What this implementation deliberately leaves out
54
88
 
55
89
  - **No process spawning.** Even `stale` does not execute. Build runners do that.
56
90
  - **No transport.** No HTTP server, no socket, no MCP server in this gem. Those are downstream wrappers (see [`./conventions.md`](./conventions.md)).
57
91
  - **No indexes.** Listing walks the filesystem each time. Premature optimisation for v1.
92
+ - **No locking.** Etag is advisory; concurrent writers can still race. Left to v1.x (§14 open question).
@@ -10,23 +10,58 @@ module Textus
10
10
  def last_writer_for(key)
11
11
  return nil unless File.exist?(@path)
12
12
 
13
- File.foreach(@path).map { |l| l.chomp.split("\t") }
14
- .select { |row| row[3] == key && %w[put delete].include?(row[2]) }
15
- .last&.fetch(1)
13
+ last_role = nil
14
+ File.foreach(@path) do |line|
15
+ parsed = parse_row(line.chomp)
16
+ next unless parsed
17
+ next unless parsed["key"] == key
18
+ next unless %w[put delete].include?(parsed["verb"])
19
+
20
+ last_role = parsed["role"]
21
+ end
22
+ last_role
16
23
  end
17
24
 
18
25
  def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
19
- fields = [
20
- Time.now.utc.iso8601, role, verb, key,
21
- etag_before || "NULL",
22
- etag_after || "NULL"
23
- ]
24
- fields << JSON.generate(extras) if extras && !extras.empty?
25
- line = fields.join("\t") + "\n"
26
+ row = {
27
+ "ts" => Time.now.utc.iso8601,
28
+ "role" => role,
29
+ "verb" => verb,
30
+ "key" => key,
31
+ "etag_before" => etag_before,
32
+ "etag_after" => etag_after,
33
+ }
34
+
35
+ if extras.is_a?(Hash) && !extras.empty?
36
+ extras = extras.dup
37
+ %w[from_key to_key uid].each do |k|
38
+ row[k] = extras.delete(k) if extras.key?(k)
39
+ end
40
+ row["extras"] = extras unless extras.empty?
41
+ end
42
+
26
43
  File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
27
44
  f.flock(File::LOCK_EX)
28
- f.write(line)
45
+ f.write(JSON.generate(row) + "\n")
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def parse_row(line)
52
+ return nil if line.empty?
53
+
54
+ if line.start_with?("{")
55
+ JSON.parse(line)
56
+ else
57
+ # Legacy TSV: ts, role, verb, key, etag_before, etag_after [, json_extras]
58
+ fields = line.split("\t")
59
+ return nil if fields.length < 4
60
+
61
+ { "ts" => fields[0], "role" => fields[1], "verb" => fields[2], "key" => fields[3] }
29
62
  end
63
+ rescue JSON::ParserError
64
+ nil
30
65
  end
31
66
  end
32
67
  end
@@ -96,14 +96,14 @@ module Textus
96
96
  "from" => Array(mentry.projection&.fetch("select", nil)).compact,
97
97
  },
98
98
  }
99
- Entry.for_format("markdown").serialize(frontmatter: frontmatter, body: body)
99
+ Entry.for_format("markdown").serialize(meta: frontmatter, body: body)
100
100
  end
101
101
 
102
102
  # Text: projection -> template -> text.serialize(body). No frontmatter, no _meta.
103
103
  def build_text(mentry, data)
104
104
  data = data.merge("intro" => Intro.run(@store)) if mentry.inject_intro
105
105
  body = render_template!(mentry, data)
106
- Entry.for_format("text").serialize(frontmatter: {}, body: body)
106
+ Entry.for_format("text").serialize(meta: {}, body: body)
107
107
  end
108
108
 
109
109
  # JSON / YAML pipeline. Templateless = default; template = escape hatch.
@@ -127,7 +127,7 @@ module Textus
127
127
  end
128
128
 
129
129
  final = inject_meta(content, mentry)
130
- strategy.serialize(frontmatter: {}, body: "", content: final)
130
+ strategy.serialize(meta: {}, body: "", content: final)
131
131
  end
132
132
 
133
133
  def render_template!(mentry, data)
@@ -11,14 +11,14 @@ module Textus
11
11
  _ = store
12
12
  _ = args
13
13
  data = JSON.parse(config["bytes"].to_s)
14
- { frontmatter: {}, body: YAML.dump(data) }
14
+ { _meta: {}, body: YAML.dump(data) }
15
15
  end
16
16
 
17
17
  Textus.action(:csv) do |config:, store:, args:|
18
18
  _ = store
19
19
  _ = args
20
20
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
21
- { frontmatter: {}, body: YAML.dump(rows) }
21
+ { _meta: {}, body: YAML.dump(rows) }
22
22
  end
23
23
 
24
24
  Textus.action(:"markdown-links") do |config:, store:, args:|
@@ -27,7 +27,7 @@ module Textus
27
27
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
28
28
  { "text" => text, "href" => href }
29
29
  end
30
- { frontmatter: {}, body: YAML.dump(links) }
30
+ { _meta: {}, body: YAML.dump(links) }
31
31
  end
32
32
 
33
33
  Textus.action(:"ical-events") do |config:, store:, args:|
@@ -46,7 +46,7 @@ module Textus
46
46
  current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
47
47
  end
48
48
  end
49
- { frontmatter: {}, body: YAML.dump(events) }
49
+ { _meta: {}, body: YAML.dump(events) }
50
50
  end
51
51
 
52
52
  Textus.action(:rss) do |config:, store:, args:|
@@ -60,7 +60,7 @@ module Textus
60
60
  "pubDate" => item.elements["pubDate"]&.text,
61
61
  }
62
62
  end
63
- { frontmatter: {}, body: YAML.dump(items) }
63
+ { _meta: {}, body: YAML.dump(items) }
64
64
  end
65
65
  end
66
66
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
@@ -0,0 +1,13 @@
1
+ module Textus
2
+ class CLI
3
+ class Accept < Verb
4
+ option :as_flag, "--as=ROLE"
5
+
6
+ def call(store)
7
+ key = positional.shift or raise UsageError.new("accept requires a key")
8
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
9
+ emit(store.accept(key, as: role))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,51 @@
1
+ module Textus
2
+ class CLI
3
+ class Action < Verb
4
+ prepend DeprecatedAliasMixin
5
+
6
+ def self.deprecated_name = "action"
7
+ def self.replacement_path = "extension run"
8
+
9
+ def parse(argv)
10
+ @raw_argv = argv
11
+ end
12
+
13
+ def call(store)
14
+ name = @raw_argv.shift
15
+ raise UsageError.new("action requires a name") if name.nil?
16
+
17
+ as_flag = nil
18
+ args = {}
19
+ @raw_argv.each do |tok|
20
+ case tok
21
+ when /\A--as=(.+)\z/ then as_flag = ::Regexp.last_match(1)
22
+ when /\A--format=/ then next
23
+ when /\A--([\w-]+)=(.*)\z/ then args[::Regexp.last_match(1)] = ::Regexp.last_match(2)
24
+ else
25
+ raise UsageError.new("unknown arg to 'action #{name}': #{tok}")
26
+ end
27
+ end
28
+
29
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
30
+ callable = store.registry.action(name)
31
+ view = StoreView.new(store, writable: true, as: role)
32
+
33
+ begin
34
+ Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
35
+ callable.call(config: {}, store: view, args: args)
36
+ end
37
+ rescue Timeout::Error
38
+ raise UsageError.new(
39
+ "action '#{name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
40
+ )
41
+ rescue Textus::Error
42
+ raise
43
+ rescue StandardError => e
44
+ raise UsageError.new("action '#{name}' raised: #{e.class}: #{e.message}")
45
+ end
46
+
47
+ emit({ "action" => name, "ok" => true })
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ class CLI
3
+ class Build < Verb
4
+ option :prefix, "--prefix=K"
5
+
6
+ def call(store)
7
+ emit(Textus::Builder.new(store).build(prefix: prefix))
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,14 @@
1
+ module Textus
2
+ class CLI
3
+ class Delete < Verb
4
+ option :as_flag, "--as=ROLE"
5
+ option :if_etag, "--if-etag=E"
6
+
7
+ def call(store)
8
+ key = positional.shift or raise UsageError.new("delete requires a key")
9
+ role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
10
+ emit(store.delete(key, if_etag: if_etag, as: role))
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ class CLI
3
+ module DeprecatedAliasMixin
4
+ def self.prepended(base)
5
+ base.extend(ClassMethods)
6
+ end
7
+
8
+ module ClassMethods
9
+ def deprecated_name
10
+ raise NotImplementedError.new("#{self}.deprecated_name must be defined")
11
+ end
12
+
13
+ def replacement_path
14
+ raise NotImplementedError.new("#{self}.replacement_path must be defined")
15
+ end
16
+ end
17
+
18
+ attr_writer :deprecated_alias
19
+
20
+ def call(store)
21
+ if @deprecated_alias
22
+ @stderr.puts(
23
+ "textus: '#{self.class.deprecated_name}' is deprecated; " \
24
+ "use 'textus #{self.class.replacement_path}' instead. Removed in 0.6.",
25
+ )
26
+ end
27
+ super
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ class CLI
3
+ class Deps < Verb
4
+ def call(store)
5
+ key = positional.shift or raise UsageError.new("deps requires a key")
6
+ emit({ "key" => key, "deps" => store.deps(key) })
7
+ end
8
+ end
9
+ end
10
+ end