textus 0.54.0 → 0.54.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.
@@ -8,11 +8,11 @@ Guidelines for shaping a `.textus/` tree, naming keys, organising schemas, and i
8
8
  ## Key naming
9
9
 
10
10
  - **Segments are lowercase, kebab- or snake-case.** The grammar `^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$` is the hard limit. Prefer `acme-dashboard` over `acmedashboard` when there's a natural word break.
11
- - **Lead with the zone in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The zone prefix makes it obvious from the key alone whether a write will be accepted.
11
+ - **Lead with the lane in the key path.** `working.projects.acme.dashboard`, not `projects.acme.dashboard`. The lane prefix makes it obvious from the key alone whether a write will be accepted.
12
12
  - **Mirror the directory structure.** If `working.projects.acme.dashboard` resolves to `working/projects/acme/dashboard.md`, do not invent shortcuts that diverge.
13
13
  - **Don't pluralise the leaf.** `working.network.org.jane`, not `working.network.org.janes`. Pluralise the container, not the entry.
14
14
 
15
- ## Zone layout
15
+ ## Lane layout
16
16
 
17
17
  Recommended top-level layout — the spec allows alternatives, but this is what tooling will default to:
18
18
 
@@ -20,12 +20,14 @@ Recommended top-level layout — the spec allows alternatives, but this is what
20
20
  .textus/
21
21
  manifest.yaml
22
22
  schemas/ # YAML schema definitions
23
- zones/
23
+ workflows/ # Textus.workflow DSL files for produced entry acquisition
24
+ templates/ # ERB templates for publish rendering
25
+ data/
24
26
  knowledge/ # authored truth: identity, voice, decisions — author-holders write
25
27
  notebook/ # agent's own durable lane (workspace) — keep-holders write
26
- feeds/ # automation-fed external inputs (fetch)
27
28
  proposals/ # AI proposals awaiting accept (propose)
28
- artifacts/ # generated by build automation — never edit by hand
29
+ artifacts/ # computed outputs produced by drain — never edit by hand
30
+ raw/ # write-once external source material (ingest)
29
31
  ```
30
32
 
31
33
  Inside `knowledge/`, group by **domain** (identity, people, projects, decisions, runbooks), not by file type or date. `knowledge.identity.*` is the convention for slow-changing identity facts. Inside `artifacts/`, group by **producer** (`artifacts/catalogs/`, `artifacts/indexes/`) so it's clear which build job owns what.
@@ -47,75 +49,57 @@ The `owner:` field in the manifest is **advisory metadata**, not an ACL. Use it
47
49
 
48
50
  Tooling around `git blame` or audit logs may filter on owner; the gem itself only echoes it back in envelopes.
49
51
 
50
- ## Derived entries
52
+ ## Produced entries
51
53
 
52
- A derived entry declares a `source:` block with a `from:` discriminator (ADR 0093/0094). A `source:` acquires **data** — it never renders; rendering is a publish concern (below). Two `from:` values for derived entries:
53
-
54
- **`source: { from: project }`** — textus computes the entry's data on `textus drain` from other store entries. Declarative; nothing shells out. Projection fields are flat under `source:`.
54
+ A produced entry declares `source: { from: external, command: "true", sources: [] }` and a matching `Textus.workflow` block in `.textus/workflows/**/*.rb`. The `source:` acquires **data** — it never renders; rendering is a publish concern (below).
55
55
 
56
56
  ```yaml
57
- - key: artifacts.derived.people
58
- path: artifacts/derived/people.md
59
- zone: artifacts
60
- schema: null
61
- owner: automation:catalog-people
62
- source:
63
- from: project
64
- select: knowledge.network.org # prefix or list of prefixes
65
- pluck: [name, relationship, org]
66
- sort_by: name
67
- format: json # the stored form is data
57
+ - key: artifacts.feeds.skills
58
+ lane: artifacts
59
+ kind: produced
60
+ format: json
61
+ source: { from: external, command: "true", sources: [] }
68
62
  publish:
