Module: Textus::Init

Defined in:
lib/textus/init.rb

Constant Summary collapse

ZONES =
%w[knowledge notebook proposals artifacts].freeze
DEFAULT_MANIFEST =
<<~YAML
  version: textus/3
  roles:
    - { name: human,      can: [author, propose] }
    - { name: agent,      can: [propose, keep] }
    - { name: automation, can: [converge] }
  lanes:
    - { name: knowledge, kind: canon,     desc: "the maintained source of truth (identity.* lives here)" }
    - { name: notebook,  kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
    - { name: proposals, kind: queue,     desc: "changes awaiting your accept" }
    - { name: artifacts, kind: machine,   desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
  entries:
    - { key: knowledge.identity, path: data/knowledge/identity.md, lane: knowledge, schema: null, owner: human:self, kind: leaf }
    - { key: knowledge.notes,    path: data/knowledge/notes,       lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
    - { key: notebook.notes,     path: data/notebook/notes,        lane: notebook,  schema: null, owner: agent:self, nested: true, kind: nested }
    - { key: proposals.notes,    path: data/proposals/notes,       lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
    # A per-host snapshot, refreshed from its declared intake by `textus drain` (scheduled, or on demand).
    # Nested so it grows to a fleet — add artifacts.feeds.machines.<host> leaves over SSH
    # (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
    # gitignored (machine info can be sensitive/noisy) but still protocol-readable
    # via `textus get artifacts.feeds.machines.local`. Delete to opt out. (ADR 0043)
    - key: artifacts.feeds.machines
      path: data/artifacts/feeds/machines
      lane: artifacts
      format: yaml
      nested: true
      tracked: false
      kind: produced
      source:
        from: fetch
        handler: machine-intake
        ttl: 1h # cadence on a long-running server
        config:
          machines:
            local: { via: local }
  rules: []
YAML
STEPS_README =
<<~MD
  # Steps

  Drop one Ruby file per step. Steps are discovered by convention.
  Files under `.textus/steps/<kind>/<name>.rb` are loaded at
  startup and registered.

  ## Conventions

  The directory name (`<kind>`) must be one of:
  - `fetch`: Acquires data from outside the store.
  - `transform`: Reshapes projection rows.
  - `validate`: Validates data before writing.
  - `observe`: Listens to store events.

  The filename (`<name>.rb`) defines the step name. The class defined
  in the file must be a subclass of `Textus::Step::<Kind>` (e.g.
  `Textus::Step::Fetch`) and be wrapped in the `Textus::Step` module.

  ## Example

  ```ruby
  module Textus
    module Step
      class MyFetch < Fetch
        def call(config:, args:, caps:, **)
          { content: { "foo" => "bar" } }
        end
      end
    end
  end
  ```

  Events: :fetch, :transform, :validate (rpc — return value used)
          :entry_written, :entry_deleted, :entry_fetched, :entry_renamed,
          :entry_produced, :produce_failed,
          :proposal_accepted, :proposal_rejected,
          :entry_published, :store_loaded, :session_opened,
          :entry_fetch_started, :entry_fetch_failed (pub-sub — return discarded)

  See SPEC.md §5.10 for the full table.
MD
AGENT_ENTRIES =
<<~YAML.gsub(/^/, "  ")
  - { key: knowledge.project, path: data/knowledge/project.md, lane: knowledge, schema: project, owner: human:self, kind: leaf }
  - { key: knowledge.runbooks, path: data/knowledge/runbooks, lane: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
  - key: artifacts.derived.orientation
    path: data/artifacts/derived/orientation.json
    lane: artifacts
    publish:
    - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
    - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
    source:
      from: derive
      select:
      - knowledge.project
      - knowledge.runbooks
      transform: orientation
    kind: produced
YAML

Class Method Summary collapse

Class Method Details

.build_result(target_root, with_agent:, mcp_status:) ⇒ Object



166
167
168
169
170
# File 'lib/textus/init.rb', line 166

def self.build_result(target_root, with_agent:, mcp_status:)
  result = { "protocol" => PROTOCOL, "initialized" => target_root, "profile" => with_agent ? "agent" : "default" }
  result["mcp_config"] = mcp_status if with_agent
  result
end

.check_target!(target_root) ⇒ Object

Raises:



119
120
121
# File 'lib/textus/init.rb', line 119

def self.check_target!(target_root)
  raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
end

.create_directories(target_root) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/textus/init.rb', line 123

def self.create_directories(target_root)
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
  FileUtils.mkdir_p(File.join(target_root, "templates"))
  FileUtils.mkdir_p(File.join(target_root, "steps/fetch"))
  FileUtils.mkdir_p(File.join(target_root, "steps/transform"))
  FileUtils.mkdir_p(File.join(target_root, "steps/validate"))
  FileUtils.mkdir_p(File.join(target_root, "steps/observe"))
  ZONES.each do |z|
    dir = File.join(target_root, "data", z)
    FileUtils.mkdir_p(dir)
    File.write(File.join(dir, ".gitkeep"), "")
  end
end

.derived_gitignore(target_root) ⇒ Object

The store’s ‘.gitignore` is generated, never hand-kept (ADR 0038), and now derived from the manifest: the run subtree plus every `tracked: false` entry’s resolved path (ADR 0043).



208
209
210
211
212
213
214
215
216
217
218
219
220
# File 'lib/textus/init.rb', line 208

def self.derived_gitignore(target_root)
  manifest = Textus::Manifest.load(target_root)
  root = Pathname.new(target_root)
  untracked = manifest.data.entries.reject(&:tracked?).map do |e|
    if e.nested? # a whole subtree of leaf files (artifacts.feeds.machines.* → data/artifacts/feeds/machines/)
      rel = e.path.start_with?("data/") ? e.path : File.join("data", e.path)
      "#{rel}/"
    else
      Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
    end
  end
  Textus::Layout.gitignore_body(untracked_paths: untracked)
end

.manifest_yaml(with_agent:) ⇒ Object

Composes the agent profile by inserting AGENT_ENTRIES immediately before the top-level ‘rules:` block of DEFAULT_MANIFEST — that block is load-bearing for this `.sub`; removing it from DEFAULT_MANIFEST would silently drop the entries.



175
176
177
178
179
# File 'lib/textus/init.rb', line 175

def self.manifest_yaml(with_agent:)
  return DEFAULT_MANIFEST unless with_agent

  DEFAULT_MANIFEST.sub(/^rules:/, "#{AGENT_ENTRIES}rules:")
end

.run(target_root, with_agent: false) ⇒ Object



107
108
109
110
111
112
113
114
115
116
117
# File 'lib/textus/init.rb', line 107

def self.run(target_root, with_agent: false)
  check_target!(target_root)
  scaffold_dir = File.expand_path("init/templates", __dir__)
  create_directories(target_root)
  write_steps_readme(target_root, scaffold_dir)
  write_manifest(target_root, with_agent:)
  mcp_status = scaffold_agent(target_root, scaffold_dir, with_agent:)
  setup_state_dirs(target_root)
  write_gitignore(target_root)
  build_result(target_root, with_agent:, mcp_status:)
end

.scaffold_agent(target_root, scaffold_dir, with_agent:) ⇒ Object



149
150
151
152
153
154
# File 'lib/textus/init.rb', line 149

def self.scaffold_agent(target_root, scaffold_dir, with_agent:)
  return nil unless with_agent

  scaffold_agent_profile(target_root, scaffold_dir)
  write_mcp_config(target_root, scaffold_dir)
end

.scaffold_agent_profile(target_root, scaffold_dir) ⇒ Object

Copies the proven orientation bundle into a freshly-init’d store.



182
183
184
185
186
187
188
189
190
191
# File 'lib/textus/init.rb', line 182

def self.scaffold_agent_profile(target_root, scaffold_dir)
  {
    "project.schema.yaml" => File.join("schemas", "project.yaml"),
    "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
    "orientation.mustache" => File.join("templates", "orientation.mustache"),
    "orientation_reducer.rb" => File.join("steps/transform", "orientation.rb"),
  }.each do |src, dest|
    File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
  end
end

.setup_state_dirs(target_root) ⇒ Object



156
157
158
159
160
# File 'lib/textus/init.rb', line 156

def self.setup_state_dirs(target_root)
  FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
  FileUtils.mkdir_p(Textus::Layout.state(target_root))
  FileUtils.mkdir_p(Textus::Layout.locks(target_root))
end

.write_gitignore(target_root) ⇒ Object



162
163
164
# File 'lib/textus/init.rb', line 162

def self.write_gitignore(target_root)
  File.write(File.join(target_root, ".gitignore"), derived_gitignore(target_root))
end

.write_manifest(target_root, with_agent:) ⇒ Object



145
146
147
# File 'lib/textus/init.rb', line 145

def self.write_manifest(target_root, with_agent:)
  File.write(File.join(target_root, "manifest.yaml"), manifest_yaml(with_agent: with_agent))
end

.write_mcp_config(target_root, scaffold_dir) ⇒ Object

The one file init writes outside .textus/: a starter .mcp.json at the project root. Write-once — never clobber a hand-authored config. The command form assumes a gem-installed ‘textus` on PATH; the user owns the file after this first write.



197
198
199
200
201
202
203
# File 'lib/textus/init.rb', line 197

def self.write_mcp_config(target_root, scaffold_dir)
  dest = File.join(File.dirname(target_root), ".mcp.json")
  return "skipped" if File.exist?(dest)

  File.write(dest, File.read(File.join(scaffold_dir, "mcp.json")))
  "written"
end

.write_steps_readme(target_root, scaffold_dir) ⇒ Object



137
138
139
140
141
142
143
# File 'lib/textus/init.rb', line 137

def self.write_steps_readme(target_root, scaffold_dir)
  File.write(File.join(target_root, "steps/README.md"), STEPS_README)
  File.write(
    File.join(target_root, "steps/fetch/machine-intake.rb"),
    File.read(File.join(scaffold_dir, "machine_intake.rb")),
  )
end