textus 0.10.0 → 0.10.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 198dc9a4561b79bf4da22a2f890caa5da2764b8d82b1665b506d6c4c8c0e3fe3
4
- data.tar.gz: dc5333c3605b7b05174f4b290fcb63260aad7ea089da900f0b3325cf3823d83c
3
+ metadata.gz: 37ec398fd3e4bc171cdd4de0452b16de423d899103b1b66c31411bc296260115
4
+ data.tar.gz: 47f2a2f213698adfa9f7ba8019d05b80d704aaf1de7ac6cf2a96ceb33642b966
5
5
  SHA512:
6
- metadata.gz: f9e08a7a3fc46732dcd9458114765a54e3f8d2aca08b3ead6b70ecfc2782c0e9fec5eec38fc933cef9582c5943db52973c8bb06e544c02afec468dc50bbc8797
7
- data.tar.gz: 9cb891ae4ce1d7583af7546e16e1f1a23d8d88af105430d31c2eb3fd4e25af6df2a60c5677c810a3d11f01582d7500a18d681b8996e9f2f5da1948e32ec2a39f
6
+ metadata.gz: 4cccb93db3f1e94f75a12ff4513c51d6c95177e2f530b94a8ba9220cefc9e0bce949acc187f5546a6c8c68a6ebf70e3d79a890ebe32e8c2e66831e10309ee4b0
7
+ data.tar.gz: af03c10214acc105d2d7a0921c486d926446b3f7d2763172ab6199c54322682d1a40aced0966054899067840686fedbe52cbf3e9d3714dbabae8693893149467
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,74 @@
1
+ # Textus architecture
2
+
3
+ ```
4
+ ┌─ Interface ────────────────────────────────────────────────┐
5
+ │ CLI verbs: ctx = Composition.context(store, role:) │
6
+ │ Composition.<use_case>(ctx).call(...) │
7
+ └──────────────────────┬─────────────────────────────────────┘
8
+
9
+ ┌─ Application ────────▼─────────────────────────────────────┐
10
+ │ Context (per-request: store, role, correlation, │
11
+ │ clock, dry_run; can_read?/can_write?) │
12
+ │ Composition (factory module) │
13
+ │ │
14
+ │ reads/get.rb writes/put.rb │
15
+ │ refresh/worker.rb writes/delete.rb │
16
+ │ refresh/orchestrator.rb writes/build.rb │
17
+ │ refresh/all.rb writes/accept.rb │
18
+ │ writes/publish.rb │
19
+ └──────────┬───────────────────────────────┬─────────────────┘
20
+ │ uses domain │ uses ports
21
+ ┌─ Domain ─▼─────────────────────────────────────────────────┐
22
+ │ Permission ← NEW: predicate, not Action │
23
+ │ Freshness::Policy Freshness::Verdict │
24
+ │ Freshness::Evaluator Action Outcome │
25
+ └──────────────────────────────────────────┬─────────────────┘
26
+ │ implements
27
+ ┌─ Infrastructure ─────────────────────────▼─────────────────┐
28
+ │ Store (pure adapter — exposes ports only) │
29
+ │ Reader#read_envelope Writer#write_envelope_… │
30
+ │ AuditLog, Staleness, Validator, Mover │
31
+ │ Manifest (incl. permission_for) │
32
+ │ Hooks::Registry EventBus Clock │
33
+ │ Refresh::Lock Refresh::Detached │
34
+ │ Infra::Publisher (file copy + sentinel) │
35
+ └────────────────────────────────────────────────────────────┘
36
+
37
+ Dependency rule: arrows point DOWN. Domain has zero outbound
38
+ imports. Application imports Domain + Infra (via ports).
39
+ ```
40
+
41
+ ## Read path (`store.get`)
42
+
43
+ 1. CLI verb (or any caller) invokes `store.get(key, as:)`.
44
+ 2. `Store#get` constructs `Reads::Get(store, orchestrator)` and calls `.call(key, as:)`.
45
+ 3. `Reads::Get#call` reads the envelope from disk via `store.reader.read_raw_envelope(key)`.
46
+ 4. Resolves `Manifest::Entry#policy` — a `Domain::Freshness::Policy` value.
47
+ 5. `Domain::Freshness::Evaluator.call(policy, envelope, now:)` returns a `Verdict`.
48
+ 6. If fresh → annotate envelope (`stale: false`, `refreshing: false`) and return.
49
+ 7. Otherwise `policy.decide(verdict) → Action` (data, not behavior).
50
+ 8. `Orchestrator.execute(action, key, as)` interprets the Action:
51
+ - `Action::Return` → `Outcome::Skipped`
52
+ - `Action::RefreshSync` → run Worker inline → `Refreshed | Failed`
53
+ - `Action::RefreshTimed(budget_ms:)` → race Worker thread vs budget; on timeout, kill thread, fire `:refresh_detached`, fork+detach child, return `Outcome::Detached`
54
+ 9. Map outcome → envelope annotations (`stale`, `refreshing`, `refresh_error`) and return.
55
+
56
+ ## Write path (`store.put`)
57
+
58
+ 1. CLI verb calls `ctx = Composition.context(store, role:)` then `Composition.writes_put(ctx).call(key, ...)`.
59
+ 2. `Writes::Put#call` checks `ctx.can_write?(zone)` — raises `write_forbidden` if denied.
60
+ 3. Delegates pure I/O to `Store::Writer#write_envelope_to_disk(key, ...)`.
61
+ 4. On success, fires `:put` event via the injected `Infra::EventBus`, including `correlation_id` from the Context.
62
+
63
+ The same pattern applies to `Writes::Delete`, `Writes::Build`, `Writes::Accept`, and `Writes::Publish`: each takes a `Context`, checks permissions at the use-case layer, then delegates raw I/O to the corresponding `Store::Writer` or `Infra::Publisher` primitive.
64
+
65
+ ## Refresh path (`textus refresh KEY`)
66
+
67
+ 1. CLI `Verb::Refresh` calls `Textus::Refresh.call(store, key, as:)`.
68
+ 2. That shim instantiates `Application::Refresh::Worker` and runs it.
69
+ 3. `Worker#run`:
70
+ - Resolves the manifest entry, looks up the `:intake` handler.
71
+ - Publishes `:refresh_began` via the injected `Infra::EventBus`.
72
+ - Invokes the handler under a 30s `Timeout.timeout` budget.
73
+ - On any error: publishes `:refresh_failed`, then re-raises (or wraps in `UsageError`).
74
+ - On success: normalizes the return shape, persists via `store.put`, publishes `:refreshed` (unless the etag is unchanged).
data/CHANGELOG.md CHANGED
@@ -8,6 +8,48 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
8
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
+ ## 0.10.2 — Doctor and store cleanup (2026-05-23)
12
+
13
+ Patch release. Internal cleanup: extracts `Store::Sentinel`, moves audit-log integrity into `Store::AuditLog`, surfaces previously-swallowed schema parse errors, and tidies two doctor checks. No CLI, wire-protocol, or behavioral changes for plugin authors. Sentinel JSON shape changes (repo-relative paths) are forward-compatible; legacy absolute paths are still read correctly.
14
+
15
+ ### Added
16
+
17
+ - `Textus::Store::Sentinel` value object owning the sentinel JSON shape (`source`/`target`/`sha256`/`mode`) and the on-disk path layout. Repo-relative paths on write; legacy absolute paths still accepted on read.
18
+ - `Textus::Store::AuditLog#verify_integrity` returns line-by-line integrity violations as `{lineno, reason, detail}` hashes.
19
+ - `Textus::Schema#unowned_fields` returns field names whose spec lacks `maintained_by`.
20
+ - New doctor check `schema_parse_error` (error level) surfaces YAML parse failures on `schemas/*.yaml`. Previously these were silently rescued in `UnownedSchemaFields`, leaving operators with no signal.
21
+
22
+ ### Changed
23
+
24
+ - `Infra::Publisher` delegates sentinel I/O to `Store::Sentinel`. The sentinel JSON now stores repo-relative `source`/`target` so example trees can be committed without leaking author paths.
25
+ - `Doctor::Check::Sentinels` delegates parse/orphan/drift detection to `Store::Sentinel`. Drops `rubocop:disable Metrics/BlockLength`.
26
+ - `Doctor::Check::AuditLog` delegates parsing to `Store::AuditLog#verify_integrity`. Drops `rubocop:disable Metrics/BlockLength`.
27
+ - `Doctor::Check::ManifestFiles` uses `Textus::Key::Path.resolve` instead of reimplementing leaf-path math.
28
+ - `Doctor::Check::UnownedSchemaFields` uses `Schema#unowned_fields` instead of reaching into `schema.fields` and the raw `maintained_by` Hash key.
29
+ - `examples/claude-plugin/.gitignore` no longer excludes `.textus/sentinels/`. The example's sentinels are now committed with repo-relative paths.
30
+
31
+ ### Documentation
32
+
33
+ - `SPEC.md` builtin doctor-check list updated to include `schema_parse_error`, and brings the prose up to date with three checks shipped in 0.9.x/0.10.0 that were missing from the list (`policy_ambiguity`, `handler_allowlist`, `legacy_intake_fields`).
34
+
35
+ ## 0.10.1 — Documentation refresh and spec hygiene (2026-05-22)
36
+
37
+ Lightweight maintenance release: documentation refresh plus spec-suite hygiene. No `lib/` changes; no CLI, wire-protocol, or behavioral changes.
38
+
39
+ ### Changed
40
+
41
+ - `docs/architecture.md` deleted. `ARCHITECTURE.md` is now the single source of truth for the layered architecture. Inbound links in `docs/zones.md`, `docs/events.md`, and `textus.gemspec` updated.
42
+ - `SPEC.md` examples and CLI snippets refer to the post-0.9.2 default zone names (`identity` / `inbox` / `review` / `output`) instead of the pre-rename `canon` / `intake` / `pending` / `derived`. Prose that explicitly explains the 0.9.2 rename — including the v0.1 back-compat manifest example and the zone-rename table — is preserved.
43
+ - `README.md` `refresh-stale` examples switched to `--zone=inbox`; the `cat .textus/zones/...` example points at the `output` zone.
44
+ - `ARCHITECTURE.md` layer diagram references `Infra::Publisher` instead of bare `Publisher`.
45
+ - `docs/zones.md` references `Textus::Infra::Publisher` instead of bare `Publisher`.
46
+
47
+ ### Testing
48
+
49
+ - Deleted redundant `spec/proposal_spec.rb` (the same `"2026-05-19-add-bob"` fixture is covered more thoroughly by `spec/application/writes/accept_spec.rb`, the canonical post-0.9.1 home for Application-layer write tests).
50
+ - Extracted `shared_context "textus_store_fixture"` into `spec/support/fixtures.rb`; 28 specs adopt it, replacing the repeated `let(:tmp)` / `let(:root)` / `after { FileUtils.remove_entry(tmp) }` triplet. `spec/spec_helper.rb` now autoloads `spec/support/**/*.rb`.
51
+ - Fixed an `instance_variable_set(:@intake_handler, nil)` anti-pattern in `spec/refresh_spec.rb` — the "no intake declared" case now uses a manifest entry that was never given an intake handler, instead of mutating a private ivar after construction.
52
+
11
53
  ## 0.10.0 — Shim removal, signal-based zone detection, Builder extraction (2026-05-22)