69
- - { to: docs/people.md, template: people.mustache } # render the data through a template
70
- ```
71
-
72
- **`source: { from: command }`** — an external build tool (rake, just, a shell script) produces the file. textus never executes the `command:`; it only tracks `sources:` so `doctor`'s `generator_drift` check can compare source mtimes against the file's `_meta.generated.at`. The role running the build must hold `converge` (default: `automation`).
73
-
74
- ```yaml
75
- - key: artifacts.derived.skills
76
- path: artifacts/derived/skills.md
77
- zone: artifacts
78
- owner: automation:catalog-skills
79
- source:
80
- from: command
81
- command: "rake catalog:skills" # informational; the automation invokes it
82
- sources: [knowledge.projects, knowledge.network]
63
+ - { to: docs/reference/skills.md, template: feeds/skills.erb }
83
64
  ```
84
65
 
85
- The build automation is responsible for writing the `generated:` frontmatter block (`by`, `at`, `from`) when it produces the file. `generated.from` SHOULD match `source.sources` — same list, recorded twice so a diff proves what was consumed.
86
-
87
- Full contract for both shapes is in [`../../SPEC.md` §5.2.1 and §5.2.2](../../SPEC.md). Data transforms (`source.transform:`) and publishing (a `publish:` list of `{ to, template? }` / `{ tree }` targets — render or copy the data) are covered in §5.2–§5.3.
66
+ The matching workflow block:
88
67
 
89
- ## Intake and freshness
68
+ ```ruby
69
+ # .textus/workflows/feeds/agentskills.rb
70
+ Textus.workflow "agentskills" do
71
+ match "artifacts.feeds.skills"
90
72
 
91
- External inputs land via `:resolve_handler` hooks, not shell commands. Each intake entry declares `source: { from: handler, handler: <name>, ttl: <dur> }`; re-pull is system-pushed via `drain` (scheduled sweep) and `hook run` (event push) — a `get` never refreshes (ADR 0089):
73
+ step :fetch do |_, _ctx|
74
+ { "content" => { "skills" => [...], "count" => 1 } }
75
+ end
92
76
 
93
- ```sh
94
- textus pulse --output=json # `stale` lists expired entries; `next_due_at` is the soonest deadline
95
- textus drain --as=automation # re-pulls every intake entry past its source.ttl
77
+ publish
78
+ end
96
79
  ```
97
80
 
98
- The re-pull cadence is the entry's own `source.ttl`. Age-based garbage collection is the orthogonal `retention:` rule slot in the top-level `rules:` block, matched by glob (`{ ttl, action: drop | archive }`); the two compose — re-pull hourly *and* archive at 90 days:
81
+ `drain` discovers workflows, matches entries, runs the steps, and writes the result back to the entry's data path. Publishing then copies or renders each `publish:` target.
99
82
 
100
- ```yaml
101
- - key: artifacts.feeds.notion.events
102
- kind: produced # produce-method (intake) read from source.from: handler
103
- zone: artifacts
104
- source: { from: handler, handler: notion, config: { ... }, ttl: 6h }
83
+ Age-based GC uses a `retention:` rule in the top-level `rules:` block:
105
84
 
85
+ ```yaml
106
86
  rules:
107
- - match: artifacts.feeds.notion.**
87
+ - match: artifacts.feeds.**
108
88
  retention: { ttl: 90d, action: archive }
109
89
  ```
110
90
 
111
- A typical scheduled integration runs `drain` on a cron to re-pull every
112
- expired feed:
91
+ Full contract in [`../../SPEC.md` §5.2](../../SPEC.md).
92
+
93
+ ## Freshness
94
+
95
+ A `get` is always a pure read (ADR 0089) — it never triggers a re-produce:
113
96
 
114
97
  ```sh
