textus 0.53.0 → 0.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +53 -73
  4. data/SPEC.md +2 -5
  5. data/docs/architecture/README.md +149 -159
  6. data/lib/textus/action/accept.rb +0 -9
  7. data/lib/textus/action/audit.rb +0 -2
  8. data/lib/textus/action/blame.rb +0 -2
  9. data/lib/textus/action/boot.rb +2 -11
  10. data/lib/textus/action/data_mv.rb +1 -3
  11. data/lib/textus/action/deps.rb +1 -15
  12. data/lib/textus/action/doctor.rb +0 -2
  13. data/lib/textus/action/drain.rb +4 -6
  14. data/lib/textus/action/enqueue.rb +3 -5
  15. data/lib/textus/action/get.rb +1 -3
  16. data/lib/textus/action/ingest.rb +135 -0
  17. data/lib/textus/action/jobs.rb +1 -3
  18. data/lib/textus/action/key_delete.rb +0 -10
  19. data/lib/textus/action/key_delete_prefix.rb +1 -3
  20. data/lib/textus/action/key_mv.rb +1 -15
  21. data/lib/textus/action/key_mv_prefix.rb +1 -3
  22. data/lib/textus/action/list.rb +0 -2
  23. data/lib/textus/action/propose.rb +0 -2
  24. data/lib/textus/action/published.rb +0 -2
  25. data/lib/textus/action/pulse.rb +7 -38
  26. data/lib/textus/action/put.rb +1 -12
  27. data/lib/textus/action/rdeps.rb +2 -12
  28. data/lib/textus/action/reject.rb +0 -12
  29. data/lib/textus/action/rule_explain.rb +0 -4
  30. data/lib/textus/action/rule_lint.rb +1 -3
  31. data/lib/textus/action/rule_list.rb +0 -4
  32. data/lib/textus/action/schema_envelope.rb +0 -2
  33. data/lib/textus/action/uid.rb +0 -2
  34. data/lib/textus/action/where.rb +0 -2
  35. data/lib/textus/action/write_verb.rb +4 -18
  36. data/lib/textus/boot.rb +40 -109
  37. data/lib/textus/command.rb +6 -1
  38. data/lib/textus/container.rb +21 -5
  39. data/lib/textus/core/freshness/evaluator.rb +18 -34
  40. data/lib/textus/core/freshness/verdict.rb +1 -1
  41. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  42. data/lib/textus/doctor/check/protocol_version.rb +6 -6
  43. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  44. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
  45. data/lib/textus/doctor/check.rb +0 -1
  46. data/lib/textus/doctor.rb +3 -42
  47. data/lib/textus/envelope/reader.rb +46 -0
  48. data/lib/textus/envelope/writer.rb +209 -0
  49. data/lib/textus/envelope.rb +19 -6
  50. data/lib/textus/errors.rb +9 -5
  51. data/lib/textus/{entry → format}/base.rb +1 -6
  52. data/lib/textus/{entry → format}/json.rb +1 -4
  53. data/lib/textus/{entry → format}/markdown.rb +2 -6
  54. data/lib/textus/{entry → format}/text.rb +2 -6
  55. data/lib/textus/{entry → format}/yaml.rb +1 -4
  56. data/lib/textus/{entry.rb → format.rb} +9 -10
  57. data/lib/textus/gate/auth.rb +58 -12
  58. data/lib/textus/gate.rb +2 -0
  59. data/lib/textus/init.rb +16 -79
  60. data/lib/textus/jobs/base.rb +23 -0
  61. data/lib/textus/jobs/materialize.rb +20 -0
  62. data/lib/textus/{background → jobs}/plan.rb +1 -1
  63. data/lib/textus/jobs/planner.rb +101 -0
  64. data/lib/textus/jobs/retention.rb +48 -0
  65. data/lib/textus/jobs/sweep.rb +27 -0
  66. data/lib/textus/{background → jobs}/worker.rb +2 -2
  67. data/lib/textus/jobs.rb +15 -0
  68. data/lib/textus/key/path.rb +1 -1
  69. data/lib/textus/manifest/entry/base.rb +4 -19
  70. data/lib/textus/manifest/entry/parser.rb +27 -10
  71. data/lib/textus/manifest/entry/produced.rb +13 -34
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  73. data/lib/textus/manifest/entry/publish.rb +0 -12
  74. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  75. data/lib/textus/manifest/entry/validators.rb +0 -1
  76. data/lib/textus/manifest/policy/source.rb +14 -62
  77. data/lib/textus/manifest/policy.rb +2 -5
  78. data/lib/textus/manifest/resolver.rb +2 -2
  79. data/lib/textus/manifest/schema/contract.rb +61 -0
  80. data/lib/textus/manifest/schema/keys.rb +6 -20
  81. data/lib/textus/manifest/schema/semantics.rb +232 -0
  82. data/lib/textus/manifest/schema/validator.rb +24 -302
  83. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  84. data/lib/textus/manifest/schema.rb +1 -4
  85. data/lib/textus/manifest.rb +1 -3
  86. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  87. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  88. data/lib/textus/produce/engine.rb +50 -0
  89. data/lib/textus/produce/render.rb +20 -0
  90. data/lib/textus/session.rb +15 -8
  91. data/lib/textus/store.rb +5 -14
  92. data/lib/textus/surfaces/mcp/catalog.rb +23 -11
  93. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  94. data/lib/textus/surfaces/mcp/server.rb +87 -110
  95. data/lib/textus/surfaces/role_scope.rb +0 -4
  96. data/lib/textus/surfaces/watcher.rb +3 -3
  97. data/lib/textus/types.rb +15 -0
  98. data/lib/textus/version.rb +2 -2
  99. data/lib/textus/workflow/collector.rb +27 -0
  100. data/lib/textus/workflow/context.rb +5 -0
  101. data/lib/textus/workflow/dsl.rb +44 -0
  102. data/lib/textus/workflow/loader.rb +17 -0
  103. data/lib/textus/workflow/pattern.rb +18 -0
  104. data/lib/textus/workflow/registry.rb +21 -0
  105. data/lib/textus/workflow/runner.rb +99 -0
  106. data/lib/textus/workflow.rb +28 -0
  107. data/lib/textus.rb +12 -1
  108. metadata +67 -66
  109. data/lib/textus/action/pulse/scanner.rb +0 -118
  110. data/lib/textus/background/job/base.rb +0 -27
  111. data/lib/textus/background/job/materialize.rb +0 -31
  112. data/lib/textus/background/job/refresh.rb +0 -22
  113. data/lib/textus/background/job/sweep.rb +0 -31
  114. data/lib/textus/background/job.rb +0 -19
  115. data/lib/textus/background/planner/plan.rb +0 -113
  116. data/lib/textus/background/retention/apply.rb +0 -50
  117. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  118. data/lib/textus/doctor/check/hooks.rb +0 -25
  119. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  120. data/lib/textus/envelope/io/reader.rb +0 -48
  121. data/lib/textus/envelope/io/writer.rb +0 -211
  122. data/lib/textus/events.rb +0 -21
  123. data/lib/textus/init/templates/machine_intake.rb +0 -49
  124. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  125. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  126. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  127. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  128. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  129. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  130. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  131. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  132. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  133. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  134. data/lib/textus/pipeline/engine.rb +0 -97
  135. data/lib/textus/pipeline/render.rb +0 -25
  136. data/lib/textus/ports/audit_subscriber.rb +0 -42
  137. data/lib/textus/projection.rb +0 -103
  138. data/lib/textus/step/base.rb +0 -35
  139. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  140. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  141. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  142. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  143. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  144. data/lib/textus/step/builtin.rb +0 -22
  145. data/lib/textus/step/catalog.rb +0 -37
  146. data/lib/textus/step/context.rb +0 -74
  147. data/lib/textus/step/discovery.rb +0 -24
  148. data/lib/textus/step/error_log.rb +0 -32
  149. data/lib/textus/step/event_bus.rb +0 -113
  150. data/lib/textus/step/fetch.rb +0 -13
  151. data/lib/textus/step/fire_report.rb +0 -23
  152. data/lib/textus/step/loader.rb +0 -108
  153. data/lib/textus/step/observe.rb +0 -31
  154. data/lib/textus/step/registry_store.rb +0 -66
  155. data/lib/textus/step/signature.rb +0 -31
  156. data/lib/textus/step/transform.rb +0 -12
  157. data/lib/textus/step/validate.rb +0 -11
  158. data/lib/textus/step.rb +0 -10
  159. data/lib/textus/surfaces/mcp/session.rb +0 -9
  160. data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
