jazari 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a2e678c6397f2809010bf357f7bce0f95a6dd41ec910b4f3b9ffad7999c910f4
4
- data.tar.gz: 34213c339b09aacbac45a878d878935f8160a55f13e8c0a5f2189cd70d96ef75
3
+ metadata.gz: b1553aebe3407e26d9ef3813203a9836b0ed5d0dec3169ec2bd4d52153e144d4
4
+ data.tar.gz: f5d8638f94e8bcfa9e1f484962ab7e45c0aff74d3bac6e0627cdf528ec15d4ae
5
5
  SHA512:
6
- metadata.gz: b880ae1c427030d149979921ea489c637a71cec89bd42018dec05bee415d4baa852c8a6a08ce4e49ac7dff2a24cc07a84fafec91e7d515276928c64476586f1b
7
- data.tar.gz: 47fb2f0a403a85a1b6394116d6e3b577481b326a31485c28193a3697ce1ca397b9046d5487e96a9d79e6edd3fa5b8bf8e06e5fee29c99f865751758332f42f57
6
+ metadata.gz: 374ee2a88370020196c89063900b11f1c8e0bf1e945421094087ad3a4c58efa20d68240e262b4120a00685334287ce9c55dc49b0a346a901d7d59ec3f8dc122e
7
+ data.tar.gz: a44421016fbaf7b9ba237bb5a3bc6936ae5d9f8aa5ae08510f6eff9afe65666341d515a149891098aa2872bc153455a211dfffafbd04795e52e47583dfa6b83b
data/CHANGELOG.md CHANGED
@@ -8,6 +8,33 @@ codes, the resolved-value shape, how revisions are computed, and the schema the
8
8
  generator emits — changes to any of those are breaking even when the method
9
9
  signatures do not move.
10
10
 
11
+ ## [0.8.0] - 2026-09-05
12
+
13
+ ### Added
14
+
15
+ - An on-demand `jazari` executable backed by SQLite: JSON calls, recipe seeding,
16
+ capability discovery, and MCP stdio with no daemon or network listener.
17
+ - The versioned `jazari/v1` request, response, error, and capability JSON
18
+ Schemas, plus backend-neutral conformance fixtures for revisions, daily
19
+ idempotency, snapshots, attribution, evidence, operator edits, and closed
20
+ runs.
21
+
22
+ ### Changed
23
+
24
+ - The shipped migration now preserves its constraints on PostgreSQL while
25
+ emitting equivalent SQLite types and UTC-date enforcement.
26
+ - Run and runbook optimistic-lock races map to `revision_conflict` on both
27
+ adapters.
28
+ - JSON and MCP schemas now close each action independently, so fields accepted
29
+ by a published schema are accepted by the matching runtime action.
30
+
31
+ ### Fixed
32
+
33
+ - Standalone schema creation is atomic under concurrent first use, and local
34
+ anchor targets cannot register or overwrite a Rails host's anchor scopes.
35
+ - The dispatcher and MCP stdio server own their load-time dependencies and keep
36
+ internal failures inside the closed `jazari/v1` error envelope.
37
+
11
38
  ## [0.7.0] - 2026-08-30
12
39
 
13
40
  ### Changed
data/README.md CHANGED
@@ -6,7 +6,8 @@ Recipes as data, per-subject runbooks, stable names for rituals that outlive any
6
6
  record, and per-run evidence — so *"did last night's run actually complete?"*
7
7
  is a query rather than a guess.
8
8
 
9
- Requires Ruby 3.2+, Rails 7.1+, and PostgreSQL.
9
+ Requires Ruby 3.2+. Embed it in Rails 7.1+ with PostgreSQL, or run the
10
+ on-demand standalone CLI with SQLite.
10
11
 
11
12
  ---
12
13
 
@@ -18,6 +19,12 @@ is the operating manual. Its source lives in [`guide/`](guide/), including
18
19
  different than you expect), [adoption](guide/02-adoption.md),
19
20
  [anchors](guide/03-anchors.md), [runs and evidence](guide/04-runs.md),
20
21
  [MCP](guide/05-mcp.md), and [migrating an existing checklist](guide/06-migrating.md).
22
+ [Standalone SQLite, JSON, and MCP stdio](guide/07-standalone.md) covers the
23
+ on-demand executable.
24
+
25
+ The public [`jazari/v1` JSON contract](protocol/v1/) and its conformance fixtures
26
+ are shipped with the gem. Development plans and agent coordination stay out of
27
+ this public repository.
21
28
 