12
54
 
13
55
  ### Breaking — Ruby API
data/README.md CHANGED
@@ -77,7 +77,7 @@ For the full shape — Claude plugin with agents, skills, commands, pending walk
77
77
 
78
78
  ## What ships today
79
79
 
80
- - **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`).
80
+ - **Per-entry formats.** `format: markdown | json | yaml | text` on a manifest entry. `cat .textus/zones/output/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`).
81
81
  - **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.
82
82
  - **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.
83
83
  - **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.
@@ -186,9 +186,9 @@ end
186
186
  To keep a batch of stale intake entries current in one shot:
187
187
 
188
188
  ```sh
189
- textus refresh-stale --prefix=working --zone=intake --as=script
190
- # or just refresh everything stale in the intake zone:
191
- textus refresh-stale --zone=intake --as=script
189
+ textus refresh-stale --prefix=working --zone=inbox --as=script
190
+ # or just refresh everything stale in the inbox zone:
191
+ textus refresh-stale --zone=inbox --as=script
192
192
  ```
193
193
 
194
194
  The primitive `Textus.hook(event, name, **opts) { ... }` is also supported. See SPEC.md §5.10 for the full contract.
data/SPEC.md CHANGED
@@ -23,7 +23,7 @@ textus is organized as five composable layers. Each layer has a single responsib
23
23
  | Layer | Name | Responsibility |
24
24
  |---|---|---|
25
25
  | L1 | **Store** | Plain-file backend: `.textus/zones/<zone>/...` with YAML frontmatter + Markdown body, addressed by dotted keys, schema-validated, etag-versioned. |
26
- | L2 | **Sources** | Declared external inputs (`intake` zone): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external runners fetch and pipe results through `textus put`. |
26
+ | L2 | **Sources** | Declared external inputs (the `inbox` zone in the default scaffold; any zone writable by `script`): URLs, files, feeds with declared parsers and TTLs. textus *describes* sources; external runners fetch and pipe results through `textus put`. |
27
27
  | L3 | **Compute** | Pure transforms from store entries to derived entries. Projections (select/pluck/sort/limit/format) plus a vendored Mustache template subset. No shell execution. |
28
28
  | L4 | **Publish** | Byte-for-byte file copy from derived entries to repo-relative paths declared via `publish_to:`. The in-store artifact is the consumer-shaped output; the published file is an identical copy. A sentinel under `.textus/sentinels/<target-rel-path>.textus-managed.json` records the source, sha256, and `mode: "copy"`. |
29
29
  | L5 | **Consumers** | Anything that reads the published files or calls the CLI — editors, LLM tools, MCP servers, CI jobs, dashboards. textus is agnostic about who consumes; the envelope is the contract. |
@@ -213,11 +213,11 @@ Every successful write records the resolved role and a wall-clock timestamp in `
213
213
 