data/lib/textus/boot.rb CHANGED
@@ -1,49 +1,10 @@
1
1
  module Textus
2
2
  # Read-only "what's in this store and how do I use it" envelope.
3
- # A single call gives an agent the working model of a textus-managed
4
- # project: lanes and their write authority, entries and their flags,
5
- # registered hooks, write flows, and the CLI verb catalog.
6
- #
7
- # Boot is side-effect-free.
3
+ # Boot is side-effect-free. Reads from pre-computed artifacts and
4
+ # the store catalog rather than computing inline.
8
5
  module Boot
9
6
  PROTOCOL_ID = PROTOCOL
10
7
 
11
- # Per-capability write-flow templates. Each lambda receives the user-facing
12
- # role name and the manifest, and returns guidance for that verb with the
13
- # live lane named by kind (ADR 0034). A role holding multiple verbs gets one
14
- # joined string; roles whose verbs have no template are omitted.
15
- WRITE_FLOW_TEMPLATES = {
16
- author: lambda do |name, manifest|
17
- "edit files in #{lane_label(manifest, :canon, "your canon lane")}, " \
18
- "then 'textus put KEY --as=#{name}'"
19
- end,
20
- keep: lambda do |name, manifest|
21
- "keep durable notes in #{lane_label(manifest, :workspace, "your workspace")}: " \
22
- "'textus put KEY --as=#{name}' (no accept needed)"
23
- end,
24
- propose: lambda do |name, manifest|
25
- authority = manifest.policy.roles_with_capability("author").first || "the author-holder"
26
- "propose changes by writing #{manifest.policy.queue_lane}.* entries with --as=#{name} " \
27
- "and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
28
- end,
29
- converge: lambda do |_name, manifest|
30
- machine = lane_label(manifest, :machine, "machine")
31
- "'textus drain' materializes derived #{machine} entries from their sources and " \
32
- "refreshes stale intake #{machine} entries from their declared source; " \
33
- "derived files are never hand-edited (reactive on canon writes, or a full pass on demand)"
34
- end,
35
- }.freeze
36
-
37
- def self.write_flows_for(manifest)
38
- manifest.data.role_caps.each_with_object({}) do |(name, caps), acc|
39
- flows = caps.filter_map do |verb|
40
- tmpl = WRITE_FLOW_TEMPLATES[verb.to_sym]
41
- tmpl&.call(name, manifest)
42
- end
43
- acc[name] = flows.join(" / ") unless flows.empty?
44
- end
45
- end
46
-
47
8
  # Human-readable name(s) for the live lane(s) of a given kind, or `fallback`