115
- textus drain --as=automation # in cron / CI re-pulls all stale feeds
98
+ textus pulse --output=json # `stale` lists entries past their ttl
99
+ textus drain --as=automation # re-produces stale entries
116
100
  ```
117
101
 
118
- See [`./zones.md` §6](zones.md) for the full intake contract and [`../how-to/writing-hooks.md`](../how-to/writing-hooks.md) for writing custom handlers.
102
+ See [`./lanes.md`](lanes.md) for lane semantics and [`../how-to/configuring-lanes.md`](../how-to/configuring-lanes.md) for setting up produced entries and workflows.
119
103
 
120
104
  ### Read vs. refresh
121
105
 
@@ -141,11 +125,11 @@ For multi-writer environments, **always pass `if_etag`** on `put`. The gem treat
141
125
 
142
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).
143
127
 
144
- - **`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(zone)`, `manifest.resolver.resolve(key)`, `manifest.rules.for(key)`.
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)`.
145
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.
146
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(...)`).
147
131
 
148
- The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/3`) are unchanged.
132
+ The user-facing CLI surface, the wire envelope shape, and the protocol version (`textus/4`) are unchanged.
149
133
 
150
134
  ## Pairing with other tools
151
135
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "date"
5
+ require "digest"
5
6
 
6
7
  module Textus
7
8
  module Action
@@ -22,6 +23,8 @@ module Textus
22
23
  view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
23
24
 
24
25
  SOURCE_KINDS = %w[url file asset].freeze
26
+ CONTENT_HASH_ALGO = "sha256"
27
+ TOMBSTONE_RETAIN = %w[ingested_at].freeze
25
28
 
26
29
  def initialize(kind:, slug:, url: nil, path: nil, zone: nil, label: nil)
27
30
  super()
@@ -43,7 +46,22 @@ module Textus
43
46
  action: :ingest, actor: call.role, key: key,
44
47
  )
45
48
 
46
- write_raw_entry(key, now, container, call)
49
+ content_hash = compute_content_hash
50
+ writer = Textus::Envelope::Writer.from(container: container, call: call)
51
+ mentry = container.manifest.resolver.resolve(key).entry
52
+ ts = now.iso8601
53
+ structured = build_structured(ts, container, now, content_hash)
54
+
55
+ index = Textus::Ports::RawIndex.new(root: container.root)
56
+ duplicate_key = find_duplicate(index, content_hash)
57
+
58
+ if duplicate_key && duplicate_key != key
59
+ supersede_entry(duplicate_key, key, structured, container, call, index)
60
+ else
61
+ env = write_raw_entry(key, structured, mentry, writer)
62
+ index.upsert(content_hash: content_hash, url: @url, key: key)
63
+ env
64
+ end
47
65
  end
48
66
 
49
67
  private
@@ -70,50 +88,100 @@ module Textus
70
88
  "raw.#{date}.#{@kind}-#{@slug}"
71
89
  end
72
90
 
73
- def write_raw_entry(key, now, container, call)
74
- ts = now.iso8601
75
- mentry = container.manifest.resolver.resolve(key).entry
76
- writer = Textus::Envelope::Writer.from(container: container, call: call)
91
+ def compute_content_hash
92
+ digest = Digest::SHA256.new
93
+ case @kind
94
+ when "url"
95
+ digest.update(@url)
96
+ when "file", "asset"
97
+ digest.file(@path)
98
+ end
99
+ "#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
100
+ end
77
101
 
102
+ def build_structured(timestamp, container, now, content_hash)
103
+ base = { "ingested_at" => timestamp, "content_hash" => content_hash }
78
104
  case @kind
79
105
  when "url"
80
- structured = {
81
- "ingested_at" => ts,
82
- "source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
83
- "body" => nil,
84
- }
85
- writer.put(key, mentry: mentry,
86
- payload: Textus::Envelope::Writer::Payload.new(
87
- meta: nil, body: nil, content: structured,
88
- ))
106
+ base.merge("source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
107
+ "body" => nil)
89
108
  when "file"
