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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +93 -86
- data/SPEC.md +141 -297
- data/docs/reference/conventions.md +39 -55
- data/lib/textus/action/ingest.rb +103 -35
- data/lib/textus/action/pulse.rb +1 -1
- data/lib/textus/boot.rb +9 -2
- data/lib/textus/container.rb +21 -5
- data/lib/textus/core/freshness/verdict.rb +1 -1
- data/lib/textus/cursor_store.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +6 -6
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
- data/lib/textus/doctor.rb +1 -0
- data/lib/textus/envelope.rb +19 -6
- data/lib/textus/errors.rb +1 -1
- data/lib/textus/init.rb +5 -5
- data/lib/textus/layout.rb +15 -7
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -4
- data/lib/textus/manifest/schema/contract.rb +61 -0
- data/lib/textus/manifest/schema/semantics.rb +232 -0
- data/lib/textus/manifest/schema/validator.rb +24 -294
- data/lib/textus/manifest/schema.rb +1 -4
- data/lib/textus/manifest.rb +1 -3
- data/lib/textus/ports/raw_index.rb +61 -0
- data/lib/textus/produce/render.rb +4 -2
- data/lib/textus/session.rb +14 -7
- data/lib/textus/store.rb +4 -4
- data/lib/textus/surfaces/mcp/catalog.rb +22 -10
- data/lib/textus/surfaces/mcp/server.rb +73 -135
- data/lib/textus/types.rb +15 -0
- data/lib/textus/version.rb +2 -2
- data/lib/textus/workflow/runner.rb +15 -0
- metadata +37 -7
- data/lib/textus/surfaces/mcp/routing.rb +0 -51
- data/lib/textus/surfaces/mcp/session.rb +0 -9
- data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
|
@@ -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
|
|
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
|
-
##
|
|
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
|
-
|
|
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/ #
|
|
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
|
-
##
|
|
52
|
+
## Produced entries
|
|
51
53
|
|
|
52
|
-
A
|
|
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.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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/
|
|
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
|
|
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
|
-
|
|
68
|
+
```ruby
|
|
69
|
+
# .textus/workflows/feeds/agentskills.rb
|
|
70
|
+
Textus.workflow "agentskills" do
|
|
71
|
+
match "artifacts.feeds.skills"
|
|
90
72
|
|
|
91
|
-
|
|
73
|
+
step :fetch do |_, _ctx|
|
|
74
|
+
{ "content" => { "skills" => [...], "count" => 1 } }
|
|
75
|
+
end
|
|
92
76
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
textus drain --as=automation # re-pulls every intake entry past its source.ttl
|
|
77
|
+
publish
|
|
78
|
+
end
|
|
96
79
|
```
|
|
97
80
|
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
87
|
+
- match: artifacts.feeds.**
|
|
108
88
|
retention: { ttl: 90d, action: archive }
|
|
109
89
|
```
|
|
110
90
|
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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 [`./
|
|
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(
|
|
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/
|
|
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
|
|
data/lib/textus/action/ingest.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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)
|
data/lib/textus/action/pulse.rb
CHANGED
|
@@ -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" =>
|
|
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)
|
data/lib/textus/container.rb
CHANGED
|
@@ -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
|
|
6
|
-
Container
|
|
7
|
-
:manifest,
|
|
8
|
-
|
|
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/
|
|
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
|
data/lib/textus/cursor_store.rb
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
# Per-role cursor cache under <root>/.
|
|
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/
|
|
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/
|
|
22
|
-
"hint" => "Upgrade the store's manifest version to textus/
|
|
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/
|
|
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/
|
|
41
|
-
"fix" => "Upgrade the store's manifest version to textus/
|
|
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
data/lib/textus/envelope.rb
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
3
5
|
module Textus
|
|
4
|
-
Envelope
|
|
5
|
-
:protocol,
|
|
6
|
-
|
|
7
|
-
|
|
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/
|
|
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/
|
|
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.
|
|
46
|
-
- { to: AGENTS.md, template: orientation.
|
|
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.
|
|
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.
|
|
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
|