jazari 0.1.0 → 0.2.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: dcd3c77813c6290564bdaeb857e3e16ccf09381f14ed5042bec05c4c78d34af1
4
- data.tar.gz: 655b4e0d33237093113ee341b02acc5f7a51b1ad16a781fa4c15faa8ce482af0
3
+ metadata.gz: 781a303d10e42b7e3337381b7d167aaeb227a5cf538e4122f1b37c2143216f8f
4
+ data.tar.gz: b6c2d6ca19ccc6d097ea30ddc644f2f58144d6fc9d6a74722e2f625c7502ba18
5
5
  SHA512:
6
- metadata.gz: dd38fccf2e5720b002824294ae7a178e206d6dccef3489cc61ce953fa507f991069e69ee9ed34b15f3a8de64c29edc7cbce8d8c02dea7c3a351a2d9ce8ab3b8f
7
- data.tar.gz: e163aa00ba58ef159416ca8f41360ee8bf1ad3743c1b129d61ad0e0e5cfb353111903f890545d5439e449149b9a4c2e0deda8c13b853ff3272fabff1788abfd4
6
+ metadata.gz: 9d42855d9257d25cb193423c6864db7451006ae2b07fe9b013442b2cf3c3c1e8f1ceca6208e5e23b1a8f60d1d57b5502ea31e5859e81734dd3a1412f0e8accd4
7
+ data.tar.gz: 156a565d0e8ab2b8f8f51dbb335c0410f60484da523ba9a1de6fd216bf3377f720cc3909722bed78f3854b5a836ea126a89106831e57c7c3ac9fa160802731f9
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ 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.2.0] - 2026-08-10
12
+
13
+ ### Added
14
+
15
+ - **`Jazari::Mcp::Actions`** — every action declared as data (`scope`, `effect`,
16
+ `confirm`, parameter schemas, summary), with `schema_fragment` for merging into
17
+ a host's own MCP tool. A host with an existing MCP surface keeps its tool name,
18
+ envelope, dispatch, and permissions, and calls `Jazari.*` directly — no
19
+ coupling to this gem's handler or reply shape.
20
+ - `Jazari::Mcp::Actions.summaries` for a tool description or paired skill.
21
+
22
+ ### Changed
23
+
24
+ - **The MCP layer is now genuinely optional.** Descriptors load with the gem;
25
+ the dispatcher does not. `require "jazari/mcp/handler"` to opt in.
26
+ - `Mcp::Handler` validates and scopes against the same declarations, so a
27
+ published schema and the implemented behaviour cannot drift. A test asserts the
28
+ handler dispatches every declared action.
29
+
30
+ ### Fixed
31
+
32
+ - `mcp/handler.rb` now requires `mcp/actions`. With the layer opt-in, a host
33
+ requiring the handler alone would have raised `NameError`.
34
+
11
35
  ## [0.1.0] - 2026-08-10
12
36
 
13
37
  First release. Proven against one host adoption.
data/README.md CHANGED
@@ -10,6 +10,14 @@ Requires Ruby 3.2+, Rails 7.1+, and PostgreSQL.
10
10
 
11
11
  ---
12
12
 
13
+ ## Guides
14
+
15
+ The README is the pitch; the [guides](guide/) are the working documents —
16
+ [concepts](guide/01-concepts.md) (start here: one word means something
17
+ different than you expect), [adoption](guide/02-adoption.md),
18
+ [anchors](guide/03-anchors.md), [runs and evidence](guide/04-runs.md),
19
+ [MCP](guide/05-mcp.md), and [migrating an existing checklist](guide/06-migrating.md).
20
+
13
21
  ## The problem
14
22
 
15
23
  You have a procedure. Verify a backup by restoring it. Provision a server.
@@ -221,34 +229,19 @@ out of coverage.
221
229
  ## What this is not
222
230
 
223
231
  Not an execution framework. Jazari holds the *state* of a procedure — the canon,