48
9
  # when the manifest declares none. Lets write-flow guidance name the live
49
10
  # lane by kind instead of a hardcoded instance name (ADR 0034).
@@ -79,6 +40,7 @@ module Textus
79
40
  { "name" => "boot" },
80
41
  { "name" => "list" },
81
42
  { "name" => "get" },
43
+ { "name" => "ingest" },
82
44
  { "name" => "where" },
83
45
  { "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
84
46
  { "name" => "put" },
@@ -128,14 +90,6 @@ module Textus
128
90
  propose_lane = manifest.policy.propose_lane_for(agent_role)
129
91
 
130
92
  {
131
- # Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
132
- # agent's real read and write surface, named as verbs the agent calls —
133
- # not CLI strings. read_verbs can neither advertise a verb the agent
134
- # cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
135
- # internal scan, ADR 0085) nor omit one it can
136
- # (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
137
- # framing (role is connection-resolved over MCP; there is no stdin).
138
- # writable_lanes / propose_lane below carry the agent's write authority.
139
93
  "read_verbs" => Textus::Surfaces::MCP::Catalog.read_verbs,
140
94
  "write_verbs" => agent_role ? Textus::Surfaces::MCP::Catalog.write_verbs : [],
141
95
  "writable_lanes" => writable_lanes,
@@ -144,10 +98,6 @@ module Textus
144
98
  }
145
99
  end
146
100
 
147
- # Recipes reference verbs, not a transport's CLI strings (ADR 0056): every
148
- # step names a verb the agent can call (each transport frames it — CLI as
149
- # `textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This
150
- # keeps shell lines out of the surface an MCP agent reads.
151
101
  def self.recipes(manifest)
152
102
  queue = manifest.policy.queue_lane
153
103
  feeds = lane_label(manifest, :machine, "the machine lane")
@@ -198,33 +148,51 @@ module Textus
198
148
  )