214
214
  ### 5.2 Compute layer (derived entries)
215
215
 
216
- Derived entries live in the `derived` zone. They are not authored by hand; their body is produced by projecting over other entries. A derived entry's frontmatter declares a `projection` block:
216
+ Derived entries live in a zone whose `writable_by:` list includes `build` — `output` in the default scaffold. They are not authored by hand; their body is produced by projecting over other entries. A derived entry's frontmatter declares a `projection` block:
217
217
 
218
218
  ```yaml
219
- - key: derived.catalogs.people
220
- zone: derived
219
+ - key: output.catalogs.people
220
+ zone: output
221
221
  projection:
222
222
  select: working.network.org # prefix OR [list of prefixes]
223
223
  pluck: [name, relationship, org]
@@ -250,7 +250,7 @@ publish_to:
250
250
  - .ai/instructions.md
251
251
  ```
252
252
 
253
- When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/derived/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
253
+ When the entry is recomputed, textus copies the in-store file byte-for-byte to each destination. The in-store artifact under `.textus/zones/<output-zone>/…` is already the consumer-shaped output (per the format strategy — see §5.x), so publish is a verbatim file copy with no parsing or stripping.
254
254
 
255
255
  A sentinel is written for each published file at `<store_root>/sentinels/<target-relative-to-repo>.textus-managed.json`, recording `source`, `target`, the target's sha256, and `mode: "copy"`. Sentinels live under the store rather than beside the consumer file so target directories stay clean. The sentinel exists so out-of-band edits can be detected on the next publish — textus refuses to clobber a destination that is not either missing or marked as managed. Legacy sibling sentinels (`<target>.textus-managed.json`) are still recognised as managed and are migrated to the new location on the next publish.
256
256
 
@@ -301,13 +301,13 @@ In intake mode the handler MUST return one of three shapes, all normalized by th
301
301
  **Refresh paths.** Two are supported:
302
302
 
303
303
  1. **In-process** — `textus refresh KEY --as=script` resolves the entry's `intake.handler`, invokes the registered `:intake` hook with `(config:, store:, args: {})`, and writes the result under role `script`.
304
- 2. **External runner** — a cron job or agent harness reads `textus list --zone=intake --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`. The CLI verb `textus refresh-stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
304
+ 2. **External runner** — a cron job or agent harness reads `textus list --zone=inbox --stale --format=json`, fetches the source out of band, and pipes bytes back through `textus put KEY --as=script --stdin`. The CLI verb `textus refresh-stale [--prefix=K] [--zone=Z]` drives this loop in one shot.
305
305
 
306
306
  Both paths share the same role gate, audit-log entry, and `:refreshed` event. User-supplied hooks live in `.textus/hooks/**/*.rb` and auto-load at `Store#initialize` — see §5.10 for the full hook contract.
307
307
 
308
308
  ### 5.5 Pending / accept workflow
309
309
 
310
- Pending entries are full proposal patches authored into the `pending` zone, typically by agents or scripts. A pending entry's frontmatter describes the patch it proposes against another zone:
310
+ Proposal entries are full patches authored into a zone whose `writable_by:` list includes `ai` `review` in the default scaffold — typically by agents or scripts. The entry's frontmatter describes the patch it proposes against another zone:
311
311
 