224
- the overrides, the runs, the evidence. It does not SSH anywhere or run your
225
- commands.
226
-
227
- For the execution half, see [braintree/runbook](https://github.com/braintree/runbook):
228
- a Ruby DSL for running operational procedures, with resumable state and a dry-run
229
- mode. It has no data model; Jazari has no executor. They compose.
230
-
231
- ## Why "Jazari"
232
-
233
- Ismail **al-Jazari** (1136–1206), engineer at the Artuqid court in Diyarbakır,
234
- built programmable automata a hand-washing machine that offered you a towel,
235
- a clock driven by a water wheel, pumps with the earliest known crankshafts.
236
-
237
- That is not why the gem carries his name.
238
-
239
- He also wrote *The Book of Knowledge of Ingenious Mechanical Devices*, finished
240
- the year he died: fifty machines, each with numbered construction steps and
241
- drawings detailed enough that a stranger who had never met him could rebuild the
242
- device. He wrote down the *procedure*, not just the result — including, in his
243
- own words, the steps he had gotten wrong first.
244
-
245
- Eight hundred years later people have built working machines from those pages.
246
-
247
- That is the whole idea here. A procedure is not lore in someone's head or prose
248
- in a document nobody opens. It is a written, versioned, checkable artefact that
249
- somebody else can execute and prove they executed.
250
-
251
- `runbook` was taken on RubyGems — by the execution framework above, fittingly.
232
+ the overrides, the runs, the evidence. It does not SSH anywhere, shell out, or
233
+ run your commands.
234
+
235
+ That boundary is deliberate. Execution is already well served by whatever you
236
+ have CI, a rake task, Ansible, a deploy tool and those differ per shop.
237
+ What none of them keep is a durable, checkable, addressable record of *which
238
+ procedure* was run, by whom, and what came back. Jazari keeps that, and stays
239
+ out of the way of however you actually run things.
240
+
241
+ (The name `runbook` was already taken on RubyGems, by a DSL for executing
242
+ operational procedures. That gem's last release was 2021 and its last commit
243
+ 2022, so it is not a dependency worth taking but the name is still occupied,
244
+ which is one reason this gem is called jazari.)
252
245
 
253
246
  ## License
254
247
 
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ module Mcp
5
+ # Declarative descriptors for every action, so a host can absorb jazari into
6
+ # ITS OWN tool instead of adopting this gem's handler.
7
+ #
8
+ # Without these, a host has two bad choices: take `Mcp::Handler`'s reply
9
+ # shape wholesale, or hand-write the action list and let it drift from the
10
+ # gem it describes. Neither is acceptable for a host that already has an
11
+ # MCP surface with its own envelope, naming, and permission model.
12
+ #
13
+ # These descriptors are the single source of truth: `Handler` validates and
14
+ # scopes against them too, so the schema a host publishes and the behaviour
15
+ # the gem implements cannot diverge.
16
+ module Actions
17
+ # effect drives how a host should annotate the action to its clients:
18
+ # :read — no writes
19
+ # :additive — writes, but cannot destroy prior state
20
+ # :overwrite — may replace an operator's content; annotate cautiously
21
+ # :destructive — removes something; gate it
22
+ Action = Data.define(:name, :scope, :effect, :summary, :params, :confirm) do
23
+ def read? = scope == :read
24
+ def confirm? = confirm == true
25
+ def to_h = { name: name, scope: scope, effect: effect, summary: summary,
26
+ params: params, confirm: confirm }
27
+ end
28
+
29
+ REVISION = { type: "string",
30
+ description: "The revision from the read immediately before this call." }.freeze
31
+ ITEM_ID = { type: "string", description: "Opaque checklist item id." }.freeze
32
+ RUN_ID = { type: "integer", description: "The run returned by start." }.freeze
33
+ ACTOR = { type: "string", description: "Opaque identity of who is acting." }.freeze
34
+
35
+ ALL = [
36
+ Action.new(name: "get", scope: :read, effect: :read, confirm: false,
37
+ summary: "Resolve the operating procedure for a target, with progress and last run.",
38
+ params: {}),
39
+ Action.new(name: "last_run", scope: :read, effect: :read, confirm: false,
40
+ summary: "The most recent run for a target — answers whether the ritual actually happened.",
41
+ params: {}),
42
+ Action.new(name: "set", scope: :write, effect: :overwrite, confirm: false,
43
+ summary: "Replace this subject's procedure. Materialises an override on first use.",
44
+ params: { expected_revision: REVISION,
45
+ topic: { type: "string", description: "Short title." },
46
+ description: { type: "string", description: "Markdown body." },
47
+ checklist: { type: "array", description: "Full checklist; replaces the existing one." } }),
48
+ Action.new(name: "add_item", scope: :write, effect: :additive, confirm: false,
49
+ summary: "Append one checklist step.",
50
+ params: { expected_revision: REVISION,
51
+ text: { type: "string", description: "The step." },
52
+ required: { type: "boolean", description: "Whether the step is required. Default true." } }),
53
+ Action.new(name: "remove_item", scope: :write, effect: :destructive, confirm: false,
54
+ summary: "Delete one checklist step.",
55
+ params: { expected_revision: REVISION, item_id: ITEM_ID }),
56
+ Action.new(name: "check_item", scope: :write, effect: :additive, confirm: false,
57
+ summary: "Mark a step done or not done.",
58
+ params: { expected_revision: REVISION, item_id: ITEM_ID,
59
+ done: { type: "boolean", description: "Default true." } }),
60
+ Action.new(name: "reset", scope: :write, effect: :destructive, confirm: true,
61
+ summary: "Discard this subject's override and reveal the current canon.",
62
+ params: { expected_revision: REVISION,
63
+ confirm: { type: "boolean", description: "Must be true — this discards operator content." } }),
64
+ Action.new(name: "start", scope: :write, effect: :additive, confirm: false,
65
+ summary: "Open a run. Under a once-per-day recipe this returns the existing run instead of erroring.",
66
+ params: { actor_ref: ACTOR }),
67
+ Action.new(name: "tick", scope: :write, effect: :additive, confirm: false,
68
+ summary: "Record a step done within a run. Does not touch the subject's own checklist.",
69
+ params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
70
+ done: { type: "boolean", description: "Default true." },
71
+ actor_ref: ACTOR,
72
+ note: { type: "string", description: "Optional free text." } }),
73
+ Action.new(name: "evidence", scope: :write, effect: :additive, confirm: false,
74
+ summary: "Attach evidence to a run: output, url, sha, count, or note.",
75
+ params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
76
+ kind: { type: "string", description: "One of: output, url, sha, count, note." },
77
+ value: { type: "string", description: "The evidence itself." } }),
78
+ Action.new(name: "finish", scope: :write, effect: :additive, confirm: false,
79
+ summary: "Close a run with an outcome: completed, abandoned, or failed.",
80
+ params: { run_id: RUN_ID, expected_revision: REVISION,
81
+ outcome: { type: "string", description: "completed | abandoned | failed" } })
82
+ ].freeze
83
+
84
+ NAMES = ALL.map(&:name).freeze
85
+
86
+ module_function
87
+
88
+ # scope: :read advertises only the read-only subset. A read-scoped
89
+ # connection should not SEE mutations in its tool list — offering them and
90
+ # then refusing is a worse experience than not offering them.
91
+ def all(scope: :write)
92
+ scope.to_s == "read" ? ALL.select(&:read?) : ALL
93
+ end
94
+
95
+ def names(scope: :write) = all(scope: scope).map(&:name)
96
+
97
+ def fetch(name)
98
+ ALL.find { |action| action.name == name.to_s } or
99
+ raise ArgumentError, "unknown runbook action #{name.inspect}"
100
+ end
101
+
102
+ # A fragment a host merges into its OWN tool's input schema. It deliberately
103
+ # returns only the action enum and the parameter properties — the host owns
104
+ # the tool name, the description, its own target params, and its reply
105
+ # envelope. Nothing here presumes this gem's handler is in the loop.
106
+ #
107
+ # frag = Jazari::Mcp::Actions.schema_fragment
108
+ # MY_TOOL[:input_schema][:properties].merge!(frag[:properties])
109
+ # MY_TOOL[:input_schema][:properties][:action][:enum] += frag[:enum]
110
+ def schema_fragment(scope: :write)
111
+ actions = all(scope: scope)
112
+ properties = actions.each_with_object({}) do |action, acc|
113
+ action.params.each { |key, spec| acc[key] ||= spec }
114
+ end
115
+ { enum: actions.map(&:name), properties: properties }
116
+ end
117
+
118
+ # Human-readable action list for a tool description or a paired skill.
119
+ def summaries(scope: :write)
120
+ all(scope: scope).map { |a| "#{a.name} — #{a.summary}" }
121
+ end
122
+ end
123
+ end
124
+ end
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The handler dispatches against Mcp::Actions, so it must require it: a host may
4
+ # `require "jazari/mcp/handler"` on its own now that this layer is opt-in.
5
+ require "jazari/mcp/actions"
6
+
3
7
  module Jazari