22
29
  ## The problem
23
30
 
@@ -65,6 +72,20 @@ The generator copies one migration. **Jazari never auto-appends migrations** to
65
72
  your schema — a shared operations table appearing in someone's next
66
73
  `db:migrate` without them asking is how a gem loses trust in a production fleet.
67
74
 
75
+ For a local standalone database:
76
+
77
+ ```bash
78
+ jazari recipes seed config/recipes --database .jazari/jazari.sqlite3
79
+ jazari capabilities --database .jazari/jazari.sqlite3 --json
80
+ jazari call --database .jazari/jazari.sqlite3 --request request.json
81
+ jazari mcp stdio --database .jazari/jazari.sqlite3 --recipes config/recipes
82
+ ```
83
+
84
+ The process starts for the command or MCP stdio session and exits with its
85
+ caller. It opens no port, installs no daemon, and stores no credentials. Queue
86
+ and anchor targets are supported; an embedding host still owns authorization
87
+ and record-backed targets.
88
+
68
89
  ## Thirty seconds
69
90
 
70
91
  ```ruby
@@ -211,7 +232,7 @@ Jazari::Mcp::Handler.new.call(action: "get", target: target)
211
232
  with its own subject vocabulary and permissions; this handler is the shared
212
233
  implementation underneath. Domain failures cross the wire as codes from a closed
213
234
  set — `target_not_found`, `invalid_runbook`, `revision_conflict`,
214
- `item_not_found`, `read_only_target`, `run_closed` — never as messages that
235
+ `item_not_found`, `item_not_in_snapshot`, `read_only_target`, `run_closed` — never as messages that
215
236
  could disclose a record or whether a target exists.
216
237
 
217
238
  `Handler.actions_for("read")` returns the read-only subset, so a read-scoped
@@ -251,15 +272,16 @@ That removes the subject's runbook. **Runs are deliberately preserved** — a ru
251
272
  records something that actually happened, and deleting the subject does not
252
273
  un-happen it.
253
274
 
254
- ## PostgreSQL only
275
+ ## PostgreSQL and SQLite
255
276
 
256
- The guarantees lean on Postgres: `jsonb`, `timestamptz`, four CHECK constraints,
257
- and a partial unique index over a `COALESCE`d polymorphic subject. Supporting a
258
- second adapter would make those conditional, which weakens the design. Other
259
- adapters are additive open an issue if you need one.
277
+ Rails hosts keep PostgreSQL's `jsonb`, `timestamptz`, CHECK constraints, and
278
+ partial expression index. Standalone mode uses SQLite JSON columns, UTC date
279
+ checks, foreign keys, WAL, a busy timeout, and the same partial expression
280
+ index. Adapter syntax differs; observable revision, idempotency, snapshot,
281
+ attribution, and evidence behavior does not.
260
282
 
261
- The test suite runs **the migration the gem ships**, so the schema cannot drift
262
- out of coverage.
283
+ The test suite runs **the migration the gem ships** against both adapters and
284
+ replays the same JSON fixtures against PostgreSQL and SQLite.
263
285
 
264
286
  ## What this is not
265
287
 
data/exe/jazari ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "optparse"
6
+ require "jazari/standalone"
7
+
8
+ module Jazari
9
+ module Standalone
10
+ class CLI
11
+ def initialize(argv, input: $stdin, output: $stdout, error: $stderr)
12
+ @argv = argv.dup
13
+ @input = input
14
+ @output = output
15
+ @error = error
16
+ end
17
+
18
+ def run
19
+ command = @argv.shift
20
+ case command
21
+ when "capabilities" then capabilities
22
+ when "call" then call
23
+ when "recipes" then recipes
24
+ when "mcp" then mcp
25
+ else usage!("unknown command #{command.inspect}")
26
+ end
27
+ rescue OptionParser::ParseError, ArgumentError, Errno::ENOENT, JSON::ParserError => exception
28
+ @error.puts("jazari: #{exception.message}")
29
+ 2
30
+ rescue Jazari::Error => exception
31
+ @error.puts("jazari: #{exception.code}: #{exception.message}")
32
+ 1
33
+ rescue ActiveRecord::ActiveRecordError, RuntimeError => exception
34
+ @error.puts("jazari: #{exception.message}")
35
+ 1
36
+ end
37
+
38
+ private
39
+
40
+ def capabilities
41
+ options = parse_common!
42
+ @output.puts(JSON.pretty_generate(app(options).capabilities))
43
+ 0
44
+ end
45
+
46
+ def call
47
+ options = parse_common!({ request: "-" }) do |parser, values|
48
+ parser.on("--request PATH", "JSON request file, or - for stdin") { |value| values[:request] = value }
49
+ end
50
+ body = options[:request] == "-" ? @input.read : File.read(options[:request])
51
+ response = app(options).call(JSON.parse(body))
52
+ @output.puts(JSON.pretty_generate(response))
53
+ response["ok"] ? 0 : 1
54
+ end
55
+
56
+ def recipes
57
+ usage!("expected `recipes seed PATH`") unless @argv.shift == "seed"
58
+ options = parse_common!(positionals: 1)
59
+ path = @argv.shift or usage!("recipe file or directory is required")
60
+ app(options)
61
+ records = RecipeRegistry.seed!(RecipeFiles.load(path))
62
+ @output.puts(JSON.pretty_generate(protocol: Protocol::Dispatcher::VERSION, seeded: records.length))
63
+ 0
64
+ end
65
+
66
+ def mcp
67
+ usage!("expected `mcp stdio`") unless @argv.shift == "stdio"
68
+ options = parse_common!
69
+ Mcp::StdioServer.new(dispatcher: app(options).dispatcher).serve(input: @input, output: @output)
70
+ end
71
+
72
+ def parse_common!(defaults = {}, positionals: 0)
73
+ options = { database: ENV["JAZARI_DATABASE"], recipes: [] }.merge(defaults)
74
+ parser = OptionParser.new do |flags|
75
+ flags.on("--database PATH", "SQLite database path") { |value| options[:database] = value }
76
+ flags.on("--recipes PATH", "Seed recipes from a file or directory") { |value| options[:recipes] << value }
77
+ flags.on("--json", "Emit JSON (machine commands already do)") {}
78
+ yield flags, options if block_given?
79
+ end
80
+ parser.parse!(@argv)
81
+ raise ArgumentError, "--database PATH or JAZARI_DATABASE is required" if options[:database].to_s.empty?
82
+ extras = @argv.drop(positionals)
83
+ raise OptionParser::InvalidArgument, extras.join(" ") if extras.any?
84
+ options
85
+ end
86
+
87
+ def app(options) = App.new(database: options.fetch(:database), recipes: options.fetch(:recipes))
88
+
89
+ def usage!(message)
90
+ raise ArgumentError, "#{message}\nusage: jazari capabilities|call|recipes seed|mcp stdio --database PATH"
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ exit Jazari::Standalone::CLI.new(ARGV).run
@@ -14,7 +14,7 @@ module Jazari
14
14
 
15
15
  source_root File.expand_path("templates", __dir__)
16
16
 
17
- desc "Copies the jazari migration into the host. PostgreSQL only."
17
+ desc "Copies the jazari migration into a PostgreSQL Rails host."
18
18
 
19
19
  def copy_migration
20
20
  migration_template "create_jazari_tables.rb", "db/migrate/create_jazari_tables.rb"
@@ -27,8 +27,9 @@ module Jazari
27
27
  say " 2. Seed your own recipes — the gem ships none by design."
28
28
  say " 3. Jazari.configure { |c| c.anchor_scopes = { ... } } at boot."
29
29
  say ""
30
- say "PostgreSQL is required: jsonb, timestamptz, CHECK constraints, and a"
31
- say "partial unique index over a COALESCEd polymorphic subject."
30
+ say "Rails hosts use PostgreSQL: jsonb, timestamptz, CHECK constraints, and a"
31
+ say "partial unique index over a COALESCEd polymorphic subject. The standalone"
32
+ say "executable manages its own SQLite schema instead."
32
33
  say ""
33
34
  end
34
35
  end
@@ -3,12 +3,18 @@
3
3
  # Host-adopted deliberately: jazari never auto-appends its migrations.
4
4
  class CreateJazariTables < ActiveRecord::Migration[7.1]
5
5
  def change
6
+ adapter = connection.adapter_name
7
+ unless %w[PostgreSQL SQLite].include?(adapter)
8
+ raise ActiveRecord::AdapterNotSpecified, "jazari supports PostgreSQL and SQLite, not #{adapter}"
9
+ end
10
+ postgres = adapter == "PostgreSQL"
11
+
6
12
  create_table :jazari_recipes do |t|
7
13
  t.string :recipe_id, null: false
8
14
  t.integer :version, null: false, default: 1
9
15
  t.string :topic, null: false
10
16
  t.text :description, null: false, default: ""
11
- t.jsonb :checklist, null: false, default: []
17
+ postgres ? t.jsonb(:checklist, null: false, default: []) : t.json(:checklist, null: false, default: [])
12
18
  t.string :run_policy, null: false, default: "unrestricted"
13
19
  t.timestamps
14
20
  t.index :recipe_id, unique: true
@@ -30,7 +36,7 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
30
36
  t.string :recipe_id, null: false
31
37
  t.string :topic, null: false
32
38
  t.text :description, null: false, default: ""
33
- t.jsonb :checklist, null: false, default: []
39
+ postgres ? t.jsonb(:checklist, null: false, default: []) : t.json(:checklist, null: false, default: [])
34
40
  # Provenance, host-defined and nullable. A runbook that exists because a
35
41
  # backfill created it is a migration artifact; one that exists because an
36
42
  # operator edited it is a decision. Both differ from the canon, so
@@ -49,21 +55,32 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
49
55
  t.string :subject_type
50
56
  t.bigint :subject_id
51
57
  t.string :actor_ref, null: false
52
- t.timestamptz :started_at, null: false
58
+ postgres ? t.timestamptz(:started_at, null: false) : t.datetime(:started_at, null: false)
53
59
  t.date :started_on, null: false
54
60
  t.string :idempotency_policy, null: false, default: "unrestricted"
55
- t.timestamptz :finished_at
61
+ postgres ? t.timestamptz(:finished_at) : t.datetime(:finished_at)
56
62
  t.string :outcome
57
- t.jsonb :checklist_snapshot, null: false # no default: every run carries its opening canon
58
- t.jsonb :ticks, null: false, default: []
59
- t.jsonb :evidence, null: false, default: []
63
+ if postgres
64
+ t.jsonb :checklist_snapshot, null: false
65
+ t.jsonb :ticks, null: false, default: []
66
+ t.jsonb :evidence, null: false, default: []
67
+ else
68
+ t.json :checklist_snapshot, null: false
69
+ t.json :ticks, null: false, default: []
70
+ t.json :evidence, null: false, default: []
71
+ end
60
72
  t.integer :lock_version, null: false, default: 0
61
73
  t.timestamps
62
74
  t.index %i[recipe_id started_at]
63
75
  t.index %i[subject_type subject_id started_at]
64
76
  t.check_constraint "idempotency_policy IN ('unrestricted', 'once_per_calendar_day')",
65
77
  name: "jazari_runs_idempotency_policy_chk"
66
- t.check_constraint "started_on = (started_at AT TIME ZONE 'UTC')::date",
78
+ utc_date_expression = if postgres
79
+ "started_on = (started_at AT TIME ZONE 'UTC')::date"
80
+ else
81
+ "started_on = date(started_at)"
82
+ end
83
+ t.check_constraint utc_date_expression,
67
84
  name: "jazari_runs_started_on_utc_chk"
68
85
  t.check_constraint "(subject_type IS NULL AND subject_id IS NULL) OR " \
69
86
  "(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
@@ -14,7 +14,7 @@ module Jazari
14
14
 
15
15
  source_root File.expand_path("templates", __dir__)
16
16
 
17
- desc "Copies the schema changes an already-installed host needs. PostgreSQL only."
17
+ desc "Copies schema changes into an already-installed PostgreSQL Rails host."
18
18
 
19
19
  def copy_migration
20
20
  migration_template "add_jazari_runbook_origin.rb", "db/migrate/add_jazari_runbook_origin.rb"
@@ -12,8 +12,8 @@ module Jazari
12
12
  #
13
13
  # 50 was inherited from a host that already had it, never benchmarked. A
14
14
  # 50-item procedure measured live serializes to 17,252 bytes at 278
15
- # characters per step (codex, on the session-anchor board; the suite's
16
- # synthetic equivalent is 16,691, since real ids and text are not uniform), so the row cap was rejecting documents four times smaller than
15
+ # characters per step (the suite's synthetic equivalent is 16,691, since
16
+ # real ids and text are not uniform), so the row cap was rejecting documents four times smaller than
17
17
  # what actually hurts — and would have accepted 50 steps of 500 characters,
18
18
  # which is nearly twice as large again.
19
19
  #
@@ -5,6 +5,8 @@
5
5
  # Checklist even when it is required on its own — which the standalone-require
6
6
  # test exists to catch, and did.
7
7
  require "jazari/checklist"
8
+ require "jazari/operations"
9
+ require "jazari/runs"
8
10
 
9
11
  module Jazari
10
12
  module Mcp
@@ -25,14 +27,29 @@ module Jazari
25
27
  # :additive — writes, but cannot destroy prior state
26
28
  # :overwrite — may replace an operator's content; annotate cautiously
27
29
  # :destructive — removes something; gate it
30
+ REQUIRED_ARGUMENTS = {
31
+ "get" => [], "last_run" => [],
32
+ "set" => %i[expected_revision topic checklist],
33
+ "add_item" => %i[expected_revision text],
34
+ "remove_item" => %i[expected_revision item_id],
35
+ "check_item" => %i[expected_revision item_id done],
36
+ "reset" => %i[expected_revision confirm],
37
+ "start" => %i[actor_ref],
38
+ "tick" => %i[run_id expected_revision item_id done],
39
+ "evidence" => %i[run_id expected_revision kind value],
40
+ "finish" => %i[run_id expected_revision outcome]
41
+ }.freeze
42
+
28
43
  Action = Data.define(:name, :scope, :effect, :summary, :params, :confirm) do
29
44
  def read? = scope == :read
30
45
  def confirm? = confirm == true
46
+ def required = REQUIRED_ARGUMENTS.fetch(name)
31
47
  def to_h = { name: name, scope: scope, effect: effect, summary: summary,
32
48
  params: params, confirm: confirm }
33
49
  end
34
50
 
35
51
  REVISION = { type: "string",
52
+ minLength: 1,
36
53
  description: "The revision from the read immediately before this call." }.freeze
37
54
  # JSON Schema patterns are ECMAScript, which has no `\A`/`\z`. Publishing
38
55
  # Ruby's source verbatim hands validators an anchor they may reject or,
@@ -40,7 +57,7 @@ module Jazari
40
57
  # else derived from ID_FORMAT, which stays the single source of truth.
41
58
  ID_PATTERN = Checklist::ID_FORMAT.source.sub('\\A', "^").sub('\\z', "$").freeze
42
59
 
43
- ITEM_ID = { type: "string", description: "Opaque checklist item id." }.freeze
60
+ ITEM_ID = { type: "string", minLength: 1, description: "Opaque checklist item id." }.freeze
44
61
 
45
62
  # An `array` with no `items` is not a contract, it is a guess. A client
46
63
  # generating from that descriptor is free to infer `string[]`, send
@@ -75,8 +92,8 @@ module Jazari
75
92
  description: "Full checklist; replaces the existing one. There is no row limit, " \
76
93
  "but the serialized checklist must be at most " \
77
94
  "#{Checklist::MAX_PAYLOAD} bytes." }.freeze
78
- RUN_ID = { type: "integer", description: "The run returned by start." }.freeze
79
- ACTOR = { type: "string", description: "Opaque identity of who is acting." }.freeze
95
+ RUN_ID = { type: "integer", minimum: 1, description: "The run returned by start." }.freeze
96
+ ACTOR = { type: "string", minLength: 1, description: "Opaque identity of who is acting." }.freeze
80
97
 
81
98
  ALL = [
82
99
  Action.new(name: "get", scope: :read, effect: :read, confirm: false,
@@ -88,13 +105,16 @@ module Jazari
88
105
  Action.new(name: "set", scope: :write, effect: :overwrite, confirm: false,
89
106
  summary: "Replace this subject's procedure. Materialises an override on first use.",
90
107
  params: { expected_revision: REVISION,
91
- topic: { type: "string", description: "Short title." },
92
- description: { type: "string", description: "Markdown body." },
108
+ topic: { type: "string", minLength: 1, maxLength: Operations::MAX_TOPIC,
109
+ description: "Short title." },
110
+ description: { type: "string", maxLength: Operations::MAX_DESCRIPTION,
111
+ description: "Markdown body." },
93
112
  checklist: CHECKLIST }),
94
113
  Action.new(name: "add_item", scope: :write, effect: :additive, confirm: false,
95
114
  summary: "Append one checklist step.",
96
115
  params: { expected_revision: REVISION,
97
- text: { type: "string", description: "The step." },
116
+ text: { type: "string", minLength: 1, maxLength: Checklist::MAX_TEXT,
117
+ description: "The step." },
98
118
  required: { type: "boolean", description: "Whether the step is required. Default true." } }),
99
119
  Action.new(name: "remove_item", scope: :write, effect: :destructive, confirm: false,
100
120
  summary: "Delete one checklist step.",
@@ -102,30 +122,35 @@ module Jazari
102
122
  Action.new(name: "check_item", scope: :write, effect: :additive, confirm: false,
103
123
  summary: "Mark a step done or not done.",
104
124
  params: { expected_revision: REVISION, item_id: ITEM_ID,
105
- done: { type: "boolean", description: "Default true." } }),
125
+ done: { type: "boolean", description: "Whether the step is done." } }),
106
126
  Action.new(name: "reset", scope: :write, effect: :destructive, confirm: true,
107
127
  summary: "Discard this subject's override and reveal the current canon.",
108
128
  params: { expected_revision: REVISION,
109
- confirm: { type: "boolean", description: "Must be true — this discards operator content." } }),
129
+ confirm: { type: "boolean", const: true,
130
+ description: "Must be true — this discards operator content." } }),
110
131
  Action.new(name: "start", scope: :write, effect: :additive, confirm: false,
111
132
  summary: "Open a run. Under a once-per-day recipe this returns the existing run instead of erroring.",
112
133
  params: { actor_ref: ACTOR }),
113
134
  Action.new(name: "tick", scope: :write, effect: :additive, confirm: false,
114
135
  summary: "Record a step done within a run. Does not touch the subject's own checklist.",
115
136
  params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
116
- done: { type: "boolean", description: "Default true." },
137
+ done: { type: "boolean", description: "Whether the step is done." },
117
138
  actor_ref: ACTOR,
118
- note: { type: "string", description: "Optional free text." } }),
139
+ note: { type: "string", maxLength: Checklist::MAX_TEXT,
140
+ description: "Optional free text." } }),
119
141
  Action.new(name: "evidence", scope: :write, effect: :additive, confirm: false,
120
142
  summary: "Attach evidence to a run: output, url, sha, count, or note.",
121
143
  params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
122
- kind: { type: "string", description: "One of: output, url, sha, count, note." },
123
- value: { type: "string", description: "The evidence itself." },
144
+ kind: { type: "string", enum: Runs::EVIDENCE_KINDS,
145
+ description: "The evidence kind." },
146
+ value: { type: "string", maxLength: Runs::MAX_EVIDENCE,
147
+ description: "The evidence itself." },
124
148
  actor_ref: ACTOR }),
125
149
  Action.new(name: "finish", scope: :write, effect: :additive, confirm: false,
126
150
  summary: "Close a run with an outcome: completed, abandoned, or failed.",
127
151
  params: { run_id: RUN_ID, expected_revision: REVISION,
128
- outcome: { type: "string", description: "completed | abandoned | failed" } })
152
+ outcome: { type: "string", enum: %w[completed abandoned failed],
153
+ description: "How the run ended." } })
129
154
  ].freeze
130
155
 
131
156
  NAMES = ALL.map(&:name).freeze
@@ -162,6 +187,26 @@ module Jazari
162
187
  { enum: actions.map(&:name), properties: properties }
163
188
  end
164
189
 
190
+ # A complete schema for a flat action-enum tool. Each action is its own
191
+ # closed variant, so a field that belongs to `tick` is not accidentally
192
+ # advertised as legal for `get` merely because both share one tool.
193
+ def input_schema(target_schema:, scope: :write)
194
+ {
195
+ type: "object",
196
+ oneOf: all(scope: scope).map do |action|
197
+ {
198
+ type: "object",
199
+ required: [ :action, :target, *action.required ],
200
+ properties: {
201
+ action: { const: action.name },
202
+ target: target_schema
203
+ }.merge(action.params),
204
+ additionalProperties: false
205
+ }
206
+ end
207
+ }
208
+ end
209
+
165
210
  # Human-readable action list for a tool description or a paired skill.
166
211
  def summaries(scope: :write)
167
212
  all(scope: scope).map { |a| "#{a.name} — #{a.summary}" }