90
109
  body_content = File.read(@path)
91
- structured = {
92
- "ingested_at" => ts,
93
- "source" => { "kind" => "file", "path" => @path,
94
- "label" => @label || File.basename(@path) },
95
- "body" => body_content,
96
- }
97
- writer.put(key, mentry: mentry,
98
- payload: Textus::Envelope::Writer::Payload.new(
99
- meta: nil, body: nil, content: structured,
100
- ))
110
+ base.merge("source" => { "kind" => "file", "path" => @path,
111
+ "label" => @label || File.basename(@path) },
112
+ "body" => body_content)
101
113
  when "asset"
102
114
  asset_rel = copy_asset_file(container, now)
103
- structured = {
104
- "ingested_at" => ts,
105
- "source" => { "kind" => "asset",
106
- "label" => @label || File.basename(@path) },
107
- "asset" => asset_rel,
108
- "body" => nil,
109
- }
110
- writer.put(key, mentry: mentry,
111
- payload: Textus::Envelope::Writer::Payload.new(
112
- meta: nil, body: nil, content: structured,
113
- ))
115
+ base.merge("source" => { "kind" => "asset",
116
+ "label" => @label || File.basename(@path) },
117
+ "asset" => asset_rel,
118
+ "body" => nil)
114
119
  end
115
120
  end
116
121
 
122
+ def write_raw_entry(key, structured, mentry, writer)
123
+ writer.put(key, mentry: mentry,
124
+ payload: Textus::Envelope::Writer::Payload.new(
125
+ meta: nil, body: nil, content: structured,
126
+ ))
127
+ end
128
+
129
+ def find_duplicate(index, content_hash)
130
+ dup = index.find_by_hash(content_hash)
131
+ return dup if dup
132
+
133
+ return unless @kind == "url"
134
+
135
+ index.find_by_url(@url)
136
+ end
137
+
138
+ def supersede_entry(old_key, new_key, structured, container, call, index)
139
+ old_mentry = container.manifest.resolver.resolve(old_key).entry
140
+ writer = Textus::Envelope::Writer.from(container: container, call: call)
141
+
142
+ reader = Textus::Envelope::Reader.from(container: container)
143
+ old_env = reader.read(old_key)
144
+ old_content = old_env&.content || {}
145
+ tombstone = {}
146
+ TOMBSTONE_RETAIN.each do |k|
147
+ tombstone[k] = old_content[k] if old_content.key?(k)
148
+ end
149
+ source_kind = old_content.dig("source", "kind")
150
+ tombstone["source"] = { "kind" => source_kind } if source_kind
151
+ tombstone["superseded_by"] = new_key
152
+
153
+ writer.put(old_key, mentry: old_mentry,
154
+ payload: Textus::Envelope::Writer::Payload.new(
155
+ meta: nil, body: nil, content: tombstone,
156
+ ))
157
+
158
+ structured["supersedes"] = old_key
159
+ env = write_raw_entry(new_key, structured, container.manifest.resolver.resolve(new_key).entry, writer)
160
+
161
+ move_asset_file(container, old_content["asset"]) if @kind == "asset" && old_content["asset"]
162
+
163
+ index.upsert(content_hash: structured["content_hash"], url: @url, key: new_key)
164
+ env
165
+ end
166
+
167
+ def move_asset_file(container, old_asset_rel)
168
+ old_path = File.join(container.root, "assets", old_asset_rel)
169
+ return unless File.exist?(old_path)
170
+
171
+ now = Time.now.utc
172
+ date_path = now.strftime("%Y/%m/%d")
173
+ filename = File.basename(old_path)
174
+ new_dir = File.join(container.root, "assets", "raw", date_path, @zone)
175
+ new_path = File.join(new_dir, filename)
176
+
177
+ return if old_path == new_path
178
+
179
+ FileUtils.mkdir_p(new_dir)
180
+ FileUtils.mv(old_path, new_path)
181
+ rescue Errno::ENOENT, Errno::EACCES => e
182
+ warn "[textus ingest] could not move asset #{old_asset_rel}: #{e.message}"
183
+ end
184
+
117
185
  def copy_asset_file(container, now)