199
149
  end
200
150
 
201
- def self.build(container:, lean: false)
151
+ def self.build(container:)
202
152
  manifest = container.manifest
203
153
  etag = Textus::Etag.for_contract(container.root)
204
154
 
205
- if lean
206
- return {
207
- "protocol" => PROTOCOL_ID,
208
- "store_root" => container.root,
209
- "lanes" => lanes_for(manifest),
210
- "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
211
- "contract_etag" => etag,
212
- }
213
- end
214
-
215
155
  {
216
156
  "protocol" => PROTOCOL_ID,
217
157
  "store_root" => container.root,
158
+ "contract_etag" => etag,
218
159
  "lanes" => lanes_for(manifest),
219
- "entries" => entries_for(manifest),
220
- "hooks" => hooks_for_container(container),
221
- "write_flows" => write_flows_for(manifest),
222
- "cli_verbs" => CLI_VERBS.map(&:dup),
223
- "agent_protocol" => agent_protocol(manifest),
224
160
  "agent_quickstart" => agent_quickstart(manifest, container.audit_log),
225
- "contract_etag" => etag,
226
- "docs" => { "spec" => "SPEC.md", "example" => ".textus/" },
227
- }
161
+ "orientation" => read_artifact_content(container, "artifacts.orientation"),
162
+ "context" => read_boot_context(container),
163
+ "index_key" => index_key_if_present(container),
164
+ "agent_protocol" => agent_protocol(manifest),
165
+ }.compact
166
+ end
167
+
168
+ def self.read_artifact_content(container, key)
169
+ res = container.manifest.resolver.resolve(key)
170
+ return nil unless res.path && File.exist?(res.path)
171
+
172
+ call = Textus::Call.build(role: Textus::Role::DEFAULT)
173
+ env = Textus::Action::Get.new(key: key).call(container: container, call: call)
174
+ env&.content
175
+ rescue Textus::Error
176
+ nil
177
+ end
178
+
179
+ def self.read_boot_context(container)
180
+ res = container.manifest.resolver.resolve("knowledge.boot")
181
+ return nil unless res.path && File.exist?(res.path)
182
+
183
+ call = Textus::Call.build(role: Textus::Role::DEFAULT)
184
+ env = Textus::Action::Get.new(key: "knowledge.boot").call(container: container, call: call)
185
+ body = env&.body&.strip
186
+ body.nil? || body.empty? ? nil : body
187
+ rescue Textus::Error
188
+ nil
189
+ end
190
+
191
+ def self.index_key_if_present(container)
192
+ res = container.manifest.resolver.resolve("artifacts.index")
193
+ res.path && File.exist?(res.path) ? "artifacts.index" : nil
194
+ rescue Textus::Error
195
+ nil
228
196
  end
229
197
 
230
198
  def self.lanes_for(manifest)
@@ -238,42 +206,5 @@ module Textus
238
206
  row
239
207
  end
240
208
  end
241
-
242
- def self.entries_for(manifest)
243
- manifest.data.entries.map do |e|
244
- derived = e.derived?
245
- {
246
- "key" => e.key,
247
- "lane" => e.lane,
248
- "schema" => e.schema,
249
- "nested" => e.is_a?(Textus::Manifest::Entry::Nested),
250
- "owner" => e.owner,
251
- "format" => e.format,
252
- "derived" => derived,
253
- "intake" => e.intake?,
254
- "publish_to" => Array(e.publish_to),
255
- }
256
- end
257
- end
258
-
259
- def self.hooks_for_container(container)
260
- hooks_for_container_internal(steps: container.steps)
261
- end
262
-
263
- def self.hooks_for_container_internal(steps:)
264
- sections = {}
265
- rpc_kind_map = {
266
- resolve_handler: :fetch,
267
- transform_rows: :transform,
268
- validate: :validate,
269
- }
270
- Step::Catalog::RPC.each_key do |event|
271
- sections[event.to_s] = steps.names(rpc_kind_map.fetch(event)).map(&:to_s).sort
272
- end
273
- Step::Catalog::PUBSUB.each_key do |event|
274
- sections[event.to_s] = steps.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
275
- end
276
- sections
277
- end
278
209
  end