312
312
  ```yaml
313
313
  ---
@@ -324,7 +324,7 @@ Proposed body content.
324
324
 
325
325
  `proposal.target_key` names the entry the patch would create or modify, and `proposal.action` is `put` or `delete`. The remaining frontmatter and body are the proposed new content.
326
326
 
327
- `textus accept <pending-key>` is **human-only**: the resolved role must be `human`. It copies the patch into the target zone, records provenance (originating pending key, original role, original timestamp) in the audit log, and removes the pending entry. Agents and scripts can propose but cannot accept.
327
+ `textus accept <proposal-key>` is **human-only**: the resolved role must be `human`. It copies the patch into the target zone, records provenance (originating proposal key, original role, original timestamp) in the audit log, and removes the proposal entry. Agents and scripts can propose but cannot accept.
328
328
 
329
329
  ### 5.6 Audit log
330
330
 
@@ -693,7 +693,7 @@ Every `Textus::Error` exposes `code`, `message`, and an optional `hint:`. The hi
693
693
 
694
694
  ## 10.2 `textus doctor`
695
695
 
696
- `textus doctor` returns a health-check envelope: `{ "protocol": "textus/2", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `manifest_files`, `schemas`, `templates`, `hooks`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`. Additional registered `:check` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
696
+ `textus doctor` returns a health-check envelope: `{ "protocol": "textus/2", "ok": bool, "issues": [...], "summary": {error, warning, info} }`. Each issue carries `code`, `level` (`error|warning|info`), `subject`, `message`, and optionally `fix`. `ok` is true iff no error-level issues are present; warnings and info do not flip the bit. Builtin checks: `manifest_files`, `schemas`, `schema_parse_error`, `templates`, `hooks`, `illegal_keys`, `sentinels`, `audit_log`, `unowned_schema_fields`, `schema_violations`, `policy_ambiguity`, `handler_allowlist`, `legacy_intake_fields`. Additional registered `:check` hooks (§5.10) run after the builtin set. Exit code is 0 on `ok`, 1 otherwise.
697
697
 
698
698
  ## 11. Versioning
699
699
 
data/docs/conventions.md CHANGED
@@ -18,7 +18,7 @@ Recommended top-level layout — the spec allows alternatives, but this is what
18
18
  manifest.yaml
19
19
  schemas/ # YAML schema definitions
20
20
  zones/
21
- identity/ # identity, voice, canon — humans only
21
+ identity/ # identity, voice, slow-changing facts — humans only
22
22
  working/ # agent-writable working memory
23
23
  inbox/ # script-fed external inputs
24
24
  review/ # AI proposals awaiting accept
@@ -1,48 +1,32 @@
1
- require "json"
2
-
3
1
  module Textus
4
2
  module Doctor
5
3
  class Check
6
4
  class AuditLog < Check
7
5
  def call
8
- out = []
9
6
  path = File.join(store.root, "audit.log")
10
- return out unless File.exist?(path)
11
-
12
- File.foreach(path).with_index(1) do |line, lineno| # rubocop:disable Metrics/BlockLength
13
- stripped = line.chomp
14
- next if stripped.empty?
7
+ Textus::Store::AuditLog.new(store.root).verify_integrity.map do |v|
8
+ {
9
+ "code" => "audit.parse_error",
10
+ "level" => "warning",
11
+ "subject" => "#{path}:#{v["lineno"]}",
12
+ "message" => violation_message(v),
13
+ "fix" => "inspect #{path} at line #{v["lineno"]} and remove the corrupted row",
14
+ }
15
+ end
16
+ end
15
17
 
16
- if stripped.start_with?("{")
17
- begin
18
- JSON.parse(stripped)
19
- rescue JSON::ParserError => e
20
- out << {
21
- "code" => "audit.parse_error",
22
- "level" => "warning",
23
- "subject" => "#{path}:#{lineno}",
24
- "message" => "audit log line #{lineno} is invalid JSON: #{e.message}",
25
- "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
26
- }
27
- end
28
- else
29
- # Legacy TSV (pre-0.5): read-only support retained for on-disk logs
30
- # written by older textus versions. Never written by current code.
31
- # Minimum 6 fields.
32
- fields = stripped.split("\t")
33
- next if fields.length >= 6
18
+ private
34
19
 
35
- out << {
36
- "code" => "audit.parse_error",
37
- "level" => "warning",
38
- "subject" => "#{path}:#{lineno}",
39
- "message" => "audit log line #{lineno} has #{fields.length} fields " \
40
- "(expected >=6 for legacy TSV; consider migrating to NDJSON)",
41
- "fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
42
- }
43
- end
20
+ def violation_message(v)
21
+ case v["reason"]
22
+ when "invalid_json"
23
+ "audit log line #{v["lineno"]} is invalid JSON: #{v["detail"]}"
24
+ when "short_tsv"
25
+ "audit log line #{v["lineno"]} #{v["detail"]} " \
26
+ "(consider migrating to NDJSON)"
27
+ else
28
+ v["detail"]
44
29
  end
45
- out
46
30
  end
47
31
  end
48
32
  end
@@ -3,11 +3,10 @@ module Textus
3
3
  class Check
4
4
  class ManifestFiles < Check
5
5
  def call
6
- out = []
7
- store.manifest.entries.each do |entry|
6
+ store.manifest.entries.each_with_object([]) do |entry, out|
8
7
  next if entry.nested
9
8
 
10
- path = leaf_path_for(entry)
9
+ path = Textus::Key::Path.resolve(store.manifest, entry)
11
10
  next if File.exist?(path)
12
11
 
13
12
  out << {
@@ -19,18 +18,6 @@ module Textus
19
18
  "(or leave empty if not yet authored)",
20
19
  }
21
20
  end
22
- out
23
- end
24
-
25
- private
26
-
27
- def leaf_path_for(entry)
28
- primary_ext = Entry.for_format(entry.format).extensions.first
29
- if File.extname(entry.path) == ""
30
- File.join(store.root, "zones", entry.path + primary_ext)
31
- else
32
- File.join(store.root, "zones", entry.path)
33
- end
34
21
  end
35
22
  end
36
23
  end
@@ -0,0 +1,28 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Surfaces YAML parse failures for files in <store>/schemas/. Without
5
+ # this check, malformed schemas are silently skipped by other doctor
6
+ # checks (UnownedSchemaFields rescues, Schemas only checks filenames),
7
+ # leaving the operator with no signal that a schema is broken.
8
+ class SchemaParseError < Check
9
+ def call
10
+ dir = File.join(store.root, "schemas")
11
+ return [] unless File.directory?(dir)
12
+
13
+ Dir.glob(File.join(dir, "*.yaml")).each_with_object([]) do |path, out|
14
+ Schema.load(path)
15
+ rescue StandardError => e
16
+ out << {
17
+ "code" => "schema.parse_error",
18
+ "level" => "error",
19
+ "subject" => path,
20
+ "message" => "schema failed to parse: #{e.class}: #{e.message}",
21
+ "fix" => "fix the YAML at #{path} (check indentation, quoted scalars, and aliases)",
22
+ }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -1,55 +1,56 @@
1
- require "digest"
2
- require "json"
3
-
4
1
  module Textus
5
2
  module Doctor
6
3
  class Check
7
4
  class Sentinels < Check
8
5
  def call
9
- out = []
10
6
  dir = File.join(store.root, "sentinels")
11
- return out unless File.directory?(dir)
7
+ return [] unless File.directory?(dir)
12
8
 
13
- Dir.glob(File.join(dir, "**", "*.textus-managed.json")).each do |sp| # rubocop:disable Metrics/BlockLength
14
- begin
15
- data = JSON.parse(File.read(sp))
16
- rescue JSON::ParserError => e
17
- out << {
18
- "code" => "sentinel.parse_error",
19
- "level" => "warning",
20
- "subject" => sp,
21
- "message" => "sentinel is not valid JSON: #{e.message}",
22
- "fix" => "delete #{sp} and re-run 'textus build' to regenerate",
23
- }
24
- next
25
- end
9
+ repo_root = File.dirname(store.root)
10
+ Dir.glob(File.join(dir, "**", "*#{Textus::Store::Sentinel::SUFFIX}")).flat_map do |sentinel_path|
11
+ inspect_sentinel(sentinel_path, repo_root)
12
+ end
13
+ end
26
14
 
27
- target = data["target"]
28
- recorded_sha = data["sha256"]
15
+ private
29
16
 
30
- if target.nil? || !File.exist?(target)
31
- out << {
32
- "code" => "sentinel.orphan",
33
- "level" => "warning",
34
- "subject" => sp,
35
- "message" => "sentinel target #{target.inspect} no longer exists",
36
- "fix" => "delete #{sp} (the published file is gone) or restore the target",
37
- }
38
- next
39
- end
17
+ def inspect_sentinel(sentinel_path, repo_root)
18
+ sentinel = Textus::Store::Sentinel.load(sentinel_path, repo_root)
19
+ return [parse_error_issue(sentinel_path)] if sentinel.nil?
20
+ return [orphan_issue(sentinel_path, sentinel)] if sentinel.orphan?
21
+ return [drift_issue(sentinel)] if sentinel.drift?
40
22
 
41
- current_sha = Digest::SHA256.hexdigest(File.binread(target))
42
- next if recorded_sha.nil? || current_sha == recorded_sha
23
+ []
24
+ end
43
25
 
44
- out << {
45
- "code" => "sentinel.drift",
46
- "level" => "warning",
47
- "subject" => target,
48
- "message" => "published file at #{target} was modified out-of-band",
49
- "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
50
- }
51
- end
52
- out
26
+ def parse_error_issue(sentinel_path)
27
+ {
28
+ "code" => "sentinel.parse_error",
29
+ "level" => "warning",
30
+ "subject" => sentinel_path,
31
+ "message" => "sentinel is not valid JSON",
32
+ "fix" => "delete #{sentinel_path} and re-run 'textus build' to regenerate",
33
+ }
34
+ end
35
+
36
+ def orphan_issue(sentinel_path, sentinel)
37
+ {
38
+ "code" => "sentinel.orphan",
39
+ "level" => "warning",
40
+ "subject" => sentinel_path,
41
+ "message" => "sentinel target #{sentinel.target.inspect} no longer exists",
42
+ "fix" => "delete #{sentinel_path} (the published file is gone) or restore the target",
43
+ }
44
+ end
45
+
46
+ def drift_issue(sentinel)
47
+ {
48
+ "code" => "sentinel.drift",
49
+ "level" => "warning",
50
+ "subject" => sentinel.target,
51
+ "message" => "published file at #{sentinel.target} was modified out-of-band",
52
+ "fix" => "re-run 'textus build' to overwrite, or copy the manual edit back into the store source",
53
+ }
53
54
  end
54
55
  end
55
56
  end
@@ -3,30 +3,36 @@ module Textus
3
3
  class Check
4
4
  class UnownedSchemaFields < Check
5
5
  def call
6
- out = []
7
6
  dir = File.join(store.root, "schemas")
8
- return out unless File.directory?(dir)
7
+ return [] unless File.directory?(dir)
9
8
 
10
- Dir.glob(File.join(dir, "*.yaml")).sort.each do |sp| # rubocop:disable Lint/RedundantDirGlobSort
11
- schema = begin
12
- Schema.load(sp)
13
- rescue StandardError
14
- next
15
- end
16
- unowned = schema.fields.each_with_object([]) do |(name, spec), acc|
17
- acc << name if spec.is_a?(Hash) && spec["maintained_by"].nil?
18
- end
19
- next if unowned.empty?
20
-
21
- out << {
22
- "code" => "schema.unowned_fields",
23
- "level" => "info",
24
- "subject" => schema.name || File.basename(sp, ".yaml"),
25
- "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
26
- "fix" => "add 'maintained_by: <role>' to each field in #{sp} (optional but recommended)",
27
- }
9
+ Dir.glob(File.join(dir, "*.yaml")).flat_map do |path|
10
+ issues_for(path)
28
11
  end
29
- out
12
+ end
13
+
14
+ private
15
+
16
+ def issues_for(path)
17
+ schema = safe_load(path)
18
+ return [] if schema.nil?
19
+
20
+ unowned = schema.unowned_fields
21
+ return [] if unowned.empty?
22
+
23
+ [{
24
+ "code" => "schema.unowned_fields",
25
+ "level" => "info",
26
+ "subject" => schema.name || File.basename(path, ".yaml"),
27
+ "message" => "schema has fields without maintained_by: #{unowned.join(", ")}",
28
+ "fix" => "add 'maintained_by: <role>' to each field in #{path} (optional but recommended)",
29
+ }]
30
+ end
31
+
32
+ def safe_load(path)
33
+ Schema.load(path)
34
+ rescue StandardError
35
+ nil
30
36
  end
31
37
  end
32
38
  end
data/lib/textus/doctor.rb CHANGED
@@ -11,6 +11,7 @@ module Textus
11
11
  CHECKS = [
12
12
  Check::ManifestFiles,
13
13
  Check::Schemas,
14
+ Check::SchemaParseError,
14
15
  Check::Templates,
15
16
  Check::Hooks,
16
17
  Check::IntakeRegistration,
@@ -1,24 +1,21 @@
1
- require "json"
2
- require "digest"
3
1
  require "fileutils"
4
2
 
5
3
  module Textus
6
4
  module Infra
7
5
  # Publishes built artifacts from the store to repo-relative consumer paths.
8
6
  # Publish = copy + sentinel. The in-store file is already the consumer-shaped
9
- # artifact; no parsing or stripping. Sentinels live under
7
+ # artifact; no parsing or stripping.
8
+ #
9
+ # Sentinel I/O is delegated to Store::Sentinel. Sentinels live under
10
10
  # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
11
11
  # consumer directories aren't polluted with `.textus-managed.json` siblings.
12
12
  module Publisher
13
- SENTINEL_SUFFIX = ".textus-managed.json".freeze
14
- SENTINEL_DIR = "sentinels".freeze
15
-
16
13
  def self.publish(source:, target:, store_root:)
17
14
  FileUtils.mkdir_p(File.dirname(target))
18
15
  refuse_if_unmanaged(target, store_root)
19
16
  File.delete(target) if File.symlink?(target)
20
17
  FileUtils.cp(source, target)
21
- write_sentinel(target, store_root: store_root, source: source)
18
+ Store::Sentinel.write!(target: target, source: source, store_root: store_root)
22
19
  cleanup_legacy_sentinel(target)
23
20
  end
24
21
 
@@ -30,43 +27,12 @@ module Textus
30
27
  end
31
28
 
32
29
  def self.managed?(target, store_root)
33
- File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
34
- end
35
-
36
- def self.write_sentinel(target, store_root:, source:)
37
- path = sentinel_path(target, store_root)
38
- FileUtils.mkdir_p(File.dirname(path))
39
- File.write(path, JSON.generate(
40
- "source" => source,
41
- "target" => target,
42
- "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
43
- "mode" => "copy",
44
- ))
45
- end
46
-
47
- # Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
48
- # The full target extension is preserved so a marketplace.json and
49
- # marketplace.yaml don't collide.
50
- def self.sentinel_path(target, store_root)
51
- repo_root = File.dirname(store_root)
52
- rel = relative_to(target, repo_root) || File.basename(target)
53
- File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
54
- end
55
-
56
- def self.legacy_sentinel_path(target)
57
- target + SENTINEL_SUFFIX
30
+ File.exist?(Store::Sentinel.sentinel_path(target, store_root)) ||
31
+ File.exist?(Store::Sentinel.legacy_path(target))
58
32
  end
59
33
 
60
34
  def self.cleanup_legacy_sentinel(target)
61
- FileUtils.rm_f(legacy_sentinel_path(target))
62
- end
63
-
64
- def self.relative_to(path, base)
65
- path = File.expand_path(path)
66
- base = File.expand_path(base)
67
- return nil unless path.start_with?(base + File::SEPARATOR)
68
-
69
- path[(base.length + 1)..]
35
+ FileUtils.rm_f(Store::Sentinel.legacy_path(target))
70
36
  end
71
37
  end
72
38
  end
data/lib/textus/schema.rb CHANGED
@@ -26,6 +26,16 @@ module Textus
26
26
  meta["maintained_by"]
27
27
  end
28
28
 
29
+ # Returns the list of field names whose spec is a Hash but lacks the
30
+ # 'maintained_by' key. Used by Doctor::Check::UnownedSchemaFields.
31
+ def unowned_fields
32
+ @fields.each_with_object([]) do |(name, spec), acc|
33
+ next unless spec.is_a?(Hash)
34
+
35
+ acc << name if spec["maintained_by"].nil?
36
+ end
37
+ end
38
+
29
39
  def evolution
30
40
  raw = @raw["evolution"] || {}
31
41
  raw.each_with_object({}) do |(k, v), h|
@@ -47,6 +47,20 @@ module Textus
47
47
  end
48
48
  end
49
49
 
50
+ # Returns an array of integrity-violation descriptors for the on-disk log.
51
+ # Each entry is { "lineno" => Integer, "reason" => String, "detail" => String }.
52
+ # Empty array means the log is well-formed (or doesn't exist yet).
53
+ def verify_integrity
54
+ return [] unless File.exist?(@path)
55
+
56
+ out = []
57
+ File.foreach(@path).with_index(1) do |line, lineno|
58
+ violation = check_line(line.chomp, lineno)
59
+ out << violation if violation
60
+ end
61
+ out
62
+ end
63
+
50
64
  private
51
65
 
52
66
  def parse_row(line)
@@ -66,6 +80,30 @@ module Textus
66
80
  rescue JSON::ParserError
67
81
  nil
68
82
  end
83
+
84
+ def check_line(stripped, lineno)
85
+ return nil if stripped.empty?
86
+
87
+ if stripped.start_with?("{")
88
+ begin
89
+ JSON.parse(stripped)
90
+ nil
91
+ rescue JSON::ParserError => e
92
+ { "lineno" => lineno, "reason" => "invalid_json", "detail" => e.message }
93
+ end
94
+ else
95
+ # parse_row accepts >= 4 fields for read-compat; integrity requires
96
+ # all 6 data columns of the legacy TSV format.
97
+ fields = stripped.split("\t")
98
+ return nil if fields.length >= 6
99
+
100
+ {
101
+ "lineno" => lineno,
102
+ "reason" => "short_tsv",
103
+ "detail" => "legacy TSV row has #{fields.length} fields (expected >= 6)",
104
+ }
105
+ end
106
+ end
69
107
  end
70
108
  end
71
109
  end
@@ -0,0 +1,93 @@
1
+ require "json"
2
+ require "digest"
3
+ require "fileutils"
4
+
5
+ module Textus
6
+ class Store
7
+ # Value object for sentinel files written by Infra::Publisher and inspected
8
+ # by Doctor::Check::Sentinels. Owns the JSON shape ({source, target,
9
+ # sha256, mode}) and the on-disk path layout (<store_root>/sentinels/
10
+ # <target-rel-to-repo>.textus-managed.json).
11
+ #
12
+ # Repo-relative target/source on write so example trees can be committed
13
+ # without leaking the author's absolute filesystem paths. Legacy absolute
14
+ # paths are still accepted on read.
15
+ class Sentinel
16
+ SUFFIX = ".textus-managed.json".freeze
17
+ DIR = "sentinels".freeze
18
+
19
+ attr_reader :target, :source, :sha256, :mode
20
+
21
+ def self.write!(target:, source:, store_root:)
22
+ path = sentinel_path(target, store_root)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ repo_root = File.dirname(store_root)
25
+ File.write(path, JSON.generate(
26
+ "source" => rel_or_abs(source, repo_root),
27
+ "target" => rel_or_abs(target, repo_root),
28
+ "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
29
+ "mode" => "copy",
30
+ ))
31
+ end
32
+
33
+ def self.load(path, repo_root)
34
+ raw = JSON.parse(File.read(path))
35
+ new(
36
+ target: absolutize(raw["target"], repo_root),
37
+ source: absolutize(raw["source"], repo_root),
38
+ sha256: raw["sha256"],
39
+ mode: raw["mode"],
40
+ )
41
+ rescue JSON::ParserError, Errno::ENOENT
42
+ nil
43
+ end
44
+
45
+ def self.sentinel_path(target, store_root)
46
+ repo_root = File.dirname(store_root)
47
+ rel = relative_to(target, repo_root) || File.basename(target)
48
+ File.join(store_root, DIR, rel + SUFFIX)
49
+ end
50
+
51
+ def self.legacy_path(target)
52
+ target + SUFFIX
53
+ end
54
+
55
+ def self.rel_or_abs(path, repo_root)
56
+ relative_to(path, repo_root) || File.expand_path(path)
57
+ end
58
+
59
+ def self.relative_to(path, repo_root)
60
+ path = File.expand_path(path)
61
+ base = File.expand_path(repo_root)
62
+ return nil unless path.start_with?(base + File::SEPARATOR)
63
+
64
+ path[(base.length + 1)..]
65
+ end
66
+
67
+ def self.absolutize(path, repo_root)
68
+ return path if path.nil?
69
+ return path if File.absolute_path?(path)
70
+
71
+ File.expand_path(path, repo_root)
72
+ end
73
+
74
+ def initialize(target:, source:, sha256:, mode:)
75
+ @target = target
76
+ @source = source
77
+ @sha256 = sha256
78
+ @mode = mode
79
+ end
80
+
81
+ def orphan?
82
+ @target.nil? || !File.exist?(@target)
83
+ end
84
+
85
+ def drift?
86
+ return false if orphan?
87
+ return false if @sha256.nil?
88
+
89
+ Digest::SHA256.hexdigest(File.binread(@target)) != @sha256
90
+ end
91
+ end
92
+ end
93
+ end
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.10.0"
2
+ VERSION = "0.10.2"
3
3
  PROTOCOL = "textus/2"
4
4
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.0
4
+ version: 0.10.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -102,10 +102,10 @@ executables:
102
102
  extensions: []
103
103
  extra_rdoc_files: []
104
104
  files:
105
+ - ARCHITECTURE.md
105
106
  - CHANGELOG.md
106
107
  - README.md
107
108
  - SPEC.md
108
- - docs/architecture.md
109
109
  - docs/conventions.md
110
110
  - exe/textus
111
111
  - lib/textus.rb
@@ -178,6 +178,7 @@ files:
178
178
  - lib/textus/doctor/check/legacy_intake_fields.rb
179
179
  - lib/textus/doctor/check/manifest_files.rb
180
180
  - lib/textus/doctor/check/policy_ambiguity.rb
181
+ - lib/textus/doctor/check/schema_parse_error.rb
181
182
  - lib/textus/doctor/check/schema_violations.rb
182
183
  - lib/textus/doctor/check/schemas.rb
183
184
  - lib/textus/doctor/check/sentinels.rb
@@ -233,6 +234,7 @@ files:
233
234
  - lib/textus/store/audit_log.rb
234
235
  - lib/textus/store/mover.rb
235
236
  - lib/textus/store/reader.rb
237
+ - lib/textus/store/sentinel.rb
236
238
  - lib/textus/store/staleness.rb
237
239
  - lib/textus/store/validator.rb
238
240
  - lib/textus/store/writer.rb
data/docs/architecture.md DELETED
@@ -1,129 +0,0 @@
1
- # Architecture
2
-
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
-
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
8
-
9
- ```
10
- exe/textus → Textus::CLI ──┬──► Store (facade — delegates to Reader/Writer/Mover)
11
- │ ├──► Store::Reader (get/list/where/uid/deps/published/stale/validate_all)
12
- │ ├──► Store::Writer (put/delete/accept)
13
- │ ├──► Store::Mover (mv)
14
- │ └──► Hooks::Dispatcher (lifecycle publish/subscribe)
15
- ├──► Builder (build verb — Pipeline + per-format renderers)
16
- ├──► Refresh (refresh verb)
17
- ├──► Doctor (doctor verb)
18
- ├──► Init (init verb)
19
- ├──► Intro (intro verb)
20
- ├──► MigrateKeys (key migrate, key mv verbs)
21
- ├──► Schema::Tools (schema init/diff/migrate verbs)
22
- ├──► Store::View (read-only projection over Store::Reader)
23
- └──► Role (role gate)
24
- ```
25
-
26
- 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.
27
-
28
- ## Module clusters
29
-
30
- ### 1. Request path — core read/write verbs
31
-
32
- `Store` is a thin facade (~110 LOC) that holds `Manifest`, `Hooks::Registry`, `Hooks::Dispatcher`, and the lazy `Store::AuditLog`, then delegates verbs to a small set of focused collaborators:
33
-
34
- - **`Store::Reader`** — owns `get`, `list`, `where`, `uid`, `deps`, `rdeps`, `published`, `schema_envelope`, `validate_all`. The only module that reads working-store entry files. (`freshness` lives in `Application::Reads::Freshness` since 0.9.2; the legacy `stale` shim was removed.)
35
- - **`Store::Writer`** — owns `put`, `delete`, `accept`. Handles serialization, uid minting, etag check, role gate, audit append, and event publication. The only module that writes working-store entry files.
36
- - **`Store::Mover`** — owns `mv` (same-zone rename) with uid preservation and one audit row.
37
- - **`Store::Validator`** / **`Store::Staleness`** — back the `validate_all` / `freshness` reads. Take explicit collaborators (`reader:`, `manifest:`, `audit_log:`, `schema_for:`) instead of the full store.
38
-
39
- Shared value modules and primitives consumed by Reader/Writer/Mover:
40
-
41
- - **`Textus::Key::Path`** — `Key::Path.resolve(manifest, mentry)` returns the absolute leaf path for a manifest entry. Single source for zone-path construction; used by `Manifest`, `Staleness`, `Builder`, and Writer.
42
- - **`Textus::Envelope`** — `Envelope.build(...)` returns the canonical envelope hash (protocol, key, zone, owner, path, format, `_meta`, body, etag, schema_ref, uid, optional content). Single source for envelope shape across `get` and `put`.
43
- - **`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.
44
- - **`Schema`** — loads YAML schema files; validates frontmatter shape and surfaces unknown-key warnings (the §6 forward-compat rule).
45
- - **`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 in Reader/Writer (on read and on write) — mismatch raises `bad_frontmatter`.
46
- - **`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.
47
- - **`Role`** — agent-vs-human gate. Writer checks `Manifest::Entry#zone_writers` before doing anything else; otherwise raises `write_forbidden`.
48
- - **`Store::AuditLog`** — append-only NDJSON; every successful write emits one line.
49
- - **`Proposal`** — `accept` verb flow for promoting a pending entry into its target zone.
50
- - **`Dependencies`** — `deps`/`rdeps`/`published` verb backing; walks manifest declarations.
51
-
52
- ### 2. Build / publish pipeline
53
-
54
- Separate from the request path. Owns derived-entry materialization and byte-copy publish. `Builder` orchestrates per-entry materialization through `Builder::Pipeline`, which runs an ordered step list and dispatches the rendering step to one of four format-specific renderers. Adding a new output format is a single-file change under `lib/textus/builder/renderer/`.
55
-
56
- ```
57
- Builder ──► Pipeline ──► LoadSources ──► Project ──► Render (per-format) ──► Write ──► Publisher ──► (sentinel)
58
-
59
- └──► Renderer::{Markdown, Text, Json, Yaml}
60
- ```
61
-
62
- - **`Builder`** — iterates `zone: derived` entries, hands each to `Pipeline.run`, then handles `Publisher` copy-out and fires the `:build` event. Holds no format-specific logic.
63
- - **`Builder::Pipeline`** — `Pipeline.run(store:, mentry:, template_loader:)` is the orchestrator: runs the projection, merges `intro` if `inject_intro: true`, dispatches to the matching renderer, writes the bytes to the derived path.
64
- - **`Builder::InjectMeta`** — builds the `_meta` block (`generated_at`, `from`, `template`, `reduce`) and threads it onto JSON/YAML content as the first key per SPEC §6 ordering.
65
- - **`Builder::Renderer::{Markdown,Text,Json,Yaml}`** — one class per format, inheriting `Builder::Renderer`. Receives a template-loader lambda and `(mentry:, data:)`; returns rendered bytes. Markdown/Text always require a template; JSON/YAML optionally accept one (otherwise default-shape the projection rows).
66
- - **`Projection`** — collects rows from manifest-declared source keys, applies optional reducer, sorts and positions. Pure data shaping.
67
- - **`Mustache`** — minimal mustache renderer for templates in `.textus/templates/`.
68
- - **`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.
69
-
70
- ### 3. Extension surface
71
-
72
- Declared in the manifest, loaded on demand, dispatched by `Store` and `Refresh`.
73
-
74
- - **`Hooks::Registry`** — loads one `.rb` per hook from `.textus/hooks/`, registers callables under their `(event, name)`. Single source of truth via the `EVENTS` table (rpc vs pubsub, arg shape, failure semantics). For pub-sub events it also forwards registrations to the `Hooks::Dispatcher`.
75
- - **`Hooks::Dispatcher`** — first-class pub/sub for lifecycle events (`:put`, `:delete`, `:refresh`, `:build`, `:accept`, `:publish`, `:mv`, `:reject`, `:loaded`). Owns the 2-second per-handler timeout and the audit-on-failure middleware (raising handlers do not abort the write; they produce an `event_error` audit row). Embedded callers can `store.dispatcher.subscribe(:put, :name) { ... }` outside `.textus/hooks/`.
76
- - **`Hooks::Builtin`** — ships built-in `:fetch` hooks (e.g. json, csv, ical-events, rss) available without user-supplied hooks.
77
- - **`Refresh`** — `refresh` verb: looks up the `:fetch` hook for a key, invokes it, normalizes the result by declared format, writes through `Store::Writer` with an etag check.
78
-
79
- ### 4. Operational tooling
80
-
81
- First-class CLI verbs that don't fit the read/write/build axes. Read-mostly; side modules off CLI.
82
-
83
- - **`Doctor`** — `doctor` verb: orchestrator that runs 9 builtin checks under `Doctor::Check::*`. Talks to Manifest/Schema/Entry/Hooks::Registry directly.
84
- - **`Doctor::Check`** — explicit base class for doctor checks. Each of the 9 builtin checks is its own file under `lib/textus/doctor/check/`.
85
- - **`MigrateKeys`** — `key migrate` and `key mv` verbs; computes renames against the manifest.
86
- - **`Schema::Tools`** — `schema init`, `schema diff`, `schema migrate` verbs.
87
- - **`Init`** — `init` verb: scaffolds `.textus/` with the five zone directories, baseline schemas, empty audit log, starter manifest.
88
- - **`Intro`** — `intro` verb: emits the human/agent-facing onboarding payload.
89
- - **`Store::View`** — read-only projection over `Store::Reader` for hook code that should not mutate.
90
- - **`Key::Distance`** — Levenshtein-ish suggestion for `did-you-mean` on unknown keys.
91
-
92
- ### 5. Primitives
93
-
94
- - **`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.
95
- - **`version`** — gem semver string (independent of the wire protocol `textus/2`).
96
-
97
- ## Invariants
98
-
99
- - **CLI is the only entry point.** No public API surface guarantees outside the verbs CLI exposes.
100
- - **Manifest is pure.** Reads at load, no mutation.
101
- - **Store::Writer is the only module that writes to working-store entry files.** Reader reads them; Mover moves them within a zone. 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 the Store facade's back.
102
- - **`name:` frontmatter matches file basename.** Enforced on read and write.
103
- - **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.
104
- - **`freshness` does not execute anything.** It walks every entry, matches it against the top-level `policies:` block, and returns each entry's verdict (`fresh|stale|never_refreshed|no_policy`). Build runners execute. This is the §5.1 "dataflow oracle, not executor" boundary.
105
-
106
- ## Policy resolution
107
-
108
- Top-level `policies:` are parsed into a `Manifest::Policies` collection. Resolution is by key, slot-aware, most-specific-wins:
109
-
110
- ```
111
- Manifest#policies_for(key)
112
- └─► Manifest::Policies#for(key)
113
- ├─► Policy::Matcher (specificity ranking)
114
- └─► returns PolicySet { refresh, handler_allowlist, promote, retention }
115
-
116
-
117
- consumers: Refresh::Worker
118
- Doctor checks
119
- Reads::PolicyExplain
120
- ```
121
-
122
- Two blocks at the same specificity filling the same slot for the same key is a manifest error reported by `doctor` (`policy_ambiguity`). Custom-named zones see no special handling — policies match against the full key.
123
-
124
- ## What this implementation deliberately leaves out
125
-
126
- - **No process spawning.** Even `stale` does not execute. Build runners do that.
127
- - **No transport.** No HTTP server, no socket, no MCP server in this gem. Those are downstream wrappers (see [`./conventions.md`](./conventions.md)).
128
- - **No indexes.** Listing walks the filesystem each time. Premature optimisation for v1.
129
- - **No locking.** Etag is advisory; concurrent writers can still race. Left to v1.x (§14 open question).