118
186
  date_path = now.strftime("%Y/%m/%d")
119
187
  filename = File.basename(@path)
@@ -46,7 +46,7 @@ module Textus
46
46
  end
47
47
 
48
48
  def index_etag(container)
49
- path = container.manifest.resolver.resolve("artifacts.index").path
49
+ path = container.manifest.resolver.resolve("artifacts.system.index").path
50
50
  File.exist?(path) ? container.file_store.etag(path) : nil
51
51
  rescue Textus::Error
52
52
  nil
data/lib/textus/boot.rb CHANGED
@@ -158,9 +158,9 @@ module Textus
158
158
  "contract_etag" => etag,
159
159
  "lanes" => lanes_for(manifest),
160
160
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
161
- "orientation" => read_artifact_content(container, "artifacts.orientation"),
161
+ "orientation" => read_artifact_content(container, "artifacts.config.orientation"),
162
162
  "context" => read_boot_context(container),
163
- "index_key" => "artifacts.index",
163
+ "index_key" => index_key_if_present(container),
164
164
  "agent_protocol" => agent_protocol(manifest),
165
165
  }.compact
166
166
  end
@@ -188,6 +188,13 @@ module Textus
188
188
  nil
189
189
  end
190
190
 
191
+ def self.index_key_if_present(container)
192
+ res = container.manifest.resolver.resolve("artifacts.system.index")
193
+ res.path && File.exist?(res.path) ? "artifacts.system.index" : nil
194
+ rescue Textus::Error
195
+ nil
196
+ end
197
+
191
198
  def self.lanes_for(manifest)
192
199
  manifest.data.declared_lane_kinds.keys.map do |name|
193
200
  verb = manifest.policy.verb_for_lane(name)
@@ -1,10 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
1
5
  module Textus
2
6
  # Single capability record handed to every use case. Replaces the
3
7
  # ReadCaps/WriteCaps/HookCaps trio from 0.26.x. Built once per Store
4
8
  # (see Store#initialize); Store delegates its readers to this record,
5
- # so this `Data.define` is the single source of truth for the field set.
6
- Container = Data.define(
7
- :manifest, :file_store, :schemas, :root,
8
- :audit_log, :workflows, :gate
9
- )
9
+ # so this struct is the single source of truth for the field set.
10
+ class Container < Dry::Struct
11
+ attribute :manifest, Types::Any
12
+ attribute :file_store, Types::Any
13
+ attribute :schemas, Types::Any
14
+ attribute :root, Types::String
15
+ attribute :audit_log, Types::Any
16
+ attribute :workflows, Types::Any
17
+ attribute :gate, Types::Any
18
+
19
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
20
+
21
+ def initialize(*)
22
+ super
23
+ freeze
24
+ end
25
+ end
10
26
  end
@@ -9,7 +9,7 @@ module Textus
9
9
  # Note on wire format: `#to_h_for_wire` is intentionally narrower than the
10
10
  # full field set. It emits the legacy keys ("stale", "stale_reason",
11
11
  # "fetching", and "fetch_error" when present) so the CLI JSON wire stays
12
- # byte-identical with textus/3. The gem-side fields `checked_at` and
12
+ # byte-identical with textus/4. The gem-side fields `checked_at` and
13
13
  # `ttl_remaining_ms` are NOT emitted on the wire.
14
14
  Verdict = Data.define(
15
15
  :stale, :fetching, :reason, :fetch_error, :checked_at, :ttl_remaining_ms
@@ -1,7 +1,7 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- # Per-role cursor cache under <root>/.run/state/cursor.<role>. A convenience so
4
+ # Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
5
5
  # `textus pulse` (no --since) means "since I last looked". Gitignored;
6
6
  # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
7
7
  class CursorStore
@@ -13,13 +13,13 @@ module Textus
13
13
 
14
14
  doc = YAML.safe_load_file(path, aliases: false) || {}
15
15
  version = doc["version"]
16
- return [] if version == "textus/3"
16
+ return [] if version == "textus/4"
17
17
 
18
18
  [{
19
19
  "code" => "protocol_mismatch",
20
20
  "severity" => "error",
21
- "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
22
- "hint" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
21
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/4.",
22
+ "hint" => "Upgrade the store's manifest version to textus/4 (see CHANGELOG for breaking changes).",
23
23
  }]
24
24
  end
25
25
 
@@ -31,14 +31,14 @@ module Textus
31
31
 
32
32
  doc = YAML.safe_load_file(path, aliases: false) || {}
33
33
  version = doc["version"]
34
- return [] if version == "textus/3"
34
+ return [] if version == "textus/4"
35
35
 
36
36
  [{
37
37
  "code" => "protocol_mismatch",
38
38
  "level" => "error",
39
39
  "subject" => path,
40
- "message" => "Store reports version=#{version.inspect}; this gem expects textus/3.",
41
- "fix" => "Upgrade the store's manifest version to textus/3 (see CHANGELOG for breaking changes).",
40
+ "message" => "Store reports version=#{version.inspect}; this gem expects textus/4.",
41
+ "fix" => "Upgrade the store's manifest version to textus/4 (see CHANGELOG for breaking changes).",
42
42
  }]
43
43
  end
44
44
  end
@@ -0,0 +1,54 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Warns when a knowledge-lane doc's `**reviewed** YYYY-MM (vX.Y)` stamp
5
+ # is more than MINOR_THRESHOLD minor versions behind Textus::VERSION.
6
+ # The stamp is the human staleness signal declared in docs conventions
7
+ # (knowledge.docs-index); this check makes it machine-enforced.
8
+ class StaleReviewedStamp < Check
9
+ STAMP_RE = /\*\*reviewed\*\*\s+\d{4}-\d{2}\s+\(v(\d+\.\d+(?:\.\d+)?)\)/
10
+ MINOR_THRESHOLD = 5
11
+
12
+ def call
13
+ current_minor = parse_minor(Textus::VERSION)
14
+ issues = []
15
+
16
+ manifest.resolver.enumerate.each do |row|
17
+ next unless row[:key].to_s.start_with?("knowledge.")
18
+ next unless row[:path] && File.file?(row[:path])
19
+
20
+ body = File.read(row[:path])
21
+ m = body.match(STAMP_RE)
22
+ next unless m
23
+
24
+ reviewed_minor = parse_minor(m[1])
25
+ behind = current_minor - reviewed_minor
26
+ next unless behind > MINOR_THRESHOLD
27
+
28
+ issues << stale_issue(row[:key], m[1], behind)
29
+ end
30
+
31
+ issues
32
+ end
33
+
34
+ private
35
+
36
+ def parse_minor(version_str)
37
+ version_str.sub(/\Av/, "").split(".").map(&:to_i)[1] || 0
38
+ end
39
+
40
+ def stale_issue(key, stamp_version, behind)
41
+ current_short = Textus::VERSION[/\A\d+\.\d+/]
42
+ {
43
+ "code" => "stale_reviewed_stamp",
44
+ "level" => "warning",
45
+ "subject" => key.to_s,
46
+ "message" => "reviewed at v#{stamp_version}; current is v#{Textus::VERSION} " \
47
+ "(#{behind} minor versions behind, threshold is #{MINOR_THRESHOLD})",
48
+ "fix" => "review the doc and update the stamp to: **reviewed** YYYY-MM (v#{current_short})",
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/textus/doctor.rb CHANGED
@@ -23,6 +23,7 @@ module Textus
23
23
  Check::GeneratorDrift,
24
24
  Check::RawAssetPaths,
25
25
  Check::NotebookSources,
26
+ Check::StaleReviewedStamp,
26
27
  ].freeze
27
28
 
28
29
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
@@ -1,10 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "dry-struct"
4
+
3
5
  module Textus
4
- Envelope = Data.define(
5
- :protocol, :key, :lane, :owner, :path, :format,
6
- :uid, :etag, :schema_ref, :meta, :body, :content, :freshness
7
- ) do
6
+ class Envelope < Dry::Struct
7
+ attribute :protocol, Types::String
8
+ attribute :key, Types::String
9
+ attribute :lane, Types::String
10
+ attribute :owner, Types::String.optional
11
+ attribute :path, Types::String
12
+ attribute :format, Types::FormatName
13
+ attribute :etag, Types::String
14
+ attribute :uid, Types::String.optional
15
+ attribute :schema_ref, Types::String.optional
16
+ attribute :meta, Types::Hash.default({}.freeze)
17
+ attribute :body, Types::String.optional
18
+ attribute :content, Types::Any.optional
19
+ attribute :freshness, Types::Any.optional
20
+
8
21
  # rubocop:disable Metrics/ParameterLists
9
22
  def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
10
23
  # rubocop:enable Metrics/ParameterLists
@@ -30,6 +43,8 @@ module Textus
30
43
  v.is_a?(String) ? v : nil
31
44
  end
32
45
 
46
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
47
+
33
48
  def to_h_for_wire
34
49
  h = {
35
50
  "protocol" => protocol,
@@ -49,8 +64,6 @@ module Textus
49
64
  h
50
65
  end
51
66
 
52
- alias_method :zone, :lane
53
-
54
67
  def stale?
55
68
  return false if freshness.nil?
56
69
 
data/lib/textus/errors.rb CHANGED
@@ -217,7 +217,7 @@ module Textus
217
217
  def initialize(old_flag, new_flag)
218
218
  super(
219
219
  "flag_renamed",
220
- "#{old_flag} was renamed in textus/3 — use #{new_flag}",
220
+ "#{old_flag} was renamed in textus/4 — use #{new_flag}",
221
221
  details: { "old" => old_flag, "new" => new_flag },
222
222
  hint: "Use #{new_flag} instead.",
223
223
  exit_code: 2,
data/lib/textus/init.rb CHANGED
@@ -6,7 +6,7 @@ module Textus
6
6
  ZONES = %w[knowledge notebook proposals artifacts raw].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
- version: textus/3
9
+ version: textus/4
10
10
  roles:
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
@@ -42,8 +42,8 @@ module Textus
42
42
  path: data/artifacts/derived/orientation.json
43
43
  lane: artifacts
44
44
  publish:
45
- - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
46
- - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
45
+ - { to: CLAUDE.md, template: orientation.erb, inject_boot: true }
46
+ - { to: AGENTS.md, template: orientation.erb, inject_boot: true }
47
47
  kind: produced
48
48
  YAML
49
49
 
@@ -93,7 +93,7 @@ module Textus
93
93
 
94
94
  def self.setup_state_dirs(target_root)
95
95
  FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
96
- FileUtils.mkdir_p(Textus::Layout.state(target_root))
96
+ FileUtils.mkdir_p(Textus::Layout.cursors(target_root))
97
97
  FileUtils.mkdir_p(Textus::Layout.locks(target_root))
98
98
  end
99
99
 
@@ -121,7 +121,7 @@ module Textus
121
121
  {
122
122
  "project.schema.yaml" => File.join("schemas", "project.yaml"),
123
123
  "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
124
- "orientation.mustache" => File.join("templates", "orientation.mustache"),
124
+ "orientation.erb" => File.join("templates", "orientation.erb"),
125
125
  }.each do |src, dest|
126
126
  File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
127
127
  end