279
210
  end
@@ -12,6 +12,11 @@ module Textus
12
12
  Accept = Data.define(:pending_key, :role)
13
13
  Reject = Data.define(:pending_key, :role)
14
14
  Enqueue = Data.define(:type, :args, :role)
15
+ Ingest = Data.define(:kind, :slug, :url, :path, :zone, :label, :role) do
16
+ def initialize(kind:, slug:, role:, url: nil, path: nil, zone: nil, label: nil)
17
+ super
18
+ end
19
+ end
15
20
  List = Data.define(:prefix, :lane, :role)
16
21
  Where = Data.define(:key, :role)
17
22
  Uid = Data.define(:key, :role)
@@ -26,7 +31,7 @@ module Textus
26
31
  Published = Data.define(:role)
27
32
  SchemaShow = Data.define(:key, :role)
28
33
  Doctor = Data.define(:checks, :role)
29
- Boot = Data.define(:lean, :role)
34
+ Boot = Data.define(:role)
30
35
  Jobs = Data.define(:state, :action, :job_id, :role)
31
36
  DataMv = Data.define(:from, :to, :dry_run, :role)
32
37
  KeyMvPrefix = Data.define(:from_prefix, :to_prefix, :dry_run, :role)
@@ -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, :steps, :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
@@ -4,15 +4,11 @@ module Textus
4
4
  module Core
5
5
  module Freshness
6
6
  # The single currency evaluator (ADR 0099). Answers "is the stored data
7
- # stale relative to its source?" for every produce-method:
8
- # - intake (source.from: fetch) -> AGE signal: now - basis > source.ttl,
9
- # basis = _meta.last_fetched_at (else file mtime). No ttl -> :no_policy
10
- # (skipped — a cadence-less handler is not auto-re-pulled).
7
+ # stale relative to its retention rule?" and detects generator drift for
8
+ # external entries.
9
+ # - retention rule TTL -> AGE signal: now - file_basis > ttl_seconds
11
10
  # - external -> DRIFT signal: a source changed since generated.at
12
- # (surfaced by the doctor generator_drift check; derived entries annotate
13
- # fresh at read time because converge runs them reactively).
14
- # Replaces Core::IntakeStaleness and Core::Staleness::GeneratorCheck and
15
- # the inline copies in Read::Get / Read::Freshness.
11
+ # (surfaced by the doctor generator_drift check).
16
12
  class Evaluator
17
13
  def initialize(manifest:, file_stat:, clock:)
18
14
  @manifest = manifest
@@ -20,32 +16,28 @@ module Textus
20
16
  @clock = clock
21
17
  end
22
18
 
23
- # Per-entry currency Verdict (drives Read::Get's annotation). Non-intake
24
- # entries are always fresh (retention is GC, not content currency).
19
+ # Per-entry currency Verdict driven by the retention rule TTL (if any).
25
20
  def verdict(mentry)
26
- return fresh unless mentry.intake?
27
-
28
- ttl = mentry.source.ttl_seconds
21
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
29
22
  return fresh if ttl.nil?
30
23
 
31
- stale = age_stale?(intake_basis(mentry), ttl)
24
+ stale = age_stale?(file_basis(mentry), ttl)
32
25
  Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
33
26
  end
34
27
 
35
- # Keys of intake entries past their source.ttl — the converge produce
36
- # scope (replaces Core::IntakeStaleness#call). A ttl-less intake entry
37
- # is :no_policy and skipped; a never-recorded one (with a ttl) is stale.
38
- def stale_intake_keys(prefix: nil, lane: nil)
28
+ # Keys of entries past their retention rule TTL — the refresh produce scope.
29
+ def stale_keys(prefix: nil, lane: nil)
39
30
  @manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
40
31
  end
41
32
 
42
- # Age basis as a Time (or nil): _meta.last_fetched_at when present, else
43
- # file mtime. The single definition the three call sites used to repeat.
44
- def intake_basis(mentry)
33
+ alias stale_intake_keys stale_keys
34
+
35
+ # File basis as a Time (or nil): file mtime when present, else nil.
36
+ def file_basis(mentry)
45
37
  path = @manifest.resolver.resolve(mentry.key).path