4
8
  module Mcp
5
9
  # Transport-neutral adapter between an MCP action name and the domain.
@@ -13,23 +17,15 @@ module Jazari
13
17
  # That split is deliberate: two products sharing this handler still present
14
18
  # their own tool, their own subject vocabulary, and their own permissions.
15
19
  class Handler
16
- READ_ACTIONS = %w[get last_run].freeze
17
- WRITE_ACTIONS = %w[set add_item remove_item check_item reset start tick evidence finish].freeze
18
- ACTIONS = (READ_ACTIONS + WRITE_ACTIONS).freeze
19
-
20
- # Advertise only what the granted scope may call. A read-scoped connection
21
- # should not see mutations in its tool list at all — hiding them is not
22
- # security, but offering them and refusing is a worse experience.
23
- def self.actions_for(scope)
24
- scope.to_s == "read" ? READ_ACTIONS : ACTIONS
25
- end
20
+ # Actions are declared once, in Mcp::Actions, so the schema a host
21
+ # publishes and the behaviour implemented here cannot drift apart.
22
+ def self.actions_for(scope) = Actions.names(scope: scope)
26
23
 
27
24
  def call(action:, target:, arguments: {})
28
25
  args = symbolize(arguments)
29
- name = action.to_s
30
- raise ArgumentError, "unknown runbook action #{action.inspect}" unless ACTIONS.include?(name)
26
+ descriptor = Actions.fetch(action) # raises on an unknown action
31
27
 
