robot_lab-cyborg 0.2.7

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d5289e0bf089059f451300a62b776b06141d684bb60ed6df407624313a695500
4
+ data.tar.gz: eab4d957cbd19ca2fb66e97d2ba2ddd806167c0dab6ffb623fad10204386cdd4
5
+ SHA512:
6
+ metadata.gz: f6a29be1c412e3005a55e25bfaacfbb60d6a535a636e9692f188da47ee09da8736268561570beba5d2164066dca8abbbac68f8f538a8171be2815697e8929d41
7
+ data.tar.gz: 01b49e8eed2356da4eafb1bf7e48dc408a84e877bcd52aae5e5a75a80927dab83a8d4daf73ad875199cf44ef5e5bcf33b8d9b0aa2379d7e8d9b52433ba7bf17c
data/.envrc ADDED
@@ -0,0 +1 @@
1
+ export RR=$(pwd)
@@ -0,0 +1,52 @@
1
+ name: Deploy Documentation to GitHub Pages
2
+ on:
3
+ push:
4
+ branches:
5
+ - main
6
+ - develop
7
+ paths:
8
+ - "docs/**"
9
+ - "mkdocs.yml"
10
+ - ".github/workflows/deploy-github-pages.yml"
11
+ workflow_dispatch:
12
+
13
+ permissions:
14
+ contents: write
15
+ pages: write
16
+ id-token: write
17
+
18
+ jobs:
19
+ deploy:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Checkout code
23
+ uses: actions/checkout@v4
24
+ with:
25
+ fetch-depth: 0
26
+
27
+ - name: Setup Python
28
+ uses: actions/setup-python@v5
29
+ with:
30
+ python-version: 3.x
31
+
32
+ - name: Install dependencies
33
+ run: |
34
+ pip install mkdocs
35
+ pip install mkdocs-material
36
+ pip install mkdocs-macros-plugin
37
+ pip install mike
38
+
39
+ - name: Configure Git
40
+ run: |
41
+ git config --local user.email "action@github.com"
42
+ git config --local user.name "GitHub Action"
43
+
44
+ - name: Build MkDocs site
45
+ run: mkdocs build
46
+
47
+ - name: Deploy to GitHub Pages
48
+ uses: peaceiris/actions-gh-pages@v4
49
+ with:
50
+ github_token: ${{ secrets.GITHUB_TOKEN }}
51
+ publish_dir: ./site
52
+ keep_files: true
data/.loki ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ # robot_lab-cyborg — human-in-the-network peer worker for RobotLab.
3
+
4
+ import_up "repo_dev.loki"
5
+
6
+ class Tasks
7
+ @@gem_name ||= "robot_lab-cyborg".freeze
8
+
9
+ header "robot_lab-cyborg v#{gem_version} — human peer worker"
10
+ end
data/.rubocop.yml ADDED
@@ -0,0 +1 @@
1
+ inherit_from: ../.rubocop-base.yml
data/CHANGELOG.md ADDED
@@ -0,0 +1,94 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Fixed (from the design/architecture review)
11
+ - **Consumer no longer wedges on a handler error.** An exception in an
12
+ `on_initiative`/channel path used to kill the Interviewer's consumer thread and
13
+ hang every future ask; it is now caught (recorded in `Interviewer#last_error`)
14
+ and the loop keeps serving. *(A1)*
15
+ - **Inbound bus tasks no longer block the poller.** A Cyborg answers an inbound
16
+ task on its own thread, so a slow or absent human never stalls bus intake, and
17
+ the default no longer deadlocks. *(A2)*
18
+ - **No more mis-attributed answers on a dumb channel.** On a channel that can't
19
+ correlate replies (`Channel#correlates? == false`, e.g. a terminal), questions
20
+ are *serialized* — one on the wire at a time — and an answer resolves that
21
+ active question. A `Terminal` now honors `receive(timeout:)` (via
22
+ `wait_readable`), so timeouts, `close`, and expiry actually work. *(A3, A4)*
23
+ - **Shared memory no longer leaks across runs.** `attach_memory`/`detach_memory`
24
+ make the target explicit, and access is thread-safe. *(A5)*
25
+ - Core `RobotLab::Robot::BusMessaging` synchronizes its message counter/outbox,
26
+ and gains `respond_to_tasks`/`serve` so a **Robot** auto-answers bus tasks the
27
+ way a Cyborg does — man/machine peers are now symmetric on the bus. *(A6, B1)*
28
+
29
+ ### Added
30
+ - **Duplex to the human's channel.** Inbound bus messages/replies are now
31
+ delivered to the human's channel (`Cyborg#tell` for the output direction);
32
+ `ChannelMessage` carries `sender`/`kind`/`at` so a terminal can label who is
33
+ speaking and only prompt on questions. *(B2, B5)*
34
+ - **Always-on listening.** `Cyborg#listen`/`converse` keep reading the channel
35
+ with no question outstanding, so a human can address the network unprompted;
36
+ their input arrives via `on_human`. *(B3)*
37
+ - **`Conversation`** — `@mention` addressing, multi-mention fan-out, and
38
+ no-mention broadcast now live in the library (`Cyborg#converse`), not in
39
+ example code. Replies flow back via the duplex. *(B4)*
40
+ - **Typed/validated answers** — `Cyborg#ask(validate:, retries:)` plus
41
+ `ask_int`/`ask_confirm` re-ask on bad input and return coerced values. *(C)*
42
+ - **Presence** — `online!`/`away!`/`offline!`/`available?`; an offline human
43
+ declines inbound tasks immediately so the network can route around/escalate.
44
+ *(B8)*
45
+ - `Cyborg#ask_async` returns the pending `Question` for non-blocking waits — the
46
+ primitive a durable/suspendable human step would persist (see README, *Durable
47
+ human steps*, for the intended `robot_lab-durable` integration — *B7*).
48
+
49
+ ### Changed
50
+ - Split reaching the human into two concerns: **`Channel`** (the *means* — a
51
+ dumb bidirectional pipe: `deliver` out, `receive` in) and **`Interviewer`**
52
+ (the *process* that conducts the ask over a channel). `$stdin`/`$stdout` now
53
+ live only inside `Channel::Terminal`; `Cyborg` depends on an injected
54
+ `channel:` and no longer takes `input:`/`output:`.
55
+ - `Interviewer::Terminal` / `Interviewer::Scripted` moved to
56
+ `Channel::Terminal` / `Channel::Scripted`. `Cyborg.new` takes `channel:`
57
+ instead of `interviewer:` (an `interviewer:` may still be injected for full
58
+ control).
59
+ - Read the questions a Scripted human was shown via `cyborg.channel.asked`
60
+ (was `cyborg.interviewer.asked`).
61
+
62
+ ### Added
63
+ - **Asynchronous asking.** A human's answer may be the next inbound message, a
64
+ later one, or never. `Interviewer#ask` now returns a `Question` (a one-shot
65
+ future); `Cyborg#ask_async` exposes it. The synchronous `Cyborg#ask` /
66
+ `Interviewer#ask_and_wait` wait with an optional timeout.
67
+ - **Reply correlation.** Inbound messages carry an optional `in_reply_to` so a
68
+ rich transport (Slack threads, email) resolves the exact question; a bare
69
+ terminal falls back to oldest-outstanding (FIFO).
70
+ - **Bounded waiting.** `Cyborg.new(ask_timeout:)` and per-call `timeout:` give a
71
+ slow or absent human a deadline, after which the answer is the default (or nil).
72
+ - `Cyborg#on_human` — surfaces unsolicited human messages (initiative) that
73
+ answer no outstanding question. (Routing initiative onto the bus is future work.)
74
+
75
+ ## [0.1.0] - 2026-07-24
76
+
77
+ ### Added
78
+ - `RobotLab::Cyborg` — a human peer worker that joins a RobotLab network at the
79
+ same level as the robots. It reuses `RobotLab::Robot::BusMessaging`, so its bus
80
+ behavior is identical to a robot's, but requires no LLM, model, or API key.
81
+ - **Receives tasking** as a pipeline step (`network.task :name, cyborg`) via the
82
+ `call`/`run` member contract, and over the bus (inbound messages are surfaced
83
+ to the human and answered, with optional `auto_reply`).
84
+ - **Issues tasking** to other members with `assign` (bus) and `delegate` (sync or
85
+ async, returning a `RobotResult` / `DelegationFuture`).
86
+ - **Shares memory** with the network via `remember` / `recall`.
87
+ - `RobotLab::Cyborg::Interviewer` — the pluggable human interface, with
88
+ `Terminal` (injectable IO) and `Scripted` (tests/automation) implementations.
89
+ - Registers itself with core via `RobotLab.register_extension(:cyborg, …)`.
90
+ - Minitest suite covering the interviewers, the network-member contract and shared
91
+ memory, the bus task/reply round-trip, and delegation.
92
+
93
+ [Unreleased]: https://github.com/MadBomber/robot_lab-cyborg/compare/v0.1.0...HEAD
94
+ [0.1.0]: https://github.com/MadBomber/robot_lab-cyborg/releases/tag/v0.1.0
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Dewayne VanHoozer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,138 @@
1
+ # robot_lab-cyborg
2
+
3
+ A [RobotLab](https://github.com/MadBomber/robot_lab) extension gem that puts a **human** into the network as a peer worker.
4
+
5
+ Robots on a RobotLab network are LLM-backed workers. A **Cyborg** is a *human*-backed worker that sits at the same level as the robots: it registers as a network task, speaks on the same [TypedBus](https://github.com/MadBomber/typed_bus) channels, reads and writes the same shared memory, **receives tasking** (as a pipeline step and as bus messages), and **issues tasking** to the other members — humans and robots alike.
6
+
7
+ A Cyborg reuses `RobotLab::Robot::BusMessaging` verbatim, so its bus behavior is byte-for-byte identical to a robot's. It deliberately does **not** subclass `Robot`: a human needs no model and no API key. The human is the "model," reached across an injectable **`Channel`** (the *means* — terminal now, Slack/email/web later) by an **`Interviewer`** (the *process* that conducts the ask).
8
+
9
+ ## Installation
10
+
11
+ Add to your Gemfile:
12
+
13
+ ```ruby
14
+ gem "robot_lab"
15
+ gem "robot_lab-cyborg"
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ```ruby
21
+ require "robot_lab"
22
+ require "robot_lab/cyborg"
23
+
24
+ # A human peer. By default it talks to a terminal; inject a Channel to
25
+ # reach the human over a web form, a queue, a chat app, or a test.
26
+ dewayne = RobotLab::Cyborg.new(name: "dewayne")
27
+ ```
28
+
29
+ ### A human as a pipeline step
30
+
31
+ The human is interchangeable with a robot anywhere a member is expected:
32
+
33
+ ```ruby
34
+ writer = RobotLab.build(name: "writer", template: :writer)
35
+
36
+ network = RobotLab.create_network(name: "release") do
37
+ task :draft, writer, depends_on: :none
38
+ task :approve, dewayne, depends_on: [:draft] # the human signs off
39
+ end
40
+
41
+ network.run(message: "Draft the release notes")
42
+ ```
43
+
44
+ ### Peers messaging over a shared bus
45
+
46
+ Robots and cyborgs talk to each other by name over one shared bus:
47
+
48
+ ```ruby
49
+ bus = TypedBus::MessageBus.new
50
+ analyst = RobotLab.build(name: "analyst", bus: bus)
51
+ dewayne = RobotLab::Cyborg.new(name: "dewayne", bus: bus)
52
+
53
+ # The robot asks the human a question; the human's answer comes back as a reply.
54
+ analyst.send_message(to: :dewayne, content: "Approve the deploy? (yes/no)")
55
+
56
+ # The human issues work to the robot, too.
57
+ dewayne.assign(to: :analyst, task: "Summarize today's error budget.")
58
+ ```
59
+
60
+ ### Shared memory
61
+
62
+ ```ruby
63
+ dewayne.remember(:decision, "ship it") # visible to every member
64
+ dewayne.recall(:sentiment, wait: 30) # block until a robot writes it
65
+ ```
66
+
67
+ ### Channels and the Interviewer
68
+
69
+ Reaching the human is split into two concerns:
70
+
71
+ - A **`Channel`** is the *means* — a dumb bidirectional pipe with two jobs: `deliver` a message **out** to the human, and surface messages the human sends **in**. It knows nothing about questions or answers. This is the injection point; `$stdin`/`$stdout` live only inside `Channel::Terminal`.
72
+ - An **`Interviewer`** is the *process* — it conducts the interaction over whatever channel is injected. Asking is **always asynchronous**: the human's answer may be the next inbound message, a later one, or never. So `ask` delivers the question and returns a `Question` you can wait on with a timeout; a background consumer matches answers to questions (by the channel's correlation id when it has one, else oldest-first) and routes anything unsolicited as human *initiative*.
73
+
74
+ Built-in channels:
75
+
76
+ - `Channel::Terminal` — the human at a keyboard (the default); IO is injectable.
77
+ - `Channel::Scripted` — canned answers in order, with exact correlation (tests, automation, replay).
78
+ - `Channel` — the abstract base; subclass it (implement `deliver`/`receive`) to bridge to Slack, email, a web UI, or a task queue.
79
+
80
+ A channel that can tie an answer back to its question (Slack threads, email) reports `correlates? == true`, and the Interviewer lets several questions be outstanding at once. A dumb channel (a bare terminal) reports `false`, and the Interviewer **serializes** — one question on the wire at a time — so an answer is never mis-attributed.
81
+
82
+ ```ruby
83
+ scripted = RobotLab::Cyborg::Channel::Scripted.new(["yes", "ship it"])
84
+ bot = RobotLab::Cyborg.new(name: "dewayne", channel: scripted)
85
+
86
+ # Give a slow or absent human a bounded wait:
87
+ oncall = RobotLab::Cyborg.new(name: "oncall", ask_timeout: 30) # nil answer if no reply
88
+ ```
89
+
90
+ ### Cooperating in a network
91
+
92
+ - **Symmetric bus membership.** A Cyborg answers inbound bus tasks out of the box; a Robot opts in with one call — `robot.serve` (run each task through the model and reply) or `robot.respond_to_tasks { |m| ... }`. Both are first-class responders.
93
+ - **Duplex.** Inbound messages/replies are shown to the human on their channel automatically; `cyborg.tell("...")` pushes a line out yourself.
94
+ - **Addressing.** `cyborg.converse(peers: %w[analyst scribe])` starts a `Conversation`: the human addresses peers by `@mention` anywhere in a message (fan-out to all mentioned; **no mention broadcasts to everyone**), and replies come back on the channel.
95
+ - **Listening.** `converse`/`listen` keep reading the channel with no question pending, so the human can speak to the network unprompted (delivered via `on_human`).
96
+ - **Typed answers.** `ask_int`, `ask_confirm`, or `ask(validate:, retries:)` re-ask on bad input and return coerced values.
97
+ - **Presence.** `online!` / `away!` / `offline!` / `available?` — an offline human declines inbound tasks immediately, so the network can route around or escalate.
98
+
99
+ ```ruby
100
+ you = RobotLab::Cyborg.new(name: "you", bus: bus)
101
+ you.converse(peers: %w[analyst scribe]) # @mention to address, no mention = broadcast
102
+ ready = you.ask_confirm("Deploy now?") # => true / false
103
+ you.away! # still asked, but use a bounded timeout
104
+ ```
105
+
106
+ #### Durable human steps (roadmap)
107
+
108
+ A human step currently holds a thread while it waits. `ask_async` returns the pending `Question` without blocking — the primitive a durable integration would persist. The intended path is to store a pending decision through **`robot_lab-durable`** (and `robot_lab-to`'s `DecisionManager`) so a human decision survives a process restart and doesn't pin a thread. Per-peer cryptographic identity/attribution (signed events) is the complementary trust direction, on top of the existing per-message `sender`/`from`.
109
+
110
+ ## Examples
111
+
112
+ Runnable demos in [`examples/`](examples) — one feature area each:
113
+
114
+ - `01_human_in_the_network.rb` — a human as a pipeline step, peers messaging over a bus, shared memory (network → human). Key-free.
115
+ - `02_terminal_mentions.rb` — a **live** human addresses peers by `@mention` via the library `Conversation` (fan-out, and no-mention broadcast); replies return on their own through the duplex. Includes a real **LLM robot** (`@assistant`, Ollama) cooperating via `serve`, plus key-free canned peers.
116
+ - `03_robot_interviews_cyborg.rb` — the Interviewer the *other* way round: an **LLM robot** (Ollama) interviews the human via `delegate`, starting with a **typed** intake (`ask_confirm`/`ask_int`, which re-ask on bad input), then builds a categorized profile.
117
+ - `04_presence_and_availability.rb` — routing to a peer who's actually there: `online`/`away`/`offline`, an offline human declining immediately, and a bounded-timeout escalation. Key-free.
118
+ - `05_listening_and_duplex.rb` — always-on `listen`: the human speaks to the network **unprompted** and replies come back on the channel — both directions handled by the library. Key-free.
119
+
120
+ ```bash
121
+ ruby examples/01_human_in_the_network.rb
122
+ ruby examples/04_presence_and_availability.rb
123
+ ruby examples/05_listening_and_duplex.rb
124
+
125
+ # Examples 2 and 3 use a real robot on Ollama (ollama pull qwen3.6; override with
126
+ # OLLAMA_MODEL / OLLAMA_API_BASE). In example 2 the canned peers still work
127
+ # without it — only @assistant needs Ollama.
128
+ ruby examples/02_terminal_mentions.rb # then type: @analyst and @scribe: status?
129
+ ruby examples/03_robot_interviews_cyborg.rb # the robot asks you the questions
130
+ ```
131
+
132
+ ## Development
133
+
134
+ After checking out the repo, run `bin/setup` to install dependencies. Then run `rake test` to run the tests, or `rake quality` to run the full gate (tests, RuboCop, Flog, Flay). `bin/console` gives an interactive prompt.
135
+
136
+ ## License
137
+
138
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.libs << 'lib'
9
+ t.test_files = FileList['test/**/*_test.rb', 'test/**/test_*.rb'].exclude('**/*_helper.rb')
10
+ t.verbose = true
11
+ t.ruby_opts << '-rtest_helper'
12
+ end
13
+
14
+ task default: :test
15
+
16
+ desc 'Run tests with verbose output'
17
+ task :test_verbose do
18
+ ENV['TESTOPTS'] = '--verbose'
19
+ Rake::Task[:test].invoke
20
+ end
21
+
22
+ desc 'Run a single test file'
23
+ task :test_file, [:file] do |_t, args|
24
+ ruby "test/#{args[:file]}"
25
+ end
26
+
27
+ desc 'Check code complexity with Flog (warn >=20, fail >=50)'
28
+ task :flog_check do
29
+ require 'flog'
30
+
31
+ method_warn = 20.0
32
+ method_fail = 50.0
33
+
34
+ flogger = Flog.new(all: true)
35
+ flogger.flog(*Dir.glob('lib/**/*.rb'))
36
+
37
+ warnings = []
38
+ failures = []
39
+
40
+ flogger.each_by_score do |method, score|
41
+ next if method.end_with?('#none')
42
+
43
+ if score > method_fail
44
+ failures << "#{format('%.1f', score)}: #{method}"
45
+ elsif score > method_warn
46
+ warnings << "#{format('%.1f', score)}: #{method}"
47
+ end
48
+ end
49
+
50
+ unless warnings.empty?
51
+ puts "\nFlog warnings (#{method_warn}–#{method_fail}) — target for future refactoring:"
52
+ warnings.each { |v| puts " #{v}" }
53
+ end
54
+
55
+ if failures.empty?
56
+ puts "\nFlog: no methods exceed the failure threshold (>=#{method_fail})"
57
+ else
58
+ puts "\nFlog failures (>=#{method_fail}) — must be refactored:"
59
+ failures.each { |v| puts " #{v}" }
60
+ abort "\nFlog quality gate failed: #{failures.size} method(s) exceed #{method_fail}"
61
+ end
62
+ end
63
+
64
+ desc 'Check for structural code duplication with Flay (mass >= 50)'
65
+ task :flay_check do
66
+ require 'flay'
67
+
68
+ mass_threshold = 50
69
+
70
+ flay = Flay.new({ mass: mass_threshold, diff: false, verbose: false, summary: false, timeout: 60 })
71
+ flay.process(*Dir.glob('lib/**/*.rb'))
72
+ flay.analyze
73
+
74
+ if flay.hashes.empty?
75
+ puts "\nFlay: no structural duplication detected (mass >= #{mass_threshold})"
76
+ else
77
+ puts "\nFlay found structural duplication (mass >= #{mass_threshold}):"
78
+ flay.report
79
+ abort "\nFlay quality gate failed: #{flay.hashes.length} pattern(s) detected"
80
+ end
81
+ end
82
+
83
+ desc 'Run all quality checks: tests (with coverage), RuboCop, Flog, and Flay'
84
+ task :quality do
85
+ gates = [
86
+ ['Tests + Coverage', 'bundle exec rake test'],
87
+ ['RuboCop', 'bundle exec rubocop'],
88
+ ['Flog Complexity', 'bundle exec rake flog_check'],
89
+ ['Flay Duplication', 'bundle exec rake flay_check']
90
+ ]
91
+
92
+ results = gates.map do |label, command|
93
+ puts "\n#{'=' * 60}"
94
+ puts "Quality Gate: #{label}"
95
+ puts '=' * 60
96
+ [label, system(command) ? :pass : :fail]
97
+ end
98
+
99
+ green = ->(s) { "\e[32m#{s}\e[0m" }
100
+ red = ->(s) { "\e[31m#{s}\e[0m" }
101
+ width = results.map { |label, _| label.length }.max
102
+
103
+ puts "\n#{'=' * 60}"
104
+ puts 'Quality Gate Summary'
105
+ puts '=' * 60
106
+ results.each do |label, status|
107
+ badge = status == :pass ? green.call('PASS') : red.call('FAIL')
108
+ puts " [#{badge}] #{label.ljust(width)}"
109
+ end
110
+ puts '-' * 60
111
+
112
+ passed = results.count { |_, s| s == :pass }
113
+ failed = results.count { |_, s| s == :fail }
114
+ tally = "#{passed} passed, #{failed} failed"
115
+ puts " #{failed.zero? ? green.call(tally) : red.call(tally)}"
116
+ puts '=' * 60
117
+
118
+ abort "\n#{red.call('Quality gate failed.')}" unless failed.zero?
119
+ puts "\n#{green.call('All quality gates passed.')}"
120
+ end
121
+
122
+ namespace :docs do
123
+ desc 'Build MkDocs documentation'
124
+ task :build do
125
+ sh 'mkdocs build'
126
+ end
127
+
128
+ desc 'Serve MkDocs documentation locally on http://localhost:8000'
129
+ task :serve do
130
+ sh 'mkdocs serve'
131
+ end
132
+ end