46
38
  return nil unless @file_stat.exists?(path)
47
39
 
48
- last_fetched_at(mentry, path) || @file_stat.mtime(path)
40
+ @file_stat.mtime(path)
49
41
  end
50
42
 
51
43
  # Generator-drift rows for one entry (replaces Staleness::GeneratorCheck#
@@ -63,17 +55,16 @@ module Textus
63
55
  def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
64
56
 
65
57
  def due?(mentry, prefix:, lane:)
66
- return false unless mentry.intake?
67
58
  return false if lane && mentry.lane != lane
68
59
  return false if prefix && !mentry.key.start_with?(prefix)
69
60
 
70
- ttl = mentry.source.ttl_seconds
71
- return false if ttl.nil? # no declared cadence -> :no_policy, skip (ADR 0099)
61
+ ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
62
+ return false if ttl.nil?
72
63
 
73
64
  path = @manifest.resolver.resolve(mentry.key).path
74
65
  return true unless @file_stat.exists?(path)
75
66
 
76
- age_stale?(intake_basis(mentry), ttl)
67
+ age_stale?(file_basis(mentry), ttl)
77
68
  end
78
69
 
79
70
  # The one age comparison. A never-recorded entry (nil basis) is stale.
@@ -83,13 +74,6 @@ module Textus
83
74
  (@clock.now - basis).to_i > ttl
84
75
  end
85
76
 
86
- def last_fetched_at(mentry, path)
87
- meta = Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]
88
- Time.parse(meta["last_fetched_at"].to_s) if meta && meta["last_fetched_at"]
89
- rescue StandardError
90
- nil
91
- end
92
-
93
77
  # --- generator drift (lifted from Staleness::GeneratorCheck) ---
94
78
 
95
79
  def drift_applicable?(mentry) = mentry.external?
@@ -108,7 +92,7 @@ module Textus
108
92
  end
109
93
 
110
94
  def generated_at_of(mentry, path)
111
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
95
+ Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
112
96
  end
113
97
 
114
98
  def parse_time(str)
@@ -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
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class NotebookSources < Check
5
+ def call
6
+ issues = []
7
+ manifest.resolver.enumerate.each do |row|
8
+ next unless row[:key].start_with?("notebook.notes.")
9
+ next unless row[:path] && File.exist?(row[:path])
10
+
11
+ sources = parse_sources(row[:path])
12
+ sources.each do |raw_key|
13
+ next if raw_entry_exists?(raw_key)
14
+
15
+ issues << {
16
+ "code" => "notebook.source_missing",
17
+ "level" => "warning",
18
+ "subject" => row[:key],
19
+ "message" => "notebook entry '#{row[:key]}' references raw key '#{raw_key}' " \
20
+ "which does not exist in the store",
21
+ "fix" => "re-ingest the source: textus ingest ..., or remove the stale sources: entry",
22
+ }
23
+ end
24
+ end
25
+ issues
26
+ end
27
+
28
+ private
29
+
30
+ def parse_sources(path)
31
+ content = File.read(path)
32
+ match = content.match(/\A---\n(.*?)\n---/m)
33
+ return [] unless match
34
+
35
+ front = YAML.safe_load(match[1])
36
+ Array(front&.dig("sources"))
37
+ rescue StandardError
38
+ []
39
+ end
40
+
41
+ def raw_entry_exists?(key)
42
+ path = manifest.resolver.resolve(key).path
43
+ File.exist?(path)
44
+ rescue Textus::UnknownKey, Textus::Error
45
+ false
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -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,50 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ class RawAssetPaths < Check
5
+ def call
6
+ raw_lane = manifest.policy.lanes_of_kind(:raw).first
7
+ return [] unless raw_lane
8
+
9
+ issues = []
10
+ manifest.resolver.enumerate.each do |row|
11
+ next unless row[:key].start_with?("raw.")
12
+ next unless row[:path] && File.exist?(row[:path])
13
+
14
+ raw_content = load_content(row[:path])
15
+ next unless raw_content.is_a?(Hash)
16
+
17
+ asset = raw_content["asset"]
18
+ next unless asset.is_a?(String) && !asset.empty?
19
+
20
+ asset_path = find_asset_path(asset)
21
+ next if File.exist?(asset_path)
22
+
23
+ issues << {
24
+ "code" => "raw_asset.missing_file",
25
+ "level" => "error",
26
+ "subject" => row[:key],
27
+ "message" => "raw entry '#{row[:key]}' references asset '#{asset}' " \
28
+ "which does not exist at #{asset_path}",
29
+ "fix" => "re-ingest the asset: textus key-delete #{row[:key]}, then textus ingest",
30
+ }
31
+ end
32
+ issues
33
+ end
34
+
35
+ private
36
+
37
+ def load_content(path)
38
+ require "yaml"
39
+ YAML.safe_load_file(path)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def find_asset_path(asset_rel)
45
+ File.join(root, "assets", asset_rel)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ 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
@@ -27,7 +27,6 @@ module Textus
27
27
 
28
28
  def root = @container.root
29
29
  def manifest = @container.manifest
30
- def steps = @container.steps
31
30
 
32
31
  # Dispatch a verb through Gate.
33
32
  def dispatch(verb, *args, **kwargs)
data/lib/textus/doctor.rb CHANGED
@@ -1,12 +1,9 @@
1
- require "timeout"
2
-
3
1
  module Textus
4
2
  # Health check for a Textus store. Returns a JSON-friendly Hash envelope
5
3
  # with an `issues` array and a summary. Each issue is a Hash with
6
4
  # `code`, `level`, `subject`, `message`, and optionally `fix`.
7
5
  module Doctor
8
6
  LEVELS = %w[error warning info].freeze
9
- DOCTOR_CHECK_TIMEOUT_SECONDS = 2
10
7
 
11
8
  CHECKS = [
12
9
  Check::ProtocolVersion,
@@ -14,19 +11,19 @@ module Textus
14
11
  Check::Schemas,
15
12
  Check::SchemaParseError,
16
13
  Check::Templates,
17
- Check::Hooks,
18
- Check::IntakeRegistration,
19
14
  Check::IllegalKeys,
20
15
  Check::Sentinels,
21
16
  Check::AuditLog,
22
17
  Check::UnownedSchemaFields,
23
18
  Check::SchemaViolations,
24
19
  Check::RuleAmbiguity,
25
- Check::HandlerPermit,
26
20
  Check::OrphanedPublishTargets,
27
21
  Check::PublishTreeIndexOverlap,
28
22
  Check::ProposalTargets,
29
23
  Check::GeneratorDrift,
24
+ Check::RawAssetPaths,
25
+ Check::NotebookSources,
26
+ Check::StaleReviewedStamp,
30
27
  ].freeze
31
28
 
32
29
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
@@ -44,7 +41,6 @@ module Textus
44
41
 
45
42
  selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
46
43
  issues = selected.flat_map { |c| c.new(container, role:).call }
47
- issues.concat(run_registered_checks(container))
48
44
 
49
45
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
50
46
  {
@@ -54,40 +50,5 @@ module Textus
54
50
  "summary" => summary,
55
51
  }
56
52
  end
57
-
58
- def run_registered_checks(container)
59
- container.steps.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
60
- end
61
-
62
- def invoke_registered_check(container, name)
63
- result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
64
- container.steps.invoke(:validate, name, caps: container)
65
- end
66
- return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
67
-
68
- [fail_issue(name, code: "doctor_check.bad_return",
69
- message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
70
- fix: "return an array of issue hashes from the doctor_check block")]
71
- rescue Timeout::Error
72
- [fail_issue(name, code: "doctor_check.timeout",
73
- message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
74
- fix: "shorten the check or split it into smaller checks")]
75
- rescue StandardError => e
76
- [fail_issue(name, code: "doctor_check.failed",
77
- message: "#{e.class}: #{e.message}",
78
- fix: "fix the :validate step in .textus/steps/validate/")]
79
- end
80
-
81
- def fail_issue(name, code:, message:, fix:)
82
- {
83
- "code" => code,
84
- "level" => "error",
85
- "subject" => name.to_s,
86
- "message" => message,
87
- "fix" => fix,
88
- }
89
- end
90
-
91
- private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
92
53
  end
93
54
  end