32
- reply(public_send(:"handle_#{name}", target, args))
28
+ reply(public_send(:"handle_#{descriptor.name}", target, args))
33
29
  rescue Jazari::Error => error
34
30
  # The closed taxonomy crosses the wire as a code, never as a message
35
31
  # that could disclose a record, a query, or whether a target exists.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Jazari
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/jazari.rb CHANGED
@@ -10,7 +10,11 @@ require "jazari/resolved_runbook"
10
10
  require "jazari/recipe_registry"
11
11
  require "jazari/runs"
12
12
  require "jazari/operations"
13
- require "jazari/mcp/handler"
13
+ # The MCP layer is OPTIONAL. Descriptors are cheap and a host may want them to
14
+ # build its own tool, so they load; the handler does not, because a host with
15
+ # its own MCP surface never calls it.
16
+ # require "jazari/mcp/handler" # only if you want the ready-made dispatcher
17
+ require "jazari/mcp/actions"
14
18
  require "jazari/railtie" if defined?(::Rails::Railtie)
15
19
 
16
20
  # Addressable operating procedures.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jazari
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nauman Tariq
@@ -60,6 +60,7 @@ files:
60
60
  - lib/jazari/anchors.rb
61
61
  - lib/jazari/checklist.rb
62
62
  - lib/jazari/errors.rb
63
+ - lib/jazari/mcp/actions.rb
63
64
  - lib/jazari/mcp/handler.rb
64
65
  - lib/jazari/operations.rb
65
66
  - lib/jazari/railtie.rb