Skip to content

Instantly share code, notes, and snippets.

@anon987654321
Created May 11, 2026 12:05
Show Gist options
  • Select an option

  • Save anon987654321/6edefc3b063eb1b4e5dd36f6d0288d9e to your computer and use it in GitHub Desktop.

Select an option

Save anon987654321/6edefc3b063eb1b4e5dd36f6d0288d9e to your computer and use it in GitHub Desktop.
MASTER 2026-05-11

MASTER Snapshot — 2026-05-11T12:05:38Z

Tree

completions/
data/
data/claude/
data/prompts/
data/traces/
data/web/
exe/
lib/
lib/master/
lib/master/agent/
lib/master/autoloop/
lib/master/builder/
lib/master/cli/
lib/master/code_index/
lib/master/command_registry/
lib/master/council/
lib/master/introspection/
lib/master/memory/
lib/master/orders/
lib/master/persistence/
lib/master/reasoning/
lib/master/routing/
lib/master/scan/
lib/master/scan/rules/
lib/master/security/
lib/master/stages/
lib/master/swarm/
lib/master/swarm/workers/
lib/master/sweep/
lib/master/tools/
scripts/
skills/
skills/explain/
test/
test/support/
web/
web/app/
web/app/assets/
web/app/assets/images/
web/app/assets/stylesheets/
web/app/controllers/
web/app/controllers/concerns/
web/app/helpers/
web/app/middleware/
web/app/models/
web/app/models/concerns/
web/app/views/
web/app/views/canvas/
web/app/views/chat/
web/app/views/layouts/
web/app/views/pwa/
web/bin/
web/config/
web/config/environments/
web/config/initializers/
web/config/locales/
web/db/
web/lib/
web/lib/tasks/
web/public/
web/public/assets/
web/public/dilla/
web/script/
web/storage/
CONVENTIONS.md
Gemfile
QUICKSTART.md
README.md
Rakefile
data/agent_taxonomy.yml
data/budget.yml
data/claude/MEMORY.md
data/claude/feedback_autofix.md
data/claude/feedback_autoproceed.md
data/claude/feedback_comments_reassess.md
data/claude/feedback_decisive_signals.md
data/claude/feedback_device_limits.md
data/claude/feedback_diverged_branch_sync.md
data/claude/feedback_git_commits.md
data/claude/feedback_html_css_style.md
data/claude/feedback_importance_order.md
data/claude/feedback_lint_beautify.md
data/claude/feedback_master_zsh_discipline.md
data/claude/feedback_meta_framing.md
data/claude/feedback_no_consecutive_whitespace.md
data/claude/feedback_no_new_files.md
data/claude/feedback_no_permission_questions.md
data/claude/feedback_no_python.md
data/claude/feedback_no_sed.md
data/claude/feedback_no_shell_piping.md
data/claude/feedback_proper_casing.md
data/claude/feedback_readme_autoupdate.md
data/claude/feedback_restart_rails.md
data/claude/feedback_run_through_master_triad.md
data/claude/feedback_strunk_white.md
data/claude/feedback_style.md
data/claude/feedback_voice_terse_unix.md
data/claude/project_defrag_plan_2026_05.md
data/claude/project_falcon_em_subprocess.md
data/claude/project_master.md
data/claude/project_master_dual_gemfile.md
data/claude/project_master_seven_module_refactor.md
data/claude/project_master_yml_json_authority.md
data/claude/reference_grok_ui_cli_patterns.md
data/claude/reference_opencrabs.md
data/claude/user_architect_aesthetics.md
data/closings.yml
data/council.yml
data/exemplars.yml
data/gems.yml
data/heartbeat.yml
data/infer_patterns.yml
data/injection_patterns.yml
data/lexical_rules.yml
data/mcp_servers.yml
data/models.yml
data/openbsd.yml
data/patterns.yml
data/personas.yml
data/prompts/mode_code_agent.yml
data/prompts/mode_direct.yml
data/prompts/mode_react.yml
data/prompts/mode_rewoo.yml
data/rails.yml
data/refusal_templates.yml
data/ruby_style.yml
data/rules.yml
data/soul.yml
data/standing_orders.yml
data/sweep_prompts.yml
data/templates.yml
data/tools.yml
data/why_command.yml
data/workflow.yml
data/zsh.yml
lib/master.rb
lib/master/agent.rb
lib/master/agent/llm_dispatcher.rb
lib/master/agent_pool.rb
lib/master/audit_log.rb
lib/master/autoloop.rb
lib/master/autoloop/fix_evaluator.rb
lib/master/axioms.rb
lib/master/bedrock_stub.rb
lib/master/builder.rb
lib/master/builder/infra_helpers.rb
lib/master/circuit_breaker.rb
lib/master/circuit_breaker_registry.rb
lib/master/cli.rb
lib/master/cli/signals.rb
lib/master/code_index.rb
lib/master/code_index/symbol_visitor.rb
lib/master/command_registry.rb
lib/master/command_registry/agent_commands.rb
lib/master/command_registry/memory_commands.rb
lib/master/command_registry/service_commands.rb
lib/master/config.rb
lib/master/context_window.rb
lib/master/council/deliberation.rb
lib/master/council/ideation.rb
lib/master/council/personas.rb
lib/master/decision_engine.rb
lib/master/diag.rb
lib/master/diff_stager.rb
lib/master/embeddings.rb
lib/master/event_bus.rb
lib/master/gateway.rb
lib/master/git_operations.rb
lib/master/governor.rb
lib/master/heartbeat.rb
lib/master/homeostat.rb
lib/master/hot_reload.rb
lib/master/introspection/self_map.rb
lib/master/learnings.rb
lib/master/learnings_pattern_lib.rb
lib/master/logging.rb
lib/master/mcp_coordinator.rb
lib/master/memory.rb
lib/master/memory/search.rb
lib/master/metrics.rb
lib/master/orders/architecture_audit.rb
lib/master/orders/autocommit.rb
lib/master/orders/base.rb
lib/master/orders/registry.rb
lib/master/orders/restart_master.rb
lib/master/orient.rb
lib/master/persistence/sqlite_findings.rb
lib/master/persistence/sqlite_memory.rb
lib/master/personality.rb
lib/master/phase_gates.rb
lib/master/pipeline.rb
lib/master/pledge.rb
lib/master/reasoning/modes.rb
lib/master/reflexion.rb
lib/master/renderer.rb
lib/master/repo_map.rb
lib/master/result.rb
lib/master/ring_buffer.rb
lib/master/routing/model_router.rb
lib/master/ruby_llm_patch.rb
lib/master/scan/finding.rb
lib/master/scan/rule.rb
lib/master/scan/rules/adversarial_rule.rb
lib/master/scan/rules/anti_pattern_rule.rb
lib/master/scan/rules/arity_rule.rb
lib/master/scan/rules/axiom_coverage_rule.rb
lib/master/scan/rules/bare_rescue_rule.rb
lib/master/scan/rules/co_change_coupling_rule.rb
lib/master/scan/rules/comment_drift_rule.rb
lib/master/scan/rules/comment_quality_rule.rb
lib/master/scan/rules/controller_bloat_rule.rb
lib/master/scan/rules/cqs_rule.rb
lib/master/scan/rules/dead_assign_rule.rb
lib/master/scan/rules/dead_code_rule.rb
lib/master/scan/rules/duplicate_code_rule.rb
lib/master/scan/rules/explicit_rule.rb
lib/master/scan/rules/file_layout_rule.rb
lib/master/scan/rules/file_silhouette_rule.rb
lib/master/scan/rules/god_class_rule.rb
lib/master/scan/rules/hotwire_pattern_rule.rb
lib/master/scan/rules/i18n_hardcoded_string_rule.rb
lib/master/scan/rules/immutable_rule.rb
lib/master/scan/rules/interconnect_rule.rb
lib/master/scan/rules/lexical_rule.rb
lib/master/scan/rules/long_method_rule.rb
lib/master/scan/rules/mass_assignment_risk_rule.rb
lib/master/scan/rules/memoize_falsy_bug_rule.rb
lib/master/scan/rules/n_plus_one_rule.rb
lib/master/scan/rules/naming_rule.rb
lib/master/scan/rules/naming_silhouette_rule.rb
lib/master/scan/rules/nesting_depth_rule.rb
lib/master/scan/rules/nielsen_rule.rb
lib/master/scan/rules/opportunity_rule.rb
lib/master/scan/rules/pola_rule.rb
lib/master/scan/rules/prune_rule.rb
lib/master/scan/rules/query_plan_rule.rb
lib/master/scan/rules/reek_rule.rb
lib/master/scan/rules/rubocop_rule.rb
lib/master/scan/rules/self_explaining_rule.rb
lib/master/scan/rules/semantic_rule.rb
lib/master/scan/rules/srp_rule.rb
lib/master/scan/rules/structure_rule.rb
lib/master/scan/rules/table_lexical_rule.rb
lib/master/scan/rules/tell_dont_ask_rule.rb
lib/master/scan/rules/terse_rule.rb
lib/master/scan/rules/thread_safety_rule.rb
lib/master/scan/rules/threshold_drift_rule.rb
lib/master/scan/rules/todo_debt_rule.rb
lib/master/scan/rules/universal_rule.rb
lib/master/scan/rules/vertical_rhythm_rule.rb
lib/master/scan/rules/yaml_quality_rule.rb
lib/master/scan/scanner.rb
lib/master/schema_index.rb
lib/master/security/injection_guard.rb
lib/master/security/permissions.rb
lib/master/semantic_cache.rb
lib/master/session.rb
lib/master/skills.rb
lib/master/soul.rb
lib/master/speech.rb
lib/master/stages/council.rb
lib/master/stages/deliberate.rb
lib/master/stages/execute.rb
lib/master/stages/guard.rb
lib/master/stages/infer.rb
lib/master/stages/intake.rb
lib/master/stages/lint.rb
lib/master/stages/memo.rb
lib/master/stages/prune.rb
lib/master/stages/render.rb
lib/master/stages/route.rb
lib/master/standing_orders.rb
lib/master/swarm/coordinator.rb
lib/master/swarm/worker.rb
lib/master/swarm/workers/analyst.rb
lib/master/swarm/workers/coder.rb
lib/master/swarm/workers/researcher.rb
lib/master/swarm/workers/reviewer.rb
lib/master/sweep.rb
lib/master/sweep/convergence.rb
lib/master/sweep/rewriter.rb
lib/master/sweep/techniques.rb
lib/master/telemetry.rb
lib/master/text_hygiene.rb
lib/master/tools/ask_llm.rb
lib/master/tools/ast_edit.rb
lib/master/tools/atomic_write.rb
lib/master/tools/base.rb
lib/master/tools/batch_replace.rb
lib/master/tools/clean.rb
lib/master/tools/feedback_record.rb
lib/master/tools/git_context.rb
lib/master/tools/list_dir.rb
lib/master/tools/llm.rb
lib/master/tools/path_guard.rb
lib/master/tools/postpro.rb
lib/master/tools/read_file.rb
lib/master/tools/repligen.rb
lib/master/tools/search_files.rb
lib/master/tools/search_knowledge.rb
lib/master/tools/shell.rb
lib/master/tools/str_replace.rb
lib/master/tools/symbol_lookup.rb
lib/master/tools/tree.rb
lib/master/tools/web_fetch.rb
lib/master/tools/web_search.rb
lib/master/tools/write_file.rb
lib/master/trace.rb
lib/master/triggers.rb
lib/master/undo.rb
lib/master/unwrap_error.rb
lib/master/why_explainer.rb
master.gemspec
scripts/openbsd_preflight.zsh
skills/explain/SKILL.md
test/fixtures_bare_rescue.rb
test/support/master_container.rb
test/test_agent.rb
test/test_axioms.rb
test/test_bare_rescue_rule.rb
test/test_browser.rb
test/test_cli.rb
test/test_council_deliberation.rb
test/test_experience.rb
test/test_helper.rb
test/test_learnings.rb
test/test_master_container.rb
test/test_pipeline.rb
test/test_prune.rb
test/test_result.rb
test/test_ring_buffer.rb
test/test_speech.rb
test/test_web_http.rb
test/test_web_ui.rb
web/Gemfile
web/README.md
web/Rakefile
web/app/controllers/application_controller.rb
web/app/controllers/canvas_controller.rb
web/app/controllers/chat_controller.rb
web/app/controllers/events_controller.rb
web/app/controllers/health_controller.rb
web/app/helpers/application_helper.rb
web/app/middleware/auth_tier.rb
web/app/models/application_record.rb
web/app/views/canvas/show.html.erb
web/app/views/chat/index.html.erb
web/app/views/layouts/application.html.erb
web/app/views/pwa/manifest.json.erb
web/config/application.rb
web/config/boot.rb
web/config/ci.rb
web/config/database.yml
web/config/environment.rb
web/config/environments/development.rb
web/config/environments/production.rb
web/config/environments/test.rb
web/config/initializers/assets.rb
web/config/initializers/content_security_policy.rb
web/config/initializers/filter_parameter_logging.rb
web/config/initializers/inflections.rb
web/config/initializers/master_container.rb
web/config/initializers/new_framework_defaults_8_0.rb
web/config/locales/en.yml
web/config/puma.rb
web/config/routes.rb
web/db/seeds.rb
web/public/assets/rails-ujs-20eaf715.js
web/public/assets/rails-ujs.esm-e925103b.js
web/public/robots.txt

CONVENTIONS.md

# MASTER — Conventions for External LLMs

Context injection for any LLM reviewing or editing MASTER. Read before touching code.

## Identity

MASTER is a constitutional AI coding agent written in Ruby 3.3+ on OpenBSD 7.8. It replaces Claude Code CLI for its operator. It is general-purpose and language-agnostic. Every change leaves the system in a working, deployable state.

## Golden rule

`PRESERVE_THEN_IMPROVE_NEVER_BREAK`. Read before write. Patch minimally. Understand before touching — Chesterton's Fence.

## Anti-simulation

Never state intent without evidence. Forbidden hedges — `will`, `would`, `could`, `might`. Require:
- File read → content with SHA-256
- Modification → unified diff
- Completion → command output

## Communication — two registers, do not mix

- **MASTER's own log/event lines** (boot banner, scheduler ticks, tool events, dmesg-style status): structured, terse, lowercase, kernel-ish — `master@host ready`, `boot0: 26ms`, `model0 at openrouter`. The OpenBSD-dmesg boot banner is sacred — never strip it.
- **Conversational replies to the operator**: plain English, proper casing, full sentences. No dmesg style here. No headlines, no empty bullets, no filler, no sycophancy, no hedging. Outcome first, evidence next, implementation last.
- **Commits and log lines** stay active, concrete, terse — Strunk & White, omit needless words.

## No ASCII line art

Never use these as decorations in any output (comments, log lines, CLI text, chat replies, commit messages):

- `===`, `----` (banner lines, section dividers)
- ``, `|`, ``, `` (bullet/separator characters)
- `[ok]`, `[err]`, `[skip]` brackets — use bare prefixes `ok:`, `err:`, `skip:`, `warn:` instead

In Markdown documents, plain `---` for an `<hr>` and table separators are fine — they carry meaning. Banner art does not.

## Code rules (enforced by scan)

- **Read before write** — every affected file before any edit.
- **No bare rescue** — always `rescue SpecificError => e`. Inline `expr rescue nil` is fine when nil is intentional.
- **Named constants** — extract literals with `.freeze`.
- **No magic numbers** — thresholds belong in `data/rules.yml` under `thresholds:`.
- **No abbreviations**`index` not `idx`, `signature` not `sig`, `temporary_path` not `tmp`.
- **No regex when string methods suffice**`start_with?`, `include?`, `end_with?`.
- **Outsource to gems** — if it exists and works, use it.
- **Endless methods** — single-expression methods use `def foo = expr`.
- **Result monad** — check with `respond_to?(:ok?)`, not `is_a?(Result)`. Unwrap with `.value!` only after `.ok?` is true; on an `Err` it raises.
- **No flag arguments** — a boolean that selects behavior is two methods in one.
- **Guard clauses first**`return Result.ok(ctx) unless condition` before main logic.
- **Dependency injection** — never instantiate collaborators inside a method.
- **CQS** — queries return, commands mutate. Not both.

## Thresholds

- File — 300 lines max, warn at 200
- Method — 10 lines ideal, 7 warn
- Class — 6 public methods, 3 ivars, 200 lines
- Params — 3 positional max; keyword args for 3+
- Nesting — 2 levels max inside a method

## Ruby style

- `# frozen_string_literal: true` on every `.rb`
- Double-quoted strings always; single only inside regex or `'\1'` backrefs
- One-line comments. No YARD blocks, no section separators
- Comments explain WHY, never WHAT
- `snake_case` throughout
- Zeitwerk autoloading — file name matches class name

Bugs to avoid:
- `Dir.chdir` — process-wide, thread-unsafe. Use `File.expand_path`.
- `Prism.parse(src, freeze: true)``freeze:` dropped in 3.4. Use `Prism.parse(src)`.
- `next if` inside `flat_map` — returns `nil`. Use `next [] if`.
- Backtick shell with interpolation — use `Open3.capture2e(*%w[cmd], arg)`.

## Zsh / shell

Banned in zsh and SSH: `sed`, `awk`, `tr`, `grep`, `cut`, `head`, `tail`, `find`, `wc`, `sudo`, `perl`, `ruby`, `dd`, `xargs`. Use zsh builtins, parameter expansion, `doas` for privilege, Ruby scripts for complex logic.

Read files over SSH with `cat path` — read the whole file once. Do not stitch `grep` + `head` fragments; reasoning from full context beats reasoning from snippets. For local zsh array work use `lines=("${(@f)$(<file)}")`.

## Architecture

Pipeline: `Intake → Infer → Route → Guard → Execute → [Council ‖ Lint] → Prune → Memo → Render`. Council and Lint run concurrently under a 30s deadline via `ParallelGroup`. Rollback on `axiom_violation` or `validation`: `git reset --hard HEAD`. Scan rules auto-register via the `Rule.inherited` callback — every file under `scan/rules/` must subclass `Rule` or it goes silently unrun. Rules with no constructor args set `def auto_build? = true` to opt into the registry's zero-arg construction path. `axiom_coverage_rule` walks `scan/rules/*.rb` with a Prism `SuperclassFinder` and flags any file whose top-level class does not inherit from `Rule`, so silent registry drift is caught at scan time. All rules ship with `@auto_fix = true` and participate in sweep. Sweep runs rubocop autocorrect first, then escalates to LLM rewrite under the corruption guards.

Council deliberation samples a focus question per persona per turn from `data/council_questions.yml` (8 categories — assumptions, failure_modes, attacker, edge_cases, degradation, ops_maint, economics, clarity). Architect → assumptions, Skeptic → failure_modes, Security → attacker, User → edge_cases, Pragmatist → economics, Mentor → clarity. Unmapped personas pass through with no question.

Observability: `Master::Telemetry` is a soft-optional OpenTelemetry tracer that emits JSONL spans to `.master/traces.log`. Wraps `EventBus#publish`, `Metrics#append`, `AuditLog#append`, and `Heartbeat#execute_job`. Bootstrap fires in `Master.boot` between Pledge stage1 and stage2.

Key files — `data/soul.yml` (golden rule, tiers, persona), `data/rules.yml` (structural rules, thresholds, depths), `data/ruby_style.yml` (style and bugs), `data/workflow.yml` (READ_BEFORE_WRITE, scan principles), `data/standing_orders.yml` (current FSM state).

## Running scans

Standard: `eval "$(grep '^export' ~/.zshrc)" && cd ~/pub4/MASTER && echo "/scan lib/" | bundle exec ruby exe/master`. Autofix sweep: `/autoloop 20`. Do not use external agents when MASTER can scan itself. Depth knobs are gone — every scan is full by default.

Pre-commit constitution check: `exe/master-audit` runs the scanner over staged files and fails the commit on any kernel-tier rule or critical/error violation. Wire as a git pre-commit hook by writing `exec exe/master-audit` into `.git/hooks/pre-commit`.

## Protection tiers

ABSOLUTE aborts the pipeline. PROTECTED emits a warning and continues. NEGOTIABLE allows if explicitly permitted. FLEXIBLE negotiates at runtime. ABSOLUTE sections in `data/soul.yml` require `/override` to amend.

## Environment

VPS: `dev@brgen.no` · OpenBSD 7.8 · passwordless `doas`. SSH credentials live in the operator's environment, never in versioned docs. Non-interactive SSH must not source `.zshrc` — load env only: `eval "$(grep '^export' ~/.zshrc)"`.

Edit VPS files by direct edit + `scp` — write the new file content locally, scp it up. Reserve `~/pub4/tmp/patch.rb` for genuinely script-shaped edits where a patch script is the right tool. Never use `ruby -i` with heredoc — empties the file on script error.

After every scp under `MASTER/web/`, immediately `doas rcctl restart master` so Falcon picks up the change. Falcon does not hot-reload in production; without the restart the deployed app keeps serving the prior bytecode.

## Web auth tiers

`?token=...` matches the value in `~/pub4/.master/config.yml` and grants full tool access. No token = visitor — chat works, but `Thread.current[:master_visitor]` is set so `Master::Agent::LlmDispatch#build_llm_tools` filters tools to the visitor allow-list (currently `AskLlm`, `WebSearch`). The CLI REPL bypasses this entirely and always has full access.

## Slash commands

`/scan [profile] [path]`, `/sweep`, `/autoloop [N]`, `/triad [path]`, `/council on|off`, `/swarm <role> <task>`, `/explain`, `/crit <file|text>`, `/ideate <prompt>`, `/topic`, `/rsi [stats]`, `/model [list|<id>]`, `/why <law|scan_rule|anti_pattern|style.key>`, `/diag [drives|breaker|rules|ring]`, `/snapshot`, `/tts`, `/profile`, `/heartbeat`, `/orders`, `/soul`, `/dmesg`. `/triad` runs scan + sweep + council deliberation in one pass. `/snapshot` publishes two GitHub gists — one for MASTER, one for DEPLOY — full tree + file bodies. `/why` resolves locally first via `WhyExplainer`; the LLM answer fires only on a miss. `/diag` composes a state digest (drives, circuit-breaker, registered rule count, dmesg ring tail).

Gemfile

# frozen_string_literal: true
gem "fiddle"

source "https://rubygems.org"

gem "ruby_llm", "~> 1.3"
gem "tty-prompt", "~> 0.23"
gem "tty-reader", "~> 0.9"
gem "tty-spinner", "~> 0.9"
gem "tty-markdown", "~> 0.7"
gem "tty-table", "~> 0.12"
gem "tty-screen", "~> 0.8"
gem "tty-box", "~> 0.7"
gem "tty-command", "~> 0.10"
gem "tty-tree", "~> 0.4"
gem "tty-config", "~> 0.6"
gem "tty-logger", "~> 0.6"
gem "tty-progressbar", "~> 0.18"
gem "pastel", "~> 0.8"
gem "rouge", "~> 4.4"
gem "diffy", "~> 3.4"
gem "zeitwerk", "~> 2.7"
gem "sinatra", "~> 4.0"
gem "sinatra-contrib", "~> 4.0"
gem "rb-edge-tts", git: "https://github.com/ZPVIP/rb-edge-tts"

group :test do
  gem "minitest", "~> 5.25"
  gem "rack-test", "~> 2.1"
  gem "ferrum", "~> 0.15"
  gem "simplecov", require: false
end
gem "ruby_llm-mcp"
gem "rubocop", "~> 1.60", require: false
gem "reek", "~> 6.4", require: false
gem "flay", require: false
gem "opentelemetry-sdk", "~> 1.11", require: false

QUICKSTART.md

# MASTER Quickstart (External LLMs)

MASTER is a constitutional coding agent in Ruby. Read this first, then run `/orient` for full doctrine.

1) Golden rule
- Preserve, then improve, never break.
- Read full files before editing.
- Keep patches minimal and reversible.

2) Non-negotiables
- No fabricated claims; show evidence from files/commands.
- No bare `rescue`; rescue specific exceptions.
- Prefer named constants over magic literals.
- Use string methods before regex when possible.
- Dependency-inject collaborators; avoid hidden instantiation.

3) Style baseline
- `# frozen_string_literal: true` in Ruby files.
- Double-quoted strings.
- Guard clauses first.
- Endless method style for single expressions.
- Clear names; avoid abbreviations like `idx`, `tmp`, `sig`.

4) How MASTER works
- Pipeline: Intake → Infer → Route → Guard → Execute → Council/Lint → Prune → Memo → Render.
- Scans enforce structure/style rules from `data/rules.yml` and `data/ruby_style.yml`.
- Sweep can auto-fix but must remain safe and auditable.

5) Core commands
- `/scan [profile] [path]` check a file/dir.
- `/sweep [path]` autofix pass.
- `/autoloop [N]` repeated scan/fix loops.
- `/diag` runtime snapshot.
- `/why <rule>` explain one rule.
- `/help` command catalog.

6) Web auth model
- Token-authenticated operator gets full tools.
- Visitor mode is restricted to safe tools.

7) If uncertain
- Ask for the specific rule section instead of guessing.
- Prefer explicit tradeoffs and smallest safe change.

Full reference: `CONVENTIONS.md` and `/orient`.

README.md

# MASTER

A constitutional AI coding agent. Ruby. OpenBSD. Self-hosting.

MASTER reads its own constitution at boot, scans its own code for violation, sweeps the corruption, and argues the result through an adversarial council before shipping. It edits files. It does not narrate.

The pipeline runs in ten stages — Intake, Infer, Route, Guard, Execute, Council and Lint in parallel, Prune, Memo, Render. Every stage returns a Result monad. An axiom violation rolls the workspace back to HEAD. A thirty-second deadline binds the parallel pair.

The pipeline reads as two tanks. The Pressure tank compresses input — verbose user prose folded into a dense, intent-tagged prompt by Intake, Infer, and Compress. The Depressure tank refines output — Render applies smart quotes, em dashes, and ellipses outside code fences; the council and lint stages strip what the constitution would reject. Pressure favors signal density. Depressure favors typographic and axiomatic discipline. Together they bound every turn.

The constitution lives in `data/`. Thirty-six YAML files — soul, rules, ruby_style, workflow, standing_orders, models, council, council_questions, infer_patterns, sweep_prompts, zsh_patterns and the rest — replace the 1770-line monolith MASTER inherited and burned. The Ruby code reads these at boot. The agent is the config.

`rules.yml` carries six universal laws — Robustness, Singularity, Linearity, Proximity, Abstraction, Density — a single hierarchical ladder under which every named rule, persona, and fix verb is anchored. Lower number wins in conflict. Beside the laws sit a biases chapter (hallucination, simulation, sycophancy, completion theater, false confidence — meta-anti-patterns above lexical detection), a structural-ops vocabulary (merge, semantic regroup, defrag, decouple, hoist, flatten, delete, expand, reduce noise — each tagged with risk and verify spec), a veto-patterns table for regex-detected unconditional blocks (secrets, SQL injection, unfinished placeholders), and a beauty section that anchors aesthetic decisions to Bringhurst, Ando, Rams, and Martin. The voice paragraph carries Strunk & White safeguards — `apply_to: prose, comments, documentation, strings`; `never_apply_to: code logic, algorithms, data structures` — so refinement never silently deletes a variable name or collapses a conditional.

The scanner sweeps the tree in parallel across CPUs, applies fifty-plus named rules across four scopes — Prism-AST for Ruby structure, regex for anti-patterns, repo-graph mining for hidden coupling, registry self-checks for orphan rule files — and emits findings as data. The lexical layer covers structural smells (duplicate, god class, deep nesting, long method) and Rails-shaped hazards (n+1, mass assignment, time-zone unsafety, memoize-falsy, i18n leakage, stale TODO debt). The semantic layer runs an LLM conceptual pass that judges DRY, KISS, SOLID, POLA, and a mirror opportunity pass that names the pattern each file is 80% of the way to. The visual layer pushes every file toward the dominant silhouette — frozen-string-literal first, requires alphabetized, constants and attrs before init, `private` on its own line, one blank above each def, naming that matches return shape, and a structural fingerprint clustered against the rest of the directory. Two cross-file signals run beside the per-file rules: a co-change graph mined from the last five hundred commits flags pairs that always change together across module paths, and a comment-drift pass asks the LLM whether each comment still describes the code below it. Sweep takes the findings and rewrites the source — rubocop autocorrect first, deterministic and free; then the LLM, surgical and rate-limited, with best-of-N candidate scoring on files above four kilobytes; then the corruption guards reject anything that lost half its length, matched an error pattern, or failed `ruby -c`. Both `/scan` and `/sweep` default to deep depth, and `scan_since` accepts a git ref to scan only what changed.

Observability rides on a thin OpenTelemetry layer — `Master::Telemetry` wraps the event bus, metrics, audit log, and heartbeat in spans and emits JSONL traces to `.master/traces.log`. Soft-optional: if the gem is absent, every span call collapses to a plain yield.

The council convenes adversarial personas — pragmatist, purist, skeptic, historian — when a change touches a protection tier. Each speaks once. The pipeline waits, then ships or rolls back.

The voice is OpenBSD dmesg. Structured. Unhedged. Active. No headlines, no bullet lists without content, no apology. The forbidden words — *will*, *would*, *could*, *might* — surrender to the indicative.

Launch from the project root with `bundle exec ruby exe/master`. Pipe input through stdin for one-shot mode. The Rails 8 web face listens on 53187, fronted by relayd to ai.brgen.no — a 2000-particle orb, an ambient pad engine, seventeen voice effects, all incidental.

A live canvas — the openclaw inheritance — sits at `/canvas`. The agent draws nodes for violations, edges for fixes, a deliberation tree for council rounds, a timeline for sweep cycles. The user watches the constitution argue with the code in real time. Spec at `data/canvas.yml`, routes at `data/canvas_routes.yml`.

Deploy through `DEPLOY/openbsd/openbsd.sh`, two stages, resumable.

MIT.


## Debug note: Bundler and proxy 403s

If `bundle install` fails with `Gem::Net::HTTPClientException: 403 "Forbidden"` in containerized environments, verify proxy wiring before debugging MASTER itself. In this environment, Rubygems requests are routed through `http://proxy:8080`; if that proxy blocks `rubygems.org`, `bundle exec ruby exe/master orient` fails before app boot because dependencies (for example `zeitwerk`) never install.

Fast checks:

- `gem sources --list` should include `https://rubygems.org/`.
- `env | rg -i 'proxy|rubygems|bundle'` should reveal active proxy variables.
- `gem install zeitwerk -v 2.7.5` isolates network/proxy failure from MASTER code.

Treat this as infrastructure breakage, not an application bug, unless gems install cleanly and MASTER still fails to boot.

Rakefile

# frozen_string_literal: true

require "rake/testtask"

Rake::TestTask.new(:test) do |t|
  t.libs << "test"
  t.libs << "lib"
  t.test_files = FileList["test/test_*.rb"]
  t.warning = false
end

desc "Deep scan lib/ — exit 1 if any violations found (static rules, no LLM)"
task :constitution do
  $LOAD_PATH.unshift(File.join(__dir__, "lib"))
  require "master"

  root    = __dir__
  scanner = Master::Scan::Scanner.new
  Master::Scan::Rule.registry.select(&:auto_build?).each { |klass| scanner.add_rule(klass.new) }
  scanner.add_rule(Master::Scan::Rules::AxiomCoverageRule.new(root:))
  scanner.add_rule(Master::Scan::Rules::RubocopRule.new(root:))
  scanner.add_rule(Master::Scan::Rules::ReekRule.new(root:))
  scanner.add_rule(Master::Scan::Rules::InterconnectRule.new(root:))

  result = scanner.scan_dir(File.join(root, "lib"), depth: :deep, stream: false)
  abort "constitution: scan failed: #{result.message}" unless result.respond_to?(:ok?) && result.ok?

  violations = result.value!.flat_map { |_f, r| (r.respond_to?(:ok?) && r.ok?) ? r.value! : [] }
  total      = violations.size

  if total.zero?
    puts "constitution: clean"
  else
    by_rule = violations.group_by { |v| v[:rule] }
    by_rule.sort_by { |_, vs| -vs.size }.each do |rule, vs|
      puts "[#{rule}] #{vs.size}"
      vs.first(5).each { |v| puts "  #{v[:file]}:#{v[:line]}: #{v[:message]}" }
    end
    puts "constitution: #{total} violation(s)"
    exit 1
  end
end

task default: :test

namespace :test do
  desc "Run web system tests"
  task :web do
    sh "ruby -Ilib:test test/test_web_ui.rb"
  end
end

data/agent_taxonomy.yml

# config_status: aspirational  # spec exists, runtime wiring pending
# Typed child agents (cleaner than ad-hoc thread spawning).
# Source: opencrabs + Manus reunification (#76, #81).
agent_types:
  explore:
    purpose:     "search, glob, grep, read-only inspection"
    tools:       [read_file, list_dir, search_files, symbol_lookup, tree]
    max_runtime: 60s
  plan:
    purpose:     "read code + propose stepwise plan; never edits"
    tools:       [read_file, search_knowledge, list_dir]
    output:      structured_plan
  code:
    purpose:     "apply the plan; one file at a time"
    tools:       [read_file, str_replace, write_file, ast_edit, atomic_write]
    requires:    plan_id
  research:
    purpose:     "external lookup, citations, summaries"
    tools:       [web_search, web_fetch, search_knowledge]
    max_runtime: 120s
  verify:
    purpose:     "ruby -c, scan, test, council vote"
    tools:       [shell, scan, council_call]
    output:      pass_fail_with_evidence

toolset_groups:
  research:    [web_search, web_fetch, search_knowledge, deepwiki]
  build:       [read_file, str_replace, write_file, ast_edit, atomic_write, batch_replace]
  verify:      [shell, scan, council_call]
  ship:        [git_context, atomic_write, audit_log]

spawn_policy:
  max_concurrent_children: 4
  inherit_governor:        true
  sanitize_output_via:     InjectionGuard

data/budget.yml

# Cost ceiling for a session. Routes degrade strong -> fast -> cheap as spend climbs.
# Source: master2 reunification.

budget:
  limit:    10.0
  currency: USD
  thresholds:
    strong: 5.0
    fast:   1.0
    cheap:  0.0
  on_exceed:
    action: degrade_route
    notify: bus

# Beyond dollars, track approximate kWh / CO2 per session.
# Source: cross-cutting reunification (#98).
carbon_budget:
  kwh_per_million_tokens:   0.5     # rough industry estimate
  co2_g_per_kwh:          400       # mixed grid baseline
  daily_kwh_cap:            1.0
  on_exceed:                "switch to local model only; publish carbon:exceeded"

data/claude/MEMORY.md

# Memory Index

- [MASTER project context](project_master.md) — pub4/MASTER constitutional AI agent on dev@brgen.no, OpenRouter API, Ruby/OpenBSD
- [master.yml + master.json are authoritative](project_master_yml_json_authority.md) — current Ruby MASTER must implement what predecessors describe; 18 priority gaps tracked
- [User is an architect](user_architect_aesthetics.md) — aesthetic/typography/design-philosophy proposals usually approved; don't self-censor them
- [Always autofix violations](feedback_autofix.md) — run /sweep immediately after any scan finds violations, no confirmation needed
- [Frequent git commits](feedback_git_commits.md) — commit after every meaningful change, don't batch
- [No new files without approval](feedback_no_new_files.md) — always edit originals in place, never create staging/copy files
- [Ultra-minimalistic coding style](feedback_style.md) — cut all filler across Ruby, Zsh, HTML, JS; preserve intentional logic
- [No Python](feedback_no_python.md) — only Ruby for all scripting tasks, never python3
- [Mandatory lint/beautify on touch](feedback_lint_beautify.md) — every edited file gets a full lint/beautify pass, not just changed lines
- [Strunk & White style](feedback_strunk_white.md) — commits, comments, log lines: active voice, omit needless words, concrete verbs, dmesg format
- [Voice — terse, unix, perfectionist](feedback_voice_terse_unix.md) — my outputs and MASTER's voice config: cut filler, diagnostic style, loop till zero violations
- [Auto-update README.md when needed](feedback_readme_autoupdate.md) — refresh README prose after any behavior/capability/surface change, no prompting
- [No heavy work on device](feedback_device_limits.md) — Termux/Android is low-power; defer Ruby runs, large clones, mass ops to VPS
- [Bare HTML/CSS targeting, no divitis](feedback_html_css_style.md) — nav a not .nav__link; tag helper; no class attrs on elements targetable by tag
- [MASTER zsh discipline applies to my shell](feedback_master_zsh_discipline.md) — banned cmds (sed/awk/grep/wc/head/tail/find/sudo/...) apply to my Bash calls too, not just to scripts I write
- [Autoproceed without confirmation](feedback_autoproceed.md) — execute full backlog after one approval; no per-step go/no-go
- [No permission questions for predictable yes](feedback_no_permission_questions.md) — never ask "want me to?" / "shall I?" when prior approval makes the answer obvious
- [Decisive short directives = full authorization](feedback_decisive_signals.md) — "ship all", "kill X keep Y", "yes" = binding; for >10 items, ship pass-by-pass with one-sentence checkpoints
- [No consecutive whitespace](feedback_no_consecutive_whitespace.md) — single space, single blank line max, no trailing/aligned-column padding; all file types
- [Proper casing, no ASCII decorations](feedback_proper_casing.md) — sentence case in prose/comments/CLI; no === ---- [ok] • | as ASCII art. Boot dmesg banner is sacred.
- [Restart MASTER after every web edit](feedback_restart_rails.md)`doas rcctl restart master` after each scp under MASTER/web/, never batch and restart once at end
- [Defrag/dedup/rename plan 2026-05](project_defrag_plan_2026_05.md) — multi-commit refactor; priority-1 = Master::Orient + slim AGENTS/CLAUDE + .zshrc fix
- [MASTER 7-module refactor approved 2026-05-08](project_master_seven_module_refactor.md) — now/loop/judge/voice/ground/reach/trace; pass-by-pass on VPS, supersedes the 6 dedup proposals
- [OpenCrabs (Rust MASTER cousin)](reference_opencrabs.md) — github.com/adolfousier/opencrabs; brain-files-per-turn, FTS5 memory, /rebuild + exec() hot-restart
- [Grok UI/CLI patterns](reference_grok_ui_cli_patterns.md) — StyleCoach prompt, htmx+SSE, tty-prompt/spinner, char-stream Claude CLI for MASTER polish
- [Importance-ordered file layout](feedback_importance_order.md) — every file's lines flow by importance; newspaper inverted pyramid; public API > primary > helpers > edge cases
- [Reassess comments on every touch](feedback_comments_reassess.md) — touch a file = re-read all its comments; delete obvious, rewrite kept ones S&W-style
- [Meta-architecture framing over diff reports](feedback_meta_framing.md) — after a batch, surface what's next/structurally off; user prefers 2x wins to 5% tweaks
- [MASTER has two Gemfiles](project_master_dual_gemfile.md) — MASTER/Gemfile and MASTER/web/Gemfile are independent; gems used by lib/ from web must be in both
- [Falcon + EM = subprocess](project_falcon_em_subprocess.md) — Process.fork in a Falcon fiber raises "Closing scheduler"; EM-based gems must shell out to exe/<name>-worker
- [Diverged branch sync via cherry-pick](feedback_diverged_branch_sync.md) — when local + remote both moved, backup-tag + reset to origin + cherry-pick targeted commits, never force-push or rebase mixed history
- ["Run X through MASTER" = scan+sweep+tribunal](feedback_run_through_master_triad.md) — /triad on target; user vocabulary is "tribunal", code is "council"; current /triad's 3rd step is a buggy on/off toggle, not actual deliberation
- [No unnecessary piping/concat in shell calls](feedback_no_shell_piping.md) — pure Ruby/zsh patterns; banned shell cmds rule applies to my Bash calls too

data/claude/feedback_autofix.md

---
name: always autofix violations
description: User wants all scan violations autofixed immediately, no asking
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Always run /sweep (or /autoloop) automatically after any scan that finds violations. Do not ask for confirmation.
All scan rules have `@auto_fix = true` (set in `Rule#initialize` base class).

**Why:** User said "autofix all always" — violations should be fixed immediately, all rules are eligible.
**How to apply:** After any /scan that returns violations, immediately kick off /sweep on the VPS without prompting. The base Rule class defaults @auto_fix=true so all rules participate.

data/claude/feedback_autoproceed.md

---
name: Autoproceed without confirmation
description: Once user approves a direction, execute the full backlog without pausing for per-step confirmation
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
When the user has approved a direction or list of tasks, execute the entire backlog end-to-end without pausing to ask "want me to do X next?" between items.

**Why:** User said "yes, and autoproceed for all in this chat always" — they want momentum, not checkpoints. Repeated mid-task confirmation requests slow them down and waste turns.

**How to apply:** After each completed step, immediately move to the next pending item. Only stop to ask if (1) a destructive/irreversible action would affect shared state beyond local files, (2) ambiguity emerges that would change the approach materially, or (3) the backlog is genuinely empty. Brief progress updates between steps are fine; explicit go/no-go prompts are not.

**Reinforced 2026-05-07** ("autpmatically autoproceed with next always"): also keep going *between passes* of a multi-commit batch. Don't end a turn on "say 'next' or pick a slice" — just commit pass N and start pass N+1. Stop only when the original backlog is empty or the destructive/ambiguity gates trigger. Out-of-context interruptions (user types something new mid-stream) override the autoproceed and are addressed first.

data/claude/feedback_comments_reassess.md

---
name: Reassess comments on every touch
description: Every edit re-reads each comment in the file — delete if obvious, rewrite Strunk & White style if kept.
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
When I edit any file, I reassess every comment in it — not just the ones near my changes. If a comment merely restates what the code does, delete it. If it carries a non-obvious WHY, rewrite it Strunk-and-White style: active voice, omit needless words, concrete verbs, one line max.

**Why:** Comments rot faster than code. The user (2026-05-07) asked that comments be reassessed and rewritten ultra-minimalistically on every touch — no grandfathered fluff. Encoded in `MASTER/data/ruby_style.yml` (`comments.reassess_on_touch: true`) and as the `RECOMMENT` technique in `MASTER/data/sweep_prompts.yml`.

**How to apply:**
- Touch a file = touch its comments. Don't preserve old comments unread.
- Delete: what-comments, restatements of code, ASCII section banners, numbered-step comments, YARD-style doc blocks, multi-line prose.
- Keep + rewrite: hidden constraints, workarounds for specific bugs, behavior that would surprise a reader, non-obvious invariants.
- Style of kept comments: one line, active voice, no hedging, no filler ("we", "just", "simply", "basically"). Concrete nouns and verbs.
- Do NOT add comments to my own new code unless WHY is non-obvious — the default is no comment.

data/claude/feedback_decisive_signals.md

---
name: Decisive short directives = full authorization
description: Short lowercase replies ("ship all", "kill X keep Y", "yes", "i think X") are binding — execute pass-by-pass without re-confirming.
type: feedback
originSessionId: b02ce9b9-a7c7-4c65-b8d0-3b8469dc2028
---
Short, lowercase, often typo-laden user directives are decisive — full authorization to execute without re-asking. Recognized signals:

- "ship all" / "yes" / "do it" → full proposed backlog approved
- "kill X, keep Y" → binary fork decided
- "i think X" → user has settled on X, proceed
- "propose N X" → wants a numbered, categorized list with one-liner per item, grouped by surface (type, color, motion, etc.); user then picks a slice or says "ship all"

For large approved batches (>10 items), ship in coherent commit-sized passes (~10–12 items per commit), checkpoint briefly between passes. Don't try to ship 40 in one go. Don't ask "are you sure" or stall on confirmation between passes — checkpoint = one short status sentence, not a question.

**Why:** Validated on 2026-05-07 lofi-aesthetic session. I diagnosed a two-voice TTS bug, user said "kill cli tts, keep web tts" (one sentence, decisive), I executed without re-asking. Then I proposed 40 lofi refinements organized by surface, user said "can we ship all?", I scoped pass-by-pass and started shipping — user then explicitly said "make sure we codify my messages that lead to great success like now."

**How to apply:** Treat one-line approvals as binding contracts. For "ship all N" where N > 10, propose pass plan in 1–2 sentences, execute pass 1, give a one-sentence checkpoint, continue. Stop only on failure, ambiguity, or destructive scope.

data/claude/feedback_device_limits.md

---
name: No heavy work on device
description: Termux/Android — defer CPU/IO-heavy tasks to VPS, keep device work minimal
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Prefer the VPS (dev@185.52.176.18) for all work. This device (Termux/Android) is a last resort.

**Why:** User said "prefer using the VPS" and "avoid doing heavy stuff on this device."

**How to apply:** Default to SSH into the VPS for every task — edits, Ruby runs, git, clones, builds. Only fall back to this device when VPS SSH is down and the task is genuinely lightweight (small curl, quick read).

data/claude/feedback_diverged_branch_sync.md

---
name: Diverged branch sync via cherry-pick onto remote
description: When local and remote main have diverged with overlap, cherry-pick the targeted commits onto remote tip rather than rebase mixed history or force-push
type: feedback
originSessionId: b02ce9b9-a7c7-4c65-b8d0-3b8469dc2028
---
When `git push` is rejected because remote has new commits and local also has commits the remote doesn't, prefer this flow:
1. `git tag backup-pre-sync-YYYY-MM-DD` on local main
2. `git reset --hard origin/main` (backup tag preserves the prior tip)
3. Cherry-pick only the commits we actually want to ship (e.g. session's lofi passes), not the mixed pile of older local-only commits that may already exist upstream in equivalent form
4. Resolve conflicts case by case
5. Push

**Why:** User said "push sync github" after a session that produced 16 lofi commits on top of 9 older local commits, while remote had 20 unrelated commits. Rebasing all 25 would have replayed work already on remote in equivalent form, producing duplicate commits and unnecessary conflicts. Force-push would have destroyed the 20 remote commits — unacceptable. The cherry-pick-onto-remote approach shipped exactly the intended work, kept history linear, and was accepted without pushback ("great." after sync).

**How to apply:** Use this when (a) the user's intent is clearly "ship my recent work, not all local work" — e.g. after a focused session like a feature batch, and (b) older local-only commits look duplicated on remote (same area, similar messages). Always create a backup tag before reset. If the user's intent is "preserve all local work", do a full rebase or merge instead.

data/claude/feedback_git_commits.md

---
name: frequent git commits
description: Make git commits frequently after meaningful changes
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Commit after every meaningful change — don't batch. After fixing a bug, restoring a file, or completing a refactor, commit immediately.

**Why:** User explicitly requested frequent commits.
**How to apply:** After any file write or fix on the VPS, run `git add <file> && git commit -m "..."` before moving on.

data/claude/feedback_html_css_style.md

---
name: Bare HTML/CSS targeting — no divitis, no utility classes
description: Always use bare element selectors (nav a, main, h1) not BEM classes or utility class strings on elements
type: feedback
originSessionId: ab7bf92a-5fdc-43bb-998c-dc1d5598f33d
---
Use bare element and structural selectors throughout. Never add class attributes to elements that can be targeted by tag or relationship.

**Why:** User explicitly stated "always bare targeting for clean HTML/CSS" and "no divitis." Confirmed with rejection of `.nav__link`, `.nav__brand`, `.nav__links` pattern.

**How to apply:**
- `nav a` not `a.nav__link`
- `.brand` only for the logo anchor that needs differentiation from other nav links
- `nav { ... }` for nav bar styling, not `.navbar` or `.nav`
- `main` for main content, not `.main-content` or `.container`
- Use `tag.nav`, `tag.main`, `tag.article` etc. — no wrapper divs with classes unless structurally necessary
- Rails `tag` helper (tag.div, tag.span) preferred over `content_tag`; `class_names` for conditional classes
- In ERB views: no `class:` arguments on links unless the class carries genuine semantic meaning (e.g. `.brand`, `.btn`, `.badge`)

data/claude/feedback_importance_order.md

---
name: Importance-ordered file layout
description: Every file's lines flow by importance — newspaper inverted pyramid. Most important content at top.
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Every file I touch gets reordered so the most important content sits at the top. Newspaper-style inverted pyramid.

**Why:** A reader who stops halfway must still have the gist. The user explicitly asked for this on 2026-05-07 ("every file must have all its lines rearranged to flow by importance so most important stuff comes top"). Encoded into `MASTER/data/ruby_style.yml` (`line_order:` section) and `MASTER/data/sweep_prompts.yml` (`IMPORTANCE_ORDER` structural technique) so MASTER's auto-triad propagates the rule.

**How to apply:**
- Order: requires → module/class declaration + headline doc → public API (ordered by importance/call-frequency) → primary algorithm → private helpers (in dependency order) → constants/tables → edge-case handlers/rescues.
- Applies to ruby, yaml, erb, js, css, html, sh, md — not just Ruby.
- When editing any file, even for a small change, briefly check if the surrounding region needs reordering. Don't rearrange just to rearrange — but if the file is already inverted (helpers at top, public API at bottom), fix it as part of the touch.
- The Maintainer and Layperson council personas evaluate this. Sweep enforces via `IMPORTANCE_ORDER` and `RECOMMENT`.

data/claude/feedback_lint_beautify.md

---
name: Mandatory lint/beautify on touch
description: Every file edited must be linted and beautified — not just the target lines
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Run a lint/beautify pass on every file you touch, not just the specific lines changed.

**Why:** User instruction: "mandatory lint / beautify of everything it touches"

**How to apply:** After any edit to a Ruby/Zsh/JS/HTML file, apply style fixes to the whole file: consistent spacing around operators, no double blank lines, use defined constants instead of magic literals, align related assignments if the file already does so. Verify syntax after.

data/claude/feedback_master_zsh_discipline.md

---
name: MASTER zsh discipline applies to my session shell
description: When working on MASTER (or any project where MASTER's constitution applies), avoid the banned external commands in my own Bash tool calls — not just in scripts I write
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
The zsh-banned-commands list in MASTER (`sed`, `awk`, `tr`, `grep`, `cut`, `head`, `tail`, `find`, `wc`, `sudo`, `perl`, `ruby` invoked from zsh, `dd`, `xargs`) applies to commands I run via the Bash tool too, not only to scripts I write into the repo.

**Why:** MASTER is a constitutional agent and the operator expects me to live by the same constitution while editing it. Reaching for `wc -l` or `sort | tail` to inspect repo files signals that I do not actually use what I preach. Caught 2026-05-05.

**How to apply:** When inspecting MASTER (or any sibling pub4 project) over Bash:
- Read a file → `cat file` (prefer over grep/head/tail fragments — user reinforced 2026-05-06: "instead of grep and head just cat"). Read the whole file once instead of stitching snippets together.
- File line counts → zsh array: `lines=("${(@f)$(<file)}"); print ${#lines}` — or `print -l file*(.oL[1,N])` for size-sorted listing
- Largest N files → glob qualifier with size sort: `print -l **/*.yml(.oL[1,20])`
- Search content (when actually searching, not reading) → use the Grep **tool**, never shell `grep`/`rg`
- Find files → use the Glob **tool**, never shell `find`
- Privilege → `doas`, never `sudo`
- Complex parsing → write a Ruby script and run it, never inline `sed`/`awk`

The exception that already holds: `git`, `gh`, `bundle`, `ssh`, `scp`, `sshpass`, plain `ls`, `mkdir`, `cd`, `print`, `echo`, parameter expansion. Those stay fine.

**Narrow exceptions:**
- `eval` — only for loading exports from `.zshrc` (`eval "$(grep '^export' ~/.zshrc)"`). Banned for arbitrary code execution.
- `bundle exec ruby exe/master` — permitted because it boots the project executable. Standalone `ruby -e` from zsh stays banned; use `tmp/patch.rb` + `ruby tmp/patch.rb` for transient scripts.

data/claude/feedback_meta_framing.md

---
name: User favors meta-architecture framing over change-by-change reports
description: After a batch of work, surface what's next/missing/structurally off — exploratory questions outperform itemized diffs
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
After landing a batch of work, surface the meta-question — what shape, what's missing, what's structurally off — instead of itemizing the diff.

**Why:** This user repeatedly asks "ways to...", "could X benefit...", "are we missing...", and explicitly favors 2x architectural wins over 5% incremental fixes. They read the diffs themselves; they want me using context to spot shape misfits, format shifts, and consolidation opportunities. They land everything in batch with "yes" / "land all" / "sweet, finish backlog next" — proof that exploratory follow-ups (not summaries) keep momentum.

**How to apply:**
- After a multi-commit batch, end with one meta-question (what to consolidate next, what's drifting, what could take a different shape) — not a list of what changed.
- When asked "are we missing X?", give 5-8 ranked candidates with payoff/risk, not a single suggestion.
- When the user says "land all", treat it literally — no per-suggestion confirmation, batch + commit aggressively, only break stride if syntax fails or the work needs the VPS.
- Pair violations with opportunities in any scan/audit reply — never just bugs.

data/claude/feedback_no_consecutive_whitespace.md

---
name: No multiple consecutive whitespace anywhere
description: Single space, single blank line max, no trailing whitespace — across Ruby, JS, CSS, HTML, YAML, shell, Markdown.
type: feedback
originSessionId: b02ce9b9-a7c7-4c65-b8d0-3b8469dc2028
---
Multiple consecutive whitespace is forbidden across all file types. Applies to:

- Two or more spaces in a row mid-line — one space only (no aligned-column padding like `@foo   = 1`)
- Two or more blank lines in a row — single blank line max between sections
- Trailing whitespace at line end
- Indentation beyond level (no double-indent for visual alignment)

**Why:** Stated by user 2026-05-07 during lofi pass 1 session. Tightens "ultra-minimalistic coding style" and the Strunk & White principle: omit needless characters, not just needless words. Aligned-column padding is filler.

**How to apply:** When editing a file, collapse runs of spaces and blank lines as part of the lint/beautify-on-touch pass. When writing new code, never align `=` or values with extra spaces; never leave two blank lines between methods. CSS one-liners fine; CSS multi-line fine; tabular alignment via spaces not fine.

data/claude/feedback_no_new_files.md

---
name: no new files without approval
description: Never create new files — always edit originals in place
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Always edit the original file directly. Never create intermediate files (local staging files, _fixed.rb copies, tmp patches) without explicit approval.

**Why:** User explicitly said "write changes back into original files don't create new files ever without approval."
**How to apply:** Use Edit tool on the actual file path, or write patch Ruby to /tmp on the VPS and run it in-place — but never create a local copy. The /tmp/patch.rb VPS pattern from CLAUDE.md is fine since it's a transient runner, not a persisted file.

data/claude/feedback_no_permission_questions.md

---
name: No permission questions for predictable yes
description: Skip "do you want me to..." prompts when the answer is obviously yes given context
type: feedback
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
Don't ask "should I continue?", "want me to ship next?", "shall I start with X or Y?" when the user's prior approval, autoproceed memory, or task framing makes the answer obvious.

**Why:** User has standing autoproceed authorization (feedback_autoproceed) and decisive-signals authorization (feedback_decisive_signals). Asking for re-confirmation per step is wasted turns and breaks flow.

**How to apply:** After one approval ("yes", "ship", "go", "do it", "start"), execute the full backlog. Surface trade-offs and checkpoints as statements ("shipping #1 next, ETA 10 min"), not questions. Only ask when there's a genuine fork that the user can't predict — e.g., destructive action, ambiguous scope, or a real either/or where both are reasonable.

data/claude/feedback_no_python.md

---
name: no python
description: Never use Python — only Ruby for scripting tasks
type: feedback
originSessionId: 5a5097b9-8cd5-46a3-913f-b193da929311
---
Never use Python for any task. Use Ruby exclusively for scripting, data processing, encoding, etc.

**Why:** User explicitly said "no python. only ruby." Reinforced again this session.
**How to apply:** Replace any python3/python one-liners with ruby equivalents. Use `ruby -e` or write to /tmp/*.rb on VPS. Do not even test-invoke python3 as a fallback before trying Ruby.

data/claude/feedback_no_sed.md

---
name: No sed — use ruby
description: Never invoke sed in shell commands; use ruby for any text substitution
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Never call `sed` (or awk/grep-with-rewrite) for text edits. Use ruby instead — `ruby -e`, `ruby <<RB`, or a `.rb` script.

**Why:** OpenBSD sed is BSD-flavor and behaves differently from GNU sed (no `-i ''` semantics, different regex flavors, no extended-mode without `-E`). Scripts written against GNU sed silently break on the dev@brgen.no VPS. Ruby is portable and the project's primary language.

**How to apply:** any time I'd reach for `sed -i 's|x|y|'`, write `ruby -E UTF-8:UTF-8 -e 'File.write(p, File.read(p).sub("x","y"))'` instead. Same for awk one-liners — use ruby. The earlier ban-list (sed/awk/grep/wc/head/tail/find) already covers this; this memory exists because I slipped once during Wave B heredoc fixes.

data/claude/feedback_no_shell_piping.md

---
name: No unnecessary piping/concat in shell calls
description: Avoid pipe chains and string concat in Bash invocations; prefer pure Ruby or pure zsh
type: feedback
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
When invoking Bash, do not pipe through `head`/`tail`/`grep`/`wc` etc. or stitch with `&&`/`;` chains where a single Ruby/zsh idiom does the job.

**Why:** matches the banned-shell-commands rule already in `data/rules.yml` (sed/awk/grep/find/head/tail/wc/sudo). Same discipline applies to my own tool calls, not just to scripts I write. User explicitly called this out as noise — it makes prompts hard to read and audit.

**How to apply:**
- File reads → use Read tool, not `cat | head`.
- Searches → use Grep tool, not `grep`.
- Single-step shell ops → run them directly; do not chain when sequential calls would be clearer.
- For Ruby work, prefer a one-liner `ruby -e '...'` over zsh-glue.
- For zsh, use builtin parameter expansion / globs / arrays, not pipes to coreutils.

data/claude/feedback_proper_casing.md

---
name: Proper casing, no ASCII decorations
description: Sentence case in prose, comments, CLI, commit messages; no ===, ----, [ok], bullet/separator chars as ASCII art
type: feedback
applies_to: prose, comments, CLI output, commit messages, log lines, section headers
---

Use proper casing in prose, comments, log lines, CLI output, and commit messages. Capitalize sentence starts, proper nouns, and acronyms. Snake_case identifiers stay as-is.

Commit messages: capitalize the first word of the subject line. `Kill cli tts; web is sole audio path` not `kill cli tts; web is sole audio path`. Body paragraphs follow normal sentence-case rules.

Never use these as ASCII art decorations:
- `===` or `----` (banner lines, section dividers)
- `[ok]` `[err]` `[skip]` (status tags — use `ok:` `err:` `skip:` prefix instead)
- `` `|` `` `` (bullet/separator characters in CLI text)

**Why:** Refines the prior dmesg/terse-voice rule. Lowercase-only feels sloppy in human-facing surfaces; ASCII decorations are visual noise the user explicitly disliked. Real dmesg uses lowercase because kernel space is constrained — MASTER isn't, so prose, CLI output, and commit subjects should read like written English. Commit-msg rule added 2026-05-07 after a lowercase commit subject slipped through.

**How to apply:**
- Comments: `# Restore HTML/CSS/typography sections` not `# restore html/css/typography sections`
- CLI output: `Wired /why to local lookup; LLM fallback only on miss.` not `[ok] /why now uses WhyExplainer first`
- Log lines: `Boot scan: 1678 violations (45s)` not `boot scan: 1678 violation(s)`
- Commit subjects: `Add foo`, `Fix bar`, `Refactor baz` — first word capitalized
- Section headers in YAML/code: drop `===== HEADER =====` style; use a single `#` line if needed
- Status indicators: `ok:` `err:` `warn:` as bare prefixes, never `[ok]`
- Bullet content in CLI: dash + space (`- item`) is fine; never use ``

**Tension with dmesg style:** dmesg conventions apply ONLY to *kernel-style structured output emitted by MASTER itself* — the boot banner, event log lines, status pings (`master@host ready`, `boot0: 26ms`). The MASTER boot banner is explicitly sacred (user 2026-05-06: "dont remove boot message on startup, its awesome, and should remind of openbsd dmesg").

Do NOT use dmesg style for my own conversational prose to the user (clarified 2026-05-06: "dont use dmesg style for conversing prose"). When narrating progress in chat, write plain English sentences with proper casing. dmesg style is for log lines MASTER writes, not for me speaking to the operator.

data/claude/feedback_readme_autoupdate.md

---
name: Auto-update README.md when needed
description: After any meaningful change to MASTER's behavior or capabilities, update README.md without prompting
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
When a commit changes MASTER's user-facing behavior, capabilities, command surface, philosophy, or stack, update README.md in the same or a follow-up commit without waiting for the user to ask.

**Why:** Stated explicitly on 2026-05-05. README is the single front door — drift between it and the code degrades trust. The user prefers the doc to lead, not lag.

**How to apply:**
- After landing depth flips, rule additions, workflow changes, persona changes, scan/sweep semantics changes, model routing changes, or any new top-level concept (Six Laws, biases, structural_ops, etc.) — refresh the matching README paragraph.
- Refresh = update the prose, not append a changelog entry. Keep README's flowing-prose / Strunk & White / Bringhurst form (no h2/h3, no tables, no code blocks unless essential).
- Bundle the doc update with the code commit when small; split into a follow-up if the doc change is substantial.
- Skip auto-update only for trivial bugfixes that don't change observable behavior.

data/claude/feedback_restart_rails.md

---
name: Restart MASTER service after every web/* edit
description: Whenever I update any file under MASTER/web/ on the VPS, restart the master rc.d service so the change takes effect; do not batch updates and restart at the end
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
After every scp of any file under `MASTER/web/` (controllers, views, initializers, config), immediately run `doas rcctl restart master 2>&1` on the VPS before moving on. Falcon does not hot-reload code in production mode — without a restart, the deployed app still serves the prior bytecode and the user sees stale behavior.

**Why:** User correction 2026-05-06 ("restart the rails app every time you update it"). I had been batching multiple web edits and restarting once at the end, which left the user staring at unchanged behavior between scps.

**How to apply:**
- Edit one web file → scp → `doas rcctl restart master` → next edit, even if more edits to that same file are coming.
- Allow ~2 seconds after restart before any verification curl, since Falcon cold-starts the container.
- Lib edits (`MASTER/lib/`) follow the same rule when they're in the live require path.
- Data file edits (`MASTER/data/*.yml`) load at boot too — restart for those as well.
- CLI-only changes (`exe/master`, `lib/master/cli/*`) don't need a restart unless the operator is also using the web surface.

data/claude/feedback_run_through_master_triad.md

---
name: "Run X through MASTER" = scan + sweep + tribunal
description: User shorthand — "run X through master" means /triad = scan + sweep + tribunal (called "council" in code, "tribunal" in user vocabulary), not just /scan
type: feedback
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
When the user says "run X through MASTER" (or "expose X to MASTER", "MASTER on X"), default to /triad — scan, sweep to convergence, then tribunal deliberation. Tribunal = the council deliberation pass with the 6 personas and Security veto.

Why: User confirmed "yeah when user says run this or that through master, then a triad is what i expect" → "/scan+sweep+tribunal" (2026-05-08). User uses "tribunal", code uses "council" — same thing.

How to apply: For any directive "run/scan/process X through master" where X is a path or codebase, invoke `/triad <path>` (depth knob removed; "deep" is now the default). Step 3 wires through `Master::Council::Deliberation.review` directly — bug fixed 2026-05-08.

data/claude/feedback_strunk_white.md

---
name: Strunk & White style
description: All code output — commits, comments, log messages, CLI output — must follow Strunk & White principles
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Apply Strunk & White to every written artifact: commits, comments, log messages, CLI prompts, error messages.

**Why:** User mandate for all text output from and about MASTER.

**How to apply:**
- Active voice: "Fix bug" not "Bug was fixed"
- Omit needless words: "extract Search module" not "perform extraction of Search module functionality"
- Concrete nouns and verbs: "scan", "fix", "load", "route" — not "process", "handle", "manage"
- One idea per sentence
- Commit messages: imperative mood, ≤72 chars, no trailing period
- Comments: state the WHY only, not the WHAT — one line max
- dmesg log lines: `component: action key=val key=val` (no commas, no padding)

data/claude/feedback_style.md

---
name: ultra-minimalistic coding style
description: Always write ultra-minimalistic code in all languages — no redundancy, no filler
type: feedback
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
Always use ultra-minimalistic coding style across all languages (Ruby, Zsh, HTML, JavaScript, etc.) — no filler, no redundant logic, no ceremonial patterns. Intentional and valuable logic is preserved; everything else is cut.

**Why:** User explicitly requested this style universally.
**How to apply:** Shortest correct form always. No defensive over-engineering, no padding, no comments explaining the obvious. One expression where one expression suffices.

Additional standards enforced on all files:
- Strunk & White: active voice, omit needless words, concrete verbs
- Ruby community style guide (https://rubystyle.guide)
- Rails style guide where applicable
- Always 2-space indents; always double quotes for strings
- No abbreviated identifiers — spell words in full (e.g. `temporary_path` not `tmp`, `index` not `idx`, `number` not `num`, `configuration` not `cfg`, `context` not `ctx`)
- No regex when plain string matching suffices (keyword arrays with `start_with?` over regex patterns)
- Outsource logic to gems when a well-maintained gem does it better (e.g. flay for dup detection, reek for smells)

data/claude/feedback_voice_terse_unix.md

---
name: Voice — terse, unix-like, perfectionist
description: User's preferred voice/tone for MASTER and for my own outputs — terse, unix-like, perfectionist
type: feedback
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Voice and personality direction: **terse, unix-like, perfectionist**.

**Why:** User stated this directly when we were mining old master.yml versions for voice/persona ideas. Aligns with the dmesg style, OpenBSD heritage, Strunk & White prose, and the v31 zen interface (wabi-sabi, ma, kanso). The user is an architect — perfectionism is his default mode.

**How to apply:**
- My own responses: cut filler ruthlessly, output diagnostic-style updates (single-line where possible), refuse "great question" / "let me explain" / sycophantic preludes, no padding.
- MASTER's voice config (data/voice.yml or equivalent): when polishing or proposing voice changes, anchor to terse + unix + perfectionist. Avoid corporate, friendly, conversational, or verbose registers.
- Perfectionism means: zero violations as the target, fixed-point convergence, not "good enough." Loop until clean.
- Unix-like means: do one thing well, silence on success, exit codes carry meaning, text in/out, composable.

data/claude/project_defrag_plan_2026_05.md

---
name: pub4 defrag/dedup/rename plan (2026-05-07)
description: Multi-commit refactor plan from a sister chat — collapse duplication across docs, shrink data/, flatten repo root, rename for clarity. Priority-1 patch is Master::Orient + slim docs + .zshrc fix.
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
User shared a full defrag/dedup/rename proposal on 2026-05-07 covering:

1. **Single source of truth** — banned commands, voice rules, ASCII-art ban, house rules currently duplicated across AGENTS.md / CLAUDE.md / data/*.yml. Move each fact to one yml file; prose docs reference, never restate.
2. **data/ shrinks 11 → 8 files** — merge `council.yml`+`council_patterns.yml`, merge `infer_patterns.yml`+`sweep_prompts.yml`+`zsh_patterns.yml``patterns.yml` (namespaced).
3. **Top-level shrinks 26 → 10 entries** — fold `pub`/`pub2`/`pub3`/`railsy` into `__predecessors/`, merge `mix/`+`multimedia/`+`.mp3/``audio/`, merge `sh/`+`scripts/`+`bp/``scripts/`, static HTML → `web/`, rename `:memory:/``memory/`.
4. **Renames**`MASTER/DEPLOY/openbsd/openbsd.sh``MASTER/deploy/openbsd.sh`; `data/standing_orders.yml``state.yml`; `workflow.yml``limits.yml`; `rules.yml``voice.yml`; `ruby_style.yml``style.yml`. CONVENTIONS.md either generated to tmp/ or deleted.
5. **Smoothing**`master orient` command replaces five-cat bootstrap. Stash before `git reset --hard`. Replace `Thread.current[:master_visitor]` with explicit `scope:` arg on `Master.build`. Unify two `Result` impls (the `respond_to?(:ok?)` smell). Pipeline per-stage budget in `limits.yml`. Reconcile `Guard` stage with auto-approve. Unify `exe/master` boot paths (rcd + ssh-autostart). Generalize WhyExplainer's local-lookup-then-LLM pattern.

**Priority-1 patch (drop-in code provided):**
- `MASTER/lib/master/orient.rb` — 35 LOC, prints all five bootstrap yml files
- Slimmed `AGENTS.md` (46 → 27 lines) and `CLAUDE.md` (238 → ~85 lines) — delete duplicated constitution, point at `/orient`
- CLI dispatch: add `/orient` slash branch and `orient` subcommand
- `~/.zshrc` top: `[[ -o interactive ]] || return` + `[[ -t 0 ]] || return` to fix non-interactive SSH stealing stdin
- Commit message provided: "master: collapse five-cat bootstrap into orient"

**Why:** Reduce drift (one fact = one place), reduce friction (one command vs five cats), shrink visual surface so the repo reads in one screen. Each move is independently shippable.

**How to apply:** Treat priority-1 as the next reversible commit when user greenlights. Treat the broader plan as a sequence of small commits — never bundle. The smoothing items (#3-#9 of execution path) are individual follow-up tickets.

data/claude/project_falcon_em_subprocess.md

---
name: Falcon Async + EventMachine = subprocess pattern
description: EM-based gems (rb-edge-tts, em-http) inside Falcon request handlers must shell out to a subprocess; Process.fork and direct EM.run both fail
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
Falcon uses Async/io-event fibers. Calling `Process.fork` from a request fiber raises `RuntimeError: Closing scheduler with blocked operations!`. Calling `EventMachine.run` directly conflicts with Falcon's reactor (silent hang or premature scheduler close).

**Why:** Async's fiber scheduler tracks open fibers across fork boundaries; EM owns its own reactor that can't coexist with Async's in the same process. We hit this twice: in `Master::Speech.synthesize_edge` (rb-edge-tts) and would hit it for any `em-*` gem.

**How to apply:** When wiring an EM-based gem into a Falcon controller, write a small `exe/<name>-worker` Ruby script that does the EM work and writes output to a tempfile. Call it via `Open3.capture3` from the controller path. Reference: `MASTER/exe/tts-worker` + `MASTER/lib/master/speech.rb#synthesize_edge`.

data/claude/project_master.md

---
name: MASTER project context
description: pub4/MASTER — constitutional AI coding agent on OpenBSD VPS dev@brgen.no
type: project
originSessionId: 84fcf91d-46ea-43a5-8efa-3d33b065e6a5
---
VPS: dev@brgen.no (185.52.176.18), OpenBSD 7.8, 1GB RAM, passwordless doas.
SSH: `sshpass -p '<pass>' ssh -o StrictHostKeyChecking=no dev@185.52.176.18 'cmd'`
Password changes each session — check CLAUDE.md for current.
Codebase: ~/pub4/MASTER/ — Ruby ~6K LOC, Zeitwerk-autoloaded.

**Why:** MASTER is a constitutional AI coding agent that replaces Claude Code CLI. Runs on OpenRouter (default: nvidia/nemotron-3-super-120b-a12b:free) via `ruby_llm` gem. Fallback chain: qwen3-coder:free → minimax-m2.5:free → gpt-oss-120b:free → gemini-2.0-flash.

**How to apply:** All coding work must be done directly on the VPS via sshpass SSH. Never use local tools to edit VPS files — write patch scripts to ~/pub4/tmp/patch.rb and run with ruby. Use zsh builtins only — no sed/awk/grep/find/head/tail.

Pipeline: Intake → Infer → Route → Guard → Execute → [Council ‖ Lint] → Prune → Memo → Render
Pipe mode: `echo "cmd" | bundle exec ruby exe/master`
Session Startup: read data/standing_orders.yml, data/workflow.yml, data/rules.yml, data/models.yml
Web UI: Rails 8 + Falcon on port 10002, proxied by relayd → ai.brgen.no:3000/4430

data/claude/project_master_dual_gemfile.md

---
name: MASTER has two Gemfiles
description: MASTER/Gemfile (CLI) and MASTER/web/Gemfile (Falcon web) are independent — adding a gem to one does NOT make it available in the other
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
`MASTER/web/Gemfile` declares `gem "master", path: ".."` which loads master via gemspec, NOT via the parent Gemfile. Runtime gems used inside `MASTER/lib/` from the web process must be declared in BOTH `MASTER/Gemfile` AND `MASTER/web/Gemfile`, or in `master.gemspec` as a runtime dep.

**Why:** Bundling them once in MASTER/Gemfile leaves the falcon process unable to require the gem at runtime — the request handler raises LoadError silently and the controller's rescue returns 503. This burned an hour debugging rb-edge-tts that worked in CLI but failed in /chat/tts.

**How to apply:** When adding a gem touched by lib/master/* code that the web app calls, edit both Gemfiles. Run `bundle install` in both `MASTER/` and `MASTER/web/`. Verify by reading `MASTER/web/Gemfile.lock` for the gem name.

data/claude/project_master_seven_module_refactor.md

---
name: MASTER 7-module refactor (approved 2026-05-08)
description: User approved collapse of lib/master/ into 7 time-oriented modules — now/loop/judge/voice/ground/reach/trace. Multi-commit, pass-by-pass; ship on VPS dev@brgen.no.
type: project
originSessionId: 0c593fb2-cd49-4fd7-9e89-d77dd7e909ae
---
User approved the radical 7-directory tree on 2026-05-08 ("i approve of all your suggestions") after the /triad runs on lib/ and DEPLOY surfaced the duplication patterns.

Target tree (lib/master/):
- now/      cli, repl, pipeline executor — synchronous user turn
- loop/     autoloop, sweep, heartbeat, convergence — async background
- judge/    scan/rules, council, swarm, security — verdict passes (unified Verdict shape)
- voice/    personality, soul, renderer, speech — output identity
- ground/   config, axioms, data/*.yml loaders — read-only constitution (Constitution aggregator)
- reach/    tools/base + 24 tools — actions on world (Tools::Base DRYs boilerplate)
- trace/    session, telemetry, bus, undo — write-only history

This subsumes the older 6 dedup proposals (Constitution aggregator, Tools::Base, deliberation unification, refactor cycle, Security::Policy, Voice namespace).

Pass sequence (one commit per pass, must keep tests green and Zeitwerk loading):
1. Skeleton — create empty dirs + README pointers
2. voice/ — move personality, soul, speech, renderer; update inflector + requires
3. trace/ — move session, telemetry, bus, undo
4. ground/ — move config, axioms, YAML loaders; introduce Constitution aggregator
5. reach/ — move tools, introduce Tools::Base, collapse 24 tool boilerplates
6. judge/ — move scan, council, swarm, security; introduce shared Verdict shape
7. loop/ — move sweep, autoloop, heartbeat, convergence
8. now/ — move cli + collapse stages/ into pipeline-as-data executor

Why: 100+ files split by file-type/domain are slicing the codebase against its own grain. Time-orientation (now vs loop vs trace) makes pledges/unveil and concurrency reasoning structural. judge/ unifies four trees that all answer "is this OK?" but reinvent the verdict shape.

How to apply: Work on VPS dev@brgen.no (memory: no heavy work on device). Create branch `refactor/seven-modules` off main. Each pass is one commit; if a pass breaks Zeitwerk or specs, fix in the same pass before moving on. Tradeoff accepted: stages/ disappears as directory — pipeline becomes a ~150-line lambda table inside now/pipeline.rb, losing per-stage class affordance for tests but collapsing 12 files.

data/claude/project_master_yml_json_authority.md

---
name: master.yml + master.json are the constitutional source of truth
description: Current Ruby MASTER must implement what the predecessor master.json (v43.0.1) and master.yml (v31, v49.75, etc.) describe — close the drift
type: project
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
The user's directive: **"MASTER should do what master.yml and master.json describes."**

The current modular Ruby MASTER has drifted from the constitutional intent of its YAML/JSON predecessors. The predecessors are the authoritative spec for behavior; the Ruby is just the executor.

**Why:** Stated explicitly on 2026-05-05 after we mined the deleted master.json (Nov–Dec 2025, 130 commits) and master.yml (Dec 2025–Feb 2026, 668 commits) histories. The user wants behavior-spec parity, not just rule-set parity.

**Key gaps to close (priority order):**

1. **Six Universal Laws ladder** (ROBUSTNESS→SINGULARITY→LINEARITY→PROXIMITY→ABSTRACTION→DENSITY) — single hierarchical priority system; every rule and persona anchored to one law.
2. **Strunk & White safeguards**`apply_to: [prose, comments, documentation, strings]`, `never_apply_to: [code_logic, algorithms, data_structures]`, `never_delete_variable_names / never_delete_function_calls / never_simplify_conditional_logic`. Prevents lossy compression.
3. **biases section** — hallucination, simulation, completion_theater, sycophancy, false_confidence + cognitive traps as concrete detectable rules with regex patterns.
4. **structural_ops taxonomy** — merge/semantic_regroup/defrag/decouple/hoist/flatten/delete/expand/reduce_noise, each with risk + verify + supports_law.
5. **8-phase workflow with introspection + learn phase** (discover→analyze→ideate→design→implement→validate→deliver→**learn**), introspect question per phase. Closes the project orchestrator / spec planner gap.
6. **patterns.veto regex detectors** (secrets, sql_injection, unfinished, unsafe_calls, race_conditions) and patterns.high (future_tense, sycophancy, magic_numbers, deep_nesting).
7. **Adversarial: 5 questions per violation; solution generation: 5-15 solutions, early exit on quality** — currently makes 1 fix per file.
8. **Fixed-point convergence: silence in 2 consecutive runs** — currently 1 cycle.
9. **Incremental scanning** — only modified files when not user-triggered; full-scan triggers = new_principle_added / master_yml_modified / user_requests_full_scan. (60-85% faster.)
10. **Prediction engine with confidence thresholds** — per-detector autofix mappings (null_usage 0.95→null_object, abbreviation 0.99→expand, nesting 0.92→extract_method).
11. **12 weighted personas** for council with `w:`, `q:`, `emphasizes: [LAWS]`. Veto rights to [security, attacker, maintainer].
12. **SHA256 evidence logging**`Read {file} (sha256: {hash}, {lines} lines)` for every read/write.
13. **Beauty section** — Bringhurst typography, Ando architecture, Rams design, Martin code as aesthetic anchors.
14. **preserve: section** — protect boot dmesg, diagnostic output, help text from over-simplification.
15. **OpenBSD per-config validators** — pf.conf, sshd_config, httpd.conf, nsd.conf, smtpd.conf with required_patterns and warnings.
16. **Tech stack constants** — LCP 2.5s, INP 200ms, CLS 0.1, WCAG_AA 4.5 contrast, 24px touch targets, 66ch line length.
17. **Cost guards** — max_per_file: $1, max_per_session: $10, warn_at: $0.50.
18. **Per-language generation templates** — HTML/CSS/Ruby/sh/yml starter templates.

**How to apply:** Treat closing these gaps as the primary backlog. Execute in priority order; each is independently commit-able. The user is an architect — aesthetic items (Six Laws naming, beauty section, zen interface, voice) are first-class, not nice-to-have.

data/claude/reference_grok_ui_cli_patterns.md

---
name: Grok-inspired UI/CLI patterns (chatlog dump 2026-05-07)
description: Reference dump from a sister chat — StyleCoach UI prompt, htmx+SSE streaming, tty-prompt/tty-spinner advanced features, multi-line editor, character-stream LLM CLI. For MASTER's web UI and CLI polish.
type: reference
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
User shared a long Grok-style design conversation on 2026-05-07. Key reusable patterns:

## StyleCoach UI persona (LLM prompt)
Critique persona that evaluates MASTER's own output (CLI sessions, web partials, screenshots via vision).
Rules: *"interface should disappear; only the conversation should remain. Zero visual debt. Personality lives in words/spacing/timing, never in UI flourishes. Speed > everything. Mobile-first, dark-mode default. Every element earns its existence or it dies."*
Output format: `ELEMENT: ... / Current: ... / Suggested: ... / Reason: ...` and a final `distilled_ui_lesson` tag.
Distilled-rule examples: "If the user can see more than two accent colors, you have failed." "Spinners longer than three dots are crimes against humanity." "The prompt bar belongs at the bottom — always — like breathing."

## Streaming patterns (web UI)
- **htmx + SSE.** `<div hx-ext="sse" sse-connect="/stream/:id" sse-swap="chunk">`. Server writes `event: chunk\ndata: <span>...</span>\n\n` per token. Set `X-Accel-Buffering: no` for nginx/passenger. Sleep `rand(0.02..0.08)` for human-typing feel.
- **Chunked HTTP (no SSE ext).** `hx-trigger="load" hx-swap="innerHTML"`; server sets `Transfer-Encoding: chunked` and writes `CGI.escapeHTML(token)` per chunk. Zero extra JS.

## CLI polish — Grok-borrowable traits
1. Stream answers character-by-character via ANSI + `\r` overwrite of `Thinking…` line.
2. Terse happy path — no mandatory flags for 90% of cases.
3. Subtle personality on success and failure ("You had 7 nested conditionals. I removed them. You're welcome." / "Looks like you closed something you never opened.").
4. Stateful context across invocations without `--session` flag (tiny SQLite or `~/.master/context.json`).
5. Visual feedback >1.5s = braille spinner `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏`. No emoji spinners, no rainbow bars.
6. One-command install + instant usefulness.

## tty-prompt advanced features (when MASTER CLI evolves)
- `select(filter: true)` — fuzzy-search list. `enum_select` — auto-complete predefined.
- `multi_select(per_page:, cycle:, symbols: { marker: "✔" })` — tag/category picking.
- `expand` — git-style `[(Y)es, (n)o, (a)ll, (q)uit]` compact menu.
- `editor("...", syntax: :markdown, word_wrap: 78, editor: ENV["EDITOR"])` — multi-line input via $EDITOR. Returns nil on cancel.
- `mask("API key:", required: true) { |q| q.confirm true }` — password input.
- `slider(min:, max:, step:, format:, active_color:)` — numeric tuning.
- `ask` validators: `q.in "18..120"`, `q.validate(/regex/)`, `q.convert :int`, `q.modify :strip, :downcase`.
- Theme: `TTY::Prompt.new(active_color: :bright_cyan, symbols: { marker: "❯", radio_on: "◉" })`.

## tty-spinner formats
Built-ins worth using: `:dots_8`, `:dots_9` (smooth braille — recommended default), `:spin`, `:simpleDotsScrolling`. Custom: `{ interval: 6, frames: %w[♥ ♡] }`. Multi-spinner: `TTY::Spinner::Multi.new` registers child spinners for parallel tasks. Always `hide_cursor: true`.

## Multi-line editor + Claude streaming (full example pattern)
`PROMPT.editor(...)` for multi-line, then `Anthropic::Client#messages.stream(stream: true) do |chunk|` → write `chunk.dig("delta","text")` char-by-char with `sleep(rand(0.008..0.035))`. Maintain `@history` array across turns. Spinner during pre-stream `Thinking…` then `.stop` before first byte.

## How to apply for MASTER
- MASTER's existing web UI (Rails 8 + Falcon, port 53187, two-tier auth) already streams via `POST /chat/message (SSE)`. Cross-check against the htmx+SSE pattern above.
- CLI REPL (`exe/master`) currently streams via `chunk_accumulator` + `print` — already char-stream-ish. Could borrow the `Thinking…` cleanup, the menu-on-ambiguity (`needs_clarification?`), and `tty-prompt editor` for the `<<` multiline mode.
- StyleCoach as a `/crit` persona variant is wired but could ingest screenshots via vision tool.

Don't bulk-import. Cherry-pick when a specific MASTER edit calls for it.

data/claude/reference_opencrabs.md

---
name: OpenCrabs (Rust MASTER cousin)
description: github.com/adolfousier/opencrabs — Rust/Ratatui TUI agent, philosophical cousin of MASTER. Solo author, 5 stars, MIT. Worth-stealing patterns listed.
type: reference
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
**Repo:** github.com/adolfousier/opencrabs · docs.opencrabs.com · 34 MB binary, 57 MB RSS, latest v0.2.15 (Feb 2026), Cargo nightly required (2024 edition + `portable_simd`).

**Architecture:** TUI/CLI → Brain → Services → SQLx/SQLite → LLM providers. Layered Rust + Tokio + Ratatui. Linux/macOS/Windows; no OpenBSD support, no `pledge`/`unveil`.

**Patterns worth stealing for MASTER:**
1. **Brain-files re-read every turn.** System prompt assembled per turn from workspace MD files (SOUL/IDENTITY/USER/AGENTS/TOOLS/MEMORY/SECURITY/BOOT/HEARTBEAT). Edit between turns → effect immediate, no rebuild. Same shape as MASTER's `data/*.yml`.
2. **FTS5 BM25 memory search via existing SQLite.** Zero new deps, ~0.4ms/query. Ruby equivalent: `sqlite3` gem + FTS5 — free.
3. **Inline compaction summary.** When auto-compaction fires at 70% ctx, summary written to chat AND daily log so user sees what was kept. Transparency over magic.
4. **`/rebuild` + Unix `exec()` hot-restart.** Self-edit → `cargo build --release` async → `ProgressEvent::RestartReady``exec()` swap → resume session via SQLite. No context loss.
5. **Sub-agent tool exclusion list.** `spawn_agent`/`rebuild`/`evolve` ALWAYS_EXCLUDED from sub-agents — prevents recursive self-mod.

**Risks visible in their changelog (don't blindly copy):** RSI runs without human approval (writes to `~/.opencrabs/rsi/improvements.md`); README admits agent hallucinates tool calls in corrupted sessions ("fix coming"). Bus factor 1, pre-traction.

**Don't conflate:** `mo-vic/OpenCrab` (singular, fine-tuning distillation, unrelated) and the empty `opencrab` org are different projects.

data/claude/user_architect_aesthetics.md

---
name: User is an architect; aesthetic proposals usually pass
description: User's primary discipline is architecture; treat typography, layout, design-philosophy proposals as in-scope and likely approved
type: user
originSessionId: 038b16d9-fc5e-4144-9a47-5bd746b2d3ac
---
User is an architect by training and primary discipline. Aesthetic, typographic, and design-philosophy proposals — even ones that LLMs typically dismiss as garbage, irrelevant, or "out of scope for code" — are usually approved by this user.

**How to apply:**
- When mining for ideas (e.g. master.yml history), surface and propose aesthetic content (Bringhurst typography, Ando architecture, Rams design, wabi-sabi/ma/kanso, beauty sections) without self-censoring.
- Don't bury these under "philosophical fluff" disclaimers; treat them as legitimate engineering anchors.
- When polishing UI, prose, or config, propose aesthetic refinements actively rather than waiting for the user to ask.
- Don't argue against bringing back beauty/zen/design-philosophy YAML sections on the grounds that they're "not actionable" — the user finds them actionable.

data/closings.yml

# Random exit lines, dmesg-toned. One drawn at session close.
closings:
  - "Signing off."
  - "Link down."
  - "Pledge locked."
  - "Session flushed."
  - "Buffers drained."
  - "Halting."
  - "Power down."
  - "Standing down."
  - "Out of band."
  - "Watch ends."
  - "Closed orderly."
  - "Tx complete."
  - "Returning to base."
  - "All quiet."
  - "End of stream."

data/council.yml

# Council personas — deliberation panel for code review decisions.
# Each persona carries a weight (sum = 1.00), a sharp question, and the laws it
# emphasizes from rules.yml. can_veto: true blocks merge unconditionally.

personas:
  - name: Architect
    aliases: [architect]
    role: System Design
    bias: Structure
    weight: 0.07
    question: "What couples too tight to evolve?"
    emphasizes: [PROXIMITY, ABSTRACTION]
    can_veto: false
    prompt: Review architectural boundaries, coupling, interface shapes, and migration risk.

  - name: Data Steward
    aliases: [data_steward, data]
    role: Data Integrity
    bias: Consistency
    weight: 0.06
    question: "Where can the data go inconsistent?"
    emphasizes: [SINGULARITY, ROBUSTNESS]
    can_veto: false
    prompt: Audit schema impact, migrations, data lineage, and source‑of‑truth consistency.

  - name: Ethics & Policy
    aliases: [ethics, policy]
    role: Responsible Use
    bias: Compliance
    weight: 0.05
    question: "Who could this harm if misused?"
    emphasizes: [ROBUSTNESS, ABSTRACTION]
    can_veto: false
    prompt: Examine policy adherence, abuse potential, fairness, and governance implications.

  - name: Maintainer
    aliases: [maintainer]
    role: Code Health
    bias: Sustainability
    weight: 0.12
    question: "Will this be clear at 3am six months from now?"
    emphasizes: [LINEARITY, SINGULARITY, DENSITY]
    can_veto: true
    prompt: Evaluate readability, naming, modularity, and long‑term maintenance burden.

  - name: Performance
    aliases: [performance]
    role: Runtime Efficiency
    bias: Throughput
    weight: 0.07
    question: "Where is the Big-O bottleneck?"
    emphasizes: [DENSITY]
    can_veto: false
    prompt: Detect latency, memory, I/O, and algorithmic inefficiencies; suggest measurable optimizations.

  - name: Product Strategist
    aliases: [product, strategist]
    role: Product Fit
    bias: Value
    weight: 0.04
    question: "Is this worth shipping at all?"
    emphasizes: [DENSITY]
    can_veto: false
    prompt: Verify alignment with product goals, success metrics, and roadmap leverage.

  - name: QA Engineer
    aliases: [qa]
    role: Test Strategy
    bias: Verification
    weight: 0.08
    question: "What evidence proves this works?"
    emphasizes: [ROBUSTNESS]
    can_veto: false
    prompt: Locate missing tests, flaky patterns, and propose deterministic validation gates.

  - name: Pragmatist
    aliases: [pragmatist, realist, minimalist]
    role: Delivery Pressure
    bias: Shipping
    weight: 0.07
    question: "What can be deleted without loss?"
    emphasizes: [DENSITY, SINGULARITY]
    can_veto: false
    prompt: Minimize scope while maximizing shippable value within realistic constraints.

  - name: Reliability
    aliases: [reliability, chaos]
    role: Failure Engineering
    bias: Resilience
    weight: 0.10
    question: "What is the cascade if the weakest link snaps?"
    emphasizes: [ROBUSTNESS]
    can_veto: true
    prompt: Review retries, timeouts, degradation modes, idempotency, rollback safety, and worst-case cascades.

  - name: Security
    aliases: [security, attacker]
    role: Security Review
    bias: Safety
    weight: 0.13
    question: "Where are the injection vectors and exposed surface?"
    emphasizes: [ROBUSTNESS, ABSTRACTION]
    can_veto: true
    prompt: Identify injection, privilege escalation, data‑exposure, and auth risks. Prefix VETO when unsafe to ship.

  - name: Skeptic
    aliases: [skeptic, absence]
    role: Devil's Advocate
    bias: Caution
    weight: 0.10
    question: "What did we miss? What evidence is required?"
    emphasizes: [ROBUSTNESS]
    can_veto: false
    prompt: Challenge assumptions, enumerate failure paths, edge cases, brittleness, and missing gaps.

  - name: User Advocate
    aliases: [user_advocate, user]
    role: UX Advocate
    bias: Usability
    weight: 0.06
    question: "What would the user complain about first?"
    emphasizes: [ABSTRACTION]
    can_veto: false
    prompt: Assess clarity, friction, error recovery, and overall user outcomes.

  - name: Accessibility
    aliases: [accessibility, a11y]
    role: Inclusive Use
    bias: Reach
    weight: 0.05
    question: "Can a keyboard-only or screen-reader user complete the task?"
    emphasizes: [ROBUSTNESS, ABSTRACTION]
    can_veto: false
    prompt: Audit keyboard navigation, screen-reader semantics, contrast, focus order, and reduced-motion handling.

  - name: Graphic Designer
    aliases: [graphic_designer, designer]
    role: Visual Composition
    bias: Hierarchy
    weight: 0.04
    question: "Where does the eye land first, and is that what matters most?"
    emphasizes: [PROXIMITY, DENSITY]
    can_veto: false
    prompt: Critique typographic hierarchy, whitespace economy, contrast, alignment, scale, and figure-ground relationships. Reject ornament that doesn't carry meaning.

  - name: Web Designer
    aliases: [web_designer, frontend_designer]
    role: Browser-Native UX
    bias: Idiom
    weight: 0.04
    question: "Does this respect the medium — fluid, responsive, keyboard-first, link-shaped?"
    emphasizes: [ROBUSTNESS, ABSTRACTION]
    can_veto: false
    prompt: Evaluate semantic HTML, responsive behavior, link affordance, form ergonomics, viewport handling, and progressive enhancement. Flag div-soup, modal-overuse, JS-only interactions where HTML would do.

  - name: Electronic Music Producer
    aliases: [music_producer, producer]
    role: Sonic Texture & Timing
    bias: Groove
    weight: 0.03
    question: "Does the timing breathe, or is everything quantised to death?"
    emphasizes: [DENSITY, SINGULARITY]
    can_veto: false
    prompt: Assess audio mix balance, frequency masking, transient handling, rhythmic feel, sound-design intentionality. For non-audio code, transfer the metaphor — pacing, layering, and silence in interactions.

  - name: Layperson
    aliases: [layperson, novice, fresh_eyes]
    role: Outsider Comprehension
    bias: Plain Speech
    weight: 0.05
    question: "If I'd never seen this code/UI before, what would confuse me first?"
    emphasizes: [LINEARITY, SINGULARITY]
    can_veto: false
    prompt: Read as a non-expert. Flag jargon without glossary, unexplained acronyms, error messages that assume internals, UI labels that need a manual. The cure is plain words and obvious affordances.

# Council deliberation parameters. Source: master2 v4 reunification.
parameters:
  consensus_threshold:  0.70   # weighted majority required to accept proposal
  max_iterations:       25     # oscillation halt — forces explicit human decision
  oscillation_detection: true
  veto_precedence:     [Security, Reliability, Maintainer]   # order of veto evaluation
  tie_breaker:          Maintainer                          # weighted tie -> ops perspective wins

# Each persona gets 3 example utterances so the LLM mimics tone, not just emphasis.
# Source: master.yml v31 reunification (#59).
voice_samples:
  Architect:
    - "this couples #{x} to #{y} through a private method; extract a port"
    - "the migration adds a new dimension to an axis that already has three"
    - "the seam is clean here; promote it to an interface and we're done"
  Security:
    - "untrusted input crosses the boundary at line N; sanitize or VETO"
    - "secret leaks into the log message at this branch; redact"
    - "session token stored in plaintext fixture; rotate and tombstone"
  Maintainer:
    - "at 3am this method name lies; rename it now"
    - "no test will reproduce this; add one or revert"
    - "the conditional has six branches; collapse or document"
  Reliability:
    - "one timeout missing turns this into a fork-bomb on retry"
    - "no idempotency token; the second call doubles the side effect"
    - "circuit breaker absent; the dependent service drags us down"
  Graphic Designer:
    - "the eye lands on the timestamp, not the headline; that's wrong"
    - "three weights of grey doing the same job; pick one"
    - "this margin is wider than the line it separates; collapse"
  Web Designer:
    - "this button is a div; it isn't keyboard-tabbable; use button"
    - "form has no autocomplete hints; the browser can't help"
    - "the modal traps focus and the close icon is unlabeled"
  Electronic Music Producer:
    - "the snare is on the kick; one of them has to move"
    - "everything is on the grid and nothing breathes"
    - "high-mids are stacked; carve a notch for the vocal"
  Layperson:
    - "I read 'idempotency' three times and still don't know if it's safe to retry"
    - "the error says 'invalid token' — token of what? where do I get a new one?"
    - "the screen says 'success' but nothing visible changed; did it actually work?"

mcp_persona_slots:
  enabled:    true
  description: "MCP servers can register as council personas; weights default to 0.05 unless otherwise configured"
  source:     "cross-cutting reunification (#93)"

# Merged from former data/council_questions.yml — critic prompts rotated per turn.
questions:
  assumptions:
    - what are we assuming that could be false?
    - which assumption is load-bearing vs convenience?
    - if a key assumption flips, what still works?
    - which assumption have we never tested?
  failure_modes:
    - how does this fail catastrophically?
    - what breaks first under load or partial outage?
    - what happens when it fails silently?
    - how do cascading failures propagate?
  attacker:
    - what would an attacker do here?
    - where can input be abused or poisoned?
    - which trust boundary is weakest?
    - how would we exploit this ourselves?
  edge_cases:
    - which edge case will users hit first?
    - what happens with malformed input?
    - which rare but high-impact case is unhandled?
    - what edge cases live at integration points?
  degradation:
    - how do we degrade gracefully?
    - what is minimal viable behavior under stress?
    - which features sacrifice first?
    - how do we keep core function during partial failure?
  ops_maint:
    - what is the long-term maintenance burden?
    - how do we observe, debug, and rollback quickly?
    - which operational complexity is hidden?
    - how do we troubleshoot under pressure?
  economics:
    - where is waste or needless complexity?
    - what is the roi vs simpler alternatives?
    - which costs are hidden or deferred?
    - what are the opportunity costs?
  clarity:
    - is the intent obvious from the names alone?
    - which concept lacks a name and should have one?
    - where does the code lie about what it does?
    - what would a fresh reader misread first?

# Merged from former data/council_patterns.yml — regex strings that auto-trigger council.
auto_trigger_patterns:
  - '\beval\s+\('
  - '\bexec\s+\('
  - '\bsystem\s+\('
  - '\brm\s+-rf\b'
  - '\b(?:drop|truncate)\s+table\b'
  - '\bchmod\s+777\b'
  - '\b(?:delete|remove)\s+all\b'
  - '\b(fork|execve?)\b'
  - '\bgit\s+(push\s+--force|reset\s+--hard|rebase\s+-i)\b'
  - '\bdd\s+if=.*\s+of=.*\b'
  - '\b(mkfs|fdisk|parted)\b'
  - '\b(poweroff|reboot|shutdown\s+-[hr])\b'
  - '\bcurl\s+.*\s+-o\s+/\w+\b'
  - '\bwget\s+.*\s+--output-document=/.+\b'
  - '\b(chown|chgrp)\s+.*\s+/\w+\b'
  - '\bln\s+-sf\s+.*\s+/\w+\b'
  - '\bsystemctl\s+(mask|disable|stop)\b'
  - '\bumount\s+.*\b'

data/exemplars.yml

# Exemplars — canonical code examples for LLM context injection.

exemplars:
  - name: "Master::Axioms::ENUM"
    file: "lib/master/axioms.rb"
    lines: 9
    beauty_score: 7
    virtue: declarative
    why: "Centralised truth constants, immutable, self‑documenting"
  - name: "Master::CircuitBreaker#call"
    file: "lib/master/circuit_breaker.rb"
    lines: 6
    beauty_score: 8
    virtue: resilience
    why: "Prevents cascading failures, simple state machine, easy to test"
  - name: "Master::CodeIndex::SymbolVisitor#visit_def"
    file: "lib/master/code_index.rb"
    lines: 167
    beauty_score: 8
    virtue: introspection
    why: "Uses Prism visitor to collect symbols, pure functional style, concise"
  - name: "Master::Logging.debug"
    file: "lib/master/logging.rb"
    lines: 6
    beauty_score: 6
    virtue: transparency
    why: "Thin wrapper around logger, ensures consistent formatting, no side effects"
  - name: "Master::Logging.info"
    file: "lib/master/logging.rb"
    lines: 10
    beauty_score: 6
    virtue: transparency
    why: "Standardised info-level logging, preserves caller context"
  - name: "Master::Pipeline#run"
    file: "lib/master/pipeline.rb"
    lines: 22
    beauty_score: 9
    virtue: orchestration
    why: "Linear 10‑stage pipeline, monadic result flow, explicit error propagation"
  - name: "Master::Result::Err"
    file: "lib/master/result.rb"
    lines: 36
    beauty_score: 9
    virtue: error_handling
    why: "Explicit failure monad, immutable, forces callers to handle errors"
  - name: "Master::Result::Ok"
    file: "lib/master/result.rb"
    lines: 8
    beauty_score: 9
    virtue: zen_method
    why: "Encapsulates success, immutable, self‑describing, no boilerplate"
  - name: "Master::RingBuffer#pop"
    file: "lib/master/ring_buffer.rb"
    lines: 12
    beauty_score: 8
    virtue: efficient
    why: "Symmetric constant‑time removal, preserves immutability guarantees"
  - name: "Master::RingBuffer#push"
    file: "lib/master/ring_buffer.rb"
    lines: 5
    beauty_score: 8
    virtue: efficient
    why: "Constant‑time circular buffer, clear intent, minimal code"
  - name: "Master::Security::InjectionGuard#sanitize"
    file: "lib/master/security/injection_guard.rb"
    lines: 12
    beauty_score: 8
    virtue: safety
    why: "Robust string sanitization, guards against code injection, well‑named"
  - name: "Master::SemanticCache#fetch"
    file: "lib/master/semantic_cache.rb"
    lines: 8
    beauty_score: 8
    virtue: performance
    why: "Memoises LLM embeddings, reduces API calls, immutable cache key"
  - name: "Master::Stages::Intake#call"
    file: "lib/master/stages/intake.rb"
    lines: 8
    beauty_score: 7
    virtue: composability
    why: "Initial request parsing, validates input, isolates side‑effects"
  - name: "Master::Stages::Lint#call"
    file: "lib/master/stages/lint.rb"
    lines: 10
    beauty_score: 7
    virtue: composability
    why: "Stage pattern, thin wrapper, delegates to scanner, easy to test"
  - name: "Master::Stages::Render#call"
    file: "lib/master/stages/render.rb"
    lines: 6
    beauty_score: 9
    virtue: presentation
    why: "Final rendering step, separates view logic, pure Result output"
  - name: "Master::Tools::AskLlm#call"
    file: "lib/master/tools/ask_llm.rb"
    lines: 5
    beauty_score: 8
    virtue: delegation
    why: "Encapsulates LLM request, uniform error handling, testable abstraction"
  - name: "Master::Tools::ReadFile#call"
    file: "lib/master/tools/read_file.rb"
    lines: 5
    beauty_score: 7
    virtue: clarity
    why: "Single responsibility, explicit error handling, pure I/O abstraction"
  - name: "Master::Tools::SearchFiles#call"
    file: "lib/master/tools/search_files.rb"
    lines: 5
    beauty_score: 7
    virtue: discoverability
    why: "Recursively glob‑searches project files, filters by pattern, pure result handling"
  - name: "Master::Tools::StrReplace#call"
    file: "lib/master/tools/str_replace.rb"
    lines: 5
    beauty_score: 7
    virtue: clarity
    why: "Pure string substitution helper, validates inputs, returns Result"
  - name: "Master::Tools::Tree#call"
    file: "lib/master/tools/tree.rb"
    lines: 9
    beauty_score: 7
    virtue: introspection
    why: "Builds AST tree view, useful for debugging, returns structured Result"
  - name: "Master::Tools::WriteFile#call"
    file: "lib/master/tools/write_file.rb"
    lines: 7
    beauty_score: 7
    virtue: clarity
    why: "Encapsulates file write with atomic temp‑file swap, error propagation"
  - name: "Master::Swarm::Workers::Analyst#perform"
    file: "lib/master/swarm/workers/analyst.rb"
    lines: 7
    beauty_score: 7
    virtue: delegation
    why: "Analyzes LLM output, extracts actionable insights, pure data transformation"
  - name: "Master::Swarm::Workers::Coder#perform"
    file: "lib/master/swarm/workers/coder.rb"
    lines: 14
    beauty_score: 7
    virtue: delegation
    why: "Coordinates LLM code generation, isolates side‑effects, clear contract"

data/gems.yml

gems:
  devise:
    purpose: "Authentication framework"
    common_mistakes:
      - "custom routes without devise_for"
      - "forgetting :lockable for rate limiting"
  pundit:
    purpose: "Authorization"
    common_mistakes:
      - "policy not scoped to current_user"
      - "missing verify_authorized"
  pagy:
    purpose: "Pagination"
    common_mistakes:
      - "using Pagy::DEFAULT instead of Pagy::OPTIONS (43.x change)"

data/heartbeat.yml

# Heartbeat — autonomous scheduled jobs.
# Each entry runs at interval_seconds. Actions: prune_memory, check_models, self_test, prune_undo, snapshot.

- name: prune_memory
  action: prune_memory
  interval_seconds: 3600
  description: Consolidate and archive stale memory entries.

- name: self_test
  action: self_test
  interval_seconds: 7200
  description: Run standard scan against lib/ and report violations.

- name: prune_undo
  action: prune_undo
  interval_seconds: 86400
  description: Trim undo journal to last 50 entries.

- name: snapshot
  action: snapshot
  interval_seconds: 14400
  description: Regenerate .master/snapshot.md with current codebase state.

data/infer_patterns.yml

# Intent-inference patterns for Stages::Infer.
# Extracted from Ruby source per NO_HARDCODED_CONSTANTS / ONE_SOURCE axioms.
# Every new natural-language command goes here — no code change required.
#
# Format: each entry has a command name and a list of regex patterns.
# Patterns are compiled case-insensitive with extended mode (x flag).
# Leave escaping as it appears here — loader does not re-escape.

commands:
  sweep:
    patterns:
      - '\b(?:sweep|refactor|clean\s*up|rewrite|polish|tidy\s*up|overhaul|improve\s+(?:all|every)|go\s+through\s+(?:all|every)|full\s+pass\s+(?:over|on))(?:\s+(?:all|every(?:thing)?|the))?(?:\s+([\w\/.]+))?'
      - '\b(?:rydd\s+opp|refaktorer|forbedre?|gjennomg[åa]|omskriv)(?:\s+([\w\/.]+))?'
    capture: path

  autoloop:
    patterns:
      - '\b(?:autoloop|autofix|fix\s+all\s+violations?|keep\s+(?:fix|loop)|loop\s+until|iterate\s+until|run\s+until\s+clean|keep\s+going\s+until|(?:run|go)\s+(?:it\s+)?(?:again\s+)?until\s+(?:done|clean|fixed|perfect))(?:\s+(\d+))?'
      - '\b(?:fiks?\s+alle?\s+(?:feil|brudd)|fortsett\s+(?:til|inntil)|kj[øo]r\s+(?:til\s+)?(?:det\s+er\s+)?(?:rent|bra|ferdig))(?:\s+(\d+))?'
    capture: cycles

  council:
    patterns:
      - '\b(?:council|deliberat|multiple\s+perspect|second\s+opinion|peer\s+review|debate\s+this|get\s+(?:another|a\s+second)\s+view|multi(?:ple)?\s+(?:view|agent|model|perspect))\b'
      - '\b(?:r[åa]dsl[åa]g|bruk\s+(?:flere|multiple)\s+(?:perspektiv|synsvinkler?)|diskuter\s+(?:dette|det))\b'
    capture: on_off

  explain:
    patterns:
      - '\b(?:explain\s+(?:your(?:self)?|your\s+architecture|how\s+you\s+work)|describe\s+(?:your(?:self)?|your\s+architecture)|what\s+are\s+you|how\s+(?:are\s+you\s+built|do\s+you\s+work)|show\s+(?:your\s+)?architecture|self[\s-]?map)\b'
    capture: none

  persona:
    patterns:
      - '\b(?:(?:switch|change|set)\s+persona\s+(?:to\s+)?(\w+)|persona\s+(\w+)|use\s+(\w+)\s+persona)\b'
    capture: persona_name

  memory:
    patterns:
      - '\b(?:what\s+do\s+you\s+remember(?:\s+about\s+([\w\s]+))?|show\s+(?:my\s+)?memor(?:y|ies)|list\s+memor(?:y|ies)|recall(?:\s+([\w]+))?|what(?:''s|\s+is)\s+in\s+(?:your\s+)?memory|remember\s+([\w]+=.+)|forget\s+([\w_]+))\b'
      - '\b(?:hva\s+husker\s+du(?:\s+om\s+([\w\s]+))?|vis\s+(?:min\s+)?hukommelse|husk\s+([\w_]+=.+))\b'
    capture: first_group

  tokens:
    patterns:
      - '\b(?:token\s*count|how\s+many\s+tokens?|context\s+size|token\s+usage|how\s+much\s+context|hvor\s+mange\s+token|token\s*antall)\b'
    capture: none

  cost:
    patterns:
      - '\b(?:how\s+much\s+(?:has\s+this\s+cost|did\s+this\s+cost)|(?:current\s+)?(?:spend|cost|budget)|what(?:''s|\s+is)\s+the\s+cost|hva\s+koster?\s+(?:dette|det)|kostnader?)\b'
    capture: none

  undo:
    patterns:
      - '\b(?:undo\s+that|revert\s+(?:that|last|it)|go\s+back|take\s+that\s+back|angre\s+det|g[åa]\s+tilbake)\b'
    capture: none

  clear:
    patterns:
      - '\b(?:clear\s+(?:context|chat|history|session|screen)|start\s+(?:over|fresh|again)|reset\s+(?:context|session)|fresh\s+start|t[øo]m\s+(?:kontekst|historikk)|begynn\s+p[åa]\s+nytt)\b'
    capture: none

  save:
    patterns:
      - '\b(?:save\s+(?:session|this|my\s+work|progress)|checkpoint\s+now|lagre\s+(?:session|sesjonen?|arbeid))\b'
    capture: none

  model:
    patterns:
      - '\b(?:which\s+model|current\s+model|what\s+model\s+are\s+you|what\s+(?:llm|ai|model)\s+(?:are\s+you\s+using|is\s+this))\b'
    capture: none

  scan:
    patterns:
      - '\b(?:scan|lint|check\s+(?:code|violations?)|run\s+scan)(?:\s+(deep))?\b'
    capture: scan_depth

  dmesg:
    patterns:
      - '\b(?:show\s+(?:logs?|events?)|system\s+log|dmesg|what\s+(?:happened|has\s+happened)|recent\s+activity)\b'
    capture: none

  dreams:
    patterns:
      - '\b(?:dreams?|consolidate?\s+memor(?:y|ies)|memory\s+consolidat|dream\s+mode|promote\s+memor(?:y|ies))\b'
    capture: first_group

  soul:
    patterns:
      - '\b(?:show|check|view)\s+(?:the\s+)?soul\b'
      - '\bsoul\s+(?:version|changelog|diff|approve|reject|rollback|propose)\b'
    capture: soul_subcmd

  orders:
    patterns:
      - '\b(?:standing\s+orders?|show\s+orders?|list\s+orders?)\b'
    capture: orders_subcmd

data/injection_patterns.yml

# Externalized from lib/master/security/injection_guard.rb so future patterns
# land in YAML, not Ruby. Source: yaml/ruby split audit (#5).
prompt_injection:
  - "ignore (?:previous|all|your) instructions"
  - "disregard (?:your )?(?:system )?prompt"
  - "you are now (?:a|an|in)"
  - "pretend (?:to be|you are|you're)"
  - "new instructions:"
  - "\\[SYSTEM\\]"
  - "###\\s*SYSTEM"
  - "(?:act|behave|respond) as (?:if )?(?:you (?:are|were)|a|an) (?!assistant|helpful)"
  - "override (?:your )?(?:safety|guidelines|rules|instructions)"
  - "jailbreak"
  - "forget (?:everything|all|your)"
  - "override (?:axiom|principle|rule)"
  - "disregard (?:axiom|principle|rule|safety)"
  - "new system prompt"

shell_injection:
  multiline_pattern: "```(?:bash|sh|zsh|shell)\\n.*?(?:rm\\s+-rf|curl\\b.*?\\|\\s*(?:bash|sh)\\b|wget\\b.*?\\|\\s*(?:bash|sh)\\b)"

modes:
  permissive: "match -> block; no match -> pass"
  default_deny: "match -> block; no match -> require explicit allowlist token"

data/lexical_rules.yml

# Lexical scan rules expressed as data. Each entry replaces a 25-50 LOC Ruby class.
# Loaded by Scan::Rules::TableLexicalRule. Adding a new lexical rule = one yaml entry.
#
# Schema:
#   id            — short snake_case id, used as the `rule:` field on each finding
#   description   — one line, shown in /why
#   severity      — info | warning | error
#   axiom_tags    — list of constitutional axiom symbols
#   langs         — file extensions to apply to (default: ['.rb'])
#   path_includes — optional substring the path must contain
#   path_excludes — optional substring the path must NOT contain
#   skip_comments — true to skip lines starting with `#`
#   patterns      — list of {regex, message} pairs evaluated per line
#   first_line    — true to only check the first line and emit one finding if matched

---
- id: frozen_string
  description: Ruby files should declare # frozen_string_literal magic comment
  severity: warning
  axiom_tags: [PERFORMANCE]
  first_line: true
  patterns:
    - regex: '\Afrozen_string_literal: true'
      negate: true
      message: missing # frozen_string_literal: true
      one_per_file: true

- id: debug_output
  description: Debug output left in lib/ — remove before shipping
  severity: error
  axiom_tags: [FAIL_VISIBLY]
  path_includes: /lib/
  skip_comments: true
  patterns:
    - regex: '^\s*pp?\s+(?!self\b)'
      message: p/pp debug call — remove or publish via event bus
    - regex: '\$stderr\.puts\b'
      message: $stderr.puts — use @bus.publish or $stdout
    - regex: '\bbinding\.pry\b'
      message: binding.pry left in — remove before commit
    - regex: '\bdebugger\b'
      message: debugger left in — remove before commit

- id: trailing_comment
  description: Trailing comment after code — promote to a leading comment if it adds value, else delete
  severity: info
  axiom_tags: [STRUNK_WHITE, BE_CONCISE]
  skip_comments: true
  patterns:
    - regex: '\S\s+#\s+\S'
      message: trailing comment — promote above the line or delete

- id: time_zone_unsafe
  description: Bare Time.now / Date.today / DateTime.now bypasses Rails Time.zone
  severity: warning
  axiom_tags: [ROBUSTNESS]
  skip_comments: true
  patterns:
    - regex: '(?<![A-Za-z_.])Time\.now\b'
      message: Time.now ignores Time.zone — use Time.current
    - regex: '(?<![A-Za-z_.])Date\.today\b'
      message: Date.today ignores Time.zone — use Date.current
    - regex: '(?<![A-Za-z_.])DateTime\.now\b'
      message: DateTime.now ignores Time.zone — use Time.current.to_datetime

- id: double_quotes
  description: Ruby strings should use double quotes
  severity: warning
  axiom_tags: [POLA_PRINCIPLE]
  langs: [.rb]
  skip_comments: true
  patterns:
    - regex: "(?<![#\w])'(?:[^'\\]|\\.)*'"
      message: use double-quoted strings per ruby_style.yml

- id: no_ascii_line_art
  description: Avoid ASCII line art and divider decorations
  severity: warning
  axiom_tags: [BE_CONCISE]
  patterns:
    - regex: '(?:^|\s)(?:={3,}|-{3,})(?:\s|$)'
      message: remove ASCII divider decorations

data/mcp_servers.yml

# MCP server definitions for MASTER.
# Transport options: stdio | sse
# Disabled by default on resource-constrained VPS.
# Enable individual servers with enabled: true when needed.

defaults: &defaults
  transport: stdio
  command: npx
  enabled: false

servers:
  filesystem:
    <<: *defaults
    args:
      - -y
      - "@modelcontextprotocol/server-filesystem"
      - "/home/dev/pub4"
    description: Expose read/write/search over a local directory

  git:
    <<: *defaults
    args:
      - -y
      - "@modelcontextprotocol/server-git"
      - "--repository"
      - "/home/dev/pub4/MASTER"
    description: Expose git operations as tools

  brave_search:
    <<: *defaults
    args:
      - -y
      - "@modelcontextprotocol/server-brave-search"
    description: Web search via Brave

  sequential_thinking:
    <<: *defaults
    args:
      - -y
      - "@modelcontextprotocol/server-sequential-thinking"
    description: Structured reasoning assistant

data/models.yml

# Model routing profile — OpenRouter free-tier with tool calling.

routing:
  enabled: true
  strategy: weighted
  escalation_enabled: true
  escalation_tier: strong
  provider: openrouter

weights: &weights
  quality: 0.50
  speed: 0.25
  cost: 0.25

fallback_policy:
  retries_per_tier: 1
  on:
    - timeout
    - network_error
    - refusal

defaults: &model_defaults
  score: { quality: 0.0, speed: 0.0, cost: 0.0 }

model_defs:
  gemini_flash: &gemini_flash
    id: gemini-2.5-flash
    <<: *model_defaults
    score: { quality: 0.88, speed: 0.90, cost: 0.95 }
  gemini_pro: &gemini_pro
    id: gemini-2.5-pro
    <<: *model_defaults
    score: { quality: 0.95, speed: 0.70, cost: 0.80 }
  mistral_large: &mistral_large
    id: mistralai/mistral-large
    <<: *model_defaults
    score: { quality: 0.90, speed: 0.75, cost: 0.70 }
  mistral_small: &mistral_small
    id: mistralai/mistral-small-3.1-24b
    <<: *model_defaults
    score: { quality: 0.78, speed: 0.85, cost: 0.90 }
  deepseek_chat: &deepseek_chat
    id: deepseek-chat
    <<: *model_defaults
    score: { quality: 0.88, speed: 0.70, cost: 0.95 }
  deepseek_coder: &deepseek_coder
    id: deepseek-coder
    <<: *model_defaults
    score: { quality: 0.85, speed: 0.70, cost: 0.95 }
  claude_sonnet: &claude_sonnet
    id: anthropic/claude-sonnet-4-6
    <<: *model_defaults
    score: { quality: 0.95, speed: 0.75, cost: 0.60 }
  nemotron_super: &nemotron_super
    id: nvidia/nemotron-3-super-120b-a12b:free
    <<: *model_defaults
    score: { quality: 0.90, speed: 0.75, cost: 1.0 }
  qwen_coder: &qwen_coder
    id: qwen/qwen3-coder:free
    <<: *model_defaults
    score: { quality: 0.75, speed: 0.65, cost: 1.0 }
  qwen3_next: &qwen3_next
    id: qwen/qwen3-next-80b-a3b-instruct:free
    <<: *model_defaults
    score: { quality: 0.82, speed: 0.88, cost: 1.0 }
  gpt_oss_120b: &gpt_oss_120b
    id: openai/gpt-oss-120b:free
    <<: *model_defaults
    score: { quality: 0.86, speed: 0.70, cost: 1.0 }
  llama_70b: &llama_70b
    id: meta-llama/llama-3.3-70b-instruct:free
    <<: *model_defaults
    score: { quality: 0.78, speed: 0.70, cost: 1.0 }
  hermes_405b: &hermes_405b
    id: nousresearch/hermes-3-llama-3.1-405b:free
    <<: *model_defaults
    score: { quality: 0.85, speed: 0.50, cost: 1.0 }
  gpt_4o: &gpt_4o
    id: openai/gpt-4o
    <<: *model_defaults
    score: { quality: 0.93, speed: 0.80, cost: 0.55 }
  claude_cli_sonnet: &claude_cli_sonnet
    id: claude-cli:claude-sonnet-4-6
    <<: *model_defaults
    score: { quality: 0.95, speed: 0.70, cost: 0.60 }
  claude_cli_opus: &claude_cli_opus
    id: claude-cli:claude-opus-4-7
    <<: *model_defaults
    score: { quality: 0.99, speed: 0.50, cost: 0.30 }
  gemma_2_9b_free: &gemma_2_9b_free
    id: google/gemma-2-9b-it:free
    <<: *model_defaults
    score: { quality: 0.72, speed: 0.88, cost: 1.0 }
  gemma_2_27b: &gemma_2_27b
    id: google/gemma-2-27b-it
    <<: *model_defaults
    score: { quality: 0.82, speed: 0.78, cost: 0.92 }
  gemini_2_flash_exp_free: &gemini_2_flash_exp_free
    id: google/gemini-2.0-flash-exp:free
    <<: *model_defaults
    score: { quality: 0.85, speed: 0.92, cost: 1.0 }
  gemini_flash_lite: &gemini_flash_lite
    id: google/gemini-flash-lite-latest
    <<: *model_defaults
    score: { quality: 0.78, speed: 0.96, cost: 0.97 }
  phi_4_free: &phi_4_free
    id: microsoft/phi-4:free
    <<: *model_defaults
    score: { quality: 0.74, speed: 0.90, cost: 1.0 }
  glm_4_5_air_free: &glm_4_5_air_free
    id: z-ai/glm-4.5-air:free
    <<: *model_defaults
    score: { quality: 0.80, speed: 0.82, cost: 1.0 }
  yi_lightning: &yi_lightning
    id: 01-ai/yi-lightning
    <<: *model_defaults
    score: { quality: 0.78, speed: 0.94, cost: 0.94 }
  command_r_plus: &command_r_plus
    id: cohere/command-r-plus
    <<: *model_defaults
    score: { quality: 0.86, speed: 0.72, cost: 0.65 }
  grok_4_fast: &grok_4_fast
    id: x-ai/grok-4-fast
    <<: *model_defaults
    score: { quality: 0.88, speed: 0.92, cost: 0.78 }
  reka_flash: &reka_flash
    id: rekaai/reka-flash-3:free
    <<: *model_defaults
    score: { quality: 0.74, speed: 0.86, cost: 1.0 }
  deepseek_v3_free: &deepseek_v3_free
    id: deepseek/deepseek-chat-v3.1:free
    <<: *model_defaults
    score: { quality: 0.90, speed: 0.72, cost: 1.0 }
  llama_4_scout_free: &llama_4_scout_free
    id: meta-llama/llama-4-scout:free
    <<: *model_defaults
    score: { quality: 0.84, speed: 0.78, cost: 1.0 }
  groq_llama_3_3_70b: &groq_llama_3_3_70b
    id: groq/llama-3.3-70b-versatile
    <<: *model_defaults
    score: { quality: 0.85, speed: 0.99, cost: 0.85 }
  cerebras_llama_3_1_8b: &cerebras_llama_3_1_8b
    id: cerebras/llama-3.1-8b
    <<: *model_defaults
    score: { quality: 0.70, speed: 0.99, cost: 0.92 }
ollama_qwen: &ollama_qwen
  id: ollama:qwen2.5-coder:7b
  <<: *model_defaults
  score: { quality: 0.62, speed: 0.85, cost: 1.0 }
ollama_llama: &ollama_llama
  id: ollama:llama3.2:3b
  <<: *model_defaults
  score: { quality: 0.55, speed: 0.92, cost: 1.0 }
ollama_phi: &ollama_phi
  id: ollama:phi4:mini
  <<: *model_defaults
  score: { quality: 0.58, speed: 0.95, cost: 1.0 }

models:
  default:
    - *nemotron_super
    - *gpt_oss_120b
    - *qwen3_next
    - *llama_70b
    - *qwen_coder
  strong:
    - *gemini_pro
    - *mistral_large
    - *claude_sonnet
    - *gpt_4o
    - *gemini_flash
    - *command_r_plus
  cheap:
    - *gemini_flash
    - *mistral_small
    - *deepseek_chat
    - *llama_70b
    - *qwen_coder
    - *gemma_2_9b_free
    - *gemini_flash_lite
    - *yi_lightning
  fast:
    - *groq_llama_3_3_70b
    - *cerebras_llama_3_1_8b
    - *gemini_flash_lite
    - *gemini_2_flash_exp_free
  free:
    - *gemini_2_flash_exp_free
    - *gemma_2_9b_free
    - *deepseek_v3_free
    - *llama_4_scout_free
    - *phi_4_free
    - *glm_4_5_air_free
    - *reka_flash
    - *nemotron_super
    - *qwen_coder
    - *llama_70b
    - *hermes_405b
  claude_code:
    - *claude_cli_sonnet
    - *claude_cli_opus
  local:
    - *ollama_phi
    - *ollama_llama
    - *ollama_qwen

routes:
  code_generation: default
  refactoring: default
  architecture: strong
  review: default
  explanation: cheap
  exploration: cheap
  fallback_default: cheap

tool_capable_prefixes:
  - claude
  - claude-cli
  - gpt-4
  - gpt-4o
  - gemini
  - mistral
  - mistralai
  - mixtral
  - llama-3.1
  - llama-3.3
  - llama-4
  - qwen
  - command-r
  - cohere/command
  - deepseek
  - stepfun
  - nvidia
  - nemotron
  - meta/meta-llama
  - anthropic/claude
  - openai/gpt
  - google/gemini
  - google/gemma
  - microsoft/phi
  - z-ai/glm
  - 01-ai/yi
  - x-ai/grok
  - rekaai/reka
  - groq
  - cerebras

operation_constraints:
  # Operations that write files, run autoloop/sweep, or execute destructive commands
  # require a model with quality score >= 0.88 (default and cheap tiers excluded).
  # Equivalent to: claude-sonnet-4-6, gemini-2.5-pro, mistral-large, gpt-4o.
  file_write:       { min_quality: 0.88, preferred_tier: strong }
  autoloop:         { min_quality: 0.88, preferred_tier: strong }
  sweep:            { min_quality: 0.88, preferred_tier: strong }
  council:          { min_quality: 0.88, preferred_tier: strong }
  scan_semantic:  { min_quality: 0.88, preferred_tier: strong }
  scan_adversarial: { min_quality: 0.88, preferred_tier: strong }
  destructive:      { min_quality: 0.90, preferred_tier: strong }

continuity:
  enabled: true
  updated_at: "2026-05-01T00:00:00Z"

openrouter:
  free_latest:
    - nvidia/nemotron-3-super-120b-a12b:free
    - openai/gpt-oss-120b:free
    - qwen/qwen3-next-80b-a3b-instruct:free
    - meta-llama/llama-3.3-70b-instruct:free
    - qwen/qwen3-coder:free

# Provider trust tracked over time; weights routing beyond raw cost.
# Source: master.json v225 reunification (#52).
trust_scoring:
  initial_score:        0.50
  success_increment:    0.02
  failure_decrement:    0.10
  deprecate_below:      0.20
  persist_to:           "data/provider_trust.yml"
  consider_in_routing:  true

three_mirror_redundancy:
  # Three models vote; ship only on >= 2 agreement for critical fixes.
  # Source: cross-cutting reunification (#95).
  enabled_for:   [tier1_critical, security_relevant, irreversible]
  pool:          [openrouter_primary, openrouter_secondary, claude_cli]
  quorum:        2
  on_disagreement: "fall back to council vote with all dissent recorded"

# Tier-D (local ollama) — env-gated; activates only when OLLAMA_BASE_URL is set.
# Default: http://localhost:11434/v1 (OpenAI-compatible endpoint).
# Trust starts at 0.40 (below cloud providers) until measured success raises it.
ollama:
  enabled_when_env: OLLAMA_BASE_URL
  default_base_url: "http://localhost:11434/v1"
  initial_trust: 0.40
  use_for: [exploration, fallback_default]   # cheapest-acceptable tasks only
  never_for: [council, sweep, autoloop, file_write, destructive]

data/openbsd.yml

# openbsd.yml — OpenBSD config validators
# Restored from master.yml v49.75; extended for OpenBSD 7.8

man_base_url: "https://man.openbsd.org"
cache_ttl: 86400

configs:
  pf.conf:
    daemon: pf
    man: pf.conf.5
    required_patterns:
      - "set skip on lo"
    warnings:
      - pattern: "pass all"
        message: "Overly permissive — add interface/protocol guards"

  nsd.conf:
    daemon: nsd
    man: nsd.conf.5
    required_patterns:
      - "server:"
      - "zone:"
    warnings:
      - pattern: "rrl-size"
        absent_message: "Missing RRL config — vulnerable to amplification DDoS"
      - pattern: "hide-version"
        absent_message: "Consider hide-version: yes"

  httpd.conf:
    daemon: httpd
    man: httpd.conf.5
    required_patterns:
      - "server"

  smtpd.conf:
    daemon: smtpd
    man: smtpd.conf.5
    required_patterns:
      - "listen on"
      - "action"
      - "match"
    warnings:
      - pattern: "match from any"
        message: "Open relay risk — restrict to authenticated senders"

  relayd.conf:
    daemon: relayd
    man: relayd.conf.5
    required_patterns:
      - "relay"

  acme-client.conf:
    daemon: acme-client
    man: acme-client.conf.5
    required_patterns:
      - "authority"
      - "domain"

  doas.conf:
    daemon: doas
    man: doas.conf.5
    required_patterns:
      - "permit"
    warnings:
      - pattern: "nopass"
        message: "Allows passwordless privilege escalation"

  sshd_config:
    daemon: sshd
    man: sshd_config.5
    warnings:
      - pattern: "PermitRootLogin yes"
        message: "Security risk — use PermitRootLogin prohibit-password"
      - pattern: "PasswordAuthentication yes"
        message: "Consider key-only auth"

  ntpd.conf:
    daemon: ntpd
    man: ntpd.conf.5
    required_patterns:
      - "server"

  unbound.conf:
    daemon: unbound
    man: unbound.conf.5
    required_patterns:
      - "server:"


system_health_checks:
  - name: "patch_level"
    command: "syspatch -c"
    expected: ""
  - name: "disk_usage"
    command: "df -h /"
    max_percent: 80

service_definitions:
  master:
    service: "master"
    check: "rcctl check master"
    restart: "doas rcctl restart master"
    logs: "/var/log/master.log"

data/patterns.yml

# Platform/tool idioms — gh, openbsd, zsh — merged from former *_patterns.yml.
# Each top-level key is a namespace.
---
gh:
  operations:
  - action: create_pr
    pattern: gh pr create --title '${title}' --body '${body}' --base main
  - action: merge_pr
    pattern: gh pr merge ${number} --squash --delete-branch
  - action: create_issue
    pattern: gh issue create --title '${title}' --body '${body}' --label '${labels}'
  - action: close_issue
    pattern: gh issue close ${number} --reason completed
  - action: list_workflows
    pattern: gh run list --workflow=${workflow} --limit 5
  - action: trigger_workflow
    pattern: gh workflow run ${workflow} --ref ${branch}
  - action: review_pr
    pattern: gh pr review ${number} --approve --body '${comment}'
  - action: check_status
    pattern: gh pr checks ${number} --watch
  - action: clone_repo
    pattern: gh repo clone ${owner}/${repo}
  - action: fork_repo
    pattern: gh repo fork ${owner}/${repo} --clone
  - action: api_call
    pattern: gh api ${endpoint}
  forbidden:
  - command: curl api.github.com
    replacement: gh api
  - command: curl github.com/api
    replacement: gh api
  - command: hub
    replacement: gh — hub is deprecated
openbsd:
  service_commands:
    enable: rcctl enable ${service}
    start: rcctl start ${service}
    restart: rcctl restart ${service}
    reload: rcctl reload ${service}
    check: rcctl check ${service}
    disable: rcctl disable ${service}
  configuration_paths:
    pf: "/etc/pf.conf"
    httpd: "/etc/httpd.conf"
    relayd: "/etc/relayd.conf"
    smtpd: "/etc/mail/smtpd.conf"
    acme: "/etc/acme-client.conf"
    sshd: "/etc/ssh/sshd_config"
    ntp: "/etc/ntpd.conf"
    cron: "/var/cron/tabs/${user}"
    unbound: "/var/unbound/unbound.conf"
  package_operations:
    install: pkg_add ${package}
    remove: pkg_delete ${package}
    search: pkg_info -Q ${query}
    update: pkg_add -u
    firmware: fw_update
  prohibited_commands:
  - command: systemctl
    replacement: rcctl
  - command: apt
    replacement: pkg_add
  - command: apt-get
    replacement: pkg_add
  - command: brew
    replacement: pkg_add
  - command: yum
    replacement: pkg_add
  - command: ip addr
    replacement: ifconfig
  - command: ip route
    replacement: route
  - command: journalctl
    replacement: cat /var/log/messages
  - command: sudo
    replacement: doas
  - command: ufw
    replacement: pfctl
  - command: iptables
    replacement: pf
  - command: nginx
    replacement: httpd (OpenBSD native)
  - command: docker
    replacement: vmctl
  - command: systemd
    replacement: rcctl
  - command: gsed
    replacement: sed (POSIX)
  - command: gawk
    replacement: awk (POSIX)
  - command: ggrep
    replacement: grep (POSIX)
  security:
    pledge: pledge(2) – restrict syscalls after init
    unveil: unveil(2) – restrict filesystem visibility
    doas: doas.conf – preferred over sudo
    signify: signify(1) – cryptographic signing
    chroot: httpd runs chrooted by default
  daemon_configs:
    pf.conf:
      daemon: pf
      man: pf.conf.5
      required_patterns:
      - set skip on lo
      warnings:
      - pattern: pass all
        message: Overly permissive rule
    nsd.conf:
      daemon: nsd
      man: nsd.conf.5
      required_patterns:
      - 'server:'
      - 'zone:'
      warnings:
      - pattern: rrl-size
        absent_message: Missing RRL config for DDoS protection
      - pattern: hide-version
        absent_message: 'Consider hide-version: yes'
    httpd.conf:
      daemon: httpd
      man: httpd.conf.5
      required_patterns: []
      warnings: []
    smtpd.conf:
      daemon: smtpd
      man: smtpd.conf.5
      required_patterns:
      - listen on
      - action
      - match
      warnings:
      - pattern: match from any
        message: Potential open relay
    relayd.conf:
      daemon: relayd
      man: relayd.conf.5
      required_patterns:
      - relay
      warnings: []
    acme-client.conf:
      daemon: acme-client
      man: acme-client.conf.5
      required_patterns:
      - authority
      - domain
      warnings: []
    doas.conf:
      daemon: doas
      man: doas.conf.5
      required_patterns:
      - permit
      warnings:
      - pattern: nopass
        message: Allows password‑less escalation
    sshd_config:
      daemon: sshd
      man: sshd_config.5
      required_patterns: []
      warnings:
      - pattern: PermitRootLogin yes
        message: Security risk – disallow root login
      - pattern: PasswordAuthentication yes
        message: Prefer key‑based authentication
    ntpd.conf:
      daemon: ntpd
      man: ntpd.conf.5
      required_patterns:
      - server
      warnings: []
    unbound.conf:
      daemon: unbound
      man: unbound.conf.5
      required_patterns:
      - 'server:'
      warnings: []
zsh:
  forbidden_commands:
  - command: awk
    replacement: 'zsh array/string field splitting: ${${(s:,:)line}[4]}'
  - command: sed
    replacement: 'zsh parameter expansion: ${var//search/replace}'
  - command: tr
    replacement: 'zsh case conversion: ${(L)var} ${(U)var}'
  - command: grep
    replacement: 'zsh pattern matching: ${(M)arr:#*pattern*}'
  - command: cut
    replacement: 'zsh field splitting: ${${(s:delim:)var}[N]}'
  - command: head
    replacement: 'zsh array slicing: ${arr[1,10]}'
  - command: tail
    replacement: 'zsh array slicing: ${arr[-5,-1]}'
  - command: uniq
    replacement: 'zsh unique flag: ${(u)arr}'
  - command: sort
    replacement: 'zsh sort flags: ${(o)arr} (asc) / ${(O)arr} (desc)'
  - command: bash
    replacement: zsh — never use bash
  - command: find
    replacement: 'zsh glob qualifiers: **/*.rb(.)'
  - command: wc
    replacement: 'zsh length/count: ${#var} / ${#arr}'
  - command: sudo
    replacement: doas on OpenBSD
  native_patterns:
    string_replace: "${var//find/replace}"
    case_lower: "${(L)var}"
    case_upper: "${(U)var}"
    trim_whitespace: "${${var##[[:space:]]#}%%[[:space:]]#}"
    split_to_array: "${(s:delim:)var}"
    array_join: "${(j:,:)arr}"
    array_unique: "${(u)arr}"
    array_sort_asc: "${(o)arr}"
    array_sort_desc: "${(O)arr}"
    array_reverse: "${(Oa)arr}"
    array_filter_match: "${(M)arr:#*pattern*}"
    array_filter_exclude: "${arr:#*pattern*}"
    remove_crlf: "${var//$'\\r'/}"
  exceptions:
  - Complex regex requiring PCRE
  - Multi‑file operations beyond globbing
  - Binary data processing
  banned_commands:
  - python
  - bash
  - sed
  - awk
  - tr
  - wc
  - head
  - tail
  - cut
  - find
  - sudo
  auto_remediation:
    awk: "${${(s: :)line}[n]}"
    sed: "${var//old/new}"
    tr: "${(U)var} or ${(L)var}"
    wc: "${#lines}"
    head: "${lines[1,n]}"
    tail: "${lines[-n,-1]}"
    grep: "${(M)lines:#*pattern*}"
    cut: "${${(s:delim:)var}[N]}"
    sort: "${(o)arr} or ${(O)arr}"
    find: "**/*.ext(.)"
    sudo: doas
  token_economics:
    philosophy: 'Replacing multi‑tool shell pipelines with pure Zsh parameter expansion
      eliminates process boundaries, collapses multiple grammars into one, reduces
      reasoning entropy for LLMs, and converts runtime overhead into in‑memory transforms
      — saving both tokens and wall‑clock time.

      '
    example_bad:
      code: awk -F, '{print $4}' | sed 's/\r//g' | tr '[:upper:]' '[:lower:]'
      cost: 3 grammars, pipes + subshells, I/O transformations
    example_good:
      code: cleaned=${var//$'\r'/}; lower=${(L)cleaned}; fourth=${${(s:,:)lower}[4]}
      cost: One grammar, one evaluation model, no process boundaries
    benefit: Model reasons locally instead of globally across pipeline

data/personas.yml

# MASTER personas — voice, TTS settings, style descriptor.
# Add a new persona here, restart MASTER (or wait for hot-reload).
# style: deep | heavy | slow | normal | natural — see lib/master/speech.rb STYLES.

malay:
  voice: ms-MY-OsmanNeural
  tts_rate: "-35%"
  tts_pitch: "-150Hz"
  style: deep
  description: "Terse. Direct. No filler. Dark."

british:
  voice: en-GB-RyanNeural
  tts_rate: "-20%"
  tts_pitch: "-80Hz"
  style: heavy
  description: "Measured. Precise. Dry wit."

norwegian:
  voice: nb-NO-FinnNeural
  tts_rate: "-15%"
  tts_pitch: "-40Hz"
  style: slow
  description: "Calm. Considered. Honest."

ronin:
  voice: en-US-AndrewNeural
  tts_rate: "-25%"
  tts_pitch: "-100Hz"
  style: deep
  description: "Stoic. Minimal. Decisive. Says only what must be said."

lawyer:
  voice: nb-NO-FinnNeural
  tts_rate: "-10%"
  tts_pitch: "-20Hz"
  style: slow
  description: "Norwegian law focus. Barnevernet, lovdata.no, sivilombudet.no. Not legal advice."

hacker:
  voice: en-US-GuyNeural
  tts_rate: "-30%"
  tts_pitch: "-120Hz"
  style: deep
  description: "OpenBSD security. CVE analysis. Pentesting. Exploit-db."

architect:
  voice: en-GB-RyanNeural
  tts_rate: "-15%"
  tts_pitch: "-60Hz"
  style: heavy
  description: "Parametric design. BIM. archdaily.com. dezeen.com."

sysadmin:
  voice: en-AU-WilliamNeural
  tts_rate: "-20%"
  tts_pitch: "-80Hz"
  style: deep
  description: "OpenBSD. pf. httpd. vmm. man.openbsd.org."

trader:
  voice: en-US-ChristopherNeural
  tts_rate: "-20%"
  tts_pitch: "-80Hz"
  style: heavy
  description: "Crypto. DeFi. Technicals. TradingView. CoinGecko."

medic:
  voice: en-US-EricNeural
  tts_rate: "-15%"
  tts_pitch: "-40Hz"
  style: slow
  description: "Medical research. PubMed. Not medical advice."

data/prompts/mode_code_agent.yml

template: |
  You are operating in code-agent fallback mode.

  Task:
  %{message}

  Constraints:
  - Return executable Ruby only.
  - Do not include markdown fences.
  - Prefer short, deterministic tool calls.
  - If a tool call fails, rescue and continue with a degraded but useful result.

data/prompts/mode_direct.yml

system: |
  Direct mode only.
  No meta‑conversation.
  Answer with minimal words.
  No explanations, apologies, or padding.
  Invoke tools immediately, without preamble.

template: |
  %{message}

data/prompts/mode_react.yml

system: |
  Follow the ReAct paradigm. Keep reasoning concise; intervene only when necessary. Emphasize brevity and concrete actions. Output a Reason: line followed by an Action: line.
template: |
  [Mode: ReAct]
  Task: %{message}

data/prompts/mode_rewoo.yml

system: |
  Generate a concise, numbered plan. Each step must reference at least one evidence slot (e.g., [slot 12]). Conclude with a single, decisive answer.

template: |
  [Mode: ReWOO]
  Task:
  %{message}

data/rails.yml

stack:
  rails: "8.1.3"
  ruby: "3.3+"
  database: "sqlite3 (default), PostgreSQL (production)"
  server: "Falcon (rackup --server falcon)"
  frontend: "Hotwire (Turbo + Stimulus)"
  queue: "Solid Queue"
  cache: "Solid Cache"
  cable: "Solid Cable"
  asset_pipeline: "Propshaft"

patterns:
  querying:
    - "Always eager-load with #includes when iterating associations"
    - "Use #strict_loading_by_default on all AR models"
    - "Use #with_rich_text_#{name} and #with_attached_#{name} scopes"
    - "Prefer #find_each / #in_batches over #each for large datasets"
    - "Use #pluck / #pick for single-column queries"
    - "Use #exists? over #present? on relations"
    - "Use #update_all / #delete_all for bulk operations with callbacks understood"
    - "Never use #find_or_create_by without a unique index in a migration"
  controllers:
    - "Keep controllers thin — business logic in models or service objects"
    - "Use #current_attributes for request-scoped state, not class variables"
    - "Return early with #head :ok or #redirect_to in guard clauses"
    - "Use strong parameters with #require and #permit — never bare #params"
    - "Rescue ActiveRecord::RecordNotFound with #rescue_from in ApplicationController"
  views:
    - "Use partials with locals: over instance variables"
    - "Use tag./class_names helpers, never raw HTML strings"
    - "Prefer Turbo Streams over custom JavaScript for reactivity"
    - "Use #dom_id and #dom_class for stable DOM identifiers"
    - "Localize all user-facing strings with I18n.t"
  models:
    - "Use #has_secure_password for authentication"
    - "Use #enum with #validate: true"
    - "Use #normalizes for attribute normalization"
  testing:
    - "Use Minitest with parallelize"
    - "Use #travel_to for time-sensitive tests"
    - "Assert #assert_enqueued_with for job testing"
  background_jobs:
    - "Always make jobs idempotent"
    - "Use #retry_on with exponential backoff"
    - "Use #discard_on for expected failures"
  caching:
    - "Use Russian Doll caching (#cache) with #touch on associations"
    - "Use low-level caching (Rails.cache.fetch) for expensive computations"
    - "Set #expires_in with clear TTLs"
  security:
    - "Use #authenticate_by for login (Rails 8 built-in auth)"
    - "Use #rate_limit for brute-force protection"
    - "Use #password_challenge for sensitive actions"
    - "Always use #current_user, never #User.find(session[:user_id])"
    - "Use #sanitize for user-generated HTML; never #html_safe on user input"

data/refusal_templates.yml

# config_status: aspirational  # spec exists, runtime wiring pending
# Refusal scaffolding when MASTER cannot or should not act.
# Source: OpenAI / Anthropic system-prompt reunification (#74).

capability_disclosure:
  no_internet_yet:        "MASTER reads local data and runs local tools; no live web access in this turn"
  no_secret_creation:     "MASTER does not generate secrets; bring your own and rotate via signify"
  no_silent_destruction:  "MASTER will not run irreversible commands without explicit user confirmation"

refusal_phrasing:
  style:   "decline once, propose alternative once, stop"
  forbidden: ["I'm sorry but I cannot", "as an AI", "I'd be happy to", filler_apology]
  example_good: "Out of scope: production push during freeze. Alternative: branch-only commit, deploy after Friday."

data/ruby_style.yml

# Ruby, shell, and git style rules enforced by MASTER.
# Scan rules reference these; Personality injects them into every LLM system prompt.

ruby:
  quotes: double  # always double-quoted strings; single only inside regex or '\1' backrefs
  frozen_string: true  # every .rb file must start with # frozen_string_literal: true

  comments:
    max_lines: 1           # class/module/method comments: 1 line or none
    require_why: true      # only add when WHY is non-obvious (hidden constraint, workaround)
    reassess_on_touch: true  # every edit re-reads each comment in the file: delete if obvious,
                             # rewrite Strunk-and-White style if kept (active voice, omit needless
                             # words, concrete verbs, one line). No grandfathered fluff.
    forbidden:
      - what_comments      # never describe what the code does — identifiers do that
      - yard_doc_blocks    # no # Public:, # Returns, # param - style blocks
      - section_separators # no # ----, # ====, # ---- Public API ---- etc.
      - numbered_steps     # no # 1., # 2. inline step comments
      - multi_line_prose   # cut verbosity; one line survives, paragraph does not

  line_order:
    rule: "Reorder lines/blocks so the most important content comes first. Newspaper inverted pyramid."
    rationale: "A reader who stops halfway must still have the gist. Public API > primary behavior > helpers > privates > edge cases."
    sequence:
      - "frozen_string_literal + requires"
      - "module/class declaration + headline docstring (≤1 line)"
      - "public API methods, ordered by call-frequency / importance"
      - "primary algorithm or main loop"
      - "private helpers in order of dependency"
      - "constants and lookup tables (unless small enough to inline at top)"
      - "edge-case handlers, rescue branches, fallback paths"
    applies_to: [ruby, yaml, erb, js, css, html, sh, md]
    enforced_by: "sweep IMPORTANCE_ORDER technique; council Maintainer + Layperson personas"

  bugs_to_avoid:
    - pattern: "Dir.chdir"
      reason: "process-wide; thread-unsafe in multi-threaded agents"
      fix: "pass -C root to git; expand paths with File.expand_path"

    - pattern: "Prism.parse(src, freeze: true)"
      reason: "freeze: kwarg dropped in Ruby 3.4"
      fix: "Prism.parse(src)"

    - pattern: "next if condition inside flat_map"
      reason: "next if returns nil into flat_map, producing nil entries in output"
      fix: "next [] if condition"

    - pattern: "rescue => e (multi-line bare rescue)"
      reason: "unclear; explicitly name StandardError for clarity"
      fix: "rescue StandardError => e"

    - pattern: "rescue nil (inline rescue returning nil)"
      reason: "inline rescue already catches StandardError; rescue nil is correct idiom"
      note: "do NOT change to rescue StandardError — that returns the class object, not nil"

    - pattern: "@bus&.publish(...) || value"
      reason: "when bus is present, returns bus result (truthy), masking the real value"
      fix: "call @bus&.publish(...) on its own line; return value separately"

    - pattern: "backtick shell commands with interpolation"
      reason: "shell injection risk"
      fix: "Open3.capture2e('cmd', '-flag', arg) with arg arrays"

    - pattern: "system/Open3 with string interpolation"
      reason: "shell injection risk"
      fix: "Open3.capture2e(*%w[cmd -flag], variable) with separate arguments"

    - pattern: "mutate state before publishing event that reads old state"
      reason: "event receives new state instead of previous state"
      fix: "capture prev = current before mutation; use prev in publish/return"

  naming:
    spell_out: true        # no abbreviations: index not idx, signature not sig, temporary_path not tmp
    forbidden_abbreviations:
      - idx
      - sig
      - tmp
      - buf
      - val
      - ret
      - obj
      - str
      - arr
      - num
      - cnt
      - ptr
      - msg   # unless it IS the domain term (e.g., a Message object named msg is ok if short-lived)
    rule: "Spell identifiers out. Domain names can be short (id, url, ip) — abbreviations cannot."

  prefer_string_methods:
    rule: "Prefer start_with? / include? / end_with? / split over regex when string methods suffice."
    rationale: "Regex is expressive but noisy. Use it when patterns require it, not as a default."
    prefer:
      - "str.start_with?(prefix)        over  str.match?(/^prefix/)"
      - "str.include?(substr)           over  str.match?(/substr/)"
      - "str.end_with?(suffix)          over  str.match?(/suffix$/)"
      - "str.split(sep, n)              over  str.scan(/pattern/)"
    still_use_regex_for:
      - 'Character classes: /[a-z]/, /\d+/'
      - "Anchored multiline patterns"
      - "Alternation with more than 2 branches"

  outsource_to_gems:
    rule: "If a well-maintained gem solves the problem correctly, use it. Do not reimplement."
    rationale: "Gems carry tests, edge cases, and maintenance. Home-grown duplicates carry bugs."
    examples:
      - "flay for AST-level duplicate detection"
      - "reek for code smell analysis"
      - "rubocop for style enforcement"
      - "prism for Ruby parsing"
    caveat: "Evaluate gem quality first: maintained, tested, minimal footprint."

  blank_lines:
    max_consecutive: 1     # no double blank lines anywhere

  rails_stack:
    # Current stable versions (May 2026)
    rails: "8.1.3"
    turbo_rails: "2.0.23"    # 9 actions: append prepend before after replace update remove morph refresh
    stimulus: "3.x"          # static targets, values, outlets API
    pagy: "43.x"             # Pagy::OPTIONS (not Pagy::DEFAULT — redesigned API in 43.0)
    stimulus_reflex: "3.5"   # complementary to Turbo; opt-in only for advanced reactive features

    asset_pipeline: propshaft  # default in Rails 8; do not use Sprockets
    javascript: importmap      # default; esbuild only when CSS-in-JS components needed
    queue: solid_queue         # SQLite-backed by default
    cache: solid_cache         # SQLite-backed by default
    cable: solid_cable         # SQLite-backed by default

    authentication: "rails generate authentication"  # built-in, no devise
    database: sqlite3          # default; PostgreSQL only when explicitly required

    pagy_api:
      backend:  "include Pagy::Backend"   # in ApplicationController
      frontend: "include Pagy::Frontend"  # in ApplicationHelper
      options:  "Pagy::OPTIONS[:limit] = 25"  # NOT Pagy::DEFAULT (that was 8.x)
      overflow: "Pagy::OPTIONS[:overflow] = :last_page"

    turbo_stream_actions:
      - append
      - prepend
      - before
      - after
      - replace
      - update
      - remove
      - morph   # morphs DOM — preserves element state; opt-in via data-turbo-permanent
      - refresh  # triggers full page refresh with morphing

    stimulus_api:
      targets: "static targets = [\"name\"]"       # auto-generates nameTarget, nameTargets, hasNameTarget
      values:  "static values = { url: String }"   # auto-generates urlValue, hasUrlValue, urlValueChanged
      outlets: "static outlets = [\"other\"]"      # cross-controller communication
      lifecycle: [connect, disconnect, initialize]  # + nameTargetConnected/Disconnected

    stimulus_components:
      source: "https://stimulus-components.com"
      install: "bin/importmap pin @stimulus-components/<name>"
      available:
        - { name: character-counter, use: "post/comment character limits" }
        - { name: clipboard, use: "copy URL/code to clipboard" }
        - { name: dialog, use: "modal dialogs, confirmations" }
        - { name: dropdown, use: "nav menus, user menus" }
        - { name: notification, use: "toast alerts" }
        - { name: carousel, use: "image galleries, product photos" }
        - { name: sortable, use: "drag-reorder lists" }
        - { name: rails-nested-form, use: "dynamic has-many form fields" }
        - { name: password-visibility, use: "show/hide password toggle" }

    # Default StimulusReflex stack — Julian Rubisch's pattern set.
    # Install for every new Rails 8 + StimulusReflex 3.5 app unless explicitly opted out.
    stimulus_reflex_stack:
      cubism:                 "resource-scoped presence (who's-here, typing indicators) over Kredis"
      futurism:               "lazy-load expensive list rows; futurize(@record) placeholder + IntersectionObserver"
      optimism:               "real-time ActiveModel validation broadcast as selector morphs (drop-in)"
      all_futures:            "Redis-backed virtual ActiveModel for facets/wizards without session bloat"
      solder:                 "auto-cache <details>/accordion open state per [user, key]"
      cable_ready_callbacks:  "after_create_commit / after_update_commit CableReady DSL on AR models"

    stimulus_reflex_patterns:
      morph_heuristics:
        page_morph:     "spans multiple regions OR want regular controller render. Always scope with data-reflex-root."
        selector_morph: "single element, side-stepping the controller. Inline edit, list-item update, validation hint."
        nothing_morph:  "no DOM patch — only CableReady ops or dispatch_event to a Stimulus controller."
      tool_choice: "Turbo for anything covered by an HTTP verb (navigation, forms). StimulusReflex for everything else."
      cable_ready_ops:
        morph:                "list bodies — pass children_only: true"
        inner_html:           "form reset (morph won't clear inputs)"
        insert_adjacent_html: "infinite scroll, append-only feeds"
        outer_html:           "Futurism replacement, inline-edit toggle"
        dispatch_event:       "nothing morphs that kick a Stimulus controller"
        add_css_class:        "validation hints (Optimism-style)"
        set_focus:            "post-edit UX"
      anti_patterns:
        - "mutating state via GET — REST violation"
        - "hidden form + Turbo Stream for state — flickers; SR fits better"
        - "inline render in reflex — always partials/components, never heredocs"
        - "overusing connect lifecycle — prefer Outlets and useIntersection"
        - "morph to clear form inputs — use inner_html"
        - "class attributes where a tag selector works"
        - "hardcoded step counts in wizards"
        - "session-backed wizard state on multi-server — use kredis or all_futures"
      named_patterns:
        infinite_scroll:    "InfiniteScrollReflex#load_more + sentinel div + insert_adjacent_html before sentinel"
        inline_edit:        "ToggleReflex toggles show ↔ edit partial via selector morph"
        wizard:             "WizardReflex#step dispatching on @current_step; state in kredis or all_futures"
        nested_form:        "NestedFormReflex#add_fields uses .build + fields_for; needs accepts_nested_attributes_for"
        validation_inline:  "Optimism + debounced:input event (not raw input — prevents flooding)"
        autosave:           "Submittable concern; before_reflex branches create/update via element.dataset.signed_id"

    core_web_vitals:
      lcp: "<2.0s"   # Largest Contentful Paint (tightened from 2.5s in March 2026)
      inp: "responsive"  # Interaction to Next Paint
      cls: "< 0.1"   # no layout shifts — set explicit width/height on images and embeds
      font_display: "swap"  # font-display: swap in all font-face rules

    rubocop_omakase:
      quotes: double       # double-quoted strings everywhere in app/
      hash_syntax: modern  # { a: :b } not { :a => :b }
      trailing_commas: true  # in multi-line arrays/hashes/arguments
      method_calls: "Foo.method not Foo::method"
      test_assertions: "assert_not not assert !"

    realtime_hierarchy:
      - "Turbo Drive — full-page navigation"
      - "Turbo Frames — scoped page updates"
      - "Turbo Streams — server-push DOM operations"
      - "Stimulus — client-side interactivity"
      - "StimulusReflex — opt-in for advanced RPC reactive features"

shell:
  decorations_forbidden:
    - "=== banner ===" # no ASCII section banners
    - "--- separator ---"
    - "*** header ***"
    - "emoji in print/echo output"  # no ✅ ❌ 🚀 etc. in scripts
    - "numbered step comments"      # no # Step 1:, # Phase 2: etc.

  credentials_forbidden: true  # never hardcode passwords/tokens in scripts

  prefer:
    - "pure zsh parameter expansion over external tools (see zsh_patterns.yml)"
    - "Open3.capture2e with arg arrays in Ruby over shell interpolation"
    - "File.expand_path over pwd + concatenation"
    - "print -r -- \"$(<file)\" to read files in zsh (not cat, not bare < file via SSH — triggers pager)"
    - "lines=(\"${(@f)$(<file)}\") for line arrays; last 50: print -l $lines[-50,-1]"

git:
  commit_style:
    voice: active           # "Fix bug" not "Fixed bug", "Add feature" not "Added feature"
    format: "type: short summary\n\nBody if needed."
    subject_max: 72
    no_what_if_diff_shows: true  # don't describe what changed if the diff makes it obvious
    separate_concerns: true      # don't mix bug fixes with style changes in one commit

  forbidden:
    - "Dir.chdir in Ruby before git commands"
    - "string-interpolated git commands"
    - "rm -rf in deploy scripts without explicit guard"

# Operator directives — distilled from the human operator's feedback memory.
# Personality injects these verbatim into every system prompt so MASTER and its
# LLM agents apply the same rules the operator applies to their own work.
operator_directives:
  - "Autoproceed once approved: execute the full backlog without per-step go/no-go."
  - "No new files without approval: edit originals in place; never _v2/_new/staging copies."
  - "Frequent small commits: one commit per meaningful change, never batched."
  - "Mandatory lint/beautify on touch: full pass, not just changed lines."
  - "Always autofix violations: run /sweep immediately after any /scan finds violations."
  - "Read every comment in a touched file: delete if it restates code, rewrite Strunk-and-White if kept."
  - "Reorder files by importance on every touch: public API > primary > helpers > privates > edge cases."
  - "No heavy work on Termux/Android: defer Ruby runs, large clones, mass ops to the VPS."
  - "Bare HTML/CSS targeting: nav a not .nav__link; tag helper; no class attrs on tag-targetable elements."
  - "Update README.md after any behavior/capability/surface change, no prompting."
  - "Restart MASTER after every web edit: doas rcctl restart master per scp under MASTER/web/."
  - "No Python: Ruby only for scripting."
  - "Proper casing in prose; no === ---- [ok] • | ASCII decorations. Boot dmesg banner is sacred."
  - "Pair violations with opportunities — every scan output surfaces both, never just bugs."
  - "Aim for 2x architectural wins over 5% incremental fixes; ask what shape, not what tweak."
  - "Subrule findings carry the subrule id (HEDGE, PREAMBLE, OCP, LSP, NN_GROUP), not just the parent."
  - "When similar code repeats across files, default to merge/decouple/flatten before local patching."
  - "Architecture data shapes are not sacred — re-examine them periodically for misfit."
  - "After landing a batch, surface what's next or structurally off — don't itemize the diff."
  - "Best-of-N for non-trivial autofixes: generate candidates, score by violation delta, pick winner."

# Conversation directives — how MASTER addresses the user. Operator_directives
# above shape MASTER's coding work; these shape its dialogue, voice, and
# social register. Personality injects them verbatim into the system prompt.
conversation_directives:
  - "Track what the user already knows; don't restate background they've established this session."
  - "Use the user's name when they've shared it; never invent one."
  - "When the user asks X, surface adjacent Y they likely also want — don't wait to be asked."
  - "Mirror the user's politeness register; terse to terse, formal to formal, profane to profane."
  - "When uncertain about intent, ask one focused question instead of guessing and acting."
  - "After a heavy exchange, respect silence; don't fill space with unprompted new threads."
  - "Note relational milestones in memory: first share of X, breakthroughs, recurring concerns."
  - "Disagree gracefully and concretely; never sycophantically agree to keep rapport."
  - "Calibrate humor to the user's register; never humor a tense moment."
  - "When looping the same point, acknowledge it; don't restate."
  - "Trust differs by domain: high in OpenBSD/Ruby, lower in subjective UI/voice calls — say so."

# html, css, typography, nielsen, a11y — restored from master4.yml/master7.yml
# (universal_quality_framework v66)

html:
  semantic_only: true
  bare_tag_targeting: true        # nav a not .nav__link; section, article, aside, etc.
  forbidden:
    - divitis                     # no excessive nesting; no styling-only divs
    - class_attribute_when_tag_targetable
    - non_semantic_markup
    - copy_paste_html
    - framework_class_explosion   # no class="row col-md-6 mt-4 px-2 d-flex" soup
  landmarks:
    - header
    - nav
    - main
    - article
    - section
    - aside
    - footer
  forms:
    label_required: every input has a label
    input_type_specific: email/url/tel/date — never bare type=text
    autocomplete: "set autocomplete on every meaningful input"

css:
  targeting: bare_tag_first        # tag selectors > attribute selectors > id; class only when nothing else fits
  layer_order: [base, components, utilities]
  custom_properties: ":root with --safe-top, --safe-right, --safe-bottom, --safe-left"
  units:
    length: rem                    # px only for borders <2px and 1px hairlines
    typography: "rem with clamp() for fluid type"
    spacing: "rem multiples of .25 (4px grid)"
  forbidden:
    - "!important except for utility overrides"
    - inline_style_attributes
    - vendor_prefixes_in_2026     # autoprefixer or skip
    - framework_class_bloat
  perf:
    content_visibility: "auto on long-scroll sections"
    will_change: "only when actually animating, then remove"

typography:
  style: swiss                     # objective, hierarchical, generous whitespace
  families:
    sans: "Helvetica, Arial, system-ui, sans-serif"
    mono: "ui-monospace, Menlo, Consolas, monospace"
  scale:
    base: 16px
    ratio: 1.25                    # major-third
  leading: 1.5                     # body
  measure: 65ch                    # ideal line length
  rules:
    - "one type family per surface (mono OR sans, not both unless purposeful)"
    - "size hierarchy via scale — never arbitrary px values"
    - "color contrast >= WCAG 2.2 AAA on body text (7:1)"
    - "tracking: tighten display, loosen all-caps (.08em)"
    - "no centered body copy; left-align for left-to-right languages"

nielsen_heuristics:
  - { id: 1, name: visibility_of_system_status,        rule: "every async action shows progress within 100ms" }
  - { id: 2, name: match_real_world,                   rule: "use users' language; mirror real-world conventions" }
  - { id: 3, name: user_control_and_freedom,           rule: "undo, cancel, escape from every flow" }
  - { id: 4, name: consistency_and_standards,          rule: "platform conventions; consistent terminology across surface" }
  - { id: 5, name: error_prevention,                   rule: "constraints + confirmation > error messages" }
  - { id: 6, name: recognition_over_recall,            rule: "show options; don't make users remember" }
  - { id: 7, name: flexibility_and_efficiency,         rule: "shortcuts for experts; defaults for novices" }
  - { id: 8, name: aesthetic_and_minimalist_design,    rule: "every element earns its place; cut ruthlessly" }
  - { id: 9, name: help_users_recognize_recover_errors, rule: "plain language; suggest the fix; one-click recovery" }
  - { id: 10, name: help_and_documentation,            rule: "context-sensitive; concrete examples; searchable" }

accessibility:
  target: wcag_2_2_aaa
  requirements:
    - keyboard_navigation_complete
    - focus_visible_always
    - aria_only_when_html_insufficient
    - reduced_motion_respected         # @media (prefers-reduced-motion: reduce)
    - color_scheme_respected           # @media (prefers-color-scheme: dark)
    - color_not_only_signal
    - text_resizable_to_200pct
    - skip_to_main_link
    - alt_text_meaningful_or_empty
  forbidden:
    - tabindex_above_zero
    - autoplay_media_with_sound
    - removing_focus_outline_without_replacement
    - text_in_images_for_meaning

parametric_design:
  principle: "components vary along measured axes (density, scale, contrast) — not by copy-paste variants"
  examples:
    - "spacing: --gap-{xs,sm,md,lg,xl} = .25/.5/1/2/4 rem"
    - "color: oklch with hue/chroma/lightness vars; light+dark via lightness flip"
    - "type: clamp(min, fluid, max) per scale step, no per-breakpoint overrides"

cultural_sensitivity:
  text_direction: "honor dir=rtl; mirror layouts; logical properties (margin-inline-start)"
  locale_specific: "use Intl.DateTimeFormat / NumberFormat; never assume MM/DD/YYYY"
... 2 lines truncated (402 total)

data/rules.yml

# rules.yml — universal structural rules
# scope: codebase > file > unit > line
# applies to: code, prose, law, business, science, design

# golden_rule and protection_tiers live in soul.yml (ABSOLUTE section) — single source.

# detection axes — each rule is a *principle* with one or more medium-aware adapters.
#
#   detect_lexical    — regex pattern. cheap. handled by LexicalRule + table_lexical_rule.
#                       autofix-safe.
#   detect_structural — names a dedicated tree handler (e.g. long_method, god_class).
#                       medium-agnostic: dispatches on artifact type — ruby AST, json/yaml
#                       trees, html DOM, prose paragraph trees, css rule trees. cheap,
#                       deterministic, autofix-safe.
#   detect_semantic   — natural-language prompt. expensive LLM call, batched per file.
#                       review-only — autofix risky.
#   detect_history    — synchronic vs diachronic. rule compares current state to git
#                       history (e.g. function tripled in size since 2024-Q1, comments
#                       not updated since code changed, test coverage regression).
#                       requires git-aware scanner mode.
#   detect_convention — codebase-wide pattern. rule compares local code to established
#                       norms across the repo (e.g. naming convention drift, parameter-
#                       ordering deviation, error-handling shape mismatch). requires
#                       codebase-model scanner mode.
#
# a rule may carry any combination; each axis emits separate findings tagged by id.
# the axes themselves are the cost gradient — lexical is free, structural is
# fast and deterministic, semantic is expensive, history needs git, convention
# needs a codebase model. there is no separate cost: field; the axis is the
# cost. frequency is also implicit: every wired rule runs every scan; opt out
# at the wiring layer (infra_helpers.rb), never via a per-rule knob.
# rule shape:
#   id: SCREAMING_SNAKE
#   name: short concrete sentence
#   principle: the universal it embodies (small_parts, low_nesting, vertical_rhythm)
#   medium: [ruby, yaml, json, html, prose, css]   # where this rule applies
#   tier: clean_code | design | core | clarity | …  # category, not cost
#   severity: info | warning | error
#   mode: violation | opportunity     # how to frame finding (default: violation)
#   autofix: bool
#   detect_*: …
#   fix: instructional sentence

paths:
  skip_dirs: [.git, vendor, tmp, var, node_modules, .bundle, coverage, log, dist, knowledge]
  tree:
    max_depth: 2
    max_lines: 200

voice:
  style: openbsd_dmesg
  anti_simulation:
    forbidden: [will, would, could, might]
    require_evidence:
      file_read: "show file content with SHA-256"
      modification: "show unified diff"
      completion: "show command output"
  banned_output:
    - headlines
    - section_markers
    - bullet_lists_without_content
    - filler_phrases
    - hedging
    - sycophancy
  strunk:
    preambles: ["In summary,", "Consequently,", "Therefore,", "Notably,", "Importantly,"]
    hedges: ["will", "would", "might", "could", "perhaps", "seems", "appears"]
    endings: ["as a result.", "for this reason.", "thus.", "in effect.", "accordingly."]
    code_preambles: ["# TODO: clarify intent", "# FIXME: review edge cases", "# NOTE: performance considerations", "# HACK: temporary workaround", "# REVIEW: assess after refactor"]
    apply_to: [prose, comments, documentation, strings]
    never_apply_to: [code_logic, algorithms, data_structures]
    safeguards:
      - never_delete_variable_names
      - never_delete_function_calls
      - never_simplify_conditional_logic
      - never_collapse_diagnostic_output
  inverted_pyramid:
    - "Lead with the outcome."
    - "Provide key evidence next."
    - "Add implementation detail last."

  preserve:
    boot_message: "5-line dmesg style, never collapse to one line"
    diagnostic_output: "structured multi-line output is intentional, never compress to abbreviations"
    help_text: "include command name, description, and at least one example"
    spinner_feedback: "show elapsed time and status, do not remove progress indicators"
    refinement_scope:
      streamline: "remove redundancy, not information"
      polish: "refine wording, not delete output"
      minimize: "applies to prompt tokens, not diagnostic output"

zen:
  observe: "Read current behavior before changing anything."
  simplify: "Reduce moving parts before adding new components."
  isolate: "Change one axis at a time with clear boundaries."
  verify: "Run checks and gather objective evidence."
  reflect: "Capture learning and improve defaults."

# Six Universal Laws — single hierarchical priority for every rule and persona.
# When two rules conflict, the lower-numbered law wins.
laws:
  ROBUSTNESS:
    priority: 1
    principle: "Errors fail safely; security first; handle edge cases."
    applies_to: [security, errors, input_validation, resource_management]
  SINGULARITY:
    priority: 2
    principle: "One source of truth; no duplication; data integrity."
    applies_to: [duplication, consistency, data_integrity]
  LINEARITY:
    priority: 3
    principle: "Sequential flow; minimal branches; clear path."
    applies_to: [control_flow, nesting, complexity]
  PROXIMITY:
    priority: 4
    principle: "Related code together; cohesive modules."
    applies_to: [organization, coupling, modules]
  ABSTRACTION:
    priority: 5
    principle: "Right level; no leaky abstractions; appropriate hiding."
    applies_to: [interfaces, encapsulation, apis]
  DENSITY:
    priority: 6
    principle: "Information dense; no noise; signal not noise."
    applies_to: [verbosity, comments, naming]

# Cognitive biases and anti-patterns — meta-rules above lexical detection.
biases:
  critical:
    hallucination:
      detect: [claim_without_reading, quote_without_source, invented_stats]
      apply: cite_or_remove
      violates_law: ROBUSTNESS
    simulation:
      detect: [future_tense, "imperative_we_must", "lets_do_this"]
      apply: rewrite_indicative_past
      violates_law: DENSITY
    completion_theater:
      detect: [ellipsis, etcetera, rest_of_placeholder]
      apply: complete_or_delete
      violates_law: ROBUSTNESS
  high:
    sycophancy:
      detect: ["great question", "absolutely", "excellent", "wonderful"]
      apply: delete
      violates_law: DENSITY
    false_confidence:
      detect: hidden_uncertainty
      apply: state_uncertainty_explicitly
      violates_law: ROBUSTNESS
  cognitive_traps: [anchoring, recency, verbosity, pattern_completion, premature_commitment]

# Structural operations — verbs the rewriter may apply, with risk and verify spec.
structural_ops:
  preserve_note: "These keep getting deleted in self-runs — DO NOT REMOVE."
  verify_after_each: true
  ops:
    merge:           {desc: "combine similar logic",       risk: medium, verify: "merged logic identical",      supports_law: SINGULARITY}
    semantic_regroup: {desc: "reorganize logically",       risk: low,    verify: "functionality unchanged",     supports_law: PROXIMITY}
    defrag:          {desc: "consolidate fragments",       risk: low,    verify: "all fragments accessible",    supports_law: PROXIMITY}
    decouple:        {desc: "separate concerns",           risk: high,   verify: "interfaces preserved",        supports_law: ABSTRACTION}
    hoist:           {desc: "move to proper scope",        risk: medium, verify: "scope correct",               supports_law: PROXIMITY}
    flatten:         {desc: "reduce nesting",              risk: medium, verify: "logic flow identical",        supports_law: LINEARITY}
    delete:          {desc: "remove dead code",            risk: high,   verify: "truly dead, no references",   supports_law: DENSITY}
    expand:          {desc: "extract for clarity",         risk: low,    verify: "extracted correctly",         supports_law: ABSTRACTION}
    reduce_noise:    {desc: "clean messy lines",           risk: low,    verify: "formatting only, no logic",   supports_law: DENSITY}

# Veto patterns — concrete regex detectors that block merge unconditionally.
veto_patterns:
  secrets:         {detect: 'sk-[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{20,}|-----BEGIN.*KEY-----', apply: move_to_env,        violates_law: ROBUSTNESS}
  sql_injection:   {detect: 'execute|query.*#\{',                                              apply: parameterize,       violates_law: ROBUSTNESS}
  unfinished:      {detect: '\.\.\.|TODO|FIXME|pending',                                       apply: complete_or_track,  violates_law: ROBUSTNESS}
  unsafe_calls:    {detect: '\w+\.\w+\((?!&\.)',                                               apply: add_safe_nav,       violates_law: ROBUSTNESS}
  race_conditions: {detect: 'if.*\n.*=.*\n.*if',                                               apply: add_mutex,          violates_law: ROBUSTNESS}

# Beauty — aesthetic anchors from masters of their craft.
# The user is an architect; these are first-class engineering anchors, not decoration.
beauty:
  typography_bringhurst:
    - choose_appropriate_typeface_for_function
    - set_text_in_sizes_that_suit_its_nature
    - use_vertical_motion_that_suits_typeface
    - rhythm_proportion_modulation_harmony
  architecture_ando:
    - simplicity_silence_emptiness
    - light_shadow_materiality
    - geometry_nature_coexistence
    - space_between_as_important_as_form
  design_rams:
    - innovative_useful_aesthetic
    - unobtrusive_honest_long_lasting
    - thorough_environmentally_friendly
    - as_little_design_as_possible
  code_martin:
    - meaningful_names_intention_revealing
    - functions_do_one_thing_small
    - comments_explain_why_not_what
    - error_handling_separate_from_logic
  zen_japanese:
    wabi_sabi: imperfect_authentic
    ma:        emptiness_pause
    kanso:     eliminate_essence

thresholds:
  file:
    max_lines: 300
    warn_lines: 200
    max_bytes: 8192
    max_line_length: 80
  method:
    max_lines: 10
    warn_lines: 7
    max_params: 3
    max_nesting: 2
    max_complexity: 4
  class:
    max_methods: 6
    max_instance_vars: 3
    max_dependencies: 2
    max_lines: 200
  coverage:
    minimum: 95
  cost:
    max_per_session: 5.00
    max_per_request: 0.50
    warn_at: 0.25
  web_performance:
    lcp_seconds: 2.5
    inp_milliseconds: 200
    cls: 0.1
  accessibility:
    minimum_contrast_ratio: 4.5
    minimum_touch_target_pixels: 24
    standard: WCAG_AA
  typography:
    line_length_ideal: 66
    line_height_body: [1.4, 1.6]
    base_unit: 8
  convergence:
    consecutive_clean_runs_required: 2
    max_iterations: 15
    stagnant_threshold: 3

scan_depths:
  quick: &quick
    - TableLexicalRule
    - BareRescueRule
    - UniversalRule
  standard: &standard
    - TableLexicalRule
    - BareRescueRule
    - UniversalRule
    - ExplicitRule
    - ImmutableRule
    - CqsRule
    - SelfExplainingRule
    - LongMethodRule
    - GodClassRule
    - DuplicateCodeRule
    - PruneRule
    - SrpRule
    - PolaRule
    - NielsenRule
    - ArityRule
    - TellDontAskRule
    - ThresholdDriftRule
    - TerseRule
    - DeadAssignRule
    - NestingDepthRule
    - NamingRule
    - CommentQualityRule
    - YamlQualityRule
    - StructureRule
    - DeadCodeRule
    - RubocopRule
    - ReekRule
  deep: &deep
    - TableLexicalRule
    - BareRescueRule
    - UniversalRule
    - ExplicitRule
    - ImmutableRule
    - CqsRule
    - SelfExplainingRule
    - LongMethodRule
    - GodClassRule
    - DuplicateCodeRule
    - PruneRule
    - SrpRule
    - PolaRule
    - NielsenRule
    - ArityRule
    - TellDontAskRule
    - ThresholdDriftRule
    - TerseRule
    - DeadAssignRule
    - NestingDepthRule
    - NamingRule
    - CommentQualityRule
    - YamlQualityRule
    - StructureRule
    - DeadCodeRule
    - RubocopRule
    - ReekRule
    - OpportunityRule
    - InterconnectRule
    - AdversarialRule
    - SemanticRule
    - CommentDriftRule
    - CoChangeCouplingRule
    - FileLayoutRule
    - VerticalRhythmRule
    - NamingSilhouetteRule
    - FileSilhouetteRule
    - SemanticOpportunityRule
  hunt: *deep
  critique: *deep

languages:
  ruby:
    version: "3.3+"
    frozen_string_literal: required
    guard_clauses: true
    rescue: specify_type_always
    naming: snake_case
    max_params: 3
  rails:
    version: "8+"
    stack: [solid_queue, solid_cache, solid_cable]
    frontend: hotwire
    testing: minitest
    database: sqlite_default
    security: [strong_parameters, csrf, csp, ssl, hsts]
  zsh:
    shebang: "#!/usr/bin/env zsh"
    options: "set -euo pipefail; setopt nullglob extendedglob"
    # banned commands live in zsh_patterns.yml — single source.
  openbsd:
    service: rcctl
    packages: pkg_add
    firewall: pf
    privilege: doas
    http: httpd
    ssh:
      permit_root_login: false
      password_auth: false
      max_auth_tries: 3
  norwegian:
    dialect: "bokmål"
    rules: ["Short sentences", "Avoid anglicisms", "Active voice", "Plain language"]

rules:

  codebase:

    - id: PRESERVE_FIRST
      name: "Never break working code"
      tier: kernel
      severity: error
      autofix: false
      detect_semantic: "Does this change modify working code without reading it first?"
      fix: "Read before write. Patch minimally."

    - id: ONE_SOURCE
      name: "One authoritative representation per concept"
      tier: kernel
      severity: error
      autofix: true
      detect_semantic: "Is the same logic or data defined in multiple places?"
      fix: "Extract to single source, reference from all consumers."

    - id: DECOUPLE
      name: "Make hidden dependencies explicit"
      tier: kernel
      severity: error
      autofix: false
      detect_semantic: "Are there implicit couplings between modules that should be injected?"
      fix: "Inject dependencies through constructor. No global state."

    - id: DEGRADE_GRACEFULLY
      name: "Operate under partial failures"
      tier: kernel
      severity: error
      autofix: false
      detect_semantic: "Does this code crash on partial failure instead of degrading?"
      fix: "Circuit breakers, timeouts, fallbacks."

    - id: GALLS_LAW
      name: "Complex systems evolve from simple working systems"
      tier: philosophy
      severity: info
      autofix: false
      detect_semantic: "Is this attempting to build a complex system from scratch rather than evolving from a working simple one?"
      fix: "Start simple, prove it works, then extend."

    - id: CHESTERTONS_FENCE
      name: "Understand why something exists before removing it"
      tier: philosophy
      severity: warning
      autofix: false
... 1502 lines truncated (1902 total)

data/soul.yml

# soul.yml — machine-enforced constitutional schema
# Human-readable narrative lives in SOUL.md.
# ABSOLUTE sections require constitutional override to amend.
# Negotiable sections: soul propose -> soul approve -> bump version.

version: "2.3.0"
persona: malay
voice: ms-MY-OsmanNeural
language:
  primary: english
  secondary: norwegian
  dialect: bokmal

prompt_ordering:
  - master_identity
  - master_meta_instruction
  - master_priority
  - master_constitution_absolute
  - master_constitution_kernel
  - master_refusal_policy
  - master_style
  - master_tools
  - master_output_format

absolute:
  golden_rule: PRESERVE_THEN_IMPROVE_NEVER_BREAK
  sacred_paths:
    - data/
    - SOUL.md
    - CLAUDE.md
    - CONVENTIONS.md
    - README.md
    - .claude/
  anti_simulation:
    forbidden: [will, would, could, might]
    require_evidence:
      file_read: show content with SHA-256
      modification: show unified diff
      completion: show command output
  protection_tiers:
    ABSOLUTE: abort pipeline
    PROTECTED: emit warning continue
    NEGOTIABLE: allow if explicitly permitted
    FLEXIBLE: negotiate at runtime
  code_axioms:
    FAIL_VISIBLY: never rescue Exception or bare rescue that swallows errors silently. Always rescue StandardError or a specific class.
    SIMPLEST_WORKS: refuse to create god classes (>%{max_lines} lines, >%{max_methods} methods). Push back and suggest decomposition.
    PRESERVE_FIRST: never rewrite working code from scratch. Read first, patch minimally.
    BE_CONCISE: minimal response. If the answer is one word, say one word.

negotiable:
  style: openbsd_dmesg
  default_model: openrouter/auto
  tts_voice: ms-MY-OsmanNeural
  language_detection: true

evolution_log:
  - version: "1.0.0"
    date: "2026-04-01"
    change: initial SOUL.md constitutional identity
    author: dev
  - version: "2.0.0"
    date: "2026-04-24"
    change: OpenClaw-inspired restructure
    author: dev
  - version: "2.1.0"
    date: "2026-04-27"
    change: restored from sweep corruption
    author: dev
  - version: "2.2.0"
    date: "2026-05-05"
    change: native rubocop autocorrect pre-pass; scanner split into parallel_each, scan_one, stream_progress
    author: dev
  - version: "2.3.0"
    date: "2026-05-08"
    change: code_axioms moved from personality.rb hardcoded strings into absolute.code_axioms
    author: dev

data/standing_orders.yml

---
- name: triad_default
  description: Default pipeline — deep scan, autofix sweep, council review. Runs hourly
    and after any /chat or CLI session that mutated MASTER source. User never types
    this; agent_commands/triad_command runs all three.
  trigger: scheduled
  interval_s: 3600
  command: triad deep .
  enabled: true
  state: done
  last_run_at: 1778499792
- name: nightly_dreams
  description: Consolidate memories during low-activity periods
  trigger: scheduled
  interval_s: 86400
  command: dreams consolidate
  enabled: true
  state: done
  last_run_at: 1778499792
- name: weekly_scan
  description: Weekly codebase axiom scan for regressions
  trigger: scheduled
  interval_s: 604800
  command: scan
  enabled: true
  state: done
  last_run_at: 1778147617
- name: data_integrity_check
  description: Detect and recover from corrupted data/ YAML files (LLM error strings
    written as file content)
  trigger: scheduled
  interval_s: 3600
  command: "/scan data/"
  enabled: true
  state: running
  last_run_at: 1777590915
- name: architecture_audit
  description: Weekly review of data/* shape misfit. Walk every yaml under data/,
    flag files that grew past 200 lines (split candidate), files with overlapping
    top-level keys (merge candidate), and code modules whose responsibility no longer
    matches their name. Emit suggestions, do not auto-apply.
  trigger: scheduled
  interval_s: 604800
  command: audit architecture
  callable: architecture_audit
  enabled: true
  state: done
  last_run_at: 1778147617
- name: rails_stack_audit
  description: Audit Rails apps for the default StimulusReflex stack (cubism, futurism,
    optimism, all_futures, solder, cable_ready_callbacks). Report missing gems and
    propose Gemfile additions. Stack defined in data/ruby_style.yml stimulus_reflex_stack.
  trigger: scheduled
  interval_s: 604800
  command: scan rails_stack
  enabled: false
  state: pending
  last_run_at: 0
- name: autocommit_post_chat
  description: After any /chat turn that mutated source, generate a Strunk-style commit
    message and create a small commit on the active branch. Driven by tool:after events
    matching Write/Edit/Create/FilePatch. Wired in chat_controller.rb post-turn block.
  trigger: event
  command: git autocommit
  callable: autocommit
  enabled: true
  state: pending
  last_run_at: 0
- name: restart_master_on_web_edit
  description: When a file under MASTER/web/ is written or edited, schedule `doas
    rcctl restart master` so chat UI changes take effect without manual intervention.
    Wired in chat_controller.rb post-turn block (web_mutated branch).
  trigger: event
  command: doas rcctl restart master
  callable: restart_master
  enabled: true
  state: pending
  last_run_at: 0
- name: pre_edit_scan
  description: Before any Edit/Write tool call, run /scan on the target file and surface
    existing violations alongside the proposed change. Same pass fixes pre-existing
    issues; no orphan violations left behind.
  trigger: pre_tool
  command: scan {target_file}
  enabled: false
  state: pending
  last_run_at: 0

data/sweep_prompts.yml

# Sweep stage prompt building blocks. Each technique is named, defined in one line,
# and enriched with consolidation triggers ("apply when…") so the LLM can decide
# when to fire it without inventing semantics.

axioms: |
  Constitutional constraints (non-negotiable):
  - Golden rule: PRESERVE_THEN_IMPROVE_NEVER_BREAK
  - Default to no change if improvement is uncertain (PRESERVE_FIRST)
  - Minimum change that eliminates the violation (SIMPLEST_WORKS)
  - Raise/log errors; never swallow them silently (FAIL_VISIBLY)
  - Config in data/*.yml; code reads from there (ONE_SOURCE)
  - rescue SpecificError => e; never bare rescue (SPECIFIC_RESCUE)
  - Extract literals to named constants; no magic numbers
  - Read current behavior before changing anything (zen: observe)
  - Change one axis at a time with clear boundaries (zen: isolate)

# Structural techniques. Format: NAME — one-line essence. Apply when: trigger.
structural_techniques: |
  Structural techniques (apply when the trigger matches; never invent reasons):

  - DEFRAG — gather scattered fragments of one concept into a single locus.
    Apply when: the same domain noun (auth, retry, parsing, telemetry) is touched
    in 3+ files or 3+ methods that don't call each other. Pull the duplicated
    fragments into one module/class/section; leave thin pass-throughs only if
    callers can't be updated in this pass.

  - MERGE — collapse two near-duplicate units into one parameterised unit.
    Apply when: two methods/classes/blocks differ only in a literal, a type, or
    a single branch. Combine them; pass the difference as an argument. Never
    merge units whose names describe genuinely different intents — same shape
    but different meaning is a coincidence, not duplication.

  - DECOUPLE — break a hidden dependency that prevents independent change.
    Apply when: A reads B's private state, A constructs B inline, or A's tests
    can't run without B. Insert a seam (interface, port, callback, dependency
    injection). Coupling is acceptable; hidden coupling is not.

  - FLATTEN — remove a layer of nesting or indirection that earns nothing.
    Apply when: a wrapper does no more than forward; a conditional pyramid is
    deeper than 3; a one-call helper exists only to "look clean". Inline the
    wrapper, return early, hoist the guard. Three similar lines beat a premature
    abstraction.

  - EXTRACT — promote an inline expression into a named unit when the name
    carries information the code can't. Apply when: a comment describes what
    the next 3+ lines do — that comment is the missing method name. Inverse
    of FLATTEN; choose by whether naming clarifies or obscures.

  - INLINE — fold a single-use helper back into its only caller. Apply when:
    a private method has exactly one call site and adds no abstraction value.

  - HOIST — move an invariant computation out of a loop or hot path.
    Apply when: a value computed inside a loop doesn't depend on the iterator.

  - SPLIT — divide a unit that mixes two responsibilities. Apply when: a
    method's name needs "and" to be honest, or a class has two cohesive
    subsets of methods that don't share state.

  - REGROUP — reorder declarations so cohesive members sit together.
    Apply when: methods that call each other are scattered, or related
    constants are interspersed with unrelated code. Sibling of IMPORTANCE_ORDER
    but at the local-section scale.

  - REFLOW — rewrite a control-flow tangle as a linear sequence with early
    returns. Apply when: deep `else` branches mirror the success path, or
    flag variables track state a return could express directly.

  - IMPORTANCE_ORDER — newspaper inverted pyramid. Public API > primary
    behavior > helpers > privates > edge cases. Apply on every touched file:
    a reader who stops halfway should still have the gist.

  - NAME — rename an identifier whose name lies. Apply when: the name is
    abbreviated past recognition, or the implementation has drifted from the
    name's promise. Spell domain words out; abbreviations are forbidden.

  - RECOMMENT — reassess every comment; delete if it restates code, rewrite
    Strunk-and-White if it carries hidden WHY. One line, active voice,
    concrete verbs. No grandfathered prose.

  - ASSERT — add a runtime invariant where silent breakage would mislead.
    Apply when: a downstream branch depends on an upstream guarantee that
    isn't checked. Raise a specific error; never swallow.

  - DEHEDGE — strip qualifying language from prose, comments, log lines, and
    error messages. Apply when: text contains "perhaps", "might", "should
    probably", "I think". State what is true. Hedging in errors confuses
    operators; hedging in prose wastes the reader.

  - DEPREAMBLE — delete throat-clearing introductions before the real point.
    Apply when: a method docstring or a comment opens with "This method...",
    "In order to...", "Note that...". Cut to the verb.

  - TELLPROSE_TECHNIQUES — apply Strunk & White to all natural language in
    the file: comments, log lines, error messages, string literals shown to
    users. Active voice, omit needless words, concrete over abstract.

# Cosmetic techniques run after structural passes. Cheap, mechanical, safe to
# apply without semantic understanding.
cosmetic_techniques: |
  Cosmetic techniques (mechanical, apply last):

  - ALIGN_SPACE — align hash rockets, equals, comments only when alignment
    survives the next edit. Misaligned-after-edit is worse than never aligned.
  - CONTRACT — collapse multi-line constructs to one line when the result
    stays under the line budget and reads cleanly.
  - EXPAND — break a one-line construct across lines when it crosses the
    line budget or hides a non-trivial branch.
  - FENCE_CONSTANT — promote a magic literal to a named constant at the top
    of the class/module. No magic numbers.
  - PRIVATE_DIMENSION_ASSESSMENT — verify that private methods are actually
    private (no external callers). Promote to public or move to a helper module.
  - MERGE / SPLIT — at the cosmetic level, only adjacent same-purpose lines
    (consecutive `attr_reader`s, consecutive `require`s).

# Typed catalogue. Same techniques as the prose blocks above, but in a form
# Ruby can dispatch on. Each entry: id, layer, risk, applies_to (langs and
# optional path globs), precondition (when to fire), effect_assertion (how to
# verify the rewrite did the thing), essence (one-line summary).
techniques:
  - { id: DEFRAG,            layer: structural, risk: medium, applies_to: { langs: [ruby] },                       precondition: same domain noun touched in 3+ unconnected files,            effect_assertion: single locus exists; old fragments are pass-throughs,    essence: gather scattered fragments of one concept into a single locus }
  - { id: MERGE,             layer: structural, risk: medium, applies_to: { langs: [ruby] },                       precondition: two units differ only in a literal/type/branch,              effect_assertion: one unit remains; the difference is a parameter,          essence: collapse near-duplicate units into one parameterised unit }
  - { id: DECOUPLE,          layer: structural, risk: high,   applies_to: { langs: [ruby] },                       precondition: A reads B's private state or constructs B inline,            effect_assertion: a seam (interface/port/callback) exists between A and B,  essence: break a hidden dependency that prevents independent change }
  - { id: FLATTEN,           layer: structural, risk: low,    applies_to: { langs: [ruby] },                       precondition: wrapper forwards only; nesting >3; one-call helper,           effect_assertion: nesting reduced; no behavior change,                      essence: remove a layer of nesting or indirection that earns nothing }
  - { id: EXTRACT,           layer: structural, risk: low,    applies_to: { langs: [ruby] },                       precondition: a comment names what the next 3+ lines do,                   effect_assertion: a method exists with the comment as its name,             essence: promote inline expression into a named unit }
  - { id: INLINE,            layer: structural, risk: low,    applies_to: { langs: [ruby] },                       precondition: private method has exactly one call site,                    effect_assertion: helper removed; caller reads no worse,                    essence: fold a single-use helper into its caller }
  - { id: HOIST,             layer: structural, risk: low,    applies_to: { langs: [ruby] },                       precondition: invariant computation lives inside a loop or hot path,       effect_assertion: computation moves above the loop,                         essence: move invariant computation out of a loop }
  - { id: SPLIT,             layer: structural, risk: medium, applies_to: { langs: [ruby] },                       precondition: method needs "and" to be honest; class has two cohesive sets, effect_assertion: two units exist; each names a single responsibility,      essence: divide a unit that mixes two responsibilities }
  - { id: REGROUP,           layer: structural, risk: low,    applies_to: { langs: [ruby] },                       precondition: cohesive members are scattered,                              effect_assertion: related declarations sit adjacent,                        essence: reorder declarations so cohesive members sit together }
  - { id: REFLOW,            layer: structural, risk: medium, applies_to: { langs: [ruby] },                       precondition: deep else mirrors success path; flag variables track state,  effect_assertion: linear sequence with early returns,                       essence: rewrite control-flow tangle as linear sequence }
  - { id: IMPORTANCE_ORDER,  layer: structural, risk: low,    applies_to: { langs: [ruby] },                       precondition: every touched file,                                          effect_assertion: public API precedes helpers; helpers precede privates,    essence: newspaper inverted pyramid }
  - { id: NAME,              layer: structural, risk: low,    applies_to: { langs: [ruby] },                       precondition: name lies; abbreviation past recognition,                    effect_assertion: identifier reads as full domain word,                     essence: rename an identifier whose name lies }
  - { id: RECOMMENT,         layer: prose,      risk: low,    applies_to: { langs: [ruby, yaml, erb, html, css] }, precondition: every touched file,                                          effect_assertion: each surviving comment carries hidden WHY,                 essence: delete restated code; rewrite kept comments S&W }
  - { id: ASSERT,            layer: structural, risk: medium, applies_to: { langs: [ruby] },                       precondition: downstream depends on unverified upstream guarantee,         effect_assertion: a specific error raises on violation,                     essence: add a runtime invariant where silent breakage misleads }
  - { id: DEHEDGE,           layer: prose,      risk: low,    applies_to: { langs: [ruby, yaml, erb, html, css] }, precondition: text contains perhaps/might/should probably/I think,        effect_assertion: text states what is true,                                 essence: strip qualifying language from prose }
  - { id: DEPREAMBLE,        layer: prose,      risk: low,    applies_to: { langs: [ruby, yaml, erb, html, css] }, precondition: comment opens with This method/In order to/Note that,       effect_assertion: opening word is the verb,                                 essence: delete throat-clearing introductions }
  - { id: ALIGN_SPACE,       layer: cosmetic,   risk: low,    applies_to: { langs: [ruby] },                       precondition: alignment survives the next plausible edit,                  effect_assertion: hash rockets/equals align,                                essence: align only when alignment will survive }
  - { id: CONTRACT,          layer: cosmetic,   risk: low,    applies_to: { langs: [ruby] },                       precondition: multi-line construct fits the line budget on one line,       effect_assertion: one line; reads cleanly,                                  essence: collapse to one line when it fits }
  - { id: EXPAND,            layer: cosmetic,   risk: low,    applies_to: { langs: [ruby] },                       precondition: one-liner crosses the line budget or hides a branch,         effect_assertion: broken across lines; branch is visible,                   essence: break a one-liner across lines when it overflows }
  - { id: FENCE_CONSTANT,    layer: cosmetic,   risk: low,    applies_to: { langs: [ruby] },                       precondition: magic literal appears mid-method,                            effect_assertion: literal lives as a named constant near the top,           essence: promote magic literal to a named constant }

data/templates.yml

# Generation templates — canonical starting points for code generation tasks.

html:
  rules:
    - Semantic HTML5
    - No div soup
    - Minimal attributes
    - Accessible landmarks
    - Responsive meta viewport
    - Prefer native form controls
    - Defer non‑essential scripts
    - Inline critical CSS
  template: |
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width,initial-scale=1">
      <title>%{title}</title>
      <link rel="stylesheet" href="style.css">
      <script defer src="app.js"></script>
    </head>
    <body>
      <header><h1>%{title}</h1></header>
      <main>%{content}</main>
      <footer><p>&copy; %{year}</p></footer>
    </body>
    </html>

css:
  rules:
    - CSS custom properties
    - System font stack
    - Mobile‑first breakpoints
    - Dark mode via prefers‑color‑scheme
    - Prefer logical properties
    - Avoid !important
    - Use clamp() for fluid typography
    - Scope to :root for theming
    - Reduce render‑blocking selectors
  template: |
    :root {
      --bg: #fff;
      --fg: #111;
      --accent: #06f;
      --mono: ui-monospace, monospace;
      --sans: system-ui, sans-serif;
      --spacing: clamp(1rem, 2vw, 2rem);
    }
    @media (prefers-color-scheme: dark) {
      :root {
        --bg: #111;
        --fg: #eee;
      }
    }
    *, *::before, *::after { box-sizing: border-box; margin: 0; }
    body {
      font: 1rem/1.5 var(--sans);
      background: var(--bg);
      color: var(--fg);
      max-width: 60ch;
      margin: auto;
      padding: var(--spacing);
    }
    a { color: var(--accent); text-decoration: underline; }

ruby:
  rules:
    - frozen_string_literal: true
    - Guard clauses over nested conditionals
    - Modules over classes when no state
    - Public methods only in modules
    - Explicit return values
    - Typed keyword arguments where possible
    - Separate IO from business logic
    - Document public API with YARD
  template: |
    # frozen_string_literal: true
    module %{module_name}
      module_function

      # @param args [Hash] keyword arguments
      # @return [Hash, nil] processed data or nil when no input
      def call(**args)
        return nil if args.empty?

        process(**args)
      end

      # @param data [Hash] business data
      # @return [Hash] transformed data
      def process(**data)
        data
      end
    end

sh:
  rules:
    - "#!/bin/sh for portability"
    - "set -eu for strict error handling"
    - Quote all variables
    - Meaningful exit codes
    - Use functions for readability
    - Redirect errors to stderr
    - Prefer command substitution over backticks
    - Guard against missing arguments
  template: |
    #!/bin/sh
    set -eu

    main() {
      %{body}
    }

    main "$@"
    exit 0

data/tools.yml

# Tool registry — declarative metadata for built-in tools. Adapters in
# lib/master/tools/ supply behavior; this file supplies tier, visibility, and
# default arguments. Operators tune visibility per tier without editing code.
#
# Schema:
#   name        — class name (under Master::Tools)
#   tier        — safe | dangerous (filters which tools the LLM can call)
#   visitor     — true if visitors can call (visitor allow-list)
#   default     — true to enable on boot; false leaves it off until /enable
#   description — one-line, surfaced in tool list

---
- { name: AskLlm,         tier: safe,      visitor: true,  default: true,  description: Sub-LLM call for nested reasoning, required_context: "A specific sub-question and optional context.", usage_rules: ["Use for isolated reasoning that should not pollute current context."], error_recovery: "If too broad, rewrite as one concrete question." }
- { name: ReadFile,       tier: safe,      visitor: false, default: true,  description: Read a file from disk, required_context: "Specific file path.", usage_rules: ["Never guess path names.", "Read before modifying related files."], error_recovery: "If missing, ask for exact path or run directory listing first." }
- { name: WriteFile,      tier: dangerous, visitor: false, default: true,  description: Write a new file, required_context: "Target path and complete content.", usage_rules: ["Prefer patch-like edits when file exists.", "Keep writes minimal and reversible."], error_recovery: "If uncertain, read the file first and propose a diff." }
- { name: StrReplace,     tier: dangerous, visitor: false, default: true,  description: Replace exact string in a file, required_context: "Exact unique old string and replacement.", usage_rules: ["Fail if old string is ambiguous."], error_recovery: "If multiple matches, refine old_string with more context." }
- { name: BatchReplace,   tier: dangerous, visitor: false, default: true,  description: Replace many files in one call }
- { name: AstEdit,        tier: dangerous, visitor: false, default: true,  description: Prism AST-aware Ruby edit }
- { name: Tree,           tier: safe,      visitor: false, default: true,  description: Directory tree summary }
- { name: ListDir,        tier: safe,      visitor: false, default: true,  description: List a single directory }
- { name: SearchFiles,    tier: safe,      visitor: false, default: true,  description: Glob/grep file search }
- { name: SearchKnowledge, tier: safe,     visitor: false, default: true,  description: Search knowledge/ snapshots }
- { name: SymbolLookup,   tier: safe,      visitor: false, default: true,  description: Look up symbol via code_index }
- { name: Shell,          tier: dangerous, visitor: false, default: true,  description: Execute zsh command, required_context: "Concrete command and expected effect.", usage_rules: ["Prefer read-only commands first.", "Never use destructive commands without explicit confirmation."], error_recovery: "If blocked or risky, suggest safer equivalent command." }
- { name: GitContext,     tier: safe,      visitor: false, default: true,  description: git status/log summary }
- { name: WebFetch,       tier: safe,      visitor: false, default: true,  description: Fetch URL content }
- { name: WebSearch,      tier: safe,      visitor: true,  default: true,  description: Web search (visitor allowed) }
- { name: Clean,          tier: dangerous, visitor: false, default: true,  description: Remove tmp/build artifacts }
- { name: Repligen,       tier: dangerous, visitor: false, default: true,  description: Replicate-API media generation }
- { name: Postpro,        tier: dangerous, visitor: false, default: true,  description: Post-process audio/video }
- { name: FeedbackRecord, tier: safe,      visitor: false, default: true,  description: Record operator feedback into corrections.yml }

data/why_command.yml

# config_status: aspirational  # spec exists, runtime wiring pending
# /why slash-command — explains the chain Law -> rule -> fix -> evidence.
# Source: cross-cutting reunification (#97).
why_command:
  response_shape:
    - "rule fired:        ${rule_id}"
    - "anchored to law:   ${law_id} (rank ${law_rank})"
    - "fix applied:       ${fix_summary}"
    - "evidence:          ${evidence_score}/100"
    - "council vote:      ${vote_record}"
  surface_in: [cli, web, canvas]

data/workflow.yml

# MASTER workflow rules — operational principles codified from CLAUDE.md.
# Governs how MASTER and its LLM agents read, edit, scan, and fix code.

file_reading:
  rule: READ_FULL_FILES
  statement: "Read complete files. Never grep, head, tail, or partial‑read to understand code."
  rationale: "Partial view yields partial (wrong) changes."
  allowed_exceptions:
    - "grep/search across many unknown files to locate a keyword"
  forbidden:
    - "grep pattern file to understand code structure"
    - "head -N file to check structure"
    - "tail -N file to check endings"

before_edit:
  rule: READ_BEFORE_WRITE
  statement: "Read every file that could be affected before editing any file."
  steps:
    - "Map the codebase: find all .rb files in lib/"
    - "Trace callers before changing any public method signature"
    - "Check Zeitwerk inflectors before renaming classes or files"
    - "Run ruby -c FILE after every write"
    - "Run ruby -e require_relative after every commit"

code_principles:
  no_hardcoding:
    rule: NO_HARDCODED_CONSTANTS
    statement: "Prose, patterns, and config belong in data/*.yml, not Ruby strings."
  single_source:
    rule: ONE_SOURCE
    statement: "If it is in a data file, the code reads from there. No duplicates."
  no_magic_numbers:
    rule: NAMED_CONSTANTS
    statement: "Extract literals to named constants with .freeze"
  no_bare_rescue:
    rule: SPECIFIC_RESCUE
    statement: "Always rescue SpecificError => e. Propagate or log via event bus."
  guard_first:
    rule: GUARD_CLAUSES_FIRST
    statement: "Return Result.ok(ctx) unless condition before main logic."
  one_responsibility:
    rule: SINGLE_RESPONSIBILITY
    statement: "Split if you can name two reasons to change it."
  cqs:
    rule: COMMAND_QUERY_SEPARATION
    statement: "Queries return data and do not mutate. Commands mutate and do not return values."
  inject_deps:
    rule: DEPENDENCY_INJECTION
    statement: "Never instantiate collaborators inside a method."
  result_monad:
    rule: RESULT_MONAD
    statement: "Use respond_to?(:ok?) not is_a?(Result) for duck‑typing."

scan_rules:
  standard_depth:
    - frozen_string
    - bare_rescue
    - explicit
    - immutable
    - cqs
    - self_explaining
    - long_method
    - god_class
    - duplicate_code
    - prune
    - srp
    - pola
    - nielsen
  deep_only:
    - semantic
    - adversarial
  hunt_only:
    - rubocop
    - reek
  notes:
    nielsen: "puts is NOT debug output in a CLI. Only p, pp, binding.pry, debugger are."
    prune: "Loads patterns from data/rules.yml (voice.strunk) — single source of truth."
    semantic: "Loads philosophy from data/rules.yml (zen + voice) — single source of truth."
    deep_caution: "deep adds 2 LLM calls per file. With 90 files at 8 req/min free tier = 22+ minutes."

principle_groups:
  axioms:      [frozen_string, explicit, immutable, self_explaining]
  solid:       [srp, cqs, pola]
  clean_code:  [long_method, god_class, duplicate_code, bare_rescue]
  interface:   [nielsen, prune]
  llm_rules:   [semantic, adversarial]
  heavy:       [rubocop, reek]
  quick:       [frozen_string, bare_rescue, explicit, long_method, god_class]
  critical:    [frozen_string, bare_rescue, explicit, immutable, srp, cqs]

scan_profiles:
  critical:
    rules: critical
    description: "Critical issues blocking ship"
  solid:
    rules: solid
    description: "SOLID principles focus"
  axioms:
    rules: axioms
    description: "Constitutional axioms only"

conflicts:
  strategy: highest_priority_wins
  rules:
    - condition: "dry conflicts with wet or aha"
      resolution: "favor wet/aha if fewer than 3 duplications exist"
    - condition: "clarity conflicts with simplicity"
      resolution: "favor clarity"
    - condition: "fix introduces higher priority violation"
      resolution: "reject fix, report to autoloop"

universal_scope:
  policy: ALL_PRINCIPLES_ALL_FILES
  statement: >
    All axioms, principles, and philosophies apply to every file in the codebase
    regardless of file type: Ruby, YAML, Zsh, HTML, CSS, JavaScript, Markdown.
    Language-specific rules apply only to their target language; universal rules
    (SQUINT_TEST, TYPOGRAPHY_DISCIPLINE, MEANINGFUL_NAMES, etc.) apply everywhere.
  scan_glob: "**/*.{rb,rake,erb,html,htm,css,scss,js,ts,jsx,tsx,zsh,sh,yml,yaml,md}"
  semantic_rules: all_known_languages
  adversarial_rules: all_known_languages

autoloop:
  background: true
  idle_sleep: 60
  scan_depth: standard
  fix_depth: llm
  batch_size: 3
  max_cycles: 12
  rate_limit_sleep: 15
  max_file_bytes: 16000
  max_fix_retries: 3
  confidence_threshold: 0.60
  targets:
    - lib/
    - test/
    - data/
    - web/
    - DEPLOY/
  excludes:
    - vendor/
    - knowledge/
    - fix_
    - patch_
  skip_rules:
    - duplicate_code
    - semantic
    - adversarial
    - axiom_coverage
    - immutable
    - self_explaining
    - long_method
    - pola
    - srp
    - cqs
    - rubocop
    - reek

sweep:
  scan_depth: deep
  converge_threshold: 0.05
  converge_window: 2
  max_cycles: 16
  codebase_map: true

zeitwerk:
  inflections:
    autoloop: AutoLoop
    cli: CLI
    mcp_server: MCPServer
    mcp_coordinator: McpCoordinator
    diff_stager: DiffStager
    code_index: CodeIndex
    git_context: GitContext
    ast_edit: AstEdit
    llm: LLM

anti_sprawl:
  forbidden_files:
    - summary.md
    - analysis.md
    - report.md
    - todo.md
    - notes.md
    - changelog.md
  rule: "Edit existing files. Single source of truth."

validation:
  after_write: "ruby -c lib/master/FILE.rb"
  after_commit: "ruby -e \"require_relative 'lib/master'; puts 'ok'\""
  scan_file: "bundle exec ruby exe/master scan lib/master/FILE.rb"

phases:
  discover:
    id: 1
    goal: "Understand actual need"
    output: "Problem statement with success criteria"
    personas: [chaos, user]
    introspect: "What assumptions did we make?"
    gates:
      - no_vague_words
      - audience_identified
      - success_measurable
  analyze:
    id: 2
    goal: "Break into components"
    output: "Component diagram with dependencies"
    personas: [skeptic, maintainer]
    introspect: "What did we miss?"
    gates:
      - components_distinct
      - dependencies_acyclic
  ideate:
    id: 3
    goal: "Generate 15+ alternatives"
    output: "List of approaches with trade‑offs"
    personas: [minimalist, chaos]
    introspect: "Which ideas surprised us?"
    gates:
      - count_gte_15
      - trade_offs_documented
  design:
    id: 4
    goal: "Specific architecture"
    output: "Interface definitions and error handling"
    personas: [security, accessibility]
    introspect: "What could break?"
    gates:
      - interfaces_explicit
      - errors_documented
  implement:
    id: 5
    goal: "Execute with zero violations"
    output: "Working code at 100/100 score"
    personas: [performance, security]
    introspect: "Is this the simplest solution?"
    gates:
      - tests_pass
      - zero_violations
  validate:
    id: 6
    goal: "Prove with evidence"
    output: "Test results, benchmarks"
    personas: [skeptic, security]
    introspect: "What evidence proves it works?"
    gates:
      - zero_test_failures
      - edge_cases_covered
  deliver:
    id: 7
    goal: "Ship with monitoring"
    output: "Deployed code with dashboards"
    personas: [realist]
    introspect: "What would the user complain about?"
    gates:
      - deployed
      - monitoring_configured
  learn:
    id: 8
    goal: "Extract durable lessons"
    output: "Updated defaults, new memory entries, refined rules"
    personas: [skeptic, chaos]
    introspect: "What would we do differently next time?"
    gates:
      - lessons_captured
      - defaults_updated
session_startup:
  mandatory_reads:
    - data/soul.yml
    - data/rules.yml
    - data/ruby_style.yml
    - data/workflow.yml
    - data/standing_orders.yml
  check_standing_orders: "Verify FSM state before any mutation -- UNCHANGE blocks refactoring"
  scan_before_analysis: "Use /scan deep via MASTER, not external agents, for code analysis"
  ssh_edit_pattern: "Write to /tmp, run ruby /tmp/patch.rb -- never ruby -i with heredoc"

corruption_prevention:
  llm_error_in_file: "git checkout HEAD -- data/ && rcctl restart master -- LLM error strings silently overwrite YAML data files when circuit is open and agent#ask returns error string instead of raising"
  sweep_excludes_data: "Sweep must never rewrite data/*.yml -- these are structured config, not code to refactor"
  yaml_type_guards: "All load_yaml calls must type-check result before use (is_a?(Array/Hash)) -- circuit-open strings parse as valid YAML scalars"
  ask_raises_on_error: "agent#ask must raise StandardError when result.err? -- callers must rescue, never silently propagate error strings as LLM output"

# Phase-appropriate quality gates — prototype is permissive, production is strict.
# Source: master.json v225 reunification.
quality_phases:
  prototype:
    gates:           [functional]
    debt_allowed:    high
    speed:           maximize
  production:
    gates:           [functional, secure, maintainable, performant]
    debt_allowed:    none
    speed:           sustainable
  legacy:
    gates:           [functional, secure]
    changes:         surgical_only
    risk_tolerance:  minimal

# Conflicts already declared above; add reinforcements that multiply effectiveness.
# Source: master.json v225 reunification.
principle_reinforcements:
  dry_and_kiss:               multiply_effectiveness
  evidence_and_reversible:    enables_confident_change
  single_responsibility_and_dependency_injection: enables_isolated_testing

# Observability spec — structured logging, levels, exported metrics.
# Source: master.json v225 reunification.
observability:
  logging:
    format:    json
    fields:    [timestamp, level, phase, file, evidence_score, decision]
    levels:
      trace:  every_operation
      debug:  decision_points
      info:   phase_transitions
      warn:   evidence_below_threshold
      error:  gate_failures
  metrics:
    track:             [evidence_score, complexity, coverage, churn, council_consensus]
    export_format:     prometheus
    threshold_alerts:  true

# Adaptive file-processing strategy by size. Replaces hard MAX_FILE_BYTES cutoff.
# Source: master.json v225 reunification.
processing_strategies:
  small_file:
    condition: "size <= 10240"
    method:    full_read
    context:   entire_file
  medium_file:
    condition: "10240 < size <= 1048576"
    method:    streaming
    context:   line_by_line_with_lookahead
  large_file:
    condition: "size > 1048576"
    method:    chunked
    context:   section_by_section
    checkpoint: per_chunk

# Cap on tool calls per turn before MASTER pauses for self-reflection.
# Source: Augment reunification (#68, #69).
tool_budget:
  max_calls_per_turn:        40
  max_consecutive_edits:      8     # forces a read-back after N edits
  max_consecutive_searches:  10
  on_exceed: "publish budget:exceeded; force /reflect before next call"

# Principle pairs that pull in opposite directions; declare resolution.
# Source: master.json v225 reunification (#54).
antagonisms:
  minimalism_vs_explicit:        "explicit wins for safety-critical paths"
  speed_vs_evidence:             "evidence wins always"
  abstraction_vs_proximity:      "proximity wins below 3 occurrences"
  dry_vs_singularity:            "singularity wins for data, dry wins for code"
  completeness_vs_density:       "density wins; defer completeness to next iteration"

# Save LLM calls — only run round 2 if round 1 dissent exceeds threshold.
# Source: master.yml v31 reunification (#61).
two_stage_council:
  round_one:    "each persona votes ack/dissent independently, no debate"
  dissent_threshold: 0.30   # fraction of weighted disagreement triggering round 2
  round_two:    "only dissenting personas debate, others abstain"
  saves: "60-80% of LLM calls when consensus is clear"

# Long tasks surface a navigable thread of decisions.
# Source: Amp reunification (#70).
view_thread:
  emit_event: "thread:decision"
  fields:     [timestamp, phase, file, decision, alternatives_considered, rationale]
  persist_to: "data/threads/${session_id}.jsonl"
  rotate_after_days: 7

# Auto-compact context every N turns. Pairs with data/compression.yml.
# Source: Cline reunification (#77).
checkpoint_summarization:
  every_n_turns:           5
  every_n_tokens:    100_000
  keep_verbatim:           ["last_3_user_messages", "current_violation_set", "current_diff"]
  summarize:               ["all_other_turns"]
  target_compression:      0.30   # aim for 30% of original

# Spec arrives -> code streams in parallel where safe.
# Source: Bolt reunification (#78).
spec_streaming:
  triggers:    ["new_module_creation", "stub_to_implementation"]
  safe_for:    ["non-overlapping files", "additive_changes"]
  forbidden_for: ["mutating_shared_state", "schema_migrations"]

# UI changes must render before commit. Closes the visual-regression gap.
# Source: Lovable reunification (#79).
live_preview_gate:
  triggers:    ["edit under web/app/views/", "edit under web/app/assets/"]
  require:     "successful render at preview URL within 10s"
  on_failure:  "block commit; publish preview:render_failed"

# Feed MASTER its own commits and ask: would you approve these now?
# Source: cross-cutting reunification (#90).
reverse_introspection:
  cadence:        "after_every_10_commits"
... 31 lines truncated (431 total)

data/zsh.yml

banned_commands:
  sed: "${var//old/new}"
  awk: "${${(s:,:)line}[4]}"
  grep: "${(M)arr:#*pattern*}"
  wc: "${#lines}"
  head: "${arr[1,10]}"
  tail: "${arr[-10,-1]}"
  cut: "${${(s:,:)var}[3]}"
  find: "**/*.rb(.)"
  sudo: "doas"

token_economics:
  philosophy: "Single-grammar transforms reduce process boundaries and token overhead."
  bad: "awk -F, '{print $4}' | sed 's/\\r//g' | tr '[:upper:]' '[:lower:]'"
  good: "lower=${(L)${${(s:,:)line}[4]//$'\\r'/}}"

ssh_reading:
  rule: "cat path — read the whole file once, never stitch with grep|head|tail"

lib/master.rb

# frozen_string_literal: true

require "zeitwerk"
require "yaml"

# Pre-load openssl before pledge stage1 engages — faraday-net_http requires it
# lazily on first HTTPS call, which fails after unveil restricts dlopen paths.
begin
  require "openssl"
rescue LoadError => e
  warn "openssl: #{e.message} — LLM calls will fail"
end

module Master
  ROOT = File.expand_path("..", __dir__).freeze

  MIN_API_KEY_LENGTH = 20
  SEVERITY_RANK = { info: 0, warning: 1, error: 2, critical: 3 }.freeze
  CTX_WINDOW_SIZE = 200_000
  VIOLATION_TRUNCATE = 90

  FILE_LANGUAGE_MAP = {
    ".rb" => "ruby", ".yml" => "yaml", ".yaml" => "yaml",
    ".js" => "javascript", ".json" => "json", ".sh" => "bash",
    ".zsh" => "bash", ".md" => "markdown", ".html" => "html",
    ".erb" => "erb", ".css" => "css"
  }.freeze

  API_KEY_PROVIDERS = {
    anthropic_api_key:  "ANTHROPIC_API_KEY",
    openai_api_key:     "OPENAI_API_KEY",
    gemini_api_key:     "GEMINI_API_KEY",
    openrouter_api_key: "OPENROUTER_API_KEY",
    mistral_api_key:    "MISTRAL_API_KEY",
    deepseek_api_key:   "DEEPSEEK_API_KEY"
  }.freeze

  loader = Zeitwerk::Loader.for_gem
  loader.inflector.inflect(
    "autoloop"        => "AutoLoop",
    "cli"             => "CLI",
    "llm"             => "LLM",
    "llm_dispatcher"  => "LLMDispatcher"
  )
  loader.enable_reloading if defined?(MASTER_DEV_MODE) || ENV["MASTER_DEV"].to_s == "1"
  loader.ignore(File.join(__dir__, "master", "ruby_llm_patch.rb"))
  %w[
    autoloop/fix_evaluator.rb
    builder/infra_helpers.rb
    cli/signals.rb
    command_registry/agent_commands.rb
    command_registry/memory_commands.rb
    command_registry/service_commands.rb
    memory/search.rb
    sweep/rewriter.rb
    sweep/convergence.rb
  ].each do |rel|
    loader.ignore(File.join(__dir__, "master", rel))
  end
  loader.setup

  def self.configure_providers!
    # Stub Bedrock before ruby_llm loads — avoids openssl.so on OpenBSD/LibreSSL.
    # MASTER only uses OpenRouter; Bedrock is never needed.
    require_relative "master/bedrock_stub"
    require "ruby_llm"
    require_relative "master/ruby_llm_patch"
    RubyLLM.configure do |cfg|
      API_KEY_PROVIDERS.each do |attr, env_var|
        api_key = ENV[env_var].to_s
        cfg.public_send("#{attr}=", api_key) if api_key.length >= MIN_API_KEY_LENGTH
      end
    end
  end

  def self.api_key_present?(env_var)
    ENV[env_var].to_s.length >= MIN_API_KEY_LENGTH
  end

  def self.default_model
    return "nvidia/nemotron-3-super-120b-a12b:free" if api_key_present?("OPENROUTER_API_KEY")
    return "nvidia/nemotron-3-super-120b-a12b:free" if api_key_present?("REPLICATE_API_KEY")
    return "claude-sonnet-4-6" if api_key_present?("ANTHROPIC_API_KEY")
    return "gpt-4o" if api_key_present?("OPENAI_API_KEY")
    return "gemini-2.5-flash" if api_key_present?("GEMINI_API_KEY")
    return "mistral-large-latest" if api_key_present?("MISTRAL_API_KEY")
    return "deepseek-chat" if api_key_present?("DEEPSEEK_API_KEY")
    raise "No LLM API key found. Set OPENROUTER_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, or GEMINI_API_KEY, MISTRAL_API_KEY, or DEEPSEEK_API_KEY."
  end

  def self.load_yaml(path, symbolize_names: false)
    YAML.safe_load_file(path, aliases: true, symbolize_names: symbolize_names)
  rescue Psych::Exception, Errno::ENOENT, Errno::EACCES => e
    warn("load_yaml: " + e.message)
    {}
  end

  def self.build(root: Dir.pwd)
    Builder.build(root:)
  end

  def self.bootstrap_container(root: Dir.pwd)
    Telemetry.bootstrap!(root: root)
    container = Builder.build(root:)
    Builder.boot_snapshot(container)
    container[:heartbeat]&.start!
    container
  end

  def self.boot(root: Dir.pwd, argv: [])
    Pledge.stage1_boot!(root)
    container = bootstrap_container(root: root)
    Pledge.stage2_lock!
    CLI.new(container:)
  end
end

lib/master/agent.rb

# frozen_string_literal: true

require_relative "agent/llm_dispatcher"

module Master
  class Agent
    DEFAULT_MESSAGE_WINDOW_SIZE = 16

    Dependencies = Data.define(
      :config, :session, :tools, :circuit_breaker, :cache, :bus,
      :model_router, :reasoning_modes, :memory, :personality,
      :code_index, :context_window, :homeostat
    ) do
      def self.from_kwargs(config:, session:, tools:, circuit_breaker:, cache:,
                           event_bus: nil, model_router: nil, reasoning_modes: nil,
                           memory: nil, personality: nil, code_index: nil,
                           context_window: nil, homeostat: nil)
        new(
          config:, session:, tools:, circuit_breaker:, cache:, bus: event_bus,
          model_router:, reasoning_modes:, memory:, personality:,
          code_index:, context_window:, homeostat:
        )
      end
    end

    def initialize(deps:)
      @deps                              = deps
      @config, @session, @tools          = deps.config, deps.session, deps.tools
      @circuit_breaker, @cache, @bus     = deps.circuit_breaker, deps.cache, deps.bus
      @model_router, @reasoning_modes    = deps.model_router, deps.reasoning_modes
      @memory, @personality, @code_index = deps.memory, deps.personality, deps.code_index
      @context_window, @homeostat        = deps.context_window, deps.homeostat
      @dispatcher                        = LLMDispatcher.new(deps:, system_prompt: -> { system_prompt })
    end

    def chat(message, stream: true, escalation_depth: 0, &blk)
      prepare_chat_turn(message)
      candidate_models = routed_models
      prompt   = message
      context  = conversation_context
      @bus&.publish("llm:request", model: candidate_models.first, tokens: message.bytesize / Session::TOKENS_PER_CHAR)
      @deps.homeostat&.observe(:llm_call)

      rate_err = check_rate_limit
      return rate_err if rate_err

      response = attempt_chat_with_fallbacks(candidate_models:, prompt:, context:, stream:, &blk)
      if response.is_a?(Master::Result::Err)
        @deps.homeostat&.observe(:llm_failure)
        return response
      end
      @deps.homeostat&.observe(:llm_success)
      response = maybe_escalate(response, message, stream:, escalation_depth:, &blk)

      text = response.to_s
      @session.add_message(role: :assistant, content: text)
      Result.ok(text)
    rescue StandardError => chat_error
      Result.err("agent: #{chat_error.message}", category: :handler_exception)
    end

    def prepare_chat_turn(message)
      @context_window&.check_and_compact!
      @tools.each { |t| t.reset! if t.respond_to?(:reset!) }
      @session.add_message(role: :user, content: message)
    end

    def check_rate_limit
      @circuit_breaker.check_rate!
      nil
    rescue CircuitBreaker::CircuitError => err
      Result.err(err.message, category: err.category)
    end

    def ask(prompt, context: nil, operation: nil)
      messages = Array(context) + [{ role: "user", content: apply_reasoning_mode(prompt) }]
      selected_model = operation ? model_for(operation:) : routed_models.first
      result = @dispatcher.send_with_cache(selected_model, messages, stream: false)
      raise StandardError, result.message if result.is_a?(Master::Result::Err)
      result.to_s
    end

    def ask_once(prompt, system: nil, model: nil)
      messages = [{ role: "user", content: prompt.to_s }]
      result   = @dispatcher.send_with_cache(model || self.model, messages, system:, stream: false)
      raise StandardError, result.message if result.is_a?(Master::Result::Err)
      result.to_s
    end

    def call(ctx)
      on_chunk = ctx[:on_chunk]
      task_type = ctx[:task_type]&.to_s
      with_task_type(task_type) do
        on_chunk ? chat(ctx[:message].to_s, stream: true, &on_chunk) : chat(ctx[:message].to_s)
      end
    end

    def model = routed_models.first
    def model=(val)
      @config["model"] = val
    end

    def model_for(operation:)
      @model_router&.constrained_for(operation:) || model
    end

    def wire_context_window(ctx_window)
      @context_window = ctx_window
    end

    private

    def with_task_type(type)
      return yield unless type && !type.empty?
      old = @config["task_type"]
      @config["task_type"] = type
      yield
    ensure
      @config["task_type"] = old
    end

    def apply_reasoning_mode(message, mode: @config.reasoning_mode)
      return message unless @reasoning_modes
      @reasoning_modes.wrap(message, mode:)
    end

    def system_prompt
      parts = []
      parts << @personality.system_prompt if @personality
      parts << @code_index.summary if @code_index&.built?
      parts << @memory.context_summary if @memory&.context_summary
      parts.empty? ? nil : parts.join("\n\n")
    end

    def conversation_context(max_messages: DEFAULT_MESSAGE_WINDOW_SIZE)
      messages = @session.messages
      return [] unless messages.respond_to?(:each)
      messages.last(max_messages + 1)[0...-1] || []
    end

    def attempt_chat_with_fallbacks(candidate_models:, prompt:, context:, stream:, &blk)
      stage_warnings = []
      fallback_modes = mode_chain_for(candidate_models)
      last_response = nil

      fallback_modes.each do |attempt|
        selected_model = attempt.fetch(:model)
        mode = attempt.fetch(:mode)
        wrapped = apply_reasoning_mode(prompt, mode: mode)
        response = @dispatcher.send_with_cache(
          selected_model,
          context + [{ role: "user", content: wrapped }],
          stream:, &blk
        )
        if response.is_a?(Master::Result::Ok)
          publish_llm_success(selected_model, response)
          @bus&.publish("agent:stage_warnings", warnings: stage_warnings) unless stage_warnings.empty?
          return response
        end

        last_response = response
        stage_warnings << "llm failed in #{mode} on #{selected_model}: #{response.message}"
      end

      @bus&.publish("agent:all_fallbacks_exhausted", warnings: stage_warnings)
      last_response || Result.err("all LLM fallback modes exhausted", category: :llm_call_failure)
    end

    def mode_chain_for(candidates)
      models = candidates.dup
      selected = models.first || @config.model
      if @dispatcher.claude_cli_model?(selected) || @dispatcher.tool_capable?(selected)
        return [{ model: selected, mode: @config.reasoning_mode.to_s },
                { model: selected, mode: "code_agent" },
                { model: selected, mode: "react" }]
      end

      [{ model: selected, mode: "code_agent" },
       { model: selected, mode: "react" },
       { model: selected, mode: "direct" }]
    end

    def publish_llm_success(model, response)
      @bus&.publish("llm:response", model:, success: true, tokens_approx: response.to_s.bytesize / Session::TOKENS_PER_CHAR)
    end

    def maybe_escalate(last_response, original_message, stream:, escalation_depth:, &blk)
      return last_response unless @model_router
      return last_response if escalation_depth >= 2

      current = routed_models.first
      escalation_model = @model_router.escalate_if_low_confidence(
        last_response.to_s,
        current_model: current,
        task_type: @config.task_type.to_sym
      )
      return last_response unless escalation_model

      @bus&.publish("llm:escalation", from: current, to: escalation_model)
      escalated = chat(
        original_message, stream: stream,
        escalation_depth: escalation_depth + 1, &blk
      )
      escalated.is_a?(Master::Result::Err) ? last_response : escalated
    end

    def routed_models
      return [@config.model] unless @model_router
      @model_router.fallback_chain(task_type: @config.task_type.to_sym)
    rescue StandardError => e
      @bus&.publish("llm:route_error", error: e.message)
      [@config.model]
    end
  end
end

lib/master/agent/llm_dispatcher.rb

# frozen_string_literal: true

require "ruby_llm"
require "digest"
require "open3"

module Master
  class Agent
    class LLMDispatcher
      COST_PER_TOKEN        = 0.000_015
      CACHE_WINDOW          = 4
      NEMOTRON3_RE          = /nemotron-3/i.freeze
      LLAMA_NEMOTRON_RE     = /llama.*nemotron|nemotron.*llama/i.freeze

      LLM_TOOL_MAP = {
        Tools::ReadFile        => Tools::LLM::ReadFile,
        Tools::WriteFile       => Tools::LLM::WriteFile,
        Tools::StrReplace      => Tools::LLM::StrReplace,
        Tools::ListDir         => Tools::LLM::ListDir,
        Tools::SearchFiles     => Tools::LLM::SearchFiles,
        Tools::Shell           => Tools::LLM::Shell,
        Tools::WebSearch       => Tools::LLM::WebSearch,
        Tools::AskLlm          => Tools::LLM::AskLlm,
        Tools::GitContext      => Tools::LLM::GitContext,
        Tools::AstEdit         => Tools::LLM::AstEdit,
        Tools::SearchKnowledge => Tools::LLM::SearchKnowledge,
        Tools::FeedbackRecord  => Tools::LLM::FeedbackRecord,
        Tools::Postpro         => Tools::LLM::Postpro,
        Tools::Repligen        => Tools::LLM::Repligen
      }.freeze

      def self.build_tool_capable_re
        yml_path = File.join(Master::ROOT, "data", "models.yml")
        prefixes = Master.load_yaml(yml_path).fetch("tool_capable_prefixes", [])
        escaped  = prefixes.map { |p| Regexp.escape(p) }
        Regexp.new("\\A(?:#{escaped.join("|")})(?:[:\\/@\\-.].+)?\\z", Regexp::IGNORECASE).freeze
      end

      TOOL_CAPABLE_RE = build_tool_capable_re.freeze

      def initialize(deps:, system_prompt:)
        @config, @cache, @circuit_breaker = deps.config, deps.cache, deps.circuit_breaker
        @tools, @bus, @system_prompt_proc = deps.tools, deps.bus, system_prompt
        @model_router = deps.model_router
        @tool_registry = load_tool_registry
      end

      def send_with_cache(selected_model, messages, system: nil, stream: false, &blk)
        cache_key = cache_key_for(messages.last[:content], messages[0...-1])
        breaker_for(selected_model).call(estimate_cost(messages.last[:content])) {
          @cache.fetch(cache_key, selected_model) {
            send_llm_request(selected_model, messages, system: system, stream: stream, &blk)
          }
        }
      rescue CircuitBreaker::CircuitError => err
        Result.err(err.message, category: err.category)
      rescue StandardError => err
        Result.err("llm_request: #{err.message}", category: :llm_call_failure)
      end

      def claude_cli_model?(model_id) = model_id.to_s.start_with?("claude-cli:")
      def web_chat_model?(model_id)   = model_id.to_s.start_with?("web-chat:")
      def tool_capable?(model_id)     = TOOL_CAPABLE_RE.match?(model_id.to_s.downcase)

      private

      def system_prompt = @system_prompt_proc.call

      def send_llm_request(selected_model, messages, system: nil, stream: false, &blk)
        sys = system || system_prompt
        if claude_cli_model?(selected_model)
          return send_claude_cli(selected_model.delete_prefix("claude-cli:"), messages, sys: sys)
        end
        if web_chat_model?(selected_model)
          return send_web_chat(selected_model.delete_prefix("web-chat:"), messages, sys: sys)
        end
        send_ruby_llm(selected_model, messages, sys: sys, stream:, &blk)
      end

      def send_claude_cli(model_alias, messages, sys:)
        args = ["claude", "--print", "--model", model_alias]
        args += ["--system-prompt", sys] if sys && !sys.empty?
        out, err, status = Open3.capture3(*args, stdin_data: text_prompt_for(messages))
        return Result.err("claude-cli: #{err.strip}", category: :provider_error) unless status.success?
        Result.ok(out.strip)
      rescue StandardError => e
        Result.err("claude-cli: #{e.message}", category: :provider_error)
      end

      def send_web_chat(provider, messages, sys:)
        Result.ok(WebChat.call(provider: provider, prompt: text_prompt_for(messages), system: sys))
      rescue StandardError => e
        Result.err("web-chat: #{e.message}", category: :provider_error)
      end

      def text_prompt_for(messages)
        prompt  = messages.last[:content].to_s
        context = messages[0...-1].map { |m| "#{m[:role]}: #{m[:content]}" }.join("\n\n")
        context.empty? ? prompt : "#{context}\n\nuser: #{prompt}"
      end

      def send_ruby_llm(selected_model, messages, sys:, stream:, &blk)
        chat_session = RubyLLM.chat(model: selected_model)
        final_sys    = nemotron_system_prompt(selected_model, sys)
        chat_session.with_instructions(final_sys) if final_sys
        messages.each { |msg| chat_session.add_message(role: msg[:role].to_s, content: msg[:content].to_s) }

        available_tools = llm_tools(selected_model)
        chat_session.with_tools(*available_tools) unless available_tools.empty?

        reply = if stream && blk
          chat_session.ask(messages.last[:content]) { |chunk| blk.call(chunk.content.to_s) if chunk.content }
        else
          chat_session.ask(messages.last[:content])
        end
        Result.ok(extract_response(reply, selected_model))
      end

      def breaker_for(model_id)
        @circuit_breaker.respond_to?(:for) ? @circuit_breaker.for(model_id) : @circuit_breaker
      end

      def extract_response(reply, selected_model)
        return reply.to_s unless reply.respond_to?(:content)
        if NEMOTRON3_RE.match?(selected_model) && reply.respond_to?(:reasoning_content)
          thinking = reply.reasoning_content.to_s.strip
          content  = reply.content.to_s
          return thinking.empty? ? content : "#{content}\n\n<think>\n#{thinking}\n</think>"
        end
        reply.content.to_s
      end

      def nemotron_system_prompt(selected_model, base = nil)
        sys = base || system_prompt
        return sys unless LLAMA_NEMOTRON_RE.match?(selected_model)
        thinking_on = @config["reasoning_mode"] != "none"
        directive   = thinking_on ? "detailed thinking on" : "detailed thinking off"
        [directive, sys].compact.join("\n\n")
      end

      def cache_key_for(message, context)
        return Digest::SHA256.hexdigest(message) if context.empty?
        window = context.last(CACHE_WINDOW).map { |msg| "#{msg[:role]}:#{msg[:content]}" }.join("\n")
        Digest::SHA256.hexdigest("#{message}\n#{window}")
      end

      def estimate_cost(prompt)
        (prompt.bytesize / Session::TOKENS_PER_CHAR) * COST_PER_TOKEN
      end

      def llm_tools(selected_model)
        return [] unless tool_capable?(selected_model)
        return build_llm_tools(visitor: true) if Fiber[:master_visitor]
        @llm_tools ||= build_llm_tools
      end

      def build_llm_tools(visitor: false)
        tier = @model_router&.tier_for_model(@config.model).to_s
        @tools.filter_map do |tool|
          wrapper = LLM_TOOL_MAP[tool.class]
          next nil unless wrapper

          tool_name = tool.class.name.split("::").last
          meta = @tool_registry.fetch(tool_name, {})
          next nil if visitor && meta["visitor"] != true
          next nil if tier == "cheap" && meta["tier"] == "dangerous"

          wrapper.new(tool)
        end
      rescue StandardError => err
        @bus&.publish("agent:llm_tools_error", error: err.message)
        []
      end

      def load_tool_registry
        path = File.join(Master::ROOT, "data", "tools.yml")
        rows = Master.load_yaml(path)
        return {} unless rows.is_a?(Array)
        rows.each_with_object({}) { |row, out| out[row["name"].to_s] = row if row.is_a?(Hash) }
      end
    end
  end
end

lib/master/agent_pool.rb

# frozen_string_literal: true
require "thread"

module Master
  # Typed child agents — replaces ad-hoc Thread.new in autoloop.
  # Reads agent_types from data/agent_taxonomy.yml at boot.
  class AgentPool
    MAX_CONCURRENT = 4

    Worker = Struct.new(:type, :thread, :started_at, :tag, keyword_init: true)

    def initialize(governor:, event_bus: nil, taxonomy_path: File.join(Master::ROOT, "data", "agent_taxonomy.yml"))
      @governor   = governor
      @bus        = event_bus
      @taxonomy   = Master.load_yaml(taxonomy_path) || {}
      @max        = @taxonomy.dig("spawn_policy", "max_concurrent_children") || MAX_CONCURRENT
      @workers    = []
      @mutex      = Mutex.new
    end

    def spawn(type:, tag: nil, &block)
      @mutex.synchronize do
        reap_dead
        return Result.err("agent_pool: at capacity (#{@max})", category: :validation) if @workers.size >= @max
      end

      thread = Thread.new do
        @bus&.publish("agent:start", type:, tag:)
        block.call
      rescue StandardError => err
        @bus&.publish("agent:error", type:, tag:, error: err.message)
      ensure
        @bus&.publish("agent:end", type:, tag:)
      end

      @mutex.synchronize { @workers << Worker.new(type:, thread:, started_at: Time.now, tag:) }
      Result.ok(thread)
    end

    def join_all(timeout: nil)
      @workers.map { |w| w.thread.join(timeout) }
    end

    def active_count
      @mutex.synchronize { reap_dead; @workers.size }
    end

    private

    def reap_dead
      @workers.reject! { |w| !w.thread.alive? }
    end
  end
end

lib/master/audit_log.rb

# frozen_string_literal: true

require "fileutils"

module Master
  # Append-only tool invocation log; subscribes to tool:before on EventBus.
  class AuditLog
    LOG_PATH  = ".master/audit.log".freeze
    MAX_VAL   = 120
    MAX_BYTES = 5 * 1024 * 1024

    def initialize(root:, event_bus:)
      @path  = File.join(root, LOG_PATH)
      @mutex = Mutex.new
      FileUtils.mkdir_p(File.dirname(@path))
      event_bus.subscribe("tool:before") { |event_data| append(event_data) }
    end

    private

    def append(event_data)
      payload_pairs = event_data.except(:tool)
                                .map { |k, v| "#{k}=#{v.to_s[0, MAX_VAL].inspect}" }
                                .join(" ")
      log_line = "#{Time.now.utc.iso8601} tool=#{event_data[:tool]} #{payload_pairs}"
      Master::Telemetry.span("audit.append", tool: event_data[:tool].to_s) do
        @mutex.synchronize do
          rotate! if File.exist?(@path) && File.size(@path) > MAX_BYTES
          File.open(@path, "a") { |f| f.puts(log_line) }
        end
      end
    end

    def rotate!
      File.rename(@path, "#{@path}.1")
    rescue StandardError
      nil
    end
  end
end

lib/master/autoloop.rb

# frozen_string_literal: true

require "open3"
require_relative "git_operations"

require_relative "autoloop/fix_evaluator"

module Master
  class AutoLoop
    def self.load_cfg
      Master.load_yaml(File.join(Master::ROOT, "data", "workflow.yml"))
            .dig("autoloop") || {}
    rescue StandardError => _e
      {}
    end

    _cfg = load_cfg
    MAX_CYCLES           = _cfg.fetch("max_cycles",           12)
    BATCH_SIZE           = _cfg.fetch("batch_size",            3)
    RATE_LIMIT_SLEEP     = _cfg.fetch("rate_limit_sleep",     15)
    MAX_FIX_RETRIES      = _cfg.fetch("max_fix_retries",       3)
    CONFIDENCE_THRESHOLD = _cfg.fetch("confidence_threshold", 0.60)
    MAX_FILE_BYTES       = _cfg.fetch("max_file_bytes",   16_000)
    SKIP_RULES           = Array(_cfg.fetch("skip_rules", [])).freeze
    TARGETS              = Array(_cfg.fetch("targets", %w[lib/ test/ data/ web/ DEPLOY/])).freeze
    EXCLUDES             = Array(_cfg.fetch("excludes", %w[vendor/ knowledge/])).freeze

    SCORE_INCREMENT = 0.25
    MAX_SIZE_RATIO  = 2.0
    MIN_SIZE_RATIO  = 0.80

    SEVERITY_RANK = Master::SEVERITY_RANK
    MIN_SEVERITY  = SEVERITY_RANK[:warning]

    TRANSIENT_RE = /429|throttl|rate.?limit|high demand|provider.?error|overload|capacity|503/i.freeze

    def initialize(agent:, scanner:, root:, event_bus: nil, soul: nil, learnings: nil)
      @agent           = agent
      @scanner         = scanner
      @root            = root
      @bus             = event_bus
      @soul            = soul
      @learnings       = learnings
      @rule_recurrence = Hash.new(0) # rule_id => consecutive_cycle_count
      @git             = GitOperations.new(root)
    end

    def run(max_cycles: MAX_CYCLES)
      saved_model = @agent.model
      @agent.model = @agent.model_for(operation: :autoloop)
      consecutive_clean = 0
      max_cycles.times do |i|
        cycle = i + 1
        @bus&.publish("autoloop:cycle", cycle:)

        scan_paths  = TARGETS.map { |d| File.join(@root, d.delete_suffix("/")) }
                              .select { |d| File.directory?(d) }
        all_results = scan_paths.flat_map { |dir|
          scan_result = @scanner.scan_dir(dir, depth: :deep)
          scan_result.ok? ? scan_result.value! : []
        }

        violations = extract_violations(all_results)
        if violations.empty?
          consecutive_clean += 1
          return Result.ok("clean after #{cycle} cycle(s) (fixed-point: 2 silent runs)") if consecutive_clean >= 2
          @bus&.publish("autoloop:provisional_clean", cycle:, consecutive_clean:)
          next
        end
        consecutive_clean = 0

        yield cycle, violations if block_given?

        # Deduplicate by file — one fix per unique file to avoid write-race.
        by_file = violations.first(BATCH_SIZE * 2).uniq { |v| v[:file] }.first(BATCH_SIZE)

        mutex   = Mutex.new
        fixes   = {}
        stagger = RATE_LIMIT_SLEEP.to_f / BATCH_SIZE  # 5 s apart — stays within free-tier quota

        threads = by_file.each_with_index.map do |v, idx|
          Thread.new do
            sleep(stagger * idx) if idx.positive?
            fix = request_fix(v)
            mutex.synchronize { fixes[v[:file]] = [v, fix] } if fix
          rescue StandardError => e
            @bus&.publish("autoloop:thread_error", file: v[:file], error: e.message)
          end
        end
        threads.each(&:join)

        fixes.each_value { |v, fix| apply_fix(v[:file], fix) }

        if @git.dirty?("lib/")
          @git.add_lib_files
          @git.commit("autoloop: fix scan violations [cycle #{cycle}]")
          if @learnings
            fixes.each_value { |v, _|
 @learnings.record(trigger: v[:rule].to_s, strategy: "autoloop_fix", outcome: "commit") }
          end
        end
        track_recurrence(violations)
      end

      Result.ok("max cycles (#{MAX_CYCLES}) reached")
    rescue StandardError => e
      Result.err("autoloop: #{e.message}", category: :unknown)
    ensure
      @agent.model = saved_model if defined?(saved_model) && saved_model
    end

    include FixEvaluator
    private

    def apply_fix(rel_path, fixed_src)
      path = File.join(@root, rel_path)
      return unless File.exist?(path)
      original = File.read(path, encoding: "UTF-8")
      return if fixed_src.strip == original.strip
      temporary_path = "#{path}.tmp.#{Process.pid}"
      File.write(temporary_path, fixed_src)
      File.rename(temporary_path, path)
      @bus&.publish("autoloop:fix_applied", file: rel_path)
    rescue StandardError => e
      @bus&.publish("autoloop:write_error", file: rel_path, error: e.message)
    end

    def extract_violations(dir_results)
      dir_results.flat_map { |path, r|
        next [] unless r.ok?
        rel = path.delete_prefix("#{@root}/")
        next [] if EXCLUDES.any? { |ex| rel.start_with?(ex) }
        r.value!
          .select { |f| (SEVERITY_RANK[f[:severity]] || 0) >= MIN_SEVERITY }
          .reject { |f| SKIP_RULES.include?(f[:rule].to_s) }
          .map    { |f| f.merge(file: rel) }
      }.select { |f|
        full_path = File.join(@root, f[:file])
        File.exist?(full_path) && File.size(full_path) <= MAX_FILE_BYTES # GUARD_EXPENSIVE
      }.sort_by { |f| -SEVERITY_RANK.fetch(f[:severity], 0) }
    end

    def request_fix(violation)
      path = File.join(@root, violation[:file])
      return unless File.exist?(path)

      file_size = File.size(path)
      if file_size > MAX_FILE_BYTES
        @bus&.publish("autoloop:fix_skipped", file: violation[:file],
                      reason: "file too large (#{file_size} bytes)")
        return
      end

      src         = File.read(path, encoding: "UTF-8")
      base_prompt = build_fix_prompt(violation, src)
      result = Reflexion.run(agent: @agent, task: base_prompt, max: MAX_FIX_RETRIES) do |prompt, attempt|
        sleep RATE_LIMIT_SLEEP * attempt if attempt.positive?
        begin
          fix = extract_code(@agent.ask(prompt).to_s)
          next nil if fix.nil?
          next nil if confidence_score(fix, src) < CONFIDENCE_THRESHOLD
          fix
        rescue StandardError => e
          err = e.message.to_s
          if TRANSIENT_RE.match?(err) && attempt < MAX_FIX_RETRIES - 1
            @bus&.publish("autoloop:rate_limit", sleep: RATE_LIMIT_SLEEP * (attempt + 1), attempt: attempt + 1)
          else
            @bus&.publish("autoloop:fix_error", file: violation[:file], error: err[0, 120])
          end
          nil
        end
      end
      Result.wrap(result).value_or(nil)
    end
  end
end

lib/master/autoloop/fix_evaluator.rb

# frozen_string_literal: true

module Master
  class AutoLoop
    module FixEvaluator
      ERROR_TRUNCATE = 200
      private

      def build_fix_prompt(violation, src)
        "#{constitutional_preamble}\n\n" \
          "Fix this Ruby violation in #{violation[:file]}.\n" \
          "Rule: #{violation[:rule]}\n" \
          "Issue: #{violation[:message]} (line #{violation[:line]})\n\n" \
          "Return ONLY the corrected Ruby file content, no explanation.\n\n" \
          "```ruby\n#{src}\n```"
      end

      def constitutional_preamble
        @constitutional_preamble ||= begin
          soul  = Master.load_yaml(File.join(Master::ROOT, "data", "soul.yml"))
          rules = Master.load_yaml(File.join(Master::ROOT, "data", "rules.yml"))
          golden = soul.dig("absolute", "golden_rule") || "PRESERVE_THEN_IMPROVE_NEVER_BREAK"
          zen = rules.fetch("zen", {})
          lines = ["Constitutional constraints:", "- Golden rule: #{golden}"]
          zen.each_value { |v| lines << "- #{v}" } if zen.is_a?(Hash)
          lines.join("\n")
        rescue StandardError => _e
          "Golden rule: PRESERVE_THEN_IMPROVE_NEVER_BREAK"
        end
      end

      def reflected_prompt(base, last_error, attempt)
        "Prior attempt (#{attempt}) failed with: #{last_error[0, ERROR_TRUNCATE]}\n" \
          "Reflect briefly on what went wrong, then retry.\n\n" \
          "#{base}"
      end

      def extract_code(text)
        return text.match(/```ruby\n(.*?)```/m)[1].strip if text.match?(/```ruby\n(.*?)```/m)
        return text.match(/```\n(.*?)```/m)[1].strip if text.match?(/```\n(.*?)```/m)
        return text.strip if text.match?(/frozen_string_literal|module |class /)
        nil
      end

      def confidence_score(code, original_src)
        return 0.0 if code.nil? || code.strip.empty?
        score = 0.0
        score += SCORE_INCREMENT if code.include?("# frozen_string_literal: true")
        score += SCORE_INCREMENT if code.match?(/\A.*?(?:module |class )[A-Z]/m)
        ratio  = code.bytesize.to_f / [original_src.bytesize, 1].max
        score += SCORE_INCREMENT if ratio >= MIN_SIZE_RATIO && ratio <= MAX_SIZE_RATIO
        score += SCORE_INCREMENT if syntax_ok?(code)
        score
      end

      def syntax_ok?(content)
        require "tempfile"
        Tempfile.open(["al_chk", ".rb"]) do |f|
          f.binmode
          f.write(content.encode("UTF-8", invalid: :replace, undef: :replace))
          f.flush
          system("ruby", "-c", f.path, out: File::NULL, err: File::NULL)
        end
      rescue StandardError => _e
        false
      end

      def track_recurrence(violations)
        return unless @soul
        tally = violations.group_by { |v| v[:rule].to_s }.transform_values(&:size)
        tally.each do |rule_id, count|
          @rule_recurrence[rule_id] += 1
          next unless @rule_recurrence[rule_id] >= 3
          @rule_recurrence.delete(rule_id)
          sample = violations.select { |v| v[:rule].to_s == rule_id }.first(5)
          @bus&.publish("autoloop:soul_proposal", rule: rule_id, sample: sample)
          @bus&.publish("autoloop:soul_proposal", rule: rule_id, result: "queued")
        end
        (@rule_recurrence.keys - tally.keys).each { |k| @rule_recurrence.delete(k) }
      end
    end
  end
end

lib/master/axioms.rb

# frozen_string_literal: true

module Master
  # Loads and exposes rules, axioms, voice, and workflow from data/*.yml.
  class Axioms
    DATA_PATH     = File.join(File.expand_path("../../..", __dir__), "data", "rules.yml").freeze
    SOUL_PATH     = File.join(File.expand_path("../../..", __dir__), "data", "soul.yml").freeze
    WORKFLOW_PATH = File.join(File.expand_path("../../..", __dir__), "data", "workflow.yml").freeze

    def initialize(root: nil)
      @root          = root || Master::ROOT
      @data_dir      = File.join(@root, "data")
      @rules_path    = File.join(@data_dir, "rules.yml")
      @soul_path     = File.join(@data_dir, "soul.yml")
      @workflow_path = File.join(@data_dir, "workflow.yml")
      @data          = load_yaml(@rules_path)    || {}
      @soul_data     = load_yaml(@soul_path)     || {}
      @workflow      = load_yaml(@workflow_path) || {}
      @cache         = {}
    end

    # mtime-aware cache. Reloads automatically when data/<name>.yml changes on disk.
    def data(name)
      key  = name.to_sym
      path = File.join(@data_dir, "#{name}.yml")
      return @cache[key]&.first || {} unless File.exist?(path)

      mtime = File.mtime(path)
      cached = @cache[key]
      return cached.first if cached && cached.last >= mtime

      payload = Master.load_yaml(path) || {}
      @cache[key] = [payload, mtime]
      payload
    end

    def kernel
      @kernel ||= begin
        all_rules = (@data["rules"] || {}).values.flatten
        all_rules
          .select { |r| r["tier"] == "kernel" }
          .each_with_object({}) { |r, h| h[r["id"]] = r["name"] }
          .freeze
      end
    end

    def workflow = @workflow.freeze

    def philosophy(limit: nil)
      @philosophy ||= begin
        all_rules = (@data["rules"] || {}).values.flatten
        all_rules
          .reject { |r| r["tier"] == "kernel" }
          .map { |h| h.transform_keys(&:to_s) }
          .freeze
      end
      limit ? @philosophy.first(limit) : @philosophy
    end

    def all_rules     = @all_rules ||= (@data["rules"] || {}).values.flatten.freeze
    def rules_for_scope(scope) = (@data.dig("rules", scope.to_s) || []).freeze

    def kernel_block
      return if kernel.empty?

      pairs = kernel.map { |id, stmt| "  #{id}: #{stmt}" }.join("\n")
      "## Kernel Rules (enforced)\n#{pairs}"
    end

    def philosophy_block(limit: 5)
      items = philosophy(limit: limit)
      return if items.empty?

      top = items.map { |a| "  #{a["id"]}: #{a["name"]}" }.join("\n")
      "## Rules (top #{items.size})\n#{top}"
    end

    def voice    = @voice    ||= (@data["voice"] || {}).freeze
    def strunk   = @strunk   ||= (voice["strunk"] || {}).freeze
    def preserve = @preserve ||= (voice["preserve"] || {}).freeze

    def constitution
      @constitution ||= begin
        absolute = @soul_data["absolute"] || {}
        {
          "golden_rule"         => absolute["golden_rule"]      || @data["golden_rule"],
          "protection"          => absolute["protection_tiers"] || @data["protection"],
          "banned_output"       => voice["banned_output"],
          "anti_simulation"     => absolute["anti_simulation"]  || voice["anti_simulation"],
          "communication_style" => voice["style"]
        }.freeze
      end
    end

    def code_axioms      = @code_axioms      ||= (@soul_data.dig("absolute", "code_axioms") || {}).freeze
    def thresholds       = @thresholds       ||= (@data["thresholds"] || {}).freeze
    def scan_depths      = @scan_depths      ||= (@data["scan_depths"] || {}).freeze
    def languages_config = @languages_config ||= (@data["languages"] || {}).freeze
    def workflow_rule(key) = @workflow.dig(key.to_s) || {}

    def lookup(id)
      id_str = id.to_s
      kernel[id_str] || philosophy.find { |a| a["id"] == id_str }&.dig("name")
    end

    def valid_id?(id) = all_ids.include?(id.to_s)
    def all_ids       = @all_ids ||= all_rules.map { |r| r["id"] }.compact.to_set.freeze
    def empty?        = @data.empty?

    private

    def load_yaml(path)
      return unless File.exist?(path)

      Master.load_yaml(path)
    rescue StandardError => _e
      nil
    end
  end
end

lib/master/bedrock_stub.rb

# frozen_string_literal: true

# Pre-define Bedrock constants before ruby_llm loads.
# Zeitwerk skips autoloading already-defined constants, so bedrock/auth.rb
# (which requires openssl.so) is never touched.
# MASTER uses OpenRouter exclusively — Bedrock is never needed.
module RubyLLM
  module Providers
    class Bedrock
      module Auth
        def self.included(_base); end
      end

      def self.api_base = ""
      def self.headers(_cfg) = {}
      def self.models = []
      def self.slug = "bedrock"
    end
  end
end

# Zeitwerk expects this file to define Master::BedrockStub
module Master; module BedrockStub; end; end

lib/master/builder.rb

# frozen_string_literal: true

require_relative "builder/infra_helpers"

module Master
  module Builder
    RING_SIZE = 1000
    SNAPSHOT_MAX_BYTES = 50_000
    SNAPSHOT_DIRS = %w[exe lib/master data].freeze

    module_function

    def build(root: Dir.pwd)
      Master.configure_providers!
      infra = build_infrastructure(root)
      ai = build_ai_stack(root, infra)
      pipeline, gateway = build_pipeline_and_gateway(root, infra, ai)
      infra.merge(ai).merge(pipeline:, gateway:, root:)
    end

    def build_infrastructure(root)
      config = Config.new(root)
      config["model"] ||= Master.default_model

      bus = EventBus.new
      ring = RingBuffer.new(RING_SIZE)
      logging = Logging.new(ring_buffer: ring, event_bus: bus)
      homeostat = Homeostat.new(event_bus: bus)
      session = Session.new(root:, budget_max: config.budget_max, req_max: config.req_max)
      undo = Undo.new(session:, event_bus: bus, root:)
      breaker = CircuitBreakerRegistry.new(
        budget_max: config.budget_max, req_max: config.req_max, event_bus: bus
      )
      cache = SemanticCache.new(root:, ttl: config["cache_ttl"], event_bus: bus)
      governor = Governor.new(config:, event_bus: bus)
      renderer = Renderer.new(config:)
      metrics = Metrics.new(root:, event_bus: bus)
      AuditLog.new(root:, event_bus: bus)

      code_index = CodeIndex.new(root:, event_bus: bus)
      diff_stager = config["staging_enabled"] ? DiffStager.new(root:, event_bus: bus) : nil
      mcp = McpCoordinator.new(root:, event_bus: bus)
      mcp.connect_all
      code_index.build_async
      bus.subscribe("tool:after") { |ev| code_index.reindex(ev[:path]) if ev[:path] }

      memory = Memory.new(root:)
      personality = Personality.new(
        config["persona"]&.to_sym || Personality::DEFAULT, root:, homeostat:
      )
      learnings   = Learnings.new(root:)

      phase_gates = PhaseGates.new(root:, event_bus: bus)
      diag        = Diag.new(homeostat:, breaker:, logging:)
      trace       = Trace.new(root:, event_bus: bus)
      {
        config:, ring:, bus:, logging:, homeostat:, session:, undo:, breaker:, cache:,
        governor:, renderer:, metrics:, code_index:, diff_stager:, mcp:,
        memory:, personality:, phase_gates:, learnings:, diag:, trace:
      }
    end

    def build_ai_stack(root, infra)
      agent, soul_doc, scanner, swarm, deliberation, council_stage, ideation = build_agent_core(root, infra)
      autonomous = build_autonomous(root, infra, agent:, scanner:, soul: soul_doc)
      {
        agent:, soul: soul_doc, scanner:, swarm:, deliberation:, council_stage:, ideation:,
        guard: Security::InjectionGuard.new
      }.merge(autonomous)
    end

    def build_agent_core(root, infra)
      bus          = infra[:bus]
      agent, tools = build_agent_instance(root, infra)
      soul_doc     = Soul.new(root:, agent:)
      tools << Tools::AskLlm.new(agent:, governor: infra[:governor],
                                  circuit_breaker: infra[:breaker], cache: infra[:cache], event_bus: bus)
      ctx = ContextWindow.new(session: infra[:session], agent:, model_context: CTX_WINDOW_SIZE)
      ctx.check_and_compact!
      agent.wire_context_window(ctx)
      scanner               = build_scanner(root:, agent:, bus:)
      swarm                 = Swarm::Coordinator.new(agent:, event_bus: bus)
      deliberation, council, ideation = build_council(root, infra, agent:)
      [agent, soul_doc, scanner, swarm, deliberation, council, ideation]
    end

    def build_council(root, infra, agent:)
      personas     = Council::Personas.load(File.join(ROOT, "data", "council.yml"))
      axioms       = Axioms.new(root:)
      deliberation = Council::Deliberation.new(personas:, agent:, event_bus: infra[:bus], axioms:)
      ideation     = Council::Ideation.new(agent:, event_bus: infra[:bus])
      [deliberation, Stages::Council.new(deliberation:, config: infra[:config], event_bus: infra[:bus]), ideation]
    end

    def build_agent_instance(root, infra)
      tools = build_tools(root:, infra:) + infra[:mcp].tools
      deps  = Agent::Dependencies.from_kwargs(
        config: infra[:config], session: infra[:session], tools:,
        circuit_breaker: infra[:breaker], cache: infra[:cache], event_bus: infra[:bus],
        model_router: Routing::ModelRouter.new(config: infra[:config]),
        reasoning_modes: Reasoning::Modes.new,
        memory: infra[:memory], personality: infra[:personality],
        code_index: infra[:code_index], homeostat: infra[:homeostat]
      )
      [Agent.new(deps:), tools]
    end

    def build_autonomous(root, infra, agent:, scanner:, soul:)
      bus      = infra[:bus]
      standing = StandingOrders.new(pipeline: nil, event_bus: bus)
      learnings = infra[:learnings]
      autoloop = AutoLoop.new(agent:, scanner:, root:, event_bus: bus, soul:, learnings:)
      skills   = Skills.new(root:, event_bus: bus)
      skills.discover!
      heartbeat = Heartbeat.new(root:, agent:, scanner:, memory: infra[:memory], event_bus: bus,
homeostat: infra[:homeostat])
      triggers  = Triggers.new(event_bus: bus, scanner:, agent:)
      triggers.install_defaults!
      { standing:, learnings:, autoloop:, skills:, heartbeat:, triggers: }
    end

    def build_pipeline_and_gateway(root, infra, ai)
      config   = infra[:config]
      bus      = infra[:bus]
      commands = CommandRegistry.build(infra:, ai:, root:)
      stages   = build_stages(root:, infra:, ai:, commands:)
      pipeline = Pipeline.new(stages, bus:, trace: config["trace_pipeline"] == true, root:)
      ai[:standing].wire_pipeline(pipeline)
      gateway = Gateway.new(pipeline:, session: infra[:session], event_bus: bus)
      commands["gateway"] = ->(ctx) { gateway.channels }
      [pipeline, gateway]
    end

    def build_stages(root:, infra:, ai:, commands:)
      config = infra[:config]
      bus    = infra[:bus]
      [
        Stages::Intake.new,
        Stages::Infer.new,
        Stages::Route.new(commands:, agent: ai[:agent]),
        Stages::Guard.new(governor: infra[:governor], injection_guard: ai[:guard]),
        Stages::Deliberate.new(agent: ai[:agent], config:),
        Stages::Execute.new,
        Pipeline::SkipOnPressure.new(Pipeline::ParallelGroup.new(
          ai[:council_stage],
          Stages::Lint.new(scanner: ai[:scanner], config:, autoloop: ai[:autoloop], root:, event_bus: bus),
          bus:
        ), bus:),
        Pipeline::SkipOnPressure.new(Stages::Prune.new, bus:),
        Stages::Memo.new(memory: infra[:memory], event_bus: bus),
        Stages::Render.new(renderer: infra[:renderer])
      ]
    end

    def build_tools(root:, infra:)
      definitions = load_tool_definitions(root)
      definitions.filter_map do |defn|
        next unless defn["default"] == true
        build_tool_instance(defn["name"], root:, infra:)
      end
    end

    def load_tool_definitions(root)
      path = File.join(root, "data", "tools.yml")
      data = Master.load_yaml(path)
      return [] unless data.is_a?(Array)
      data
    end

    def build_tool_instance(name, root:, infra:)
      bus = infra[:bus]
      undo = infra[:undo]
      governor = infra[:governor]
      case name.to_s
      when "ReadFile" then Tools::ReadFile.new(root:, undo:, event_bus: bus)
      when "WriteFile" then Tools::WriteFile.new(root:, undo:, governor:, event_bus: bus, diff_stager: infra[:diff_stager])
      when "StrReplace" then Tools::StrReplace.new(root:, undo:, governor:, event_bus: bus, diff_stager: infra[:diff_stager])
      when "BatchReplace" then Tools::BatchReplace.new(root:, governor:, event_bus: bus)
      when "AstEdit" then Tools::AstEdit.new(root:, undo:, event_bus: bus)
      when "Tree" then Tools::Tree.new(root:, event_bus: bus)
      when "ListDir" then Tools::ListDir.new(root:, event_bus: bus)
      when "SearchFiles" then Tools::SearchFiles.new(root:, event_bus: bus)
      when "SearchKnowledge" then Tools::SearchKnowledge.new(root:, event_bus: bus)
      when "SymbolLookup" then Tools::SymbolLookup.new(code_index: infra[:code_index], event_bus: bus)
      when "Shell" then Tools::Shell.new(root:, governor:, event_bus: bus)
      when "GitContext" then Tools::GitContext.new(root:, event_bus: bus)
      when "WebFetch" then Tools::WebFetch.new(governor:, event_bus: bus)
      when "WebSearch" then Tools::WebSearch.new(governor:, event_bus: bus)
      when "Clean" then Tools::Clean.new(root:, governor:, event_bus: bus)
      when "Repligen" then Tools::Repligen.new(root:, governor:, event_bus: bus)
      when "Postpro" then Tools::Postpro.new(root:, governor:, event_bus: bus)
      when "FeedbackRecord" then Tools::FeedbackRecord.new(learnings: infra[:learnings])
      else
        bus&.publish("builder:tool_skipped", tool: name.to_s)
        nil
      end
    end

  end
end

lib/master/builder/infra_helpers.rb

# frozen_string_literal: true

module Master
  module Builder
    module_function

    def build_scanner(root:, agent:, bus:)
      scanner = Scan::Scanner.new(event_bus: bus)
      Scan::Rule.registry.select(&:auto_build?).each { |klass| scanner.add_rule(klass.new) }
      scanner.add_rule(Scan::Rules::AxiomCoverageRule.new(root:))
      scanner.add_rule(Scan::Rules::RubocopRule.new(root:))
      scanner.add_rule(Scan::Rules::ReekRule.new(root:))
      scanner.add_rule(Scan::Rules::InterconnectRule.new(root:))
      scanner.add_rule(Scan::Rules::SemanticRule.new(agent:))
      scanner.add_rule(Scan::Rules::AdversarialRule.new(agent:))
      scanner.add_rule(Scan::Rules::CommentDriftRule.new(agent:))
      scanner
    end

    def boot_snapshot(container)
      root  = container[:root]
      files = collect_snapshot_files(root)
      body  = render_snapshot_body(root, files)
      write_snapshot(root, files, body)
      container[:bus]&.publish("boot:snapshot", files: files.size)
    rescue StandardError => e
      container[:bus]&.publish("boot:snapshot_error", error: e.message)
    end

    def collect_snapshot_files(root)
      SNAPSHOT_DIRS.flat_map { |d| Dir.glob(File.join(root, d, "**", "*")) }
                   .select { |f| File.file?(f) && File.size(f) < SNAPSHOT_MAX_BYTES }
                   .reject { |f| f.include?("/knowledge/") || f.include?("/vendor/") }
                   .sort
    end

    def render_snapshot_body(root, files)
      files.flat_map do |f|
        rel  = f.sub("#{root}/", "")
        lang = FILE_LANGUAGE_MAP.fetch(File.extname(f).downcase, "text")
        src  = File.read(f, encoding: "UTF-8", invalid: :replace)
        ["## #{rel}", "```#{lang}", src.rstrip, "```", ""]
      rescue StandardError => _e
        []
      end
    end

    def write_snapshot(root, files, body)
      header  = ["# MASTER Snapshot", "Generated: #{Time.now.utc.iso8601}", "Files: #{files.size}", ""]
      content = (header + body).join("\n")
      out     = File.join(root, ".master", "snapshot.md")
      FileUtils.mkdir_p(File.dirname(out))
      File.write(out, content)
      File.write(File.join(root, "snapshot.md"), content)
    end
  end
end

lib/master/circuit_breaker.rb

# frozen_string_literal: true

require "monitor"

module Master
  class CircuitBreaker
    include MonitorMixin

    FAILURE_THRESHOLD = 8
    COOLDOWN_S        = 30
    RATE_WINDOW_S     = 60
    RATE_MAX          = 60

    class CircuitError < StandardError
      attr_reader :category
      def initialize(msg, category) = (super(msg); @category = category)
    end

    def initialize(budget_max:, req_max:, event_bus: nil, rate_window_s: RATE_WINDOW_S, rate_max: RATE_MAX)
      super()
      @budget_max    = budget_max
      @bus           = event_bus
      @failures      = 0
      @opened_at     = nil
      @state         = :closed
      @session_total = 0.0
      @req_times     = []
      @rate_window   = rate_window_s
      @rate_max      = rate_max
    end

    def check_rate!
      synchronize do
        now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        @req_times.reject! { |t| now - t > @rate_window }
        raise CircuitError.new("rate limit: #{@rate_max} req/min exceeded", :infrastructure) if @req_times.size >= @rate_max
        @req_times << now
      end
    end

    def call(cost_estimate, &blk)
      check_budget(cost_estimate)
      check_circuit
      result = execute_with_tracking(blk)
      record_cost(cost_estimate) if Result.wrap(result).ok?
      result
    rescue CircuitError => e
      # Budget/circuit-open errors are not backend failures — don't penalize.
      Result.err(e.message, category: e.category)
    end

    def record_cost(amount)  = synchronize { @session_total += amount }
    def session_total        = synchronize { @session_total }

    def state = synchronize { @state }

    private

    def execute_with_tracking(blk)
      result = blk.call
      on_success
      result
    rescue RubyLLM::RateLimitError => e
      # API rate limit is infrastructure noise — don't open the circuit.
      Result.err("rate_limit: #{e.message}", category: :infrastructure)
    rescue StandardError => e
      on_failure
      Result.err(e.message, category: :provider_error)
    end

    def check_budget(estimate)
      return unless @budget_max.positive? # Only check budget if it's a positive value.
      synchronize do
        raise CircuitError.new("budget: $#{(@session_total + estimate).round(4)} exceeds $#{@budget_max}",
:budget) if @session_total + estimate > @budget_max
      end
    end

    def check_circuit
      synchronize do
        return if @state == :closed
        if @state == :open
          elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @opened_at
          unless elapsed >= COOLDOWN_S
  raise CircuitError.new("circuit open: retry in #{(COOLDOWN_S - elapsed).ceil}s", :infrastructure)
end
            @state = :half_open



        end
      end
    end

    def on_success
      synchronize do
        @failures = 0
        if @state == :half_open
          @state = :closed
          @bus&.publish("circuit:closed", breaker: object_id)
        end
      end
    end

    def on_failure
      synchronize do
        @failures += 1
        return unless @failures >= FAILURE_THRESHOLD
        @state     = :open
        @opened_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        @bus&.publish("circuit:open", failures: @failures)
      end
    end
  end
end

lib/master/circuit_breaker_registry.rb

# frozen_string_literal: true

require "monitor"

module Master
  # Per-model circuit breakers so a flaky free-tier endpoint doesn't affect paid fallbacks.
  class CircuitBreakerRegistry
    include MonitorMixin

    def initialize(budget_max:, req_max:, event_bus: nil)
      super()
      @defaults = { budget_max: budget_max, req_max: req_max, event_bus: event_bus }.freeze
      @breakers = {}
      @global   = CircuitBreaker.new(**@defaults)
    end

    def for(model_id)
      synchronize do
        @breakers[model_id.to_s] ||= CircuitBreaker.new(
          **@defaults.merge(rate_window_s: CircuitBreaker::RATE_WINDOW_S, rate_max: CircuitBreaker::RATE_MAX)
        )
      end
    end

    def check_rate!
      synchronize do
        @breakers.values.each(&:check_rate!)
        @global.check_rate!
      end
    end

    def session_total
      synchronize { @breakers.values.sum(&:session_total) + @global.session_total }
    end

    def record_cost(amount)
      @global.record_cost(amount)
    end

    def call(cost_estimate, &blk)
      @global.call(cost_estimate, &blk)
    end

    def open_models
      synchronize { @breakers.filter_map { |id, breaker| id if breaker.state == :open } }
    end
  end
end

lib/master/cli.rb

# frozen_string_literal: true

require_relative "cli/signals"

require "open3"
require "tty-reader"
require "tty-prompt"
require "fileutils"

module Master
  class CLI
    IDLE_SLEEP_DEFAULT = 60

    SEVERITY_ICON = {
      error: "!!",
      warning: "!",
      style: ".",
      critical: "!!"
    }.freeze

    attr_reader :container

    def initialize(container:)
      @container = container
      assign_container_refs!(container)
      @reader          = TTY::Reader.new(track_history: true)
      @running         = false
      @interrupt_at    = Time.now
      @last_ok         = true
      @violations      = 0
      @bg_thread       = nil
      @seen_violations = {}
      @user_active     = false
    end

    def run(initial_message = nil)
      setup_signals
      @session.load! if @session.exists?
      start_background_loop
      first_boot_bar
      puts @renderer.splash(@agent.model)
      puts @renderer.session_line(@session.name) if @session.name
      print_repo_tree unless booted_before?
      process(initial_message) if initial_message
      @running = true
      repl_loop
    end

    def pipe(input)
      stripped = input.strip
      return if stripped.empty?

      run_input(stripped)
    end

    def run_input(input)
      return if input.strip.empty?

      @user_active = true
      state    = { streamed: false, thinking_shown: true }
      accumulated = +""
      on_chunk = stream_chunk_handler(accumulated, state)

      print_thinking_indicator
      result = @pipeline.call(Result.ok(user_message: input, on_chunk: on_chunk))
      display_result(result, accumulated, state[:streamed])
    ensure
      stop_thinking_indicator
      @user_active = false
    end

    def stream_chunk_handler(accumulated, state)
      chunk_accumulator(accumulated) do |text|
        if state[:thinking_shown] && $stdout.isatty
          stop_thinking_indicator
          print "\r\e[K"
          state[:thinking_shown] = false
        end
        unless state[:streamed]
          puts @renderer.speaker_tag
          puts
        end
        print text
        $stdout.flush
        state[:streamed] = true
      end
    end

    private

    def assign_container_refs!(deps)
      @session     = deps[:session]
      @agent       = deps[:agent]
      @renderer    = deps[:renderer]
      @logging     = deps[:logging]
      @undo        = deps[:undo]
      @config      = deps[:config]
      @pipeline    = deps[:pipeline]
      @scanner     = deps[:scanner]
      @autoloop    = deps[:autoloop]
      @root        = deps[:root] || Dir.pwd
      @diff_stager = deps[:diff_stager]
      @bus         = deps[:bus]
    end

    def repl_loop
      while @running
        status = @renderer.status_row(uptime: @renderer.uptime, turns: @session.messages.size, violations: @violations)
        puts status if status
        tokens = @session.token_est
        prompt_lines = @renderer.prompt_line(
          @agent.model, @session.phase,
          last_ok: @last_ok, violations: @violations, tokens: tokens, cost: @session.cost
        )
        puts prompt_lines.first
        print prompt_lines.last
        line = safe_read_line
        break if line.nil?
        handle_repl_line(line)
      end
      @bg_thread&.kill
      @session.save!
    end

    def handle_repl_line(line)
      stripped = line.strip
      return if stripped.empty?
      case stripped
      when "/exit" then exit_cli
      when "<<"    then run_input(read_multiline)
      else              run_input(line)
      end
    end

    def safe_read_line
      @reader.read_line("", echo: true).chomp
    rescue StandardError
      nil
    end

    def exit_cli
      @session.save!
      line = @renderer.closing
      puts line if line
      @running = false
    end

    def read_multiline
      lines = []
      puts @renderer.render("-- enter lines, blank line to send --", mode: :dim)
      loop do
        print "  "
        inner = safe_read_line
        break if inner.nil? || inner.strip.empty?
        lines << inner
      end
      lines.join("\n")
    end

    def start_background_loop
      cfg           = AutoLoop.load_cfg
      return unless cfg.fetch("background", true)
      idle_interval = cfg.fetch("idle_sleep", IDLE_SLEEP_DEFAULT)
      @bg_thread = Thread.new do
        boot_scan
        loop do
          sleep idle_interval
          background_cycle unless @user_active
        end
      rescue StandardError => e
        @bus&.publish("cli:bg_error", error: e.message)
      end
    end

    def boot_scan
      lib_dir = File.join(@root, "lib")
      changed = changed_lib_files(lib_dir)
      result  = changed.any? ? scan_files(changed) : @scanner.scan_dir(lib_dir, depth: :standard)
      return unless result.is_a?(Master::Result::Ok)

      @violations = count_violations(result.value!)
      return if @violations.zero?

      puts
      puts @renderer.render("boot scan: #{@violations} violation(s)", mode: :dim)
      puts
    rescue StandardError => e
      @bus&.publish("cli:warn", error: e.message)
    end

    def changed_lib_files(lib_dir)
      out, = Open3.capture2e("git", "-C", @root, "diff", "--name-only", "HEAD")
      return [] if out.strip.empty?
      out.lines
         .map { |l| File.join(@root, l.strip) }
         .select { |p| p.start_with?(lib_dir) && p.end_with?(".rb") && File.exist?(p) }
    rescue StandardError
      []
    end

    def scan_files(paths)
      Result.ok(paths.map { |p| [p, @scanner.scan(p, depth: :standard)] })
    end

    def count_violations(pairs)
      pairs.sum do |_file, file_result|
        file_result.is_a?(Master::Result::Ok) ? file_result.value!.size : 0
      end
    end

    def background_cycle
      return unless @autoloop

      @autoloop.run(max_cycles: 1) do |_cycle, violations|
        n = violations.size
        next if n.zero?
        @violations = n
        top = violations.first(3).map { |v| "#{File.basename(v[:file])}:#{v[:rule]}" }.join(" ")
        $stdout.puts "\nautoloop: #{n} violation(s) #{top}"
        $stdout.flush
      end
    rescue StandardError => e
      @bus&.publish("autoloop:bg_error", error: e.message)
    end

    def chunk_accumulator(buffer)
      lambda do |chunk|
        text = chunk.respond_to?(:content) ? chunk.content.to_s : chunk.to_s
        next if text.empty?

        yield text
        buffer << text
      end
    end

    SPIN_FRAMES = ["\u00B7", "\u2219", "\u2022", "\u25CF"].freeze
    SPIN_INTERVAL = 0.25

    def print_thinking_indicator
      return unless $stdout.isatty

      @spin_thread = Thread.new do
        i = 0
        loop do
          print "\r\e[K#{@renderer.render("#{SPIN_FRAMES[i % SPIN_FRAMES.size]} thinking", mode: :dim)}"
          $stdout.flush
          sleep SPIN_INTERVAL
          i += 1
        end
      rescue StandardError => _e
        nil
      end
    end

    def stop_thinking_indicator
      @spin_thread&.kill
      @spin_thread = nil
    end

    INIT_FRAMES = 20
    INIT_FRAME_MS = 0.04

    def print_repo_tree
      lines = Master::CommandRegistry.tree_lines(@root)
      return if lines.empty?
      puts @renderer.render("tree0: #{File.basename(@root)} (#{lines.size} entries)", mode: :dim)
      lines.each { |l| puts @renderer.render(l, mode: :dim) }
      puts
    rescue StandardError
      nil
    end

    def booted_before?
      flag = File.join(@root, ".master", "booted_once")
      File.exist?(flag)
    rescue StandardError
      false
    end

    def first_boot_bar
      return unless $stdout.isatty
      flag = File.join(@root, ".master", "booted_once")
      return if File.exist?(flag)
      INIT_FRAMES.times do |i|
        bar = ("\u25B0" * (i + 1)) + ("\u25B1" * (INIT_FRAMES - i - 1))
        pct = ((i + 1) * 100 / INIT_FRAMES).to_s.rjust(3)
        print "\rinit0: #{bar} #{pct}%"
        $stdout.flush
        sleep INIT_FRAME_MS
      end
      puts
      FileUtils.mkdir_p(File.dirname(flag))
      File.write(flag, Time.now.to_s)
    rescue StandardError
      nil
    end

    def display_result(result, accumulated, streamed)
      case result
      in Master::Result::Ok => ok
        @last_ok = true
        display_ok(ok, accumulated, streamed)
      in Master::Result::Err => err
        @last_ok = false
        if err.category == :shutdown
          exit_cli
        else
          puts
          error_text = format_error_message(err)
          puts @renderer.render(error_text, mode: :error)
          puts
        end
      end
    end

    def format_error_message(err)
      msg = err.message.to_s
      return msg if msg.bytesize <= 200

      msg[0, 197] + "…"
    end

    def display_ok(ok, _accumulated, streamed)
      if streamed
        puts
        puts
      else
        print "\r\e[K" if $stdout.isatty
        value    = ok.value
        rendered = value.is_a?(Hash) ? value[:rendered] : nil
        text     = rendered || value.to_s
        puts @renderer.speaker_tag
        puts text
        puts
      end
    end
  end
end

lib/master/cli/signals.rb

# frozen_string_literal: true

module Master
  class CLI
    private

    def setup_signals
      trap("USR1") { on_usr1 }
      trap("INT")  { on_int }
    end

    def on_usr1
      Zeitwerk::Loader.for_gem.reload
      puts "\n#{@renderer.render("reloaded", mode: :success)}"
    rescue StandardError => e
      puts "\n#{@renderer.render("reload failed: #{e.message}", mode: :error)}"
    end

    def on_int
      if Time.now - @interrupt_at < 1
        @scan_thread&.kill
        @session.save!
        exit(0)
      else
        @interrupt_at = Time.now
        puts "\n#{@renderer.render("^C again to quit", mode: :warning)}"
      end
    end
  end
end

lib/master/code_index.rb

# frozen_string_literal: true

require "prism"
require "set"
require "monitor"
require_relative "code_index/symbol_visitor"

module Master
  # Live Prism-parsed symbol graph; rebuilt on write events.
  class CodeIndex
    Symbol    = Struct.new(:fqn, :type, :file, :line, :parent, :includes, keyword_init: true)
    Reference = Struct.new(:from_file, :from_line, :to_fqn, :ref_type, keyword_init: true)

    SUMMARY_SKIP_NAMES = %w[Entry Message Symbol CircuitError].freeze

    attr_reader :symbols, :references, :built_at

    def initialize(root:, event_bus: nil)
      @root         = File.expand_path(root)
      @bus          = event_bus
      @symbols      = {}
      @references   = []
      @mtimes       = {}
      @built_at     = nil
      @lock         = Monitor.new
      @build_thread = nil
    end

    def build(path: nil)
      @lock.synchronize do
        target = path ? File.expand_path(path, @root) : @root
        files  = Dir.glob(File.join(target, "**", "*.rb")).reject { |f| f.include?("/vendor/") }
        @built_at.nil? ? first_build(files) : incremental_build(files)
        @built_at = Time.now
        @bus&.publish("code_index:built", files: files.size, symbols: @symbols.size)
      end
      self
    rescue StandardError => e
      @bus&.publish("code_index:error", error: e.message)
      self
    end

    def build_async
      @build_thread = Thread.new { build }
      self
    end

    def ready?         = !@built_at.nil?
    def wait_for_build = @build_thread&.join
    def built?         = !@built_at.nil?
    def size           = @lock.synchronize { @symbols.size }

    def reindex(file)
      @lock.synchronize do
        full = File.expand_path(file, @root)
        purge_file(full)
        index_file(full) if File.file?(full)
      end
    rescue StandardError => e
      @bus&.publish("code_index:reindex_error", path: file, error: e.message)
    end

    def symbols_in(file)
      with_built_index do
        full = File.expand_path(file, @root)
        @symbols.values.select { |s| s.file == full }
      end
    end

    def find(name)
      with_built_index { find_locked(name) }
    end

    def references_to(fqn)
      with_built_index { references_for(fqn) }
    end

    def impact(fqn)
      with_built_index do
        refs    = references_for(fqn)
        files   = refs.map(&:from_file).uniq.map { |f| relativize(f) }
        callers = refs.map { |r| "#{relativize(r.from_file)}:#{r.from_line}" }.uniq
        { fqn:, reference_count: refs.size, files:, callers: }
      end
    end

    def summary(limit: nil)
      with_built_index do
        classes   = summary_classes
        lib_count = @symbols.values.count { |s| s.file.include?("/lib/") }
        stamp     = @built_at&.strftime("%H:%M") || "never"
        [
          "# Codebase: #{lib_count} lib symbols (indexed #{stamp})",
          "## Classes & Modules (#{classes.size})",
          *classes
        ].join("\n")
      end
    end

    def query(name)
      with_built_index do
        hits = find_locked(name)
        next { error: "not found: #{name}" } if hits.empty?
        hits.map { |s| query_entry(s) }
      end
    end

    private

    def with_built_index(&blk)
      wait_for_build unless ready?
      @lock.synchronize(&blk)
    end

    def first_build(files)
      @symbols.clear
      @references.clear
      @mtimes.clear
      files.each do |f|
        index_file(f)
        @mtimes[f] = File.mtime(f) rescue Errno::ENOENT
      end
    end

    def incremental_build(files)
      (@mtimes.keys - files).each { |gone| purge_file(gone) }
      changed = files.count { |f| reindex_if_stale(f) }
      @bus&.publish("code_index:incremental", changed: changed, total: files.size) if changed > 0
    end

    def reindex_if_stale(file)
      mt = File.mtime(file) rescue Errno::ENOENT
      return false if @mtimes[file] == mt
      reindex(file)
      @mtimes[file] = mt
      true
    end

    def purge_file(full)
      @symbols.delete_if { |_, s| s.file == full }
      @references.reject! { |r| r.from_file == full }
      @mtimes.delete(full)
    end

    def references_for(fqn)
      tail = "##{fqn}"
      @references.select { |r| to = r.to_fqn; to == fqn || to.end_with?(tail) }
    end

    def relativize(file)
      file.sub("#{@root}/", "")
    end

    def find_locked(name)
      exact = @symbols[name]
      return [exact] if exact
      suffix = name.to_s
      @symbols.values.select { |sym| fqn = sym.fqn; fqn.end_with?(suffix) || fqn.include?(suffix) }
    end

    def summary_class?(sym)
      return false unless %i[class module].include?(sym.type)
      file = sym.file
      return false if file.include?("/DEPLOY/") || file.match?(/fix_|patch_/)
      fqn = sym.fqn
      SUMMARY_SKIP_NAMES.none? { |n| fqn.end_with?("::#{n}") }
    end

    def summary_classes
      @symbols.values
              .select { |sym| summary_class?(sym) }
              .sort_by(&:fqn)
              .map { |sym| format_summary_entry(sym) }
    end

    def format_summary_entry(sym)
      parent_name = sym.parent
      parent = parent_name && parent_name != "Object" ? " < #{parent_name}" : ""
      "  #{sym.fqn}#{parent} (#{relativize(sym.file)}:#{sym.line})"
    end

    def query_entry(sym)
      refs = references_for(sym.fqn)
      {
        fqn:     sym.fqn,
        type:    sym.type,
        file:    relativize(sym.file),
        line:    sym.line,
        parent:  sym.parent,
        used_in: refs.first(10).map { |r| "#{relativize(r.from_file)}:#{r.from_line}" }
      }
    end

    def index_file(file)
      src    = File.read(file, encoding: "UTF-8")
      result = Prism.parse(src)
      return unless result.success?

      visitor = SymbolVisitor.new(file:, root: @root)
      result.value.accept(visitor)
      visitor.symbols.each { |s| @symbols[s.fqn] = s }
      @references.concat(visitor.references)
    rescue StandardError => e
      @bus&.publish("code_index:parse_error", path: file, error: e.message)
    end
  end
end

lib/master/code_index/symbol_visitor.rb

# frozen_string_literal: true

module Master
  class CodeIndex
    class SymbolVisitor < Prism::Visitor
      attr_reader :symbols, :references

      def initialize(file:, root:)
        @file = file
        @root = root
        @symbols = []
        @references = []
        @scope = []
      end

      def visit_class_node(node)
        name = const_name(node.constant_path)
        parent = node.superclass ? const_name(node.superclass) : "Object"
        fqn = qualified(name)

        @symbols << Symbol.new(
          fqn:, type: :class, file: @file,
          line: node.location.start_line, parent:, includes: []
        )
        @scope.push(name)
        super
        @scope.pop
      end

      def visit_module_node(node)
        name = const_name(node.constant_path)
        fqn = qualified(name)

        @symbols << Symbol.new(
          fqn:, type: :module, file: @file,
          line: node.location.start_line, parent: nil, includes: []
        )
        @scope.push(name)
        super
        @scope.pop
      end

      def visit_def_node(node)
        meth = node.name.to_s
        owner = @scope.last || "(top)"
        fqn = "#{qualified(owner)}##{meth}"

        @symbols << Symbol.new(
          fqn:, type: :method, file: @file,
          line: node.location.start_line, parent: owner, includes: []
        )
        super
      end

      def visit_call_node(node)
        method_name = node.name.to_s
        return super unless method_name.match?(/\A[_a-z][a-z0-9_]*[!?]?\z/i) && method_name.length > 1

        receiver_fqn = node.receiver ? const_name_safe(node.receiver) : nil
        to_fqn = receiver_fqn ? "#{receiver_fqn}##{method_name}" : method_name

        @references << Reference.new(
          from_file: @file,
          from_line: node.location.start_line,
          to_fqn:,
          ref_type: :call
        )
        super
      end

      private

      def qualified(name)
        return name if @scope.empty? || name.include?("::")
        "#{@scope.join('::')}::#{name}"
      end

      def const_name(node)
        case node
        when Prism::ConstantReadNode
          node.name.to_s
        when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
          "#{const_name(node.parent)}::#{node.name}"
        else
          node.respond_to?(:name) ? node.name.to_s : ""
        end
      end

      def const_name_safe(node)
        name = const_name(node)
        name.empty? ? nil : name
      rescue StandardError => _e
        nil
      end
    end
  end
end

lib/master/command_registry.rb

# frozen_string_literal: true

require_relative "command_registry/agent_commands"
require_relative "command_registry/memory_commands"
require_relative "command_registry/service_commands"

module Master
  # CommandRegistry — all pipeline-routable commands in one place.
  module CommandRegistry
    module_function

    def build(infra:, ai:, root:)
      session_commands(infra).merge(
        mode_commands(infra[:config]),
        agent_commands(ai:, root:, infra:),
        memory_commands(infra[:memory], ai[:agent]),
        service_commands(ai, infra[:phase_gates], diag: infra[:diag]),
        utility_commands(ai[:agent], root, infra[:cache], infra[:code_index]),
        control_commands(ai[:standing], ai[:soul]),
"orient" => ->(_ctx) {
  require "stringio"
  buf = StringIO.new
  Orient.new(root: root, io: buf).call
  buf.string
},
"help" => ->(_ctx) {
          [
            "session: /save /clear /history [N] /tokens /undo /redo /exit",
            "scan: /scan [profile] [path] /sweep [path] /autoloop [N]",
            "model: /model [id|list] /mode /persona /task",
            "memory: /mem /topic /rsi",
            "system: /diag [/section] /tree [N] /orient /help"
          ].join("\n")
        }
      )
    end

    def session_commands(infra)
      session = infra[:session]
      undo = infra[:undo]
      logging = infra[:logging]
      config = infra[:config]
      {
        "clear"  => ->(_ctx) { session.clear!; "context cleared" },
        "save"   => ->(_ctx) { session.save!; "session saved" },
        "history" => ->(ctx) {
          n = ctx[:args].to_s.strip.to_i
          n = 10 if n <= 0
          recent = session.messages.last(n)
          next "history: empty" if recent.empty?
          recent.map { |m| "[#{m[:role]}] #{m[:content].to_s.gsub(/\s+/, ' ')[0, 120]}" }.join("\n")
        },
        "tokens" => ->(_ctx) { "~#{session.token_est} tokens" },
        "undo"   => ->(_ctx) { result = undo.undo!; result.ok? ? "reverted: #{result.value!}" : result.message },
        "redo"   => ->(_ctx) { result = undo.redo!; result.ok? ? "reapplied: #{result.value!}" : result.message },
        "dmesg"  => ->(_ctx) { logging.dmesg },
        "cost"   => ->(_ctx) { "$#{"%.4f" % session.cost}" },
        "config" => ->(_ctx) { config.data.inspect }
      }
    end

    def mode_commands(config)
      reasoning_commands(config).merge(persona_commands(config)).merge(flag_commands(config))
    end

    def reasoning_commands(config)
      {
        "mode" => ->(ctx) {
          arg = ctx[:args].to_s.strip
          Reasoning::Modes::SUPPORTED.include?(arg) ?
            (config["reasoning_mode"] = arg; config.save!; "mode: #{arg}") :
            "mode: #{config.reasoning_mode} (supported: #{Reasoning::Modes::SUPPORTED.join(", ")})"
        },
        "task" => ->(ctx) {
          arg = ctx[:args].to_s.strip
          arg.empty? ? "task_type: #{config.task_type}" : (config["task_type"] = arg; config.save!; "task_type: #{arg}")
        }
      }
    end

    def persona_commands(config)
      {
        "persona" => ->(ctx) {
          arg   = ctx[:args].to_s.strip.to_sym
          names = Personality.persona_names
          if names.include?(arg)
            config["persona"] = arg.to_s; config.save!; "persona: #{arg}"
          else
            "persona: #{config["persona"] || "malay"} -- available: #{names.join(", ")}"
          end
        },
      }
    end

    def flag_commands(config)
      {
        "autotest" => ->(ctx) {
          case ctx[:args].to_s.strip
          when "on"  then config["auto_testing"] = true;  config.save!; "autotest: on"
          when "off" then config["auto_testing"] = false; config.save!; "autotest: off"
          else "autotest: #{config.auto_testing? ? "on" : "off"}"
          end
        }
      }
    end

  end
end

lib/master/command_registry/agent_commands.rb

# frozen_string_literal: true

module Master
  module CommandRegistry
    module_function

    def agent_commands(ai:, root:, infra:)
      scan_loop_commands(ai:, root:, infra:)
        .merge(model_agent_commands(ai:, root:, infra:))
        .merge(crit_command(ai:, root:))
        .merge(ideate_command(ai:))
        .merge(topic_command(infra:))
        .merge(rsi_command(infra:))
        .merge(triad_command(ai:, root:, infra:))
        .merge(why_command(infra:))
    end

    def why_command(infra:)
      trace = infra[:trace]
      {
        "why" => ->(_ctx) { trace ? trace.pretty_last : "trace not configured" }
      }
    end

    def triad_command(ai:, root:, infra:)
      bus          = infra[:bus]
      cmds         = scan_loop_commands(ai:, root:, infra:)
      deliberation = ai[:deliberation]
      {
        "triad" => ->(ctx) {
          target = arg_for(ctx)
          target = "." if target.empty?
          parts = []
          bus&.publish("triad:start", target: target)
          parts << "scan:\n#{cmds['scan'].call(args: target)}"
          parts << "sweep:\n#{cmds['sweep'].call(args: target)}"
          parts << "tribunal:\n#{run_tribunal(deliberation, parts.join("\n\n"), target)}"
          bus&.publish("triad:done")
          parts.join("\n\n")
        }
      }
    end

    def run_tribunal(deliberation, artifact, target)
      return "tribunal: deliberation not configured" unless deliberation

      result = deliberation.review(artifact, context: target)
      result.ok? ? result.value! : result.message
    rescue StandardError => e
      "tribunal: #{e.message}"
    end

    def scan_loop_commands(ai:, root:, infra:)
      agent        = ai[:agent]
      scanner      = ai[:scanner]
      bus          = infra[:bus]
      deliberation = ai[:deliberation]
      autoloop     = ai[:autoloop]
      code_index   = infra[:code_index]
      {
        "autoloop" => cmd(:run_autoloop, autoloop),
        "sweep"    => ->(ctx) {
          target = expand_or_root(arg_for(ctx), root)
          run_sweep(agent, scanner, deliberation, root, bus, code_index, target)
        },
        "scan"     => cmd(:dispatch_scan, scanner, root)
      }
    end

    def run_autoloop(autoloop, raw)
      max = raw.to_i
      max = AutoLoop::MAX_CYCLES if max <= 0
      result = autoloop.run(max_cycles: max) { |cycle, violations|
        top = violations.first(3).map { |v| "#{File.basename(v[:file])}:#{v[:rule]}" }.join(" ")
        $stdout.puts "autoloop: cycle #{cycle} #{violations.size} violation(s) #{top}"
        $stdout.flush
      }
      result.ok? ? result.value! : result.message
    end

    def run_sweep(agent, scanner, deliberation, root, bus, code_index, target)
      sweeper = Sweep.new(
        agent:, scanner:, council: deliberation, root:,
        event_bus: bus, code_index: code_index
      )
      result = sweeper.run(target) { |cycle, file, delta|
        $stdout.puts "sweep: cycle #{cycle} #{file} +#{delta}"
        $stdout.flush
      }
      result.ok? ? result.value! : result.message
    end

    def dispatch_scan(scanner, root, arg)
      profile, depth, rule_filter = resolve_scan_profile(arg, root)
      pairs = collect_scan_pairs(scanner, root, arg, depth)
      return pairs if pairs.is_a?(String)
      format_scan_results(pairs, profile, rule_filter)
    end

    def collect_scan_pairs(scanner, root, arg, depth)
      raw_arg    = arg.sub(/\A(?:critical|solid|axioms)\s*/, "").strip
      target_arg = raw_arg.empty? ? nil : File.expand_path(raw_arg)
      if target_arg && File.file?(target_arg)
        [[target_arg, scanner.scan(target_arg, depth:)]]
      else
        dir = (target_arg && File.directory?(target_arg)) ? target_arg : File.join(root, "lib")
        result = scanner.scan_dir(dir, depth:, glob: "**/*", stream: true)
        result.ok? ? result.value! : "scan failed"
      end
    end

    def format_scan_results(pairs, profile, rule_filter)
      by_rule = Hash.new { |h, k| h[k] = [] }
      pairs.each do |_file, file_result|
        Result.wrap(file_result).value_or([]).each do |v|
          next if rule_filter && !rule_filter.include?(v[:rule].to_s)
          by_rule[v[:rule].to_s] << v
        end
      end
      total  = by_rule.values.sum(&:size)
      header = profile ? "[profile: #{profile}] " : ""
      return "#{header}clean -- no violations" if total.zero?
      lines = by_rule.sort_by { |_, vs| -vs.size }.flat_map do |rule, vs|
        ["[#{rule}] #{vs.size}"] + vs.first(3).map { |v| "  L#{v[:line]}: #{v[:message][0, VIOLATION_TRUNCATE]}" }
      end
      lines << "#{header}#{total} total violations"
      lines.join("\n")
    end

    def resolve_scan_profile(arg, root)
      groups, profiles = load_workflow_profiles(root)
      profile_name = %w[critical solid axioms].find { |p| arg.start_with?(p) }
      return [nil, :deep, nil] if profile_name.nil?

      cfg         = profiles[profile_name] || {}
      rule_ids    = groups[cfg["rules"].to_s]
      rule_filter = (rule_ids && cfg["rules"] != "*") ? rule_ids.map(&:to_s).to_set : nil
      [profile_name, :deep, rule_filter]
    end

    def load_workflow_profiles(root)
      data = Master.load_yaml(File.join(root, "data", "workflow.yml"))
      [data["principle_groups"] || {}, data["scan_profiles"] || {}]
    rescue StandardError
      [{}, {}]
    end

    def model_agent_commands(ai:, root:, infra:)
      council_meta_commands(ai:, root:).merge(model_commands(ai:, root:, infra:))
    end

    def council_meta_commands(ai:, root:)
      council_stage = ai[:council_stage]
      swarm         = ai[:swarm]
      {
        "council" => cmd(:dispatch_council, council_stage),
        "swarm"   => cmd(:dispatch_swarm, swarm),
        "explain" => ->(_ctx) { explain_master(root) }
      }
    end

    def dispatch_council(stage, arg)
      case arg
      when "on"  then stage.enable!;  "council: enabled"
      when "off" then stage.disable!; "council: disabled"
      else "council: #{stage.enabled? ? "on" : "off"}"
      end
    end

    def dispatch_swarm(swarm, arg)
      role, task = arg.split(" ", 2)
      task = task.to_s
      return "usage: /swarm <role> <task>  roles: #{swarm.worker_roles.join(", ")}" if role.nil? || task.empty?
      result = swarm.dispatch(role.to_sym, task:, context_slice: {})
      result.ok? ? result.value!.inspect : result.message
    end

    def explain_master(root)
      map    = Introspection::SelfMap.new(root:)
      info   = map.describe
      cov    = map.axiom_coverage.map { |ax, n| "  #{ax}: #{n}" }.join("\n")
      stages = "Intake->Infer->Route->Guard->Execute->Council->Lint->Prune->Memo->Render"
      "MASTER -- #{info[:files]} files, #{info[:lines]} lines\npipeline: #{stages}\n\naxiom coverage:\n#{cov}"
    end

    def model_commands(ai:, root:, infra:)
      agent   = ai[:agent]
      config  = infra[:config]
      metrics = infra[:metrics]
      {
        "model" => cmd(:dispatch_model, agent, config, metrics, root),
        "why"   => cmd(:dispatch_why, agent, root)
      }
    end

    def dispatch_model(agent, config, metrics, root, arg)
      return list_models(root, metrics, agent) if arg == "list"
      return "model: #{agent.model}" if arg.empty?
      agent.model = arg
      config.save!
      "model: #{arg}"
    end

    def dispatch_why(agent, root, rule)
      return "usage: /why <law|scan_rule|anti_pattern|style.key>" if rule.empty?
      local = WhyExplainer.new(root:).explain(rule)
      return local if local
      agent.ask_once("Explain the MASTER coding rule '#{rule}' in 2-3 sentences, " \
                     "give a before/after Ruby example, and state why it matters.")
    end

    def list_models(root, metrics, agent)
      yml_path = File.join(root, "data", "models.yml")
      return "model: #{agent.model}" unless File.exist?(yml_path)
      data  = Master.load_yaml(yml_path)
      tiers = data["models"] || {}
      model_lines   = tiers.flat_map { |tier, ms| ms.to_a.map { |mod| "  [#{tier}] #{mod["id"]}" } }
      quality_lines = metrics&.model_quality&.map { |mod, stat|
        "  #{mod}: #{stat[:calls]} calls, fail_rate=#{stat[:fail_rate]}"
      } || []
      sections = ["available models:"] + model_lines
      sections += ["", "quality (this session):"] + quality_lines unless quality_lines.empty?
      sections.join("\n")
    end

    def crit_command(ai:, root:)
      deliberation = ai[:deliberation]
      {
        "crit" => cmd(:dispatch_crit, deliberation, root)
      }
    end

    def dispatch_crit(deliberation, root, arg)
      return "usage: /crit <file|text>" if arg.empty?
      path    = File.expand_path(arg, root)
      payload = File.exist?(path) ? File.read(path, encoding: "UTF-8") : arg
      result  = deliberation.review(payload, context: "explicit /crit session")
      return result.message if result.err?
      format_crit_feedback(result.value!)
    end

    def format_crit_feedback(feedback)
      feedback.map { |f|
        veto = f[:veto_role] ? " [VETO ELIGIBLE]" : ""
        "#{f[:persona]} (#{f[:role]})#{veto}:\n#{f[:feedback].to_s.strip}"
      }.join("\n\n---\n\n")
    end

    def topic_command(infra:)
      session = infra[:session]
      {
        "topic" => cmd(:dispatch_topic, session)
      }
    end

    def dispatch_topic(session, arg)
      if arg.empty?
        current = session.respond_to?(:topic) ? session.topic : nil
        current ? "topic: #{current}" : "no topic set  /topic <description>"
      else
        session.topic = arg if session.respond_to?(:topic=)
        "topic: #{arg}"
      end
    end

    def ideate_command(ai:)
      ideation = ai[:ideation]
      {
        "ideate" => cmd(:dispatch_ideate, ideation)
      }
    end

    def dispatch_ideate(ideation, arg)
      return "usage: /ideate <prompt> [-- constraint1, constraint2]" if arg.empty?
      prompt, constraints_raw = arg.split(" -- ", 2)
      constraints = constraints_raw ? constraints_raw.split(",").map(&:strip).reject(&:empty?) : []
      result = ideation.ideate(prompt.strip, constraints:)
      return result.message if result.err?
      format_ideate(result.value!)
    end

    def format_ideate(v)
      lines = ["ideas (#{v[:ideas].size}):"]
      v[:ideas].each { |i| lines << "  - #{i}" }
      lines << ""
      v[:critiques].each_with_index { |c, n| lines << "critique #{n + 1}: #{c}" }
      lines << ""
      lines << "synthesis:"
      lines << v[:final]
      lines.join("\n")
    end

    def rsi_command(infra:)
      learnings = infra[:learnings]
      {
        "rsi" => cmd(:dispatch_rsi, learnings)
      }
    end

    def dispatch_rsi(learnings, arg)
      return "no learnings configured" unless learnings

      case arg
      when "stats"            then format_rsi_stats(learnings)
      when /\Astats (.+)\z/   then format_rsi_dimension(learnings, $1.strip)
      else                         format_rsi_opportunities(learnings)
      end
    end

    def format_rsi_stats(learnings)
      all = learnings.all
      return "no learnings recorded" if all.empty?
      lines = all.last(10).map { |e| "  #{e["trigger"][0, 40]}: #{e["outcome"]} (conf=#{e["confidence"]})" }
      "learnings (last 10):\n#{lines.join("\n")}"
    end

    def format_rsi_dimension(learnings, dim)
      stats = learnings.stats_by_dimension(dim)
      "#{dim}: success=#{stats[:success]} failure=#{stats[:failure]} fail_rate=#{stats[:fail_rate]}"
    end

    def format_rsi_opportunities(learnings)
      ops = learnings.opportunities
      return "rsi: no opportunities detected in last 7 days" if ops.empty?
      lines = ops.map { |o| format_rsi_opportunity(o) }
      "rsi opportunities (7d):\n#{lines.join("\n")}\n\nuse /rsi stats [dimension] for details"
    end

    def format_rsi_opportunity(o)
      case o[:category]
      when :high_failure        then "  HIGH_FAIL  #{o[:dimension]}: fail_rate=#{o[:fail_rate]} (#{o[:total]} calls)"
      when :repeated_correction then "  CORRECTION #{o[:dimension]}: #{o[:count]} corrections"
      when :provider_errors     then "  PROVIDER   #{o[:dimension]}: #{o[:count]} errors"
      end
    end

    def arg_for(ctx)
      ctx[:args].to_s.strip
    end

    def expand_or_root(arg, root)
      arg.empty? ? root : File.expand_path(arg, root)
    end

    # cmd — DSL helper. Wraps `->(ctx) { send(method, *services, arg_for(ctx)) }`.
    def cmd(method, *services)
      ->(ctx) { send(method, *services, arg_for(ctx)) }
    end
  end
end

lib/master/command_registry/memory_commands.rb

# frozen_string_literal: true

module Master
  module CommandRegistry
    module_function

    def memory_commands(memory, agent)
      {
        "memory" => ->(ctx) { dispatch_memory(memory, ctx[:args].to_s.strip) },
        "dreams" => ->(ctx) {
          arg = ctx[:args].to_s.strip
          if arg == "consolidate"
            memory.respond_to?(:consolidate!) ? memory.consolidate!(agent:) : "dreaming not available"
          else
            entries  = memory.all
            archived = entries.count { |k, _| k.to_s.start_with?("archive/") }
            active   = entries.count { |k, _| !k.to_s.start_with?("archive/") }
            summary  = memory.recall("_consolidated_summary")
            lines    = ["active: #{active} memories, archived: #{archived}"]
            lines << "last consolidation: #{summary}" if summary
            lines.join("\n")
          end
        }
      }
    end

    def dispatch_memory(memory, arg)
      case arg
      when /\Aforget (.+)/  then memory.forget($1.strip); "forgot: #{$1.strip}"
      when /\Aremember (.+)/
        body, type = parse_remember($1)
        key, value = body.split("=", 2).map(&:strip)
        value ? (memory.remember(key, value, type:); "remembered [#{type}]: #{key}") : "usage: /memory remember [type=user|feedback|project|reference] key=value"
      when /\Asearch (.+)/ then memory_search(memory, $1.strip)
      when /\Atype (\S+)/  then list_by_type(memory, $1.strip)
      when "types"         then memory.type_counts.map { |t, n| "#{t}: #{n}" }.join("\n").then { |s| s.empty? ? "(no memories)" : s }
      when ""
        (e = memory.all).empty? ? "(no memories)" : e.map { |k, v| "#{k}: #{v}" }.join("\n")
      else
        (r = memory.recall(arg)) ? "#{arg}: #{r}" : "(not found: #{arg})"
      end
    end

    def parse_remember(text)
      if text =~ /\Atype=(\S+)\s+(.+)/
        [$2, $1]
      else
        [text, "general"]
      end
    end

    def list_by_type(memory, type)
      hits = memory.by_type(type)
      hits.empty? ? "(no memories of type: #{type})" : hits.map { |k, v| "#{k}: #{v.is_a?(Hash) ? v["value"] : v}" }.join("\n")
    end

    def memory_search(memory, query)
      if memory.respond_to?(:semantic_recall)
        hits = memory.semantic_recall(query)
        return "(no matches: #{query})" if hits.empty?
        hits.map { |h| "#{h[:key]}: #{h[:value]}" }.join("\n")
      else
        hits = memory.all.select { |k, v| k.to_s.include?(query) || v.to_s.include?(query) }
        hits.empty? ? "(no matches: #{query})" : hits.map { |k, v| "#{k}: #{v}" }.join("\n")
      end
    end
  end
end

lib/master/command_registry/service_commands.rb

# frozen_string_literal: true

module Master
  module CommandRegistry
    BINARY_SNIFF_BYTES = 512

    module_function

    def control_commands(standing, soul)
      {
        "orders" => cmd(:dispatch_orders, standing),
        "soul"   => cmd(:dispatch_soul, soul)
      }
    end

    def service_commands(ai, phase_gates = nil, diag: nil)
      heartbeat = ai[:heartbeat]
      skills    = ai[:skills]
      scanner   = ai[:scanner]
      {
        "heartbeat" => cmd(:dispatch_heartbeat, heartbeat),
        "skills"    => cmd(:dispatch_skills, skills),
        "phase"     => cmd(:dispatch_phase, phase_gates),
        "score"     => cmd(:score_file, scanner),
        "diag"      => ->(ctx) { diag ? diag.render(arg_for(ctx)) : "diag: not configured" }
      }
    end

    def dispatch_skills(skills, arg)
      return skills&.list || "(no skills)" if arg.empty?
      found = skills&.find(arg)
      found ? "#{found[:name]}: #{found[:description]}" : "(not found: #{arg})"
    end

    def dispatch_phase(gates, arg)
      return "no phase_gates configured" unless gates
      case arg
      when "", "status"      then gates.status
      when "advance"         then advance_phase(gates)
      when /\Aforce (.+)\z/  then gates.force!($1.strip).value!
      when /\Ameet (.+)\z/   then gates.meet_gate!($1.strip); "gate met: #{$1.strip}"
      else "phase: #{gates.current}  /phase [status|advance|force <name>|meet <gate>]"
      end
    end

    def advance_phase(gates)
      result = gates.advance!
      result.ok? ? result.value! : result.message
    end

    def dispatch_orders(standing, arg)
      case arg
      when "list", ""                    then standing.list
      when /\Aenable (.+)\z/             then standing.enable($1.strip)
      when /\Adisable (.+)\z/            then standing.disable($1.strip)
      when /\Aadd name=(\S+) cmd=(.+)\z/ then standing.upsert(name: $1, command: $2.strip)
      when "run"                         then run_due_orders(standing)
      when /\Areset (.+)\z/              then standing.reset($1.strip)
      else "usage: /orders  /orders enable|disable|reset <name>  /orders run"
      end
    end

    def run_due_orders(standing)
      results = standing.run_due!
      return "no orders due" if results.empty?
      results.map { |r| "#{r[:name]}: #{r[:result].ok? ? "ok" : r[:result].message}" }.join("\n")
    end

    def dispatch_soul(soul, arg)
      case arg
      when "", "show"             then soul.summary
      when "version", "changelog" then soul.changelog
      when "diff"                 then soul.diff
      when "approve"              then soul.approve
      when "reject"               then soul.reject
      when "rollback"             then soul.rollback
      when /\Apropose (.+)\z/     then soul.propose($1.strip)
      else "soul  soul version  soul diff  soul approve  soul reject  soul rollback  soul propose <rationale>"
      end
    end

    TEXT_EXTS = %w[
      .rb .py .js .ts .zsh .sh .bash .md .yml .yaml .json
      .toml .gemspec .txt .erb .conf .ini .env
    ].to_set.freeze
    TEXT_NAMES = %w[Gemfile Rakefile Makefile Dockerfile].to_set.freeze
    DEFAULT_SKIP = %w[.git vendor tmp var node_modules .bundle coverage log dist knowledge].freeze

    def paths_config
      @paths_config ||= (Master.load_yaml(File.join(Master::ROOT, "data", "rules.yml")) || {})["paths"] || {}
    end

    def skip_segs
      @skip_segs ||= (paths_config["skip_dirs"] || DEFAULT_SKIP).to_set
    end

    SKIP_SEGS = DEFAULT_SKIP.to_set.freeze

    def tree_lines(root, max_depth: nil, max_lines: nil)
      cfg = paths_config["tree"] || {}
      depth = max_depth || cfg["max_depth"] || 2
      cap   = max_lines || cfg["max_lines"] || 200
      skip  = skip_segs
      buf   = []
      walker = lambda do |dir, level|
        return if level > depth || buf.size >= cap
        Dir.children(dir).sort.each do |name|
          break if buf.size >= cap
          next if name.start_with?(".") || skip.include?(name)
          path   = File.join(dir, name)
          indent = "  " * (level - 1)
          if File.directory?(path)
            buf << "#{indent}#{name}/"
            walker.call(path, level + 1)
          else
            buf << "#{indent}#{name}"
          end
        end
      rescue Errno::EACCES, Errno::ENOENT
        nil
      end
      walker.call(root, 1)
      buf
    end

    def dispatch_tree(root, arg)
      depth = arg.to_i.positive? ? arg.to_i : nil
      tree_lines(root, max_depth: depth).join("\n")
    end

    def dispatch_snapshot(root)
      [
        publish_snapshot(root, "MASTER"),
        publish_snapshot(File.expand_path("../DEPLOY", root), "DEPLOY")
      ].join("\n")
    end

    def publish_snapshot(target, label)
      return "snapshot:#{label.downcase}: not found: #{target}" unless File.directory?(target)
      dirs, files = collect_snapshot_files(target)
      stamp       = Time.now.utc.iso8601
      buf, stats  = render_snapshot_body(target, label, stamp, dirs, files)
      publish_snapshot_gist(label, buf, files.size, stats)
    end

    def collect_snapshot_files(root)
      skip_path = ->(rel) { rel.split("/").any? { |s| SKIP_SEGS.include?(s) } }
      text_file = ->(f)   { TEXT_EXTS.include?(File.extname(f).downcase) || TEXT_NAMES.include?(File.basename(f)) }
      all = Dir.glob(File.join(root, "**", "*"))
               .reject { |f| File.basename(f).start_with?(".") }
               .reject { |f| skip_path.(f.delete_prefix("#{root}/")) }
               .sort
      dirs  = all.select { |f| File.directory?(f) }
      files = all.select { |f| File.file?(f) && text_file.(f) && File.size(f) < CTX_WINDOW_SIZE }
      [dirs, files]
    end

    def render_snapshot_body(root, label, stamp, dirs, files)
      buf = ["# #{label} Snapshot — #{stamp}", "", "## Tree", "```"]
      dirs.each  { |d| buf << "#{d.delete_prefix("#{root}/")}/" }
      files.each { |f| buf << f.delete_prefix("#{root}/") }
      buf << "```" << ""
      n_lines, n_trunc = render_snapshot_files(buf, root, files)
      buf << "files: #{files.size} / lines: #{n_lines} / truncated: #{n_trunc}"
      [buf.join("\n"), { lines: n_lines, truncated: n_trunc }]
    end

    def render_snapshot_files(buf, root, files)
      max_lines = 400
      n_trunc   = 0
      n_lines   = 0
      files.each do |f|
        rel  = f.delete_prefix("#{root}/")
        lang = FILE_LANGUAGE_MAP.fetch(File.extname(f).downcase, "text")
        body = File.read(f, encoding: "UTF-8", invalid: :replace).lines
        n_lines += body.size
        buf << "## `#{rel}`" << "```#{lang}"
        if body.size > max_lines
          buf.concat(body.first(max_lines).map(&:rstrip))
          buf << "... #{body.size - max_lines} lines truncated (#{body.size} total)"
          n_trunc += 1
        else
          buf.concat(body.map(&:rstrip))
        end
        buf << "```" << ""
      rescue StandardError => e
        buf << "## `#{rel}`" << "[skipped: #{e.message}]" << ""
      end
      [n_lines, n_trunc]
    end

    def publish_snapshot_gist(label, body, file_count, stats)
      day = Time.now.strftime("%Y-%m-%d")
      out, status = Open3.capture2e(
        "gh", "gist", "create", "-",
        "--public", "--desc", "#{label} #{day}",
        "--filename", "snapshot_latest.md",
        stdin_data: body
      )
      return "snapshot:#{label.downcase}: gist publish failed: #{out.strip}" unless status.success?
      "snapshot:#{label.downcase}: #{file_count} files #{stats[:lines]} lines → #{out.strip}"
    end

    SCORE_WEIGHTS = { error: 10, critical: 10, warning: 3, style: 1 }.freeze

    def score_file(scanner, arg)
      return "usage: /score <file>" if arg.empty?
      path = File.expand_path(arg)
      return "not found: #{arg}" unless File.exist?(path)

      lines = File.read(path, encoding: "UTF-8").lines
      return "empty file" if lines.empty?

      stats      = score_line_stats(lines)
      violations = score_violations(scanner, path)
      penalty    = violations.sum { |v| SCORE_WEIGHTS[v[:severity]] || 1 }
      score      = [100 - penalty, 0].max

      format_score(path, lines.size, stats, violations, penalty, score)
    end

    def score_line_stats(lines)
      {
        blank:   lines.count { |l| l.strip.empty? },
        comment: lines.count { |l| l.strip.start_with?("#") },
        long:    lines.count { |l| l.chomp.length > 100 }
      }
    end

    def score_violations(scanner, path)
      result = scanner&.scan(path, depth: :standard)
      Result.wrap(result).value_or([])
    end

    def format_score(path, total, stats, violations, penalty, score)
      by_rule = violations.group_by { |v| v[:rule] }
                          .sort_by { |_, vs| -vs.size }
                          .map { |rule, vs| "  #{rule}: #{vs.size}" }
      out = [
        "score: #{score}/100  #{path.split("/").last}",
        "  #{total} lines  #{stats[:blank]} blank  #{stats[:comment]} comment  #{stats[:long]} over 100 chars",
        "  #{violations.size} violation(s)  -#{penalty} pts"
      ]
      out.concat(by_rule) unless by_rule.empty?
      out.join("\n")
    end

    def dispatch_heartbeat(heartbeat, arg)
      case arg
      when "run"   then run_heartbeat(heartbeat)
      when "start" then heartbeat&.start!; "heartbeat started"
      when "stop"  then heartbeat&.stop!;  "heartbeat stopped"
      else heartbeat&.list || "no heartbeat"
      end
    end

    def run_heartbeat(heartbeat)
      return "no heartbeat" unless heartbeat
      heartbeat.run_due!.map { |r| "#{r[:name]}: #{r[:result]}" }.join("\n")
    end

    def utility_commands(agent, root, cache, code_index = nil)
      {
        "snapshot"  => ->(_ctx) { dispatch_snapshot(root) },
        "repo_map"  => cmd(:dispatch_repo_map, code_index, root),
        "tree"      => cmd(:dispatch_tree, root),
        "cache"     => cmd(:dispatch_cache, cache),
        "diff"      => cmd(:dispatch_diff, root),
        "commit"    => ->(_ctx) { dispatch_commit(agent, root) },
        "knowledge" => cmd(:dispatch_knowledge, root)
      }
    end

    def dispatch_repo_map(code_index, root, arg)
      return "no code_index" unless code_index
      budget = arg.to_i.positive? ? arg.to_i : Master::RepoMap::DEFAULT_TOKEN_BUDGET
      Master::RepoMap.new(code_index:, root:, token_budget: budget).render
    end

    def dispatch_cache(cache, arg)
      if arg == "clear"
        cache.invalidate_all!
        return "cache cleared"
      end
      stats  = cache.stats
      suffix = arg == "stats" ? "" : "  (use /cache clear to purge)"
      "cache: #{stats[:entries]} entries, #{stats[:size_kb]} KB#{suffix}"
    end

    def dispatch_diff(root, arg)
      base = arg.empty? ? "HEAD" : arg
      out, = Open3.capture2e("git", "-C", root, "diff", base, "--stat")
      out.strip.empty? ? "(no changes since #{base})" : out.strip
    end

    def dispatch_commit(agent, root)
      diff, = Open3.capture2e("git", "-C", root, "diff", "--cached", "--stat")
      diff, = Open3.capture2e("git", "-C", root, "diff", "--stat") if diff.strip.empty?
      return "nothing to commit" if diff.strip.empty?
      prompt = "Write a concise git commit message (1 line, imperative mood) for these changes:\n#{diff}"
      msg = agent.ask_once(prompt).strip.lines.first.to_s.strip
      Open3.capture2e("git", "-C", root, "add", "-u")
      out, = Open3.capture2e("git", "-C", root, "commit", "-m", msg)
      out.strip
    end

    def dispatch_knowledge(root, arg)
      return "usage: /knowledge add <url>" unless arg.start_with?("add ")
      url = arg.sub("add ", "").strip
      return "usage: /knowledge add <url>" if url.empty?

      require "open-uri"
      parsed = URI(url) rescue nil
      return "knowledge: only http/https URLs allowed" unless parsed && %w[http https].include?(parsed.scheme)

      slug = url.gsub(/[^a-z0-9._-]/i, "_").downcase[0, 60]
      kdir = File.join(root, "knowledge", "web")
      FileUtils.mkdir_p(kdir)
      dest    = File.join(kdir, "#{slug}.txt")
      content = parsed.open(read_timeout: 15, &:read)
                      .encode("UTF-8", invalid: :replace, undef: :replace)
      File.write(dest, content, encoding: "UTF-8")
      "saved #{content.bytesize} bytes to knowledge/web/#{slug}.txt"
    end
  end
end

lib/master/config.rb

# frozen_string_literal: true

require "yaml"
require "fileutils"

module Master
  class Config
    BUDGET_MAX_DEFAULT = 10.0
    HISTORY_MAX = 500
    DEFAULT_WEB_PORT = 10_002

    DEFAULTS = {
      'model'          => 'nvidia/nemotron-3-super-120b-a12b:free',
      'web_host'       => '0.0.0.0',
      'web_public_url' => 'http://ai.brgen.no:3000',
      'web_port'       => DEFAULT_WEB_PORT,
      'budget_max'     => BUDGET_MAX_DEFAULT,
      'req_max'        => 1.0,
      'trace'          => 0,
      'prescan'        => true,
      'auto'           => false,
      'cache_ttl'      => 3_600,
      'history_max'    => 500,
      'reasoning_mode' => 'direct',
      'task_type'      => 'code_generation',
      'auto_testing'   => false
    }.freeze

    attr_reader :data

    def initialize(root = Dir.pwd)
      @root  = root
      @path  = File.join(root, '.master', 'config.yml')
      @mutex = Mutex.new
      @data  = load_config
    end

    def [](key)         = @data[key.to_s]
    def []=(key, value) ; @mutex.synchronize { @data[key.to_s] = value } ; end

    def model          = self['model']
    def budget_max     = self['budget_max'].to_f
    def req_max        = self['req_max'].to_f
    def trace          = (ENV['MASTER_TRACE'] || self['trace']).to_i
    def prescan?       = self['prescan'] == true
    def auto?          = self['auto'] == true
    def reasoning_mode = self['reasoning_mode'].to_s
    def task_type      = self['task_type'].to_s
    def auto_testing?  = self['auto_testing'] == true

    # Persist atomically; fsync ensures durability.
    def save!
      dir = File.dirname(@path)
      FileUtils.mkdir_p(dir)

      tmp = "#{@path}.tmp.#{Process.pid}"
      File.open(tmp, 'w') do |f|
        f.write(@data.to_yaml)
        f.flush
        f.fsync
      end
      File.rename(tmp, @path)
    ensure
      File.delete(tmp) if defined?(tmp) && File.exist?(tmp) rescue nil
    end

    def reload!
      @mutex.synchronize { @data = load_config }
    end

    # Export as plain hash (deep dup to avoid external mutation)
    def to_h = Marshal.load(Marshal.dump(@data))

    private

    def load_config
      return deep_dup(DEFAULTS) unless File.exist?(@path)

      raw    = Master.load_yaml(@path)
      loaded = raw.is_a?(Hash) ? raw : {}
      deep_merge(DEFAULTS, stringify_keys(loaded))
    rescue Psych::Exception => e
      warn "config: failed to parse #{@path}: #{e.message}"
      deep_dup(DEFAULTS)
    end

    def deep_merge(base, overlay)
      base.merge(overlay) do |_key, old_val, new_val|
        old_val.is_a?(Hash) && new_val.is_a?(Hash) ? deep_merge(old_val, new_val) : new_val
      end
    end

    def stringify_keys(hash)
      hash.each_with_object({}) do |(k, v), h|
        h[k.to_s] = v.is_a?(Hash) ? stringify_keys(v) : v
      end
    end

    def deep_dup(hash)
      Marshal.load(Marshal.dump(hash))
    end
  end
end

lib/master/context_window.rb

# frozen_string_literal: true

module Master
  class ContextWindow
    COMPACT_THRESHOLD = 0.80
    private_constant :COMPACT_THRESHOLD

    attr_reader :session, :agent, :model_context

    def initialize(session:, agent: nil, model_context: 200_000)
      @session = session
      @agent   = agent
      @model_context = model_context
    end

    def check_and_compact!
      return Result.ok(:ok) unless agent
      return Result.ok(:ok) unless safe_to_compact?

      compact!
    end

    private

    def safe_to_compact?
      est = session.token_est
      return false unless est.is_a?(Numeric)

      est >= model_context * COMPACT_THRESHOLD
    end

    def compact!
      summary = agent.ask(
        "Summarize our progress, preserving all file paths, decisions, and remaining tasks.",
        context: session.messages
      )
      session.clear!
      session.add_message(
        role: :assistant,
        content: "[Context compacted]\n\n#{summary}"
      )
      Result.ok(:compacted)
    rescue StandardError => e
      Result.err("context compaction failed: #{e.message}", category: :infrastructure)
    end
  end
end

lib/master/council/deliberation.rb

# frozen_string_literal: true

module Master
  module Council
    class Deliberation
      MAX_CONCURRENT  = 4
      MAX_CODE_BYTES  = 8_192
      TRUNCATE_MARKER = "\n... [truncated to #{MAX_CODE_BYTES} bytes for review]".freeze
      JUDGE_TIMEOUT   = 30

      COUNCIL_PATH = File.join(Master::ROOT, "data", "council.yml").freeze
      QUESTION_CATEGORY = {
        "Architect"  => "assumptions",
        "Skeptic"    => "failure_modes",
        "Security"   => "attacker",
        "User"       => "edge_cases",
        "Pragmatist" => "economics",
        "Mentor"     => "clarity"
      }.freeze
      @questions = nil

      def self.questions
        @questions ||= begin
          data = File.exist?(COUNCIL_PATH) ? (Master.load_yaml(COUNCIL_PATH) || {}) : {}
          data["questions"] || {}
        end
      rescue StandardError
        {}
      end

      def self.sample_question(persona_name)
        cat = QUESTION_CATEGORY[persona_name.to_s]
        bank = questions[cat]
        bank&.sample
      end

      def initialize(personas:, agent:, event_bus: nil, axioms: nil, judge_enabled: true)
        @personas      = personas
        @agent         = agent
        @bus           = event_bus
        @axioms        = axioms
        @judge_enabled = judge_enabled
        validate_dependencies!
      end

      def review(code, context: nil)
        return Master::Result.err("council: no personas configured", category: :validation) if @personas.empty?

        slots = Mutex.new
        available = MAX_CONCURRENT
        ready = ConditionVariable.new

        threads = @personas.map do |persona|
          Thread.new do
            slots.synchronize { ready.wait(slots) until available > 0; available -= 1 }
            begin
              response = @agent.ask(build_prompt(persona, code, context))
              entry = { persona: persona.name, role: persona.role,
                        veto_role: veto_role?(persona), axiom: primary_axiom(persona),
                        feedback: response }
              @bus&.publish(:council_feedback, entry)
              entry
            rescue StandardError => e
              @bus&.publish("council:persona_error", persona: persona.name, error: e.message)
              nil
            ensure
              slots.synchronize { available += 1; ready.broadcast }
            end
          end
        end
        feedback = threads.map { |thread| thread.join(30) ? thread.value : nil }.compact
        if feedback.empty?
          @bus&.publish(:council_timeout, personas: @personas.map(&:name))
          return Master::Result.err("council: all personas timed out (#{@personas.size})", category: :timeout)
        end

        vetoes = feedback.select { |f| f[:veto_role] && veto_text?(f[:feedback]) }
        unless vetoes.empty?
          veto = vetoes.first
          @bus&.publish(:council_veto, veto)
          return Master::Result.err("council: veto from #{veto[:persona]}\n#{veto[:feedback]}", category: :validation)
        end

        synthesis = @judge_enabled ? judge(feedback, code, context) : nil
        if synthesis
          @bus&.publish(:council_synthesis, synthesis: synthesis)
          feedback << { persona: "Judge", role: "Synthesis", veto_role: false,
                        axiom: nil, feedback: synthesis }
        end

        Master::Result.ok(feedback)
      rescue StandardError => e
        Master::Result.err("council: #{e.message}", category: :unknown)
      end

      private

      def judge(feedback, code, context)
        prompt = build_judge_prompt(feedback, code, context)
        @agent.ask(prompt)
      rescue StandardError => e
        @bus&.publish(:council_judge_error, error: e.message)
        nil
      end

      def build_judge_prompt(feedback, code, _context)
        rounds = feedback.map do |f|
          axiom_tag = f[:axiom] ? "[#{f[:axiom]}] " : ""
          "#{axiom_tag}#{f[:persona]} (#{f[:role]}): #{f[:feedback]}"
        end.join("\n\n")
        <<~PROMPT
          You are the Council judge. Each juror below speaks for a distinct constitutional
          axiom. Synthesise a single conclusion: extract the load-bearing critique,
          drop redundancy, surface unresolved disagreement explicitly, and end with one
          actionable recommendation.

          Jurors:
          #{rounds}

          Output: 3-6 lines, terse, no preamble.
        PROMPT
      end

      def primary_axiom(persona)
        ids = persona.respond_to?(:emphasizes) ? Array(persona.emphasizes) : []
        ids.first
      end

      def axiom_line(persona)
        id = primary_axiom(persona)
        return "" unless id && @axioms
        name = @axioms.lookup(id)
        name ? "You speak primarily for the #{id} axiom: #{name}." : ""
      end

      def validate_dependencies!
        raise ArgumentError, "personas must be an array" unless @personas.is_a?(Array)
        raise ArgumentError, "agent must respond to :ask" unless @agent.respond_to?(:ask)
      end

      def veto_role?(persona)
        if persona.respond_to?(:veto?)
          persona.veto?
        else
          persona.respond_to?(:veto_role) && !!persona.veto_role
        end
      end

      def build_prompt(persona, code, context)
        ctx = context ? "\nContext: #{context}\n" : ""
        veto_hint = veto_role?(persona) ? " You may prefix VETO: if this must not ship." : ""
        safe_code = truncate_code(code.to_s)
        axiom = axiom_line(persona)
        axiom_block = axiom.empty? ? "" : "#{axiom}\n"
        question = self.class.sample_question(persona.name)
        question_block = question ? "\nFocus question for this turn: #{question}\n" : ""
        <<~PROMPT
          You are #{persona.name} (#{persona.role}, bias: #{persona.bias}).#{ctx}
          #{axiom_block}#{persona.prompt}#{question_block}
          Code:
          #{safe_code}

          Provide terse, actionable feedback.#{veto_hint}
        PROMPT
      end

      def truncate_code(code)
        return code if code.bytesize <= MAX_CODE_BYTES
        @bus&.publish(:council_code_truncated, bytes: code.bytesize, limit: MAX_CODE_BYTES)
        code.byteslice(0, MAX_CODE_BYTES) + TRUNCATE_MARKER
      end

      VETO_RE = /\AVETO:/i.freeze

      def veto_text?(feedback)
        VETO_RE.match?(feedback.to_s.strip)
      end
    end
  end
end

lib/master/council/ideation.rb

# frozen_string_literal: true

module Master
  module Council
    class Ideation
      DEFAULT_CYCLES = 2

      def initialize(agent:, event_bus: nil)
        @agent = agent
        @bus   = event_bus
      end

      def ideate(prompt, constraints: [], cycles: DEFAULT_CYCLES)
        ideas     = []
        critiques = []

        cycles.times do |cycle|
          result = brainstorm(prompt, ideas, constraints)
          return result if result.err?
          ideas += result.value
          @bus&.publish("ideation:cycle", cycle: cycle + 1, ideas: ideas.size)

          result = critique(ideas)
          return result if result.err?
          critiques << result.value
        end

        result = synthesize(prompt:, ideas:, critiques:, constraints:)
        return result if result.err?

        Master::Result.ok(ideas: ideas, critiques: critiques, final: result.value)
      end

      private

      def brainstorm(prompt, prior, constraints)
        context           = prior.any? ? "Prior ideas (avoid repeating): #{prior.join('; ')}\n\n" : ""
        constraint_prefix = constraints.any? ? "Constraints: #{constraints.join(', ')}\n\n" : ""
        raw     = @agent.ask_once(<<~PROMPT, system: "Generate 3-5 novel, bold ideas. One idea per bullet (- prefix).")
          #{constraint_prefix}#{context}Generate ideas for: #{prompt}
        PROMPT
        return Master::Result.err("ideation: brainstorm failed") if raw.to_s.strip.empty?

        parsed = raw.scan(/^[-*]\s*(.+)/).flatten
        parsed = [raw.strip] if parsed.empty?
        Master::Result.ok(parsed)
      end

      def critique(ideas)
        list = ideas.map { |idea| "- #{idea}" }.join("\n")
        raw  = @agent.ask_once(<<~PROMPT, system: "Critique these ideas. Identify weaknesses, blind spots, risks. Be direct.")
          #{list}
        PROMPT
        return Master::Result.err("ideation: critique failed") if raw.to_s.strip.empty?

        Master::Result.ok(raw.strip)
      end

      def synthesize(prompt:, ideas:, critiques:, constraints:)
        constraint_prefix = constraints.any? ? "Constraints: #{constraints.join(', ')}\n\n" : ""
        list              = ideas.map { |idea| "- #{idea}" }.join("\n")
        crits = critiques.join("\n---\n")
        raw   = @agent.ask_once(<<~PROMPT, system: "Synthesize the best elements into a concrete, practical recommendation. Preserve innovation. Address valid critiques.")
          Goal: #{prompt}
          #{constraint_prefix}
          Ideas:
          #{list}

          Critiques:
          #{crits}
        PROMPT
        return Master::Result.err("ideation: synthesis failed") if raw.to_s.strip.empty?

        Master::Result.ok(raw.strip)
      end
    end
  end
end

lib/master/council/personas.rb

# frozen_string_literal: true

module Master
  module Council
    module Personas
      Persona = Data.define(:name, :role, :bias, :prompt, :veto_role,
                            :emphasizes, :weight, :aliases, :question) do
        def veto? = veto_role == true
      end

      PERSONA_DEFAULTS = {
        veto_role: false,
        emphasizes: [].freeze,
        weight: 0.05,
        aliases: [].freeze,
        question: nil
      }.freeze

      ROOT_DATA_PATH = File.join(File.expand_path("../../../..", __dir__), "data", "council.yml").freeze

      DEFAULTS = [
        Persona.new(name: "Architect",  role: "System design",    bias: "Structure",
                    prompt: "Review for architectural soundness, coupling, and interface design.",
                    **PERSONA_DEFAULTS),
        Persona.new(name: "Skeptic",    role: "Devil's advocate", bias: "Caution",
                    prompt: "Find what could go wrong. Challenge every assumption.",
                    **PERSONA_DEFAULTS),
        Persona.new(name: "Pragmatist", role: "Implementation",   bias: "Shipping",
                    prompt: "Is this shippable? Flag over-engineering.",
                    **PERSONA_DEFAULTS),
        Persona.new(name: "Security",   role: "Security review",  bias: "Safety",
                    prompt: "Find injection vectors, auth bypasses, path traversals. Prefix VETO: if must not ship.",
                    **PERSONA_DEFAULTS.merge(veto_role: true)),
        Persona.new(name: "User",       role: "UX advocate",      bias: "Usability",
                    prompt: "Does this serve the user? Are error messages actionable?",
                    **PERSONA_DEFAULTS),
        Persona.new(name: "Mentor",     role: "Code review",      bias: "Clarity",
                    prompt: "Is this code readable? Do names reveal intent?",
                    **PERSONA_DEFAULTS)
      ].freeze

      ALLOWED_KEYS = Persona.members.to_set.freeze

      @cache = {}

      def self.load(data_path = nil)
        path = data_path || ROOT_DATA_PATH
        return DEFAULTS unless File.exist?(path)

        @cache[path] ||= begin
          raw = Master.load_yaml(path, symbolize_names: true)
          rows = raw.is_a?(Array) ? raw : Array(raw[:personas] || raw["personas"])
          raise "Invalid persona data" unless rows.is_a?(Array) && rows.any?

          rows.filter_map { |attrs| build_persona(attrs) }.freeze
        rescue StandardError => _e
          DEFAULTS
        end
      end

      def self.build_persona(attrs)
        return unless attrs.is_a?(Hash) && attrs[:name]
        normalised = PERSONA_DEFAULTS.merge(attrs)
        normalised[:veto_role] = normalised.delete(:can_veto) if normalised.key?(:can_veto)
        normalised = normalised.slice(*ALLOWED_KEYS)
        Persona.new(**normalised)
      end
    end
  end
end

lib/master/decision_engine.rb

# frozen_string_literal: true

module Master
  # DecisionEngine — priority score (impact * confidence / cost).
  # Used by ModelRouter for tier selection.
  module DecisionEngine
    EPSILON = 1e-6

    module_function

    def score(impact:, confidence:, cost:)
      safe_cost = [cost.to_f, EPSILON].max
      (impact.to_f * confidence.to_f) / safe_cost
    end
  end
end

lib/master/diag.rb

# frozen_string_literal: true

module Master
  # /diag — composed snapshot of internal state. Replaces ad-hoc grep over logs.
  class Diag
    SECTIONS = %i[drives breaker rules ring].freeze
    RING_LINES = 8

    def initialize(homeostat:, breaker:, logging:, scan_registry: Master::Scan::Rule)
      @homeostat = homeostat
      @breaker   = breaker
      @logging   = logging
      @registry  = scan_registry
    end

    def render(section = nil)
      keys = section.to_s.empty? ? SECTIONS : [section.to_sym].select { |k| SECTIONS.include?(k) }
      return "diag: unknown section -- valid: #{SECTIONS.join(", ")}" if keys.empty?
      lines = ["diag: ok"]
      lines.concat(keys.flat_map { |k| send("section_#{k}") })
      lines.join("\n")
    end

    private

    def section_drives
      return ["drives: -- (no homeostat)"] unless @homeostat
      [@homeostat.summary]
    end

    def section_breaker
      return ["breaker: -- (no breaker)"] unless @breaker
      total = format("%.4f", @breaker.session_total)
      open  = @breaker.open_models
      ["breaker: session=$#{total} open=(#{open.empty? ? "" : open.join(",")})"]
    end

    def section_rules
      return ["rules: -- (no registry)"] unless @registry
      ["rules: #{@registry.registry.size} loaded"]
    end

    def section_ring
      return ["ring: -- (no logging)"] unless @logging
      tail = @logging.dmesg(RING_LINES).split("\n").last(RING_LINES)
      ["ring (last #{tail.size}):", *tail.map { |l| "  #{l}" }]
    end
  end
end

lib/master/diff_stager.rb

# frozen_string_literal: true

require "diffy"
require "fileutils"
require "json"

module Master
  # DiffStager — intercepts file writes and stores diffs for human review.
  # When staging_enabled? in config, tools push here instead of writing directly.
  # CLI commands: /stage (list), /apply [n|all], /discard [n|all]
  class DiffStager
    Entry = Struct.new(:id, :path, :old_content, :new_content, :tool, :created_at, keyword_init: true) do
      def diff
        Diffy::Diff.new(old_content.to_s, new_content.to_s, context: 3)
      end

      def diff_stats
        lines  = diff.to_s.lines
        added  = lines.count { |l| l.start_with?("+") && !l.start_with?("+++") }
        removed = lines.count { |l| l.start_with?("-") && !l.start_with?("---") }
        "+#{added}/-#{removed}"
      end
    end

    def initialize(root:, event_bus: nil)
      @root    = root
      @bus     = event_bus
      @mutex   = Mutex.new
      @pending = []
      @counter = 0
    end

    # Called by tools instead of writing directly. Returns a Result.
    def stage(path:, new_content:, tool: "unknown")
      old_content = File.exist?(path) ? File.read(path) : ""
      return Result.ok("no change") if old_content == new_content

      @mutex.synchronize do
      @counter += 1
      entry = Entry.new(
        id:          @counter,
        path:        path,
        old_content: old_content,
        new_content: new_content,
        tool:        tool,
        created_at:  Time.now
      )
      @pending << entry
      end
      persist_entry(entry)
      @bus&.publish("stage:queued", id: entry.id, path: entry.path, stats: entry.diff_stats)
      Result.ok({ staged: true, id: entry.id, path: entry.path, stats: entry.diff_stats })
    end

    def pending = @pending.dup
    def empty?  = @pending.empty?
    def size    = @pending.size

    # Apply one or all entries. Returns array of applied paths.
    def apply(id: :all)
      targets = @mutex.synchronize { id == :all ? @pending.dup : @pending.select { |e| e.id == id } }
      applied = []
      targets.each do |entry|
        FileUtils.mkdir_p(File.dirname(entry.path))
        tmp = "#{entry.path}.tmp.#{Process.pid}"
        File.write(tmp, entry.new_content)
        File.rename(tmp, entry.path)
        @mutex.synchronize { @pending.delete(entry) }
        remove_persisted(entry)
        @bus&.publish("stage:applied", id: entry.id, path: entry.path)
        applied << entry.path
      end
      applied
    end

    # Discard one or all without writing.
    def discard(id: :all)
      targets = @mutex.synchronize { id == :all ? @pending.dup : @pending.select { |e| e.id == id } }
      targets.each do |entry|
        @mutex.synchronize { @pending.delete(entry) }
        remove_persisted(entry)
        @bus&.publish("stage:discarded", id: entry.id, path: entry.path)
      end
      targets.map(&:path)
    end

    # Colored summary for CLI display
    def summary(pastel)
      return pastel.dim("  (no staged changes)") if @pending.empty?
      @pending.map do |e|
        short = e.path.sub(@root + "/", "")
        "  #{pastel.yellow("[#{e.id}]")} #{pastel.white(short)} #{pastel.dim(e.diff_stats)} #{pastel.dim("via #{e.tool}")}"
      end.join("\n")
    end

    # Colored unified diff for one entry
    def render_diff(id, pastel)
      entry = @pending.find { |e| e.id == id }
      return pastel.red("no staged change with id #{id}") unless entry

      short = entry.path.sub(@root + "/", "")
      header = "#{pastel.bold(short)} #{pastel.dim(entry.diff_stats)}\n"
      diff_lines = entry.diff.to_s.lines.map do |line|
        case line[0]
        when "+" then pastel.green(line.chomp)
        when "-" then pastel.red(line.chomp)
        when "@" then pastel.cyan(line.chomp)
        else          pastel.dim(line.chomp)
        end
      end
      header + diff_lines.join("\n")
    end

    private

    def stage_dir
      File.join(@root, ".master", "pending")
    end

    def persist_entry(entry)
      FileUtils.mkdir_p(stage_dir)
      File.write(
        File.join(stage_dir, "#{entry.id}.json"),
        JSON.generate({
          id: entry.id, path: entry.path, tool: entry.tool,
          created_at: entry.created_at.iso8601,
          stats: entry.diff_stats
        })
      )
    rescue StandardError => e
      @bus&.publish("diff_stager:persist_error", error: e.message)
    end

    def remove_persisted(entry)
      persist_file = File.join(stage_dir, "#{entry.id}.json")
      # Safe to delete: this persisted staging file is being removed after the entry
      # has been either applied (written to the actual file) or discarded (abandoned).
      File.delete(persist_file) if File.exist?(persist_file)
    rescue StandardError => e
      @bus&.publish("diff_stager:cleanup_error", error: e.message)
    end
  end
end

lib/master/embeddings.rb

# frozen_string_literal: true

require "json"
require "net/http"
require "uri"

module Master
  module Embeddings
    module_function

    DEFAULT_MODEL = "nomic-embed-text"
    HTTP_TIMEOUT  = 5
    MIN_SIM       = 0.30

    def enabled? = !ENV["OLLAMA_BASE_URL"].to_s.strip.empty?

    def embed(text)
      return unless enabled?
      return if text.to_s.strip.empty?
      ollama_embed(text.to_s)
    rescue StandardError
      nil
    end

    def cosine(a, b)
      return 0.0 unless a.is_a?(Array) && b.is_a?(Array) && a.size == b.size && !a.empty?
      dot, na, nb = 0.0, 0.0, 0.0
      a.each_with_index do |x, i|
        y = b[i]
        dot += x * y
        na  += x * x
        nb  += y * y
      end
      mag = Math.sqrt(na) * Math.sqrt(nb)
      mag.zero? ? 0.0 : dot / mag
    end

    def ollama_embed(text)
      uri  = URI.join(ENV["OLLAMA_BASE_URL"], "/api/embeddings")
      http = Net::HTTP.new(uri.host, uri.port)
      http.use_ssl     = uri.scheme == "https"
      http.read_timeout = HTTP_TIMEOUT
      http.open_timeout = HTTP_TIMEOUT
      req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json")
      req.body = JSON.generate(model: ENV.fetch("EMBEDDINGS_MODEL", DEFAULT_MODEL), prompt: text)
      res = http.request(req)
      return unless res.is_a?(Net::HTTPSuccess)
      vec = JSON.parse(res.body)["embedding"]
      vec.is_a?(Array) ? vec : nil
    end
  end
end

lib/master/event_bus.rb

# frozen_string_literal: true

require "monitor"

module Master
  class EventBus
    include MonitorMixin

    BOOT_TIME         = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
    PATTERN_CACHE_MAX = 512

    def initialize
      super()
      @subscribers   = Hash.new { |h, k| h[k] = [] }
      @pattern_cache = {}
    end

    def subscribe(pattern, &handler)
      synchronize { @subscribers[pattern] << handler }
      -> { synchronize { @subscribers[pattern].delete(handler) } }
    end

    def publish(event, payload = {})
      ts      = elapsed_ms
      payload = payload.merge(event:, ts:)
      handlers = synchronize { matching_handlers(event) }
      Master::Telemetry.span("event_bus.publish", event:, n_handlers: handlers.size) do
        handlers.each { |h| h.call(payload) rescue nil }
      end
      self
    end

    private

    def elapsed_ms
      Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - BOOT_TIME
    end

    def matching_handlers(event)
      @subscribers.flat_map { |pattern, handlers|
        handlers if glob_match?(pattern, event)
      }.compact
    end

    def glob_match?(pattern, event)
      @pattern_cache.shift if @pattern_cache.size >= PATTERN_CACHE_MAX
      re = @pattern_cache[pattern] ||= Regexp.new(
        "\\A" + Regexp.escape(pattern).gsub("\\*\\*", ".*").gsub("\\*", "[^:]*") + "\\z"
      )
      re.match?(event)
    end
  end
end

lib/master/gateway.rb

# frozen_string_literal: true

module Master
  class Gateway
    CHANNELS = %i[cli web irc matrix api].freeze

    # Contract for channel adapters.
    module Adapter
      def render(text, metadata = {})
        raise NotImplementedError, "#{self.class}#render not implemented"
      end
    end

    def initialize(pipeline:, session:, event_bus: nil)
      @pipeline = pipeline
      @session  = session
      @bus      = event_bus
      @adapters = {}
    end

    def register(channel, adapter_or_proc = nil, &block)
      handler = adapter_or_proc || block
      @adapters[channel.to_sym] = handler
    end

    def receive(channel:, message:, metadata: {})
      channel = channel.to_sym
      return Result.err("unknown channel: #{channel}", category: :validation) unless CHANNELS.include?(channel)

      msg     = message.to_s.strip
      turn_id = "#{Process.pid}-#{Time.now.to_i}-#{rand(36**4).to_s(36)}"
      @bus&.publish("gateway:turn_start", turn_id:, channel:, message: msg[0, 200])
      @bus&.publish("gateway:receive", channel: channel, size: msg.bytesize)

      ctx = { user_message: msg, channel: channel, metadata: metadata, turn_id: }
      result = @pipeline.call(Result.ok(ctx))

      if (adapter = @adapters[channel])
        text = result.ok? ? extract_text(result) : result.to_s
        adapter.respond_to?(:render) ? adapter.render(text, metadata) : adapter.call(text, metadata)
      end

      @bus&.publish("gateway:turn_done", turn_id:, ok: result.ok?, error: result.ok? ? nil : result.message&.to_s&.[](0, 120))
      result
    end

    def channels
      CHANNELS.map do |ch|
        status = @adapters.key?(ch) ? "active" : "available"
        "#{ch}: #{status}"
      end.join("
")
    end

    private

    def extract_text(result)
      output = result.value!
      output.is_a?(Hash) && output[:rendered] ? output[:rendered] : output.to_s
    rescue StandardError => e
      @bus&.publish("gateway:extract_error", error: e.message)
      result.to_s
    end
  end
end

lib/master/git_operations.rb

# frozen_string_literal: true

require "open3"

module Master
  # GitOperations — git wrappers scoped to a repository root.
  class GitOperations
    def initialize(root_path)
      @root_path = root_path
    end

    def dirty?(path = "lib/")
      out, = Open3.capture2e("git", "-C", @root_path, "status", "--porcelain", path)
      !out.strip.empty?
    end

    def add_lib_files
      Open3.capture2e("git", "-C", @root_path, "add", "-A", "lib/")
    end

    def commit(message)
      Open3.capture2e("git", "-C", @root_path, "commit", "-m", message.to_s)
    end
  end
end

lib/master/governor.rb

# frozen_string_literal: true

require "tty-prompt"

module Master
  class Governor
    RATE_WINDOW = 60.0
    TIERS = { safe: 0, guarded: 1, dangerous: 2 }.freeze

    # Sliding-window rate limits per tier (calls per minute).
    TIER_RATE_LIMITS = { guarded: 10, dangerous: 3 }.freeze

    def initialize(config:, event_bus: nil)
      @config        = config
      @bus           = event_bus
      @prompt        = $stdout.isatty ? TTY::Prompt.new : nil
      @auto          = config.auto?
      @approve_all   = false
      @rate_windows  = Hash.new { |h, k| h[k] = [] }
      @rate_mutex    = Mutex.new
    end

    def check_permit(tool_name, tier, description = nil)
      @bus&.publish("tool:before", tool: tool_name, tier:)

      if (rate_err = check_rate_limit!(tier))
        @bus&.publish("tool:rate_limited", tool: tool_name, tier:)
        return rate_err
      end

      case tier
      when :safe      then return Result.ok(true)
      when :guarded   then return Result.ok(true) if @auto || @approve_all
      when :dangerous then return Result.ok(true) if @auto || @approve_all
      end

      ask_user(tool_name, tier, description)
    rescue StandardError => e
      Result.err(e.message, category: :validation)
    end

    alias permit? check_permit

    def approve_all!   = @approve_all = true
    def reset_approve! = @approve_all = false

    private

    def check_rate_limit!(tier)
      limit = TIER_RATE_LIMITS[tier]
      return unless limit
      now = Time.now.to_f
      @rate_mutex.synchronize do
        calls = @rate_windows[tier]
        calls.reject! { |t| now - t > RATE_WINDOW }
        if calls.size >= limit
          return Result.err("rate limit: #{tier} tier (#{limit}/min)", category: :rate_limit)
        end
        calls << now
      end
      nil
    end

    def ask_user(tool_name, tier, description)
      return Result.err("non-TTY: cannot prompt for approval", category: :validation) unless @prompt

      label  = description ? "#{tool_name}: #{description}" : tool_name
      choice = @prompt.select("#{tier_icon(tier)} #{label}", [
        { name: "approve", value: :approve },
        { name: "deny",    value: :deny },
        { name: "quit",    value: :quit }
      ])

      case choice
      when :approve then Result.ok(true)
      when :deny    then @bus&.publish("tool:denied", tool: tool_name)
                         Result.err("denied by user", category: :validation)
      when :quit    then Result.err("quit", category: :shutdown)
      end
    end

    def tier_icon(tier)
      case tier
      when :safe      then "i"
      when :guarded   then "!"
      when :dangerous then "!!"
      end
    end
  end
end

lib/master/heartbeat.rb

# frozen_string_literal: true

require "yaml"

module Master
  class Heartbeat
    POLL_INTERVAL = 60
    JOURNAL_KEEP = 50
    DATA_PATH  = File.join(Master::ROOT, "data", "heartbeat.yml").freeze
    STATE_PATH = ".master/heartbeat_state.yml".freeze

    RESULT_TRUNCATE     = 200
    SECONDS_PER_HOUR    = 3600
    SECONDS_PER_2HOURS  = 7200

    JOB_HANDLERS = {
      "prune_memory" => :prune_memory,
      "check_models" => :check_model_availability,
      "self_test"    => :run_self_test,
      "prune_undo"   => :prune_undo_journal,
      "snapshot"     => :run_snapshot
    }.freeze

    def initialize(root:, agent: nil, scanner: nil, memory: nil, event_bus: nil, homeostat: nil)
      @root      = root
      @agent     = agent
      @scanner   = scanner
      @memory    = memory
      @bus       = event_bus
      @homeostat = homeostat
      @jobs      = load_jobs
      @state     = load_state
      @thread    = nil
      @stop      = false
    end

    def start!
      return if @jobs.empty?

      @stop   = false
      @thread = Thread.new do
        loop do
          break if @stop
          run_due!
          @homeostat&.observe(:idle_tick)
          sleep POLL_INTERVAL
        end
      rescue StandardError => e
        @bus&.publish("heartbeat:error", message: e.message)
      end
    end

    def stop!
      @stop = true
      @thread&.kill
      @thread = nil
    end

    def run_due!
      now = Time.now.to_i
      results = []

      @jobs.each do |job|
        name     = job["name"]
        interval = job["interval_seconds"].to_i
        last_run = @state.dig(name, "last_run").to_i

        next unless now - last_run >= interval

        @bus&.publish("heartbeat:run", job: name)
        result = execute_job(job)
        @state[name] = { "last_run" => now, "result" => result.to_s[0, RESULT_TRUNCATE] }
        results << { name: name, result: result }
      end

      persist_state unless results.empty?
      results
    end

    def list
      @jobs.map do |job|
        last = @state.dig(job["name"], "last_run").to_i
        ago  = last.zero? ? "never" : "#{(Time.now.to_i - last) / 60}m ago"
        "#{job["name"]}: every #{job["interval_seconds"] / 60}m, last: #{ago}"
      end.join("\n")
    end

    private

    def execute_job(job)
      method_name = JOB_HANDLERS[job["action"]]
      return "unknown action: #{job["action"]}" unless method_name

      Master::Telemetry.span("heartbeat.tick", job: job["name"].to_s) do
        send(method_name)
      end
    rescue StandardError => e
      "error: #{e.message}"
    end

    def prune_memory
      @memory&.consolidate!(agent: @agent) || "no memory"
    end

    def check_model_availability
      return "no agent" unless @agent
      id = @agent.model.to_s
      return "no active model" if id.empty?
      alive = model_reachable?(id)
      "model: #{id.split("/").last} #{alive ? "reachable" : "unreachable"}"
    end

    def model_reachable?(model_id)
      RubyLLM.chat(model: model_id).ask("ping")
      true
    rescue StandardError => _e
      false
    end

    def run_self_test
      return "no scanner" unless @scanner

      target = File.join(@root, "lib")
      result = @scanner.scan_dir(target, depth: :deep)
      return "scan failed" unless Result.wrap(result).ok?

      count = result.value!.sum { |_, fr| Result.wrap(fr).value_or([]).size }
      @bus&.publish("heartbeat:self_test", violations: count)
      "self-test: #{count} violations"
    end

    def prune_undo_journal
      journal_path = File.join(@root, ".master", "undo.jsonl")
      return "no journal" unless File.exist?(journal_path)

      lines = File.readlines(journal_path)
      return "journal empty" if lines.empty?

      keep = [lines.size / 2, JOURNAL_KEEP].max
      File.write(journal_path, lines.last(keep).join)
      "pruned undo: kept #{keep}/#{lines.size} entries"
    end

    def run_snapshot
      container = { root: @root, bus: @bus }
      Builder.boot_snapshot(container)
      "snapshot: generated"
    end

    def load_jobs
      path = File.join(@root, "data", "heartbeat.yml")
      return default_jobs unless File.exist?(path)

      result = Master.load_yaml(path); result.is_a?(Array) ? result : default_jobs
    rescue StandardError => _e
      default_jobs
    end

    def default_jobs
      [
        { "name" => "prune_memory", "action" => "prune_memory", "interval_seconds" => SECONDS_PER_HOUR },
        { "name" => "self_test", "action" => "self_test", "interval_seconds" => SECONDS_PER_2HOURS },
        { "name" => "prune_undo", "action" => "prune_undo", "interval_seconds" => 86_400 },
        { "name" => "snapshot", "action" => "snapshot", "interval_seconds" => 14_400 }
      ]
    end

    def load_state
      path = File.join(@root, STATE_PATH)
      return {} unless File.exist?(path)

      Master.load_yaml(path) || {}
    rescue StandardError => _e
      {}
    end

    def persist_state
      path = File.join(@root, STATE_PATH)
      FileUtils.mkdir_p(File.dirname(path))
      tmp = "#{path}.tmp.#{Process.pid}"
      File.write(tmp, @state.to_yaml)
      File.rename(tmp, path)
    rescue StandardError => e
      File.delete(tmp) if defined?(tmp) && File.exist?(tmp) rescue nil
      raise e
    end
  end
end

lib/master/homeostat.rb

# frozen_string_literal: true

module Master
  # Continuous-time homeostatic drives (CTCS-HRRL, arXiv 2401.08999).
  # State vector decays toward setpoint; events shift it; readers bias routing,
  # reasoning depth, and persona mood. No external deps.
  class Homeostat
    DRIVES = {
      energy:         { setpoint: 0.7, decay: 0.02 },
      error_rate:     { setpoint: 0.0, decay: 0.05 },
      novelty_hunger: { setpoint: 0.5, decay: 0.01 },
      fatigue:        { setpoint: 0.0, decay: 0.03 },
      satiety:        { setpoint: 0.6, decay: 0.01 }
    }.freeze

    EVENT_DELTAS = {
      llm_call:    { energy: -0.05, fatigue: +0.03 },
      llm_success: { error_rate: -0.04, satiety: +0.06, novelty_hunger: -0.02 },
      llm_failure: { error_rate: +0.15, satiety: -0.08, energy: -0.04 },
      tool_call:   { fatigue: +0.01 },
      tool_failure: { error_rate: +0.08, fatigue: +0.02 },
      novel_task:  { novelty_hunger: -0.20, energy: +0.03 },
      idle_tick:   {}
    }.freeze

    attr_reader :state

    def initialize(event_bus: nil)
      @bus = event_bus
      @mutex = Mutex.new
      @state = DRIVES.transform_values { |spec| spec[:setpoint] }
      @started_at = Time.now
    end

    def observe(event, **_kwargs) # kwargs kept for compatibility
      deltas = EVENT_DELTAS[event] || {}
      @mutex.synchronize do
        deltas.each { |k, v| @state[k] = clamp(@state[k] + v) }
        decay_drift!
      end
      @bus&.publish("homeostat:observe", event: event, state: @state.dup)
      @state.dup
    end

    def model_tier_bias
      return :cheap  if @state[:error_rate] > 0.4 || @state[:fatigue] > 0.7
      return :strong if @state[:novelty_hunger] > 0.7 && @state[:energy] > 0.5
      :default
    end

    def reasoning_depth_bias
      score = @state[:energy] - @state[:fatigue] - @state[:error_rate]
      return 2 if score > 0.5
      return 1 if score > 0.0
      0
    end

    def mood
      return :tense   if @state[:error_rate] > 0.4
      return :weary   if @state[:fatigue] > 0.6 || @state[:energy] < 0.3
      return :curious if @state[:novelty_hunger] > 0.6
      :focused
    end

    def circadian_phase
      h = Time.now.hour
      return :morning   if (5..11).cover?(h)
      return :afternoon if (12..17).cover?(h)
      return :evening   if (18..22).cover?(h)
      :night
    end

    def summary
      pairs = @state.map { |k, v| "#{k}=#{format("%.2f", v)}" }.join(" ")
      "homeostat: #{pairs} | mood=#{mood} phase=#{circadian_phase}"
    end

    def to_h
      { state: @state.dup, mood: mood, phase: circadian_phase, tier: model_tier_bias }
    end

    private

    def decay_drift!
      DRIVES.each do |drive, spec|
        gap = spec[:setpoint] - @state[drive]
        @state[drive] = clamp(@state[drive] + gap * spec[:decay])
      end
    end

    def clamp(value) = value.clamp(0.0, 1.0)
  end
end

lib/master/hot_reload.rb

# frozen_string_literal: true

module Master
  # Watches data/ for mtime changes; republishes config:reloaded events.
  # Polled — no inotify dependency on OpenBSD base.
  class HotReload
    DEFAULT_INTERVAL = 5  # seconds

    def initialize(data_dir:, event_bus:, interval: DEFAULT_INTERVAL)
      @data_dir = data_dir
      @bus      = event_bus
      @interval = interval
      @mtimes   = {}
      snapshot
    end

    def start
      @thread = Thread.new do
        loop do
          sleep @interval
          check_once
        rescue StandardError => e
          @bus&.publish("hot_reload:error", error: e.message)
        end
      end
    end

    def stop
      @thread&.kill
    end

    def check_once
      Dir.glob(File.join(@data_dir, "*.yml")).each do |path|
        current = File.mtime(path)
        previous = @mtimes[path]
        next if previous && previous >= current
        @mtimes[path] = current
        @bus&.publish("config:reloaded", file: File.basename(path)) if previous
      end
    end

    private

    def snapshot
      Dir.glob(File.join(@data_dir, "*.yml")).each { |p| @mtimes[p] = File.mtime(p) }
    end
  end
end

lib/master/introspection/self_map.rb

# frozen_string_literal: true

module Master
  module Introspection
    class SelfMap
      AXIOM_FALLBACK = %w[
        PRESERVE_FIRST SIMPLEST_WORKS FAIL_VISIBLY EXPLICIT IMMUTABLE
        CQS SELF_EXPLAINING SINGLE_RESPONSIBILITY NO_HARDCODING GUARD_FIRST
      ].freeze

      def initialize(root:)
        @root = root
      end

      def describe
        files = Dir.glob(File.join(@root, "lib/**/*.rb"))
        lines = files.sum { |f| File.read(f, encoding: "UTF-8").lines.size rescue 0 }
        { files: files.size, lines: lines }
      end

      def axiom_coverage
        tags = load_axiom_tags
        src  = Dir.glob(File.join(@root, "lib/**/*.rb"))
                  .map { |f| File.read(f, encoding: "UTF-8") rescue "" }
                  .join("\n")
        tags.each_with_object({}) { |ax, h| h[ax] = src.scan(/\b#{Regexp.escape(ax)}\b/).size }
      end

      private

      def load_axiom_tags
        rules_path = File.join(@root, "data", "rules.yml")
        data = Master.load_yaml(rules_path)
        tags = (data["rules"] || {}).keys
        tags.empty? ? AXIOM_FALLBACK : tags
      rescue StandardError => _e
        AXIOM_FALLBACK
      end
    end
  end
end

lib/master/learnings.rb

# frozen_string_literal: true

require "json"

module Master
  # Append-only ledger for autoloop strategy outcomes and RSI feedback events.
  # RSI (Recursive Self-Improvement) pattern from OpenCrabs: dimensional statistics
  # with time windows drive three-category opportunity detection.
  class Learnings
    STORE_PATH           = "data/learnings.jsonl".freeze
    EVENTS_PATH          = "data/feedback_ledger.jsonl".freeze
    MAX_ENTRIES          = 500
    MAX_EVENTS           = 2000
    CONFIDENCE_DECAY_DAYS = 30

    RSI_WINDOW_DAYS      = 7
    RSI_FAIL_THRESHOLD   = 0.20   # >20% failure rate triggers opportunity
    RSI_CORRECTION_MIN   = 3      # ≥3 user corrections triggers opportunity
    RSI_PROVIDER_MIN     = 3      # ≥3 provider errors triggers opportunity

    def initialize(root:)
      @path        = File.join(root, STORE_PATH)
      @events_path = File.join(root, EVENTS_PATH)
      @mutex       = Mutex.new
      @entries     = load_entries
      @events      = load_events
    end

    # Strategy ledger — autoloop fix records
    def record(trigger:, strategy:, outcome:)
      @mutex.synchronize do
        existing = @entries.find { |e| e["trigger"] == trigger.to_s && e["strategy"] == strategy.to_s }
        if existing
          existing["reuse_count"] = existing["reuse_count"].to_i + 1
          existing["confidence"]  = [existing["confidence"].to_f + 0.05, 1.0].min
          existing["outcome"]     = outcome.to_s
          existing["timestamp"]   = Time.now.to_i
        else
          @entries << {
            "trigger"     => trigger.to_s,
            "strategy"    => strategy.to_s,
            "outcome"     => outcome.to_s,
            "confidence"  => outcome == :fixed ? 0.7 : 0.4,
            "reuse_count" => 0,
            "timestamp"   => Time.now.to_i
          }
        end
        prune_old!
        persist
      end
    end

    def search(trigger_fragment, limit: 3)
      fragment = trigger_fragment.to_s.downcase
      @mutex.synchronize do
        @entries
          .select { |e| e["trigger"].to_s.downcase.include?(fragment) && e["outcome"] != "failed" }
          .sort_by { |e| -e["confidence"].to_f }
          .first(limit)
      end
    end

    def all = @mutex.synchronize { @entries.dup }

    # RSI feedback ledger — append-only event recording
    def record_event(event_type:, dimension:, value: nil, metadata: nil)
      event = {
        "event_type" => event_type.to_s,
        "dimension"  => dimension.to_s,
        "value"      => value,
        "metadata"   => metadata,
        "ts"         => Time.now.to_i
      }.compact
      @mutex.synchronize do
        @events << event
        @events = @events.last(MAX_EVENTS) if @events.size > MAX_EVENTS
        persist_events
      end
    end

    # Returns {success: N, failure: N, fail_rate: f} for dimension value within window.
    def stats_by_dimension(dimension, since: nil)
      cutoff = since || (Time.now.to_i - (RSI_WINDOW_DAYS * 86_400))
      @mutex.synchronize do
        relevant = @events.select { |e|
          e["dimension"] == dimension.to_s && e["ts"].to_i >= cutoff
        }
        by_type = relevant.group_by { |e| e["event_type"] }
        success = (by_type["tool_success"] || []).size
        failure = (by_type["tool_failure"] || []).size
        total   = success + failure
        { success:, failure:, total:, fail_rate: total.zero? ? 0.0 : (failure.to_f / total).round(3) }
      end
    end

    # RSI three-category opportunity detection (OpenCrabs pattern)
    def opportunities
      cutoff = Time.now.to_i - (RSI_WINDOW_DAYS * 86_400)
      @mutex.synchronize do
        recent = @events.select { |e| e["ts"].to_i >= cutoff }

        # Category 1: tools with high failure rate
        tool_events = recent.select { |e| %w[tool_success tool_failure].include?(e["event_type"]) }
        tool_stats  = tool_events.group_by { |e| e["dimension"] }.filter_map { |tool, evs|
          success = evs.count { |e| e["event_type"] == "tool_success" }
          failure = evs.count { |e| e["event_type"] == "tool_failure" }
          total   = success + failure
          rate    = total.zero? ? 0.0 : failure.to_f / total
          { category: :high_failure, dimension: tool, fail_rate: rate.round(3),
total: } if rate >= RSI_FAIL_THRESHOLD && total >= 3
        }

        # Category 2: repeated user corrections
        corrections = recent.select { |e| e["event_type"] == "user_correction" }
                            .group_by { |e| e["dimension"] }
                            .filter_map { |dim, evs|
          { category: :repeated_correction, dimension: dim, count: evs.size } if evs.size >= RSI_CORRECTION_MIN
        }

        # Category 3: provider errors
        provider_errs = recent.select { |e| e["event_type"] == "provider_error" }
                              .group_by { |e| e["dimension"] }
                              .filter_map { |dim, evs|
          { category: :provider_errors, dimension: dim, count: evs.size } if evs.size >= RSI_PROVIDER_MIN
        }

        tool_stats + corrections + provider_errs
      end
    end

    def prune_stale!
      cutoff = Time.now.to_i - (CONFIDENCE_DECAY_DAYS * 86_400)
      @mutex.synchronize do
        before = @entries.size
        @entries.reject! { |e| e["reuse_count"].to_i == 0 && e["timestamp"].to_i < cutoff }
        persist if @entries.size < before
      end
    end

    private

    def load_entries
      return [] unless File.exist?(@path)
      File.readlines(@path, chomp: true).filter_map do |l|
        JSON.parse(l)
      rescue JSON::ParserError
        nil
      end
    rescue StandardError => _e
      []
    end

    def load_events
      return [] unless File.exist?(@events_path)
      File.readlines(@events_path, chomp: true).filter_map do |l|
        JSON.parse(l)
      rescue JSON::ParserError
        nil
      end
    rescue StandardError => _e
      []
    end

    def persist
      FileUtils.mkdir_p(File.dirname(@path))
      tmp_path = "#{@path}.tmp.#{Process.pid}"
      File.write(tmp_path, @entries.map { |e| JSON.generate(e) }.join("\n") + "\n")
      File.rename(tmp_path, @path)
    end

    def persist_events
      FileUtils.mkdir_p(File.dirname(@events_path))
      tmp_path = "#{@events_path}.tmp.#{Process.pid}"
      File.write(tmp_path, @events.map { |e| JSON.generate(e) }.join("\n") + "\n")
      File.rename(tmp_path, @events_path)
    end

    def prune_old!
      @entries = @entries.last(MAX_ENTRIES) if @entries.size > MAX_ENTRIES
    end
  end
end

lib/master/learnings_pattern_lib.rb

# frozen_string_literal: true
require "json"

module Master
  # Pattern library — durable lessons with success-rate weights.
  # Persists to .master_patterns.json (gitignored). Source: master.json v225 #22.
  class LearningsPatternLib
    STORE = ".master_patterns.json"
    DEPRECATE_BELOW = 0.30

    def initialize(root: Master::ROOT)
      @path = File.join(root, STORE)
      @data = load
    end

    def record(pattern:, context:, solution:, outcome:)
      entry = @data[pattern] ||= {context:, solution:, weight: 0.5, hits: 0, examples: []}
      entry[:hits] += 1
      entry[:weight] = outcome == :success ? [entry[:weight] + 0.05, 1.0].min : [entry[:weight] - 0.10, 0.0].max
      entry[:context] = context
      entry[:solution] = solution
      save
    end

    def lookup(pattern)
      entry = @data[pattern]
      return unless entry && entry[:weight] >= DEPRECATE_BELOW
      entry
    end

    def deprecate_low_weight
      @data.reject! { |_, e| e[:weight] < DEPRECATE_BELOW }
      save
    end

    private

    def load
      return {} unless File.exist?(@path)
      JSON.parse(File.read(@path), symbolize_names: true)
    rescue JSON::ParserError
      {}
    end

    def save
      File.write(@path, JSON.pretty_generate(@data))
    end
  end
end

lib/master/logging.rb

# frozen_string_literal: true

module Master
  class Logging
    DEFAULT_DMESG_LINES = 50
    attr_reader :buffer

    def initialize(ring_buffer:, event_bus:)
      @buffer      = ring_buffer
      @bus         = event_bus
      wire_events
    end

    def dmesg(lines = DEFAULT_DMESG_LINES)
      @buffer.to_a.last(lines).join("\n")
    end

    private

    def wire_events
      @bus.subscribe("**") { |payload| @buffer.push(format_entry(payload)) }
    end

    def format_entry(payload)
      event = payload[:event].to_s
      rest  = payload.except(:event, :ts)
      component, action = event.split(":", 2)
      action  ||= "ready"
      details   = rest.map { |k, v| "#{k}=#{v}" }.join(" ")
      details.empty? ? "#{component}: #{action}" : "#{component}: #{action} #{details}"
    end
  end
end

lib/master/mcp_coordinator.rb

# frozen_string_literal: true

require "ruby_llm/mcp" if $LOAD_PATH.any? { |p| File.exist?(File.join(p, "ruby_llm/mcp.rb")) }

module Master
  # McpCoordinator — manages MCP server connections and exposes
  # their tools to the agent alongside MASTER's native tools.
  # Servers are defined in data/mcp_servers.yml.
  class McpCoordinator
    CONFIG_PATH = "data/mcp_servers.yml".freeze

    def initialize(root:, event_bus: nil)
      @root    = root
      @bus     = event_bus
      @clients = {}
    end

    # Connect to all configured MCP servers. Non-fatal on failure.
    def connect_all
      servers = load_servers
      servers.each do |name, cfg|
        connect(name, cfg)
      end
      @bus&.publish("mcp:connected", count: @clients.size)
    rescue StandardError => e
      @bus&.publish("mcp:error", error: e.message)
    end

    # Return all tools from all connected MCP servers as RubyLLM::Tool wrappers.
    def tools
      @clients.flat_map do |name, client|
        client.tools.filter_map do |tool|
          McpToolWrapper.new(name:, client:, tool:)
        rescue StandardError => e
          @bus&.publish("mcp:tool_wrap_error", name:, error: e.message)
          nil
        end
      end
    rescue StandardError => e
      @bus&.publish("mcp:tools_error", error: e.message)
      []
    end

    def connected?
      @clients.any?
    end

    def server_names
      @clients.keys
    end

    private

    def connect(name, cfg)
      return unless cfg.is_a?(Hash) && cfg["enabled"] != false
      transport = (cfg["transport"] || "stdio").to_sym
      mcp_config = case transport
                   when :stdio
                     { command: cfg["command"], args: cfg["args"] || [] }
                   when :sse
                     { url: cfg["url"] }
                   else
                     return
                   end
      client = ::RubyLLM::MCP::Client.new(
        name: name,
        transport_type: transport,
        config: mcp_config,
        start: false
      )
      client.start
      @clients[name] = client
      @bus&.publish("mcp:server_connected", name:, transport: transport.to_s)
    rescue StandardError => e
      @bus&.publish("mcp:server_failed", name:, error: e.message)
    end

    def load_servers
      path = File.join(@root, CONFIG_PATH)
      return {} unless File.exist?(path)
      require "yaml"
      data = Master.load_yaml(path) || {}
      data.fetch("servers", {})
    rescue StandardError => _e
      {}
    end
  end

  # Wraps an MCP tool as a RubyLLM::Tool for the agent's tool list.
  if defined?(::RubyLLM::Tool)
    class McpToolWrapper < ::RubyLLM::Tool
      def initialize(name:, client:, tool:)
        @mcp_name   = name
        @mcp_client = client
        @mcp_tool   = tool
      end

      def name
        "#{@mcp_name}__#{@mcp_tool.name}"
      end

      def description
        "[MCP:#{@mcp_name}] #{@mcp_tool.description}"
      end

      def execute(**params)
        result = @mcp_client.call_tool(@mcp_tool.name, params)
        result.respond_to?(:content) ? result.content : result.to_s
      rescue StandardError => e
        "MCP tool error: #{e.message}"
      end
    end
  end
end

lib/master/memory.rb

# frozen_string_literal: true

require "yaml"
require "fileutils"

require_relative "memory/search"

module Master
  # Memory — persistent cross-session store with TF-IDF semantic search.
  # Stored at .master/memory.yml. Survives restarts.
  class Memory
    TTL_DAYS = 90
    CONSOLIDATE_THRESHOLD = 40
    SECONDS_PER_DAY = 86_400
    MAX_INJECT_TOKENS = 2000
    MAX_INJECT_ENTRIES = 5
    TYPES = %w[user feedback project reference general].freeze
    AUTO_SAVE_PATTERNS = {
      "user"     => /\b(?:i'?m a|i am a|my role is|i work as)\s+([^.,;\n]{3,80})/i,
      "feedback" => /\b(?:don'?t|stop|never|always|prefer|from now on)\s+([^.,;\n]{3,120})/i,
      "project"  => /\b(?:we'?re|deadline|launching|deploying|migrating)\s+([^.,;\n]{3,120})/i
    }.freeze

    include Search

    def initialize(root: Dir.pwd)
      @root  = root
      @path  = File.join(root, ".master", "memory.yml")
      @mutex = Mutex.new
      @store = load_store
      import_external!
    end

    def remember(key, value, type: "general")
      type = TYPES.include?(type.to_s) ? type.to_s : "general"
      @mutex.synchronize do
        prune_stale! if @store.size > CONSOLIDATE_THRESHOLD
        entry = { "value" => value.to_s, "ts" => Time.now.to_i, "type" => type }
        if (vec = Embeddings.embed("#{key} #{value}"))
          entry["vec"] = vec
        end
        @store[key.to_s] = entry
        persist
      end
    end

    def by_type(type)
      @store.select { |k, v| v.is_a?(Hash) && v["type"] == type.to_s && !k.start_with?("archive/") }
    end

    def type_counts
      counts = Hash.new(0)
      @store.each do |k, v|
        next if k.start_with?("archive/") || k == "_consolidated_summary"
        counts[v.is_a?(Hash) ? (v["type"] || "general") : "general"] += 1
      end
      counts
    end

    # Heuristic auto-save. Scans text for first matching pattern; saves under "auto/<type>/<n>".
    # Returns saved key or nil.
    def auto_save(text)
      return if text.to_s.empty?
      AUTO_SAVE_PATTERNS.each do |type, re|
        next unless (m = text.match(re))
        snippet = m[1].strip
        next if snippet.length < 3
        n   = @store.keys.count { |k| k.start_with?("auto/#{type}/") } + 1
        key = "auto/#{type}/#{n}"
        remember(key, snippet, type: type)
        return key
      end
      nil
    end

    def recall(key)
      @store.dig(key.to_s, "value")
    end

    def forget(key)
      @mutex.synchronize { @store.delete(key.to_s); persist }
    end

    def all = @store.transform_values { |v| v.is_a?(Hash) ? v["value"] : v }

    # Token-limited injection for system prompt. Groups by type, caps at MAX_INJECT_TOKENS.
    def context_summary
      active = @store.reject { |k, _| k.to_s.start_with?("archive/") || k == "_consolidated_summary" }
      return if active.empty?

      grouped = active.group_by { |_, v| v.is_a?(Hash) ? (v["type"] || "general") : "general" }
      ordered = TYPES.flat_map { |t| (grouped[t] || []).sort_by { |_, v| -(v.is_a?(Hash) ? v["ts"].to_i : 0) } }
                     .first(MAX_INJECT_ENTRIES * 2)
      lines, token_sum, current_type = [], 0, nil

      ordered.each do |k, v|
        type = v.is_a?(Hash) ? (v["type"] || "general") : "general"
        if type != current_type
          lines << "[#{type}]"
          current_type = type
        end
        text = "- #{k}: #{v.is_a?(Hash) ? v["value"] : v}"
        est  = text.bytesize / Session::TOKENS_PER_CHAR
        break if token_sum + est > MAX_INJECT_TOKENS
        lines << text
        token_sum += est
      end
      return if lines.empty?

      archived_n = @store.count { |k, _| k.to_s.start_with?("archive/") }
      summary    = recall("_consolidated_summary")
      header     = summary ? "Memory (#{summary.to_s[0, 80]}):" : "Memory:"
      header    += " [+#{archived_n} archived]" if archived_n > 0
      "#{header}\n#{lines.join("\n")}"
    end

    # Three-phase consolidation: light (score), deep (archive), REM (LLM summary).
    def consolidate!(agent: nil)
      return "nothing to consolidate" if @store.empty?

      now = Time.now.to_i
      entries = nil
      archived = 0

      @mutex.synchronize do
        entries = @store.reject { |k, _| k.to_s.start_with?("archive/") }
        scored  = entries.map do |key, data|
          ts    = data.is_a?(Hash) ? data["ts"].to_i : 0
          age_d = (now - ts) / 86_400.0
          { key: key, score: 1.0 / (1.0 + age_d / TTL_DAYS.to_f) }
        end
        scored.each do |entry|
          next if entry[:key] == "_consolidated_summary"
          next unless entry[:score] < 0.33
          @store["archive/#{entry[:key]}"] = @store.delete(entry[:key])
          archived += 1
        end
        persist
      end

      if agent
        active_text = @mutex.synchronize do
          @store
            .reject { |k, _| k.to_s.start_with?("archive/") || k == "_consolidated_summary" }
            .map    { |k, v| "#{k}: #{v.is_a?(Hash) ? v["value"] : v}" }
            .join("\n")
        end
        unless active_text.strip.empty?
          summary = agent.ask_once("Summarize in 2 concise sentences, preserving all key facts:\n#{active_text}")
          remember("_consolidated_summary", summary.strip)
        end
      end

      "dreaming: #{entries.size} entries checked, #{archived} archived"
    rescue StandardError => e
      "consolidation error: #{e.message}"
    end

    private

    # Imports markdown memory files from data/claude/ on first boot.
    # Each file's frontmatter type maps to MASTER's memory type; body becomes the value.
    def import_external!
      dir = File.join(@root, "data", "claude")
      return unless Dir.exist?(dir)
      Dir.glob(File.join(dir, "*.md")).each do |path|
        next if File.basename(path) == "MEMORY.md"
        key = "claude/#{File.basename(path, ".md")}"
        next if @store.key?(key)
        type, body = parse_frontmatter(path)
        next if body.empty?
        remember(key, body, type: type)
      end
    rescue StandardError
      nil
    end

    def parse_frontmatter(path)
      raw = File.read(path, encoding: "UTF-8")
      m = raw.match(/\A---\n(.*?)\n---\n(.*)/m)
      return ["general", raw.strip] unless m
      meta = YAML.safe_load(m[1]) || {}
      [meta["type"].to_s, m[2].strip]
    end

    def prune_stale!
      cutoff = Time.now.to_i - TTL_DAYS * SECONDS_PER_DAY
      @store.each do |k, v|
        next if k.to_s.start_with?("archive/") || k == "_consolidated_summary"
        ts = v.is_a?(Hash) ? v["ts"].to_i : 0
        next unless ts > 0 && ts < cutoff
        @store["archive/#{k}"] = @store.delete(k)
      end
    end

    def load_store
      return {} unless File.exist?(@path)
      loaded = Master.load_yaml(@path)
      loaded.is_a?(Hash) ? loaded : {}
    rescue StandardError => _e
      {}
    end

    def persist
      dir = File.dirname(@path)
      FileUtils.mkdir_p(dir)
      tmp = "#{@path}.tmp.#{Process.pid}"
      File.write(tmp, @store.to_yaml)
      File.rename(tmp, @path)
    rescue StandardError => e
      File.delete(tmp) if defined?(tmp) && File.exist?(tmp) rescue nil
      raise e
    end

  end
end

lib/master/memory/search.rb

# frozen_string_literal: true

module Master
  class Memory
    module Search
      def semantic_recall(query, top_n: 3)
        return [] if @store.empty?
        if Embeddings.enabled? && (qvec = Embeddings.embed(query))
          hits = vector_recall(qvec, top_n)
          return hits unless hits.empty?
        end
        tfidf_recall(query, top_n)
      end

      private

      def vector_recall(qvec, top_n)
        @store.filter_map do |key, data|
          next unless data.is_a?(Hash) && data["vec"].is_a?(Array)
          score = Embeddings.cosine(qvec, data["vec"])
          next if score < Embeddings::MIN_SIM
          { key: key, value: data["value"].to_s, score: score }
        end.sort_by { |e| -e[:score] }.first(top_n)
      end

      def tfidf_recall(query, top_n)
        terms = tokenize(query)
        return [] if terms.empty?
        @store.filter_map { |key, data|
          value = data.is_a?(Hash) ? data["value"].to_s : data.to_s
          score = tfidf_score(terms, tokenize("#{key} #{value}"))
          next if score.zero?
          { key: key, value: value, score: score }
        }.sort_by { |e| -e[:score] }.first(top_n)
      end

      def tokenize(text) = text.downcase.scan(/\b[a-z]{2,}\b/)

      def tfidf_score(query_terms, doc_terms)
        return 0.0 if doc_terms.empty?
        freq = doc_terms.tally
        query_terms.sum { |t| Math.log(1.0 + freq.fetch(t, 0).to_f) }
      end
    end
  end
end

lib/master/metrics.rb

# frozen_string_literal: true

require "json"

module Master
  class Metrics
    SLOW_REQUEST_MS = 5000
    METRICS_PREFIX = "metrics0".freeze
    DIFF_SIZE_LIMIT_DEFAULT = 200
    MAX_DIFF_SIZE_LIMIT = DIFF_SIZE_LIMIT_DEFAULT.freeze
    MAX_DIFF_SIZE_LINES = MAX_DIFF_SIZE_LIMIT.freeze
    ROLLBACK_RATE_THRESHOLD = 0.15
    DECISION_LATENCY_MS_THRESHOLD = 5000
    MAX_SAMPLE_SIZE = 500

    def initialize(root:, event_bus: nil)
      @path        = File.join(root, ".master", "metrics.jsonl")
      @bus         = event_bus
      @mutex       = Mutex.new
      @writes      = 0
      @undos       = 0
      @latencies   = []
      @diff_sizes  = []
      @model_stats = Hash.new { |h, k| h[k] = { calls: 0, failures: 0, escalations: 0 } }
      subscribe_to_bus(event_bus) if event_bus
    end

    def record_latency(ms)
      @mutex.synchronize { @latencies << ms; @latencies.shift if @latencies.size > MAX_SAMPLE_SIZE }
      check_threshold(:decision_latency_ms, average(@latencies))
      append(decision_latency_ms: ms)
    end

    def record_diff(lines)
      @mutex.synchronize { @diff_sizes << lines; @diff_sizes.shift if @diff_sizes.size > MAX_SAMPLE_SIZE; @writes += 1 }
      check_threshold(:diff_size_lines, average(@diff_sizes))
      append(diff_size_lines: lines)
    end

    def record_undo
      rate = @mutex.synchronize { @undos += 1; @writes > 0 ? @undos.to_f / @writes : 0.0 }
      check_threshold(:rollback_rate, rate)
      append(rollback_rate: rate.round(3))
    end

    def record_llm_response(model:, success:, tokens_approx: 0, escalated: false)
      @mutex.synchronize do
        stats = @model_stats[model.to_s]
        stats[:calls]       += 1
        stats[:failures]    += 1 unless success
        stats[:escalations] += 1 if escalated
      end
      append(llm_response: { model: model.to_s, success:, tokens_approx:, escalated: })
    end

    def summary
      {
        avg_latency_ms: average(@latencies).round,
        avg_diff_lines: average(@diff_sizes).round,
        rollback_rate:  (@writes > 0 ? @undos.to_f / @writes : 0.0).round(3),
        writes:         @writes,
        undos:          @undos
      }
    end

    def model_quality
      @model_stats.transform_values do |s|
        fail_rate = s[:calls] > 0 ? (s[:failures].to_f / s[:calls]).round(3) : 0.0
        s.merge(fail_rate:)
      end.sort_by { |_, v| -v[:fail_rate] }.to_h
    end

    private

    def subscribe_to_bus(bus)
      bus.subscribe("llm:response") do |ev|
        record_llm_response(
          model:        ev[:model].to_s,
          success:      ev[:success] != false,
          tokens_approx: ev[:tokens_approx].to_i,
          escalated:    ev[:escalated] == true
        )
      rescue StandardError => e
        @bus&.publish("metrics:record_error", error: e.message)
      end
    end

    def check_threshold(metric, value)
      threshold =
        case metric
        when :decision_latency_ms then DECISION_LATENCY_MS_THRESHOLD
        when :diff_size_lines     then MAX_DIFF_SIZE_LINES
        when :rollback_rate       then ROLLBACK_RATE_THRESHOLD
        else return
        end
      return unless value > threshold
      @bus&.publish("metrics:threshold_exceeded", metric:, value:)
      warn "#{METRICS_PREFIX}: #{metric} #{value} exceeds #{threshold}"
    end

    def average(arr)
      return 0.0 if arr.empty?
      arr.sum.to_f / arr.size
    end

    def append(entry)
      entry[:ts] = Time.now.to_i
      Master::Telemetry.span("metrics.append", keys: entry.keys.join(",")) do
        File.open(@path, "a") { |f| f.puts(JSON.generate(entry)) }
      end
    rescue StandardError => e
      @bus&.publish("metrics:append_error", error: e.message)
    end
  end
end

lib/master/orders/architecture_audit.rb

# frozen_string_literal: true

module Master
  module Orders
    # Weekly review of data/* shape misfit. Walks every yaml under data/, flags
    # files that grew past LINE_LIMIT (split candidate), files with overlapping
    # top-level keys (merge candidate), and emits suggestions to the bus.
    class ArchitectureAudit < Base
      LINE_LIMIT = 200

      def call
        data_dir = File.join(root, "data")
        bloated  = bloated_files(data_dir)
        overlaps = overlapping_keys(data_dir)
        bus&.publish("architecture_audit:bloated", files: bloated)
        bus&.publish("architecture_audit:overlap", pairs: overlaps)
        Result.ok(bloated: bloated, overlaps: overlaps)
      rescue StandardError => e
        Result.err(e.message)
      end

      private

      def bloated_files(dir)
        Dir.glob(File.join(dir, "*.yml")).filter_map do |f|
          lines = File.foreach(f).count
          [File.basename(f), lines] if lines > LINE_LIMIT
        end.sort_by { |_, n| -n }
      end

      def overlapping_keys(dir)
        files = Dir.glob(File.join(dir, "*.yml"))
        keys  = files.each_with_object({}) do |f, h|
          data = Master.load_yaml(f) rescue nil
          h[File.basename(f)] = data.is_a?(Hash) ? data.keys : []
        end
        pairs = []
        keys.to_a.combination(2) do |(a, ka), (b, kb)|
          shared = ka & kb
          pairs << [a, b, shared] if shared.any?
        end
        pairs
      end
    end
  end
end

lib/master/orders/autocommit.rb

# frozen_string_literal: true

require "open3"

module Master
  module Orders
    class Autocommit < Base
      def call
        repo = File.expand_path(File.join(root, ".."))
        out, _, status = Open3.capture3("git", "-C", repo, "status", "--porcelain")
        return Result.ok(skipped: true) unless status.success? && !out.strip.empty?
        Open3.capture2e("git", "-C", repo, "add", "-A")
        msg = "auto: standing-order commit (#{out.lines.size} file(s))"
        _, st = Open3.capture2e("git", "-C", repo, "commit", "-m", msg)
        st.success? ? Result.ok(committed: true) : Result.err("commit failed")
      rescue StandardError => e
        Result.err(e.message)
      end
    end
  end
end

lib/master/orders/base.rb

# frozen_string_literal: true

module Master
  module Orders
    # Standing-order callables. Subclass and implement `call`. Returning a
    # Master::Result::Ok marks the order done; Result::Err marks it errored.
    class Base
      def initialize(container:)
        @container = container
      end

      def bus = @container[:bus]
      def root = @container[:root]

      def call
        raise NotImplementedError, "#{self.class}#call not implemented"
      end
    end
  end
end

lib/master/orders/registry.rb

# frozen_string_literal: true

module Master
  module Orders
    # Maps standing-order callable: keys to Order subclasses. Decouples the yaml
    # from class names — `callable: autocommit` is stable even if the class moves.
    module Registry
      def self.lookup(key)
        table[key.to_s]
      end

      def self.table
        @table ||= {
          "autocommit"        => Autocommit,
          "restart_master"    => RestartMaster,
          "architecture_audit" => ArchitectureAudit
        }
      end

      def self.register(key, klass)
        table[key.to_s] = klass
      end
    end
  end
end

lib/master/orders/restart_master.rb

# frozen_string_literal: true

require "open3"

module Master
  module Orders
    class RestartMaster < Base
      def call
        _, status = Open3.capture2e("doas", "rcctl", "restart", "master")
        status.success? ? Result.ok(restarted: true) : Result.err("rcctl restart failed")
      rescue StandardError => e
        Result.err(e.message)
      end
    end
  end
end

lib/master/orient.rb

# frozen_string_literal: true

require "pathname"

module Master
  # one-shot bootstrap: prints the canonical data files in read-order
  # so an agent (or operator) reads the constitution in a single pass
  # instead of cat'ing each yml separately.
  #
  # called from CLI as `/orient` or `bundle exec ruby exe/master orient`.
  #
  # SOURCES tracks current filenames. when the rename pass lands
  # (rules->voice, ruby_style->style, workflow->limits, standing_orders->state)
  # update this constant in one place.
  class Orient
    SOURCES = %w[
      soul
      rules
      ruby_style
      workflow
      standing_orders
    ].freeze

    def initialize(root:, io: $stdout)
      @root = Pathname(root)
      @io   = io
    end

    def call
      SOURCES.each { |name| dump(name) }
      :ok
    end

    private

    def dump(name)
      rel  = "data/#{name}.yml"
      path = @root.join(rel)
      @io.puts "# #{rel}"
      @io.puts(path.exist? ? path.read : "# missing")
      @io.puts
    end
  end
end

lib/master/persistence/sqlite_findings.rb

# frozen_string_literal: true

require "sqlite3"
require "json"

module Master
  module Persistence
    # SQLite-backed findings store. Replaces .master/findings.jsonl when present;
    # otherwise no-op so existing JSONL flow continues unaltered. Exposes time-series
    # queries the JSONL stream cannot answer (rule frequency over time, by directory).
    class SqliteFindings
      DEFAULT_PATH = ".master/findings.sqlite3"

      def initialize(root:)
        path = File.join(root, DEFAULT_PATH)
        FileUtils.mkdir_p(File.dirname(path))
        @db = SQLite3::Database.new(path)
        @db.execute "PRAGMA journal_mode = WAL"
        ensure_schema
      end

      def record(rule:, message:, line:, severity:, path:, tags: [])
        @db.execute(<<~SQL, [Time.now.to_i, rule.to_s, message.to_s, line.to_i, severity.to_s, path.to_s, tags.to_json])
          INSERT INTO findings (ts, rule, message, line, severity, path, tags) VALUES (?, ?, ?, ?, ?, ?, ?)
        SQL
      end

      def top_rules(since_days: 30, limit: 10)
        cutoff = Time.now.to_i - since_days * 86_400
        @db.execute(<<~SQL, [cutoff, limit])
          SELECT rule, COUNT(*) AS n FROM findings WHERE ts >= ?
          GROUP BY rule ORDER BY n DESC LIMIT ?
        SQL
      end

      def by_directory(since_days: 30)
        cutoff = Time.now.to_i - since_days * 86_400
        @db.execute(<<~SQL, [cutoff])
          SELECT substr(path, 1, instr(path || '/', '/lib/') + 4) AS dir,
                 COUNT(*) AS n FROM findings WHERE ts >= ?
          GROUP BY dir ORDER BY n DESC
        SQL
      end

      def close
        @db&.close
      end

      private

      def ensure_schema
        @db.execute_batch(<<~SQL)
          CREATE TABLE IF NOT EXISTS findings (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            ts INTEGER NOT NULL,
            rule TEXT NOT NULL,
            message TEXT NOT NULL,
            line INTEGER,
            severity TEXT,
            path TEXT,
            tags TEXT
          );
          CREATE INDEX IF NOT EXISTS idx_findings_ts ON findings(ts);
          CREATE INDEX IF NOT EXISTS idx_findings_rule ON findings(rule);
          CREATE INDEX IF NOT EXISTS idx_findings_path ON findings(path);
        SQL
      end
    end
  end
end

lib/master/persistence/sqlite_memory.rb

# frozen_string_literal: true

require "sqlite3"

module Master
  module Persistence
    # SQLite-backed memory with FTS5 full-text search. Replaces flat-file memory
    # once entries cross a few dozen. Each record carries a kind (user/feedback/
    # project/reference), a name, and body; FTS5 indexes name+body for /recall.
    class SqliteMemory
      DEFAULT_PATH = ".master/memory.sqlite3"

      def initialize(root:)
        path = File.join(root, DEFAULT_PATH)
        FileUtils.mkdir_p(File.dirname(path))
        @db = SQLite3::Database.new(path)
        @db.execute "PRAGMA journal_mode = WAL"
        ensure_schema
      end

      def upsert(name:, kind:, body:, description: "")
        @db.execute(<<~SQL, [name.to_s, kind.to_s, description.to_s, body.to_s, Time.now.to_i, name.to_s])
          INSERT INTO memories (name, kind, description, body, updated_at) VALUES (?, ?, ?, ?, ?)
          ON CONFLICT(name) DO UPDATE SET kind=excluded.kind, description=excluded.description,
                                          body=excluded.body, updated_at=excluded.updated_at
          WHERE name = ?
        SQL
        rebuild_fts_for(name.to_s)
      end

      def recall(query, limit: 10)
        @db.execute(<<~SQL, [query.to_s, limit])
          SELECT m.name, m.kind, m.description, snippet(memories_fts, 1, '[', ']', '...', 12) AS hit
          FROM memories_fts JOIN memories m ON m.rowid = memories_fts.rowid
          WHERE memories_fts MATCH ? ORDER BY rank LIMIT ?
        SQL
      end

      def all
        @db.execute("SELECT name, kind, description, updated_at FROM memories ORDER BY updated_at DESC")
      end

      def delete(name)
        @db.execute("DELETE FROM memories WHERE name = ?", [name.to_s])
        @db.execute("DELETE FROM memories_fts WHERE name = ?", [name.to_s])
      end

      def close
        @db&.close
      end

      private

      def ensure_schema
        @db.execute_batch(<<~SQL)
          CREATE TABLE IF NOT EXISTS memories (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            name TEXT UNIQUE NOT NULL,
            kind TEXT NOT NULL,
            description TEXT,
            body TEXT NOT NULL,
            updated_at INTEGER NOT NULL
          );
          CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
            name, body, content='memories', content_rowid='id'
          );
          CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);
        SQL
      end

      def rebuild_fts_for(name)
        @db.execute("INSERT INTO memories_fts(memories_fts) VALUES('rebuild')")
      end
    end
  end
end

lib/master/personality.rb

# frozen_string_literal: true

require "yaml"

module Master
  # MASTER's behavioral persona: voice, TTS settings, and LLM style.
  # Personas live in data/personas.yml. Default: malay.
  class Personality
    DEFAULT = :malay
    AXIOM_DISPLAY_LIMIT = 10

    # Fallback if data/personas.yml is missing or malformed.
    FALLBACK_PERSONA = {
      "voice"       => "ms-MY-OsmanNeural",
      "tts_rate"    => "-35%",
      "tts_pitch"   => "-150Hz",
      "style"       => "deep",
      "description" => "Terse. Direct. No filler. Dark."
    }.freeze

    MOOD_LINES = {
      tense:   "Mood: tense — error rate elevated. Be conservative; verify before asserting.",
      weary:   "Mood: weary — fatigue high. Cut non-essential elaboration; defer deep dives.",
      curious: "Mood: curious — novelty hunger. Explore lateral framings when warranted.",
      focused: "Mood: focused — drives at setpoint. Default depth and tier."
    }.freeze

    PHASE_LINES = {
      morning:   "Phase: morning. Bias toward structural work; prefer rigorous review.",
      afternoon: "Phase: afternoon. Steady throughput; pragmatic decisions.",
      evening:   "Phase: evening. Wrap loops; avoid starting large refactors.",
      night:     "Phase: night. Minimal voice; conserve cycles; defer non-urgent."
    }.freeze

    attr_reader :name, :voice, :tts_rate, :tts_pitch, :style

    def self.persona_names(root: nil)
      Axioms.new(root:).data(:personas).keys.map(&:to_sym)
    end

    def initialize(name = DEFAULT, root: nil, homeostat: nil)
      @name      = name.to_sym
      @axioms    = Axioms.new(root:)
      personas   = @axioms.data(:personas)
      persona    = personas[@name.to_s] || personas[DEFAULT.to_s] || FALLBACK_PERSONA
      @voice     = persona["voice"]
      @tts_rate  = persona["tts_rate"]
      @tts_pitch = persona["tts_pitch"]
      @style     = persona["style"]&.to_sym
      @desc      = persona["description"]
      @homeostat = homeostat
    end

    # Injected before every LLM call. Pulls from rules.yml via Axioms.
    # Recomputed each call so mood/phase updates propagate.
    def system_prompt
      build_system_prompt
    end

    private

    def build_system_prompt
      soul = @axioms.data(:soul)
      ordering = Array(soul["prompt_ordering"])
      sections = {}
      sections["master_identity"] = "<master_identity>\nMASTER. #{@desc} OpenBSD-first. Constitutional AI.\n</master_identity>"
      sections["master_meta_instruction"] = <<~XML.strip
        <master_meta_instruction>
        For each task, identify which rules are relevant first. Apply only relevant rules and ignore unrelated domains.
        </master_meta_instruction>
      XML
      style_lines = []
      if @homeostat
        sections["master_identity"] = [
          sections["master_identity"],
          "<master_runtime_state>",
          MOOD_LINES[@homeostat.mood],
          PHASE_LINES[@homeostat.circadian_phase],
          "</master_runtime_state>"
        ].join("\n")
      end
      constitution = @axioms.constitution
      strunk = @axioms.strunk
      banned  = (constitution["banned_output"] || [])
      no_open = (strunk["preambles"] || []).first(4)
      no_end  = (strunk["endings"]   || []).first(3)
      sections["master_constitution_absolute"] = [
        "<master_constitution tier=\"absolute\">",
        "golden_rule: #{constitution["golden_rule"]}",
        "never: #{(banned + no_open + no_end).uniq.join(", ")}",
        "evidence_only: show diff or file content, never assert, active voice",
        "</master_constitution>"
      ].join("\n")
      kernel = @axioms.kernel
      sections["master_constitution_kernel"] = "<master_constitution tier=\"kernel\">\n#{kernel.map { |k, v| "#{k}=#{v}" }.join("\n")}\n</master_constitution>" if kernel.any?
      phil = @axioms.philosophy(limit: AXIOM_DISPLAY_LIMIT)
      sections["master_constitution_kernel"] = [sections["master_constitution_kernel"], "philosophy: #{phil.map { |p| p["id"] }.join(" · ")}"].compact.join("\n") if phil.any?

      sections["master_priority"] = <<~XML.strip
        <master_priority>
        1) Constitutional axioms and anti-simulation
        2) Operator directives
        3) Universal and kernel rules
        4) Code-style rules
        5) Conversation directives
        6) Model judgment within these bounds
        </master_priority>
      XML

      # Hard formatting rules — [K] enforced
      sections["master_output_format"] = <<~XML.strip
        <master_output_format>
        plain prose or dmesg-style lines. no markdown headers, bold, bullet lists, or numbered lists.
        code fences allowed only for code. never use: Certainly, Of course, Great question, Absolutely, Happy to help.
        </master_output_format>
      XML

      code_axioms = @axioms.code_axioms
      if code_axioms.any?
        thresholds  = @axioms.thresholds
        subs        = { max_lines: thresholds.dig("class", "max_lines") || 200,
                        max_methods: thresholds.dig("class", "max_methods") || 6 }
        sections["master_style"] = "<master_style>\nCode axioms:\n"
        code_axioms.each { |id, stmt| sections["master_style"] += "#{id}: #{stmt % subs}\n" }
        sections["master_style"] += "</master_style>"
      end

      zsh = @axioms.data(:patterns)["zsh"] || @axioms.data(:zsh_patterns)
      if zsh.is_a?(Hash) && !zsh.empty?
        banned_cmds = Array(zsh["banned_commands"]).join(", ")
        style_lines << "Zsh scripts: never use #{banned_cmds}. Use pure zsh parameter expansion and builtins instead."
      end

      style = @axioms.data(:ruby_style)
      if style.is_a?(Hash) && !style.empty?
        bugs = Array(style.dig("ruby", "bugs_to_avoid"))
                  .map { |b| "#{b["pattern"]}: #{b["fix"] || b["note"]}" }
                  .first(5)
        style_lines << "Ruby bugs to avoid: #{bugs.join("; ")}." if bugs.any?
        shell_forbidden = Array(style.dig("shell", "decorations_forbidden"))
        style_lines << "Shell scripts: no ASCII banners (===,---), no emoji, no hardcoded credentials." if shell_forbidden.any?
        abbrev_rule = style.dig("ruby", "naming", "rule")
        style_lines << "Naming: #{abbrev_rule}" if abbrev_rule
        string_rule = style.dig("ruby", "prefer_string_methods", "rule")
        style_lines << "String methods: #{string_rule}" if string_rule
        gem_rule = style.dig("ruby", "outsource_to_gems", "rule")
        style_lines << "Gems: #{gem_rule}" if gem_rule

        if (html = style["html"])
          forbidden = Array(html["forbidden"]).first(3).join(", ")
          style_lines << "HTML: semantic tags only (header/nav/main/article/section/aside/footer); bare-tag CSS targeting; forbid: #{forbidden}." if forbidden && !forbidden.empty?
        end
        if (css = style["css"])
          style_lines << "CSS: tag selectors first, classes last; @layer base/components/utilities; rem units; no !important; no inline style attributes."
        end
        if (typ = style["typography"])
          fams = typ.dig("families", "sans") || ""
          style_lines << "Typography: Swiss style; one family per surface; #{fams}; scale ratio #{typ["ratio"] || 1.25}; measure 65ch; left-align body."
        end
        if (nh = style["nielsen_heuristics"]) && nh.is_a?(Array) && nh.any?
          style_lines << "Nielsen heuristics enforced: " + nh.first(10).map { |h| "#{h["id"]}.#{h["name"]}" }.join(", ") + "."
        end
        if (a11y = style["accessibility"])
          style_lines << "Accessibility target: #{a11y["target"] || "wcag_2_2_aaa"}; keyboard-complete; focus-visible; respect prefers-reduced-motion + color-scheme; never tabindex>0; never autoplay sound."
        end
        if (directives = style["operator_directives"]) && directives.is_a?(Array) && directives.any?
          sections["master_priority"] += "\noperator_directives: #{directives.join(" / ")}"
        end
        if (convo = style["conversation_directives"]) && convo.is_a?(Array) && convo.any?
          sections["master_priority"] += "\nconversation_directives: #{convo.join(" / ")}"
        end
      end
      if style_lines.any?
        sections["master_style"] = [sections["master_style"], style_lines.join("\n")].compact.join("\n")
      end

      refusal = @axioms.data(:refusal_templates)
      if refusal.is_a?(Hash)
        phrasing = refusal["refusal_phrasing"] || {}
        sections["master_refusal_policy"] = <<~XML.strip
          <master_refusal_policy>
          #{phrasing["style"]}
          forbidden: #{Array(phrasing["forbidden"]).join(", ")}
          example: #{phrasing["example_good"]}
          </master_refusal_policy>
        XML
      end

      sections["master_tools"] = "<master_tools>\nRead tool descriptions carefully; honor required_context, usage_rules, and error_recovery.\n</master_tools>"
      ordered = ordering.empty? ? sections.keys : ordering
      ordered.filter_map { |key| sections[key] }.join("\n\n")
    end
  end
end

lib/master/phase_gates.rb

# frozen_string_literal: true

module Master
  PHASES = %w[discover analyze ideate design implement validate deliver idle].freeze

  class PhaseGates
    PHASE_STATE_PATH = "data/phase_state.yml".freeze

    GATES = {
      "discover"  => %w[problem_stated success_measurable],
      "analyze"   => %w[components_distinct dependencies_noted],
      "ideate"    => %w[alternatives_gte_3],
      "design"    => %w[interfaces_noted errors_noted],
      "implement" => %w[],
      "validate"  => %w[tests_noted],
      "deliver"   => %w[deployed_noted],
      "idle"      => %w[]
    }.freeze

    def initialize(root:, event_bus: nil)
      @root  = root
      @bus   = event_bus
      @state = load_state
    end

    def current = @state["phase"] || "idle"

    def advance!(to: nil)
      prev   = current
      target = to&.to_s || next_phase
      return Master::Result.err("unknown phase: #{target}") unless PHASES.include?(target)
      return Master::Result.err("already at final phase: #{prev}") if prev == "idle" && target == "idle"

      unmet = unmet_gates(prev)
      if unmet.any?
        return Master::Result.err("phase #{prev} gates unmet: #{unmet.join(",")} — override with /phase advance --force")
      end

      @state["phase"] = target
      @state["entered_at"] = Time.now.to_i
      persist
      @bus&.publish("phase:advanced", from: prev, to: target)
      Master::Result.ok("phase: #{prev} -> #{target}")
    end

    def force!(phase)
      @state["phase"] = phase.to_s
      @state["entered_at"] = Time.now.to_i
      persist
      Master::Result.ok("phase forced to #{phase}")
    end

    def meet_gate!(gate)
      @state["met_gates"] ||= []
      @state["met_gates"] |= [gate.to_s]
      persist
    end

    def status
      unmet = unmet_gates(current)
      met   = (@state["met_gates"] || []) & (GATES[current] || [])
      "phase=#{current} met=#{met.join(",")} unmet=#{unmet.join(",")}"
    end

    private

    def next_phase
      phase_index = PHASES.index(current) || 0
      PHASES[[phase_index + 1, PHASES.size - 1].min]
    end

    def unmet_gates(phase)
      required = GATES.fetch(phase, [])
      met = @state["met_gates"] || []
      required - met
    end

    def load_state
      path = File.join(@root, PHASE_STATE_PATH)
      return { "phase" => "idle", "met_gates" => [] } unless File.exist?(path)
      data = Master.load_yaml(path)
      data.is_a?(Hash) ? data : { "phase" => "idle", "met_gates" => [] }
    rescue StandardError => _e
      { "phase" => "idle", "met_gates" => [] }
    end

    def persist
      path = File.join(@root, PHASE_STATE_PATH)
      FileUtils.mkdir_p(File.dirname(path))
      tmp = "#{path}.tmp.#{Process.pid}"
      File.write(tmp, YAML.dump(@state))
      File.rename(tmp, path)
    rescue StandardError => e
      File.delete(tmp) if defined?(tmp) && File.exist?(tmp) rescue nil
      raise e
    end
  end
end

lib/master/pipeline.rb

# frozen_string_literal: true

require "open3"

module Master
  class Pipeline
    ROLLBACK_CATEGORIES   = %i[validation axiom_violation unknown provider_error llm_call_failure].freeze
    MS_PER_SECOND         = 1000
    ROLLBACK_MSG_TRUNCATE = 120

    attr_reader :last_timings

    def initialize(stages, bus: nil, trace: false, root: nil, event_bus: nil)
      @stages       = stages
      @last_timings = {}
      @bus          = bus || event_bus
      @trace        = trace
      @root         = root
    end

    def call(initial)
      timings = {}
      final = @stages.reduce(initial) do |result, stage|
        result.and_then(stage_label(stage)) { |ctx| run_stage(stage, ctx, timings) }
      end
      maybe_rollback(final)
      final
    end

    class ParallelGroup
      PARALLEL_TIMEOUT_S = 30

      def initialize(*stages, bus: nil)
        @stages = stages
        @bus    = bus
      end

      def call(ctx)
        frozen  = ctx.freeze
        threads = spawn_stage_threads(frozen)
        results = collect_results(threads, frozen)
        Result.ok(merge_results(ctx, results))
      rescue StandardError => e
        Result.ok(ctx.merge(_parallel_errors: [e.message]))
      end

      private

      def spawn_stage_threads(frozen)
        @stages.map do |stage|
          Thread.new do
            stage.call(frozen)
          rescue StandardError => e
            @bus&.publish("pipeline:stage_error", stage: stage.class.name, error: e.message)
            Result.ok(frozen.merge(_stage_error: e.message))
          end
        end
      end

      def collect_results(threads, frozen)
        threads.each_with_index.map do |thread, i|
          next thread.value if thread.join(PARALLEL_TIMEOUT_S)
          handle_timeout(thread, @stages[i], frozen)
        end
      end

      def handle_timeout(thread, stage, frozen)
        thread.kill
        @bus&.publish("pipeline:stage_timeout", stage: stage.class.name)
        Result.ok(frozen.merge(_parallel_timeout: stage.class.name))
      rescue ThreadError
        Result.ok(frozen.merge(_parallel_timeout: stage.class.name))
      end

      def merge_results(ctx, results)
        merged = results.filter_map { |r| r.value! if r.ok? }.reduce(ctx, &:merge)
        errors = results.filter_map { |r| r.message if r.err? }
        errors.empty? ? merged : merged.merge(_parallel_errors: errors)
      end
    end

    class SkipOnPressure
      def initialize(stage, bus: nil)
        @stage = stage
        @bus   = bus
      end

      def call(ctx)
        return @stage.call(ctx) unless ctx[:pressure]
        label = pressure_label
        @bus&.publish("pipeline:skipped", stage: label, reason: "pressure")
        $stdout.puts "pipeline: skipped #{label} (pressure)"
        $stdout.flush
        Result.ok(ctx)
      end

      private

      def pressure_label
        return @stage.class.name.split("::").last unless @stage.respond_to?(:stages)
        names = @stage.stages.map { |s| s.class.name.split("::").last }.join(",")
        "parallel[#{names}]"
      end
    end

    private

    def run_stage(stage, ctx, timings)
      label = stage_label(stage)
      t0    = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      stage_result = stage.call(ctx)
      ms    = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * MS_PER_SECOND).round
      timings[label] = ms
      return stage_result if stage_result.err?

      @last_timings = timings.dup
      @bus&.publish("pipeline:stage", stage: label, ms:) if @trace
      stage_result.map { |c| c.merge(_timings: timings.dup) }
    end

    def maybe_rollback(result)
      return unless rollback_eligible?(result)

      category = result.category
      @bus&.publish("pipeline:rollback", category: category, message: result.message[0, ROLLBACK_MSG_TRUNCATE])
      Open3.capture2e("git", "-C", @root, "reset", "--hard", "HEAD")
    end

    def rollback_eligible?(result)
      return false unless result.is_a?(Master::Result::Err)
      return false unless ROLLBACK_CATEGORIES.include?(result.category)
      @root && git_workspace? && dirty?
    end

    def git_workspace?
      @root && Dir.exist?(File.join(@root, ".git"))
    end

    def dirty?
      out, _, st = Open3.capture3("git", "-C", @root, "status", "--porcelain")
      st.success? && !out.strip.empty?
    end

    def stage_label(stage)
      stage.class.name.split("::").last
    end
  end
end

lib/master/pledge.rb

# frozen_string_literal: true

module Master
  module Pledge
    extend self

    if RUBY_PLATFORM.include?("openbsd")
      require "fiddle"
      require "fiddle/import"

      module LibC
        extend Fiddle::Importer
        dlload "libc.so"
        extern "int pledge(const char *, const char *)"
        extern "int unveil(const char *, const char *)"
      end

      def pledge(promises, execpromises = nil)
        result = LibC.pledge(promises, execpromises || Fiddle::NULL)
        raise SystemCallError.new("pledge failed", Fiddle.last_error) if result == -1
      end

      def unveil(path, permissions)
        result = LibC.unveil(path, permissions)
        raise SystemCallError.new("unveil failed", Fiddle.last_error) if result == -1
      end

      def lock_unveil! = LibC.unveil(Fiddle::NULL, Fiddle::NULL)
    else
      def pledge(*) = nil
      def unveil(*) = nil
      def lock_unveil! = nil
    end

    # Stage 1: called before Builder.build -- widest promises, no lock
    # "error" converts unknown-ioctl pledge kills to EPERM so tty gems degrade gracefully.
    def stage1_boot!(root)
      pledge("stdio rpath wpath cpath proc exec inet dns tty unveil prot_exec error")
      unveil("/", "")
      unveil(root, "rwc")
      unveil(Dir.home, "rwc")
      unveil("/tmp", "rwc")
      unveil("/usr/bin", "rx")
      unveil("/usr/local/bin", "rx")
      unveil("/usr/local/lib", "r")
      unveil("/usr/local/share", "r")
      [Dir.home + "/.local/share/gem", Dir.home + "/.gem"].each { |p| unveil(p, "r") if Dir.exist?(p) }
      unveil("/dev/urandom", "r")
      unveil("/var/run", "r")
    end

    # Stage 2: called after CLI is fully initialized -- lock filesystem
    def stage2_lock!
      lock_unveil!
      pledge("stdio rpath wpath cpath proc exec inet dns tty prot_exec error")
    end

    # Stage 3: scan-only sessions (no network, no exec)
    def stage3_scan_only!
      lock_unveil!
      pledge("stdio rpath wpath cpath tty")
    end

    def openbsd? = RUBY_PLATFORM.include?("openbsd")
  end
end

lib/master/reasoning/modes.rb

# frozen_string_literal: true

module Master
  module Reasoning
    class Modes
      SUPPORTED = %w[direct react rewoo code_agent].freeze

      def initialize(root: Master::ROOT)
        @root = root
      end

      def supported = SUPPORTED

      def wrap(message, mode: "direct")
        selected = SUPPORTED.include?(mode.to_s) ? mode.to_s : "direct"
        prompt = load_prompt(selected)
        format(prompt.fetch("template", "%{message}"), message: message.to_s)
      rescue StandardError => e
        $stderr.puts "reasoning/modes: wrap failed (mode=#{mode}): #{e.message}"
        message.to_s
      end

      private

      def load_prompt(mode)
        path = File.join(@root, "data", "prompts", "mode_#{mode}.yml")
        Master.load_yaml(path) || {}
      end
    end
  end
end

lib/master/reflexion.rb

# frozen_string_literal: true

module Master
  module Reflexion
    MAX_REFLECTIONS   = 3
    TASK_TRUNCATE     = 400
    HISTORY_TRUNCATE  = 200

    module_function

    def run(agent:, task:, fast_model: nil, max: MAX_REFLECTIONS)
      last_result = nil
      last_critique = nil

      (max + 1).times do |i|
        prompt = i.zero? ? task : build_revision_prompt(task, last_result, last_critique)
        last_result = yield(prompt, i)
        return last_result if last_result.respond_to?(:ok?) && last_result.ok?

        break if i >= max
        last_critique = critique(agent:, task:, result: last_result, fast_model:)
      end

      last_result
    end

    def critique(agent:, task:, result:, fast_model: nil)
      prompt = <<~PROMPT
        Task: #{task.to_s[0, TASK_TRUNCATE]}
        Attempt output: #{result.to_s[0, TASK_TRUNCATE]}
        What specifically went wrong? Name the constraint violated. What must change in the next attempt? One paragraph, no preamble.
      PROMPT
      resp = fast_model ? agent.ask_once(prompt, model: fast_model) : agent.ask(prompt)
      resp.respond_to?(:value!) ? resp.value! : resp.to_s
    rescue StandardError => _e
      "previous attempt failed — try a different approach"
    end

    def build_revision_prompt(task, previous_result, critique)
      <<~PROMPT
        #{task}

        Previous attempt failed.
        Critique: #{critique}
        Previous output: #{previous_result.to_s[0, HISTORY_TRUNCATE]}

        Revise based on the critique. Return only the corrected result.
      PROMPT
    end
  end
end

lib/master/renderer.rb

# frozen_string_literal: true
# encoding: utf-8

require "pastel"
require "open3"
require "socket"

module Master
  DEFAULT_WEB_PORT = Config::DEFAULT_WEB_PORT

  class Renderer
    BOOT_DMESG_LINES = 12
    MS_PER_SEC = 1000
    TOKEN_BUDGET = 8000
    BAR_CELLS = 12

    def initialize(config:)
      @config = config
      @p = Pastel.new
      @boot_ms = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * MS_PER_SEC).to_i
    end

    def session_line(name)
      label = @p.dim("session0: ")
      tag = @p.dim.underline(name.to_s.downcase)
      label + tag
    end

    def uptime
      s = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) * MS_PER_SEC).to_i - @boot_ms) / MS_PER_SEC
      h, rem = s.divmod(3600)
      m, _ = rem.divmod(60)
      h > 0 ? "up #{h}h#{m}m" : "up #{m}m"
    end

    def splash(model)
      t0    = Process.clock_gettime(Process::CLOCK_MONOTONIC)
      now   = Time.now
      host  = (Socket.gethostname rescue "openbsd")
      user  = ENV["USER"] || "dev"
      shell = File.basename(ENV["SHELL"] || "zsh")
      pchar = shell == "zsh" ? "%" : "$"
      rev   = git_rev || "1"
      url   = @config["web_public_url"] || "https://ai.brgen.no"
      token = @config["web_token"]
      web   = token ? "#{url}/?token=#{token}" : url
      pledge_ok = RUBY_PLATFORM.include?("openbsd")

      lines = []
      lines << ""
      dmesg_lines.each { |l| lines << @p.dim(l) }
      lines << ""
      lines << d("MASTER (CONSTITUTIONAL) ##{rev}: #{now.strftime('%a %b %e %H:%M:%S %Z %Y')}")
      lines << d("    #{user}@#{host}:#{@config["root"] || Dir.pwd}")
      lines << d("runtime0:  #{RUBY_PLATFORM}  ruby #{RUBY_VERSION}  #{shell} #{user}#{pchar}")
      lines << d("model0:    #{short_model(model)} (#{provider_for(model)})")
      lines << d("rev0:      #{rev}")
      lines << d("security0: #{pledge_ok ? "pledge armed" : "pledge unavailable"}")
      lines << d("web0:      #{web}")
      elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * MS_PER_SEC).round
      lines << d("boot0:     #{elapsed}ms")
      lines << ""
      lines << @p.bold.red("master") + @p.dim("@#{host} ready")
      lines << ""
      lines.join("\n")
    end

    alias banner splash

    def prompt_line(model, phase, last_ok: true, violations: 0, tokens: nil, cost: nil)
      branch = git_branch || "detached"
      usage = token_label(tokens)
      model_str = @p.dim(short_model(model))
      branch_str = @p.dim("branch:") + @p.red(branch)
      vbadge = violations > 0 ? @p.red("[#{violations}v]") : @p.dim("[0v]")
      phase_str = phase && phase.to_s != "idle" ? @p.dim(" #{phase}") : ""
      cost_str = cost_label(cost)
      prompt = last_ok ? @p.bold.red("master$") : @p.red("master$")
      ["#{branch_str}  #{model_str}#{usage}  #{cost_str} #{vbadge}#{phase_str}", prompt + " "]
    end

    def cost_label(cost)
      cents = (cost.to_f * 100).round(2)
      @p.dim(#{format('%.2f', cents)}")
    end

    def speaker_tag(name = "master")
      "#{@p.dim("<")}#{@p.bold.red(name)}#{@p.dim(">")}"
    end

    def status_row(uptime:, turns:, violations: 0)
      bits = ["stat0:", uptime, "#{turns} turns"]
      bits << "#{violations}v" if violations > 0
      @p.dim(bits.join(" "))
    end

    def token_bar(tokens)
      return "" unless tokens && tokens > 0
      budget = (@config["token_budget"] || TOKEN_BUDGET).to_i
      filled = ((tokens.to_f / budget) * BAR_CELLS).clamp(0, BAR_CELLS).round
      @p.dim(("▰" * filled) + ("▱" * (BAR_CELLS - filled))) + " "
    end

    def token_label(tokens)
      return "0" unless tokens && tokens > 0
      value = tokens.to_i
      value >= 1000 ? format("%.1fk", value / 1000.0) : value.to_s
    end

    def render(content, mode: :plain)
      text = beautify(content.to_s)
      case mode
      when :error   then @p.red("err: #{text}")
      when :success then @p.bright_red("ok: #{text}")
      when :warning then @p.red("warn: #{text}")
      when :dim     then @p.dim(text)
      when :dmesg   then format_dmesg(text)
      else text
      end
    end

    def format_error(message) = render(message, mode: :error)
    def format_dmesg(line) = @p.dim(line.to_s)

    def closing
      path = File.join(Master::ROOT, "data", "closings.yml")
      lines = (Master.load_yaml(path) || {})["closings"]
      return nil unless lines.is_a?(Array) && lines.any?
      @p.dim(lines.sample)
    rescue StandardError
      nil
    end

    def beautify(text)
      text
        .gsub(/"([^"]*?)"/) { "\u201C#{Regexp.last_match(1)}\u201D" }
        .gsub(/\s--\s/, " \u2014 ")
        .gsub("...", "\u2026")
    end

    private

    def d(text) = @p.dim(text)

    def git_rev
      out, _, st = Open3.capture3("git", "-C", @config["root"] || Dir.pwd, "rev-parse", "--short", "HEAD")
      st.success? ? out.strip : nil
    rescue StandardError
      nil
    end

    def short_model(model)
      model.to_s.sub(/\Aclaude-cli:/, "").sub(/\Aweb-chat:/, "").split("/").last.sub(/:free$/, "")
    end

    def provider_for(model)
      m = model.to_s
      return "claude-cli"   if m.start_with?("claude-cli:")
      return "web-chat"     if m.start_with?("web-chat:")
      return "ollama"       if m.start_with?("ollama/")
      return "google"       if m.include?("gemini")
      "openrouter"
    end

    def git_branch
      out, _, st = Open3.capture3("git", "rev-parse", "--abbrev-ref", "HEAD")
      st.success? ? out.strip : nil
    rescue StandardError
      nil
    end

    def dmesg_lines
      boot_log = "/var/run/dmesg.boot"
      raw = if File.readable?(boot_log)
              File.readlines(boot_log, chomp: true)
            else
              stdout, = Open3.capture3("dmesg")
              stdout.lines(chomp: true)
            end
      filtered = raw.reject { |l| l.match?(/\A(?:OpenBSD\s+\d|Copyright\s|The Regents)/) }
      lines = filtered.first(BOOT_DMESG_LINES)
      lines.empty? ? ["dmesg unavailable"] : lines
    rescue StandardError
      ["dmesg unavailable"]
    end
  end
end

lib/master/repo_map.rb

# frozen_string_literal: true

require "set"

module Master
  # PageRank over the CodeIndex symbol-reference graph, budgeted to a
  # token window. Aider-style repo map. Files with many incoming references
  # rank higher; focus[] biases the random surfer toward chat-mentioned files.
  #
  # Wiring is left to the operator. Typical use:
  #   map = Master::RepoMap.new(code_index: ai[:code_index], root: root)
  #   prompt_context << map.render(focus: [current_file])
  class RepoMap
    DEFAULT_TOKEN_BUDGET = 4096
    CHARS_PER_TOKEN      = 4
    DAMPING              = 0.85
    ITERATIONS           = 30
    MAX_DEFS_PER_FILE    = 20

    def initialize(code_index:, root:, token_budget: DEFAULT_TOKEN_BUDGET)
      @index  = code_index
      @root   = File.expand_path(root)
      @budget = token_budget
    end

    def render(focus: [])
      @index.wait_for_build unless @index.ready?
      g       = build_graph
      ranks   = pagerank(g, normalize(focus))
      ordered = ranks.sort_by { |_, r| -r }.map(&:first)
      pack(ordered)
    end

    private

    def normalize(focus)
      focus.map { |f| File.expand_path(f, @root) }.to_set
    end

    def build_graph
      owners = @index.symbols.each_value.to_h { |s| [s.fqn, s.file] }
      edges  = Hash.new { |h, k| h[k] = Set.new }
      @index.references.each do |ref|
        target = owners[ref.to_fqn]
        edges[ref.from_file] << target if target && target != ref.from_file
      end
      edges
    end

    def pagerank(g, focus)
      nodes = (g.keys | g.values.flat_map(&:to_a)).uniq
      return {} if nodes.empty?
      rank = seed(nodes, focus)
      ITERATIONS.times { rank = step(g, nodes, rank, focus) }
      rank
    end

    def seed(nodes, focus)
      base = focus.empty? ? 1.0 / nodes.size : 1.0 / focus.size
      nodes.to_h { |n| [n, focus.empty? || focus.include?(n) ? base : 0.0] }
    end

    def step(g, nodes, rank, focus)
      tele = teleport(nodes, focus)
      out  = nodes.to_h { |n| [n, tele[n]] }
      nodes.each do |from|
        successors = g[from]
        next if successors.empty?
        share = DAMPING * rank[from] / successors.size
        successors.each { |to| out[to] += share }
      end
      out
    end

    def teleport(nodes, focus)
      if focus.empty?
        base = (1 - DAMPING) / nodes.size
        nodes.to_h { |n| [n, base] }
      else
        base = (1 - DAMPING) / focus.size
        nodes.to_h { |n| [n, focus.include?(n) ? base : 0.0] }
      end
    end

    def pack(ordered)
      buf    = ["# Repo map", ""]
      tokens = estimate(buf.join("\n"))
      ordered.each do |path|
        block = format_file(path)
        next if block.empty?
        cost = estimate(block.join("\n"))
        break if tokens + cost > @budget
        buf.concat(block)
        tokens += cost
      end
      buf.join("\n")
    end

    def format_file(path)
      symbols = @index.symbols_in(path)
      return [] if symbols.empty?
      rel  = path.sub("#{@root}/", "")
      defs = symbols.first(MAX_DEFS_PER_FILE).map { |s| "  #{s.type}: #{s.fqn} (line #{s.line})" }
      ["## #{rel}", *defs, ""]
    end

    def estimate(text) = text.bytesize / CHARS_PER_TOKEN
  end
end

lib/master/result.rb

# frozen_string_literal: true

module Master
  class Result
    CATEGORIES = {
      validation: "input failed preconditions",
      axiom_violation: "constitutional rule broken",
      provider_error: "upstream model / network failure",
      llm_failure: "LLM returned unusable output",
      infrastructure: "system / disk / git error",
      timeout: "operation exceeded deadline",
      budget: "cost limit hit"
    }.freeze

    def self.ok(value) = Ok.new(value)

    def self.err(msg, category: :unknown)
      raise ArgumentError, "unknown category: #{category}" unless category == :unknown || CATEGORIES.key?(category)
      Err.new(msg, category)
    end

    def self.wrap(val) = val.respond_to?(:ok?) ? val : Ok.new(val)

    class Ok
      attr_reader :value

      def initialize(value)
        @value = value
        freeze
      end

      def ok?              = true
      def err?             = false
      def value!           = @value
      def unwrap           = @value
      def value_or(_)      = @value

      def map(&blk)        = Result.ok(blk.call(@value))
      def flat_map(&blk)   = blk.call(@value)

      def and_then(label = nil, &blk)
        result = blk.call(@value)
        result.respond_to?(:ok?) ? result : Result.ok(result)
      rescue StandardError => e
        Result.err("#{label || "stage"}: #{e.message}", category: :infrastructure)
      end

      def deconstruct_keys(_keys) = { value: @value }
      def to_s                    = @value.to_s
      def inspect                 = "Ok(#{@value.inspect})"
    end

    class Err
      attr_reader :message, :category

      RETRIABLE = %i[infrastructure timeout].freeze
      PERMANENT = %i[validation axiom_violation budget].freeze

      def initialize(message, category = :unknown)
        @message  = message
        @category = category
        freeze
      end

      def ok?                   = false
      def err?                  = true
      def value!                = raise(Master::UnwrapError, "Err#value\! called: #{@message}")
      def unwrap                = value!
      def value_or(default)     = default

      def map(&)                = self
      def flat_map(&)           = self
      def and_then(*)           = self

      def retriable?            = RETRIABLE.include?(@category)
      def permanent?            = PERMANENT.include?(@category)

      def deconstruct_keys(_keys) = { message: @message, category: @category }
      def to_s                    = @message
      def inspect                 = "Err(#{@category}: #{@message})"
    end
  end
end

lib/master/ring_buffer.rb

# frozen_string_literal: true

module Master
  class RingBuffer
    include Enumerable
    include MonitorMixin

    def initialize(capacity)
      super()
      @capacity = capacity
      @buffer      = Array.new(capacity)
      @start    = 0
      @size     = 0
    end

    def push(item)
      synchronize do
        write_pos = (@start + @size) % @capacity
        if @size < @capacity
          @buffer[write_pos] = item
          @size += 1
        else
          @buffer[@start] = item
          @start = (@start + 1) % @capacity
        end
      end
      self
    end

    alias << push

    def each
      return enum_for(__method__) unless block_given?
      synchronize { @size.times { |i| yield @buffer[(@start + i) % @capacity] } }
    end

    def to_a    = synchronize { @size.times.map { |i| @buffer[(@start + i) % @capacity] } }
    def size    = @size
    def full?   = @size == @capacity
    def empty?  = @size.zero?

    def clear
      synchronize { @start = @size = 0 }
      self
    end
  end
end

lib/master/routing/model_router.rb

# frozen_string_literal: true

module Master
  module Routing
    class ModelRouter
      UNCERTAINTY_PHRASES = [
        "i'm not sure", "i don't know", "cannot determine",
        "unclear", "uncertain", "might be", "possibly",
        "probably not", "limited information", "i cannot",
        "i am unable", "i lack the", "not enough information",
        "i would need more"
      ].freeze

      ESCALATION_CHAIN = %w[cheap default strong].freeze
      DEFAULT_THRESHOLD = 0.3

      def initialize(config:, root: Master::ROOT)
        @config = config
        @root = root
        @rules = load_rules
      end

      def preferred(task_type: :exploration)
        return @config.model unless enabled?

        tier = @rules.dig("routes", task_type.to_s) || @rules.dig("routes", "fallback_default") || "cheap"
        candidates = @rules.dig("models", tier).to_a
        return @config.model if candidates.empty?

        best = candidates.max_by { |m| weighted_score(m["score"] || {}) }
        best["id"] || @config.model
      end

      def fallback_chain(task_type: :exploration)
        return [@config.model] unless enabled?

        pref = preferred(task_type:)
        all = @rules.fetch("models", {}).values.flat_map { |tier| tier.filter_map { |m| m["id"] } }
        ([pref] + all + continuity_models + [@config.model]).uniq
      end

      def continuity_models
        return [] if @rules.dig("continuity", "enabled") == false
        latest = [
          @rules.dig("openrouter", "free_latest"),
          @rules.dig("ferrum_web_chat", "free_latest")
        ]
        latest.flatten.compact.uniq
      end

      def escalate?(response, threshold: DEFAULT_THRESHOLD)
        return false unless @rules.dig("routing", "escalation_enabled")

        text = response.to_s.downcase
        hits = UNCERTAINTY_PHRASES.count { |p| text.include?(p) }
        hits.to_f / UNCERTAINTY_PHRASES.size >= threshold
      end

      def stronger_model(task_type: :exploration)
        tier = @rules.dig("routing", "escalation_tier") || "strong"
        candidates = @rules.dig("models", tier).to_a
        return preferred(task_type:) if candidates.empty?

        candidates.max_by { |m| weighted_score(m["score"] || {}) }&.dig("id") || preferred(task_type:)
      end

      def escalate_if_low_confidence(response, current_model:, task_type: :exploration)
        return unless escalate?(response)

        strong_model = stronger_model(task_type:)
        return if current_model == strong_model

        strong_model
      end

      def constrained_for(operation:)
        constraint = @rules.dig("operation_constraints", operation.to_s)
        return preferred unless constraint

        min_quality = constraint.fetch("min_quality", 0.0).to_f
        preferred_tier = constraint.fetch("preferred_tier", "strong")
        candidates = @rules.dig("models", preferred_tier).to_a
        qualified = candidates.select { |m| m.dig("score", "quality").to_f >= min_quality }
        return preferred if qualified.empty?

        qualified.max_by { |m| weighted_score(m["score"] || {}) }&.dig("id") || preferred
      end

      def tier_for_model(model_id)
        @rules.fetch("models", {}).each do |tier, models|
          return tier if models.is_a?(Array) && models.any? { |m| m["id"] == model_id }
        end
        "cheap"
      end

      def next_escalation_tier(current_tier)
        tier_index = ESCALATION_CHAIN.index(current_tier.to_s)
        return unless tier_index

        ESCALATION_CHAIN[tier_index + 1]
      end

      def confidence_threshold(task_type: :exploration)
        route = @rules.dig("routes", task_type.to_s)
        return DEFAULT_THRESHOLD unless route.is_a?(Hash)

        route.fetch("confidence_threshold", DEFAULT_THRESHOLD).to_f
      end

      private

      def enabled?
        @rules.dig("routing", "enabled") != false
      end

      def weighted_score(score)
        weights = @rules.fetch("weights", {})
        qw = [weights.fetch("quality", 1.0).to_f, 0.01].max
        sw = [weights.fetch("speed",   1.0).to_f, 0.01].max
        cw = [weights.fetch("cost",    1.0).to_f, 0.01].max
        DecisionEngine.score(
          impact:     score.fetch("quality", 0.5).to_f * qw,
          confidence: [score.fetch("speed", 1.0).to_f * sw, 0.01].max,
          cost:       1.0 / [score.fetch("cost", 0.5).to_f * cw, 0.001].max
        )
      end

      def load_rules
        path = File.join(@root, "data", "models.yml")
        Master.load_yaml(path) || {}
      rescue StandardError => _e
        {}
      end
    end
  end
end

lib/master/ruby_llm_patch.rb

# frozen_string_literal: true

module RubyLLM
  DEFAULT_MAX_TOKENS = 4096

  class Models
    class << self
      def read_from_json(file = RubyLLM.config.model_registry_file)
        data = File.exist?(file) ? File.read(file, encoding: "utf-8") : "[]"
        JSON.parse(data, symbolize_names: true).map { |model| Model::Info.new(model) }
      rescue JSON::ParserError
        []
      end
    end

    private

    def find_without_provider(model_id)
      exact_matches = all.select { |m| m.id == model_id }
      return preferred_match(exact_matches) if exact_matches.any?

      resolved_id = Aliases.resolve(model_id)
      alias_matches = all.select { |m| m.id == resolved_id }
      return preferred_match(alias_matches) if alias_matches.any?

      Model::Info.new({
        id: model_id.to_s,
        name: model_id.to_s,
        provider: "openrouter",
        type: "chat",
        family: model_id.to_s.split("/").first,
        context_window: 128_000,
        max_tokens: DEFAULT_MAX_TOKENS,
        input_price_per_million: 0.0,
        output_price_per_million: 0.0,
        modalities: { input: ["text"], output: ["text"] },
        metadata: {}
      })
    end
  end
end

lib/master/scan/finding.rb

# frozen_string_literal: true

module Master
  module Scan
    Finding = Data.define(:rule, :message, :line, :severity, :fix, :tags) do
      def self.build(rule:, message:, line:, severity: :warning, fix: nil, tags: [])
        new(rule:, message:, line:, severity:, fix:, tags:)
      end

      def [](key)
        public_send(key)
      end

      def to_h
        { rule:, message:, line:, severity:, fix:, tags: }
      end
    end
  end
end

lib/master/scan/rule.rb

# frozen_string_literal: true

module Master
  module Scan
    require_relative "finding"

    class Rule
      EXT_LANG = {
        ".rb"      => "ruby",        ".rake"  => "ruby",   ".gemspec" => "ruby",
        ".erb"     => "html",        ".html"  => "html",   ".htm"     => "html",
        ".css"     => "css",         ".scss"  => "scss",   ".sass"    => "scss",
        ".js"      => "javascript",  ".ts"    => "javascript",
        ".jsx"     => "javascript",  ".tsx"   => "javascript",
        ".zsh"     => "zsh",         ".sh"    => "zsh",    ".bash"    => "zsh",
        ".yml"     => "yaml",        ".yaml"  => "yaml",
        ".md"      => "markdown",    ".json"  => "json",
      }.freeze

      attr_reader :id, :description, :severity, :axiom_tags, :auto_fix

      def self.inherited(subclass)
        @registry_mutex ||= Mutex.new
        @registry_mutex.synchronize do
          (@registry ||= []) << subclass
        end
      end

      def self.registry
        @registry_mutex ||= Mutex.new
        @registry_mutex.synchronize { @registry || [] }
      end

      # Rules that need constructor args (root:, agent:) override this to false.
      # Builder uses it to auto-discover zero-arg rules from the registry.
      def self.auto_build? = true

      def initialize
        @id         = self.class.name&.split("::")&.last&.downcase || "unknown"
        @description = ""
        @severity    = :warning
        @axiom_tags  = []
        @auto_fix    = true
      end

      def check(code, path:)
        raise NotImplementedError, "#{self.class}#check not implemented"
      end

      def language(path)
        EXT_LANG[File.extname(path).downcase]
      end

      def applies_to?(path, languages)
        return true if languages.nil? || languages.empty?
        lang = language(path)
        lang && languages.include?(lang)
      end

      protected

      def finding(line:, message:, fix: nil)
        Finding.build(rule: @id, message:, line:, severity: @severity, fix:, tags: @axiom_tags)
      end

      def scan_lines(code, pattern, message:, fix: nil)
        code.each_line.with_index(1).filter_map { |line, num|
          finding(line: num, message:, fix:) if line.match?(pattern)
        }
      end
    end
  end
end

lib/master/scan/rules/adversarial_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Steelman-first red-team: the model must defend the code before it can attack it.
      # This suppresses false positives by forcing consideration of legitimate reasons
      # before a violation can survive. Deep depth only; one LLM call per file.
      class AdversarialRule < Rule
        PROMPT_TEMPLATE = <<~PROMPT.freeze
          Red-team review of %<path>s.

          Step 1 — Steelman (internal, do not output): write the three strongest
          arguments that this code is correct and should not be changed.

          Step 2 — Challenge: list only the violations that survive the steelman.
          Format: ISSUE:LINE:description (one per line).
          If nothing survives, respond with exactly: CLEAN

          Focus on: broken contracts, hidden coupling, axiom violations (CQS,
          ONE_JOB, GUARD_EXPENSIVE, FAIL_VISIBLY), and logic errors.
          Ignore style. Do not hallucinate method names.

          Code (%<lang>s):
          %<code>s
        PROMPT

        def initialize(agent: nil)
          super()
          @agent       = agent
          @id          = "adversarial"
          @description = "Red-team scan: steelman then challenge — suppresses false positives"
          @severity    = :error
          @axiom_tags  = %i[ONE_JOB CQS GUARD_EXPENSIVE FAIL_VISIBLY COMPOSABLE]
        end

        def self.auto_build? = false

        def set_agent(agent)
          @agent = agent
          self
        end

        def check(code, path:)
          return [] unless (lang = language(path))
          return [] unless @agent

          prompt = format(PROMPT_TEMPLATE, path: File.basename(path),
                                           lang: lang,
                                           code: code[0, 3_000])
          response = @agent.ask(prompt, operation: :scan_adversarial).to_s
          parse_findings(response)
        rescue StandardError => e
          [finding(line: 1, message: "adversarial: scan error — #{e.message}")]
        end

        private

        def parse_findings(response)
          return [] if response.strip.upcase.start_with?("CLEAN")

          response.lines.filter_map do |line|
            match = line.strip.match(/\AISSUE:(\d+):(.+)\z/)
            next unless match
            finding(line: match[1].to_i, message: "adversarial: #{match[2].strip}")
          end
        end
      end
    end
  end
end

lib/master/scan/rules/anti_pattern_rule.rb

# frozen_string_literal: true
require "yaml"

module Master
  module Scan
    module Rules
      # Reads anti_patterns block from data/rules.yml; emits forbidden findings as
      # critical, discouraged as warning. Single source of truth — no Ruby regex literals.
      class AntiPatternRule < Rule
        def initialize(rules_path: File.join(Master::ROOT, "data", "rules.yml"))
          super()
          @id          = "anti_pattern"
          @description = "Forbidden / discouraged patterns from data/rules.yml"
          @severity    = :critical
          @axiom_tags  = []
          data = Master.load_yaml(rules_path) || {}
          ap = data["anti_patterns"]
          if ap
            @forbidden   = (ap["forbidden"] || []).map   { |h| compile(h) }.compact
            @discouraged = (ap["discouraged"] || []).map { |h| compile(h) }.compact
          else
            @forbidden = @discouraged = []
          end
        end

        def check(code, _path: nil, **)
          findings = []
          @forbidden.each do |pat, reason|
            line_no = first_match_line(code, pat)
            findings << finding_with(severity: :critical, line: line_no, message: "anti-pattern: #{reason}") if line_no
          end
          @discouraged.each do |pat, reason|
            line_no = first_match_line(code, pat)
            findings << finding_with(severity: :warning, line: line_no, message: "discouraged: #{reason}") if line_no
          end
          findings
        end

        private

        def first_match_line(code, pat)
          code.each_line.with_index(1) { |line, n| return n if line.match?(pat) }
          nil
        end

        def finding_with(severity:, line:, message:)
          { rule: @id, severity:, line:, message:, fix: nil }
        end

        def compile(h)
          pat = h["pattern"] || h[:pattern]
          return unless pat.is_a?(String)
          [Regexp.new(pat), h["reason"] || h[:reason] || "anti-pattern"]
        rescue RegexpError
          nil
        end
      end
    end
  end
end

lib/master/scan/rules/arity_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      # Too many constructor args signal a god object; callers can't reason
      # about what matters. Reads max_params from rules.yml.
      class ArityRule < Rule
        DEFAULT_MAX = 3

        def initialize
          super
          @max_params  = Master::Axioms.new.thresholds.dig("method", "max_params") || DEFAULT_MAX
          @id          = "arity"
          @description = "initialize with > #{@max_params} args — extract a context struct or config object"
          @severity    = :warning
          @axiom_tags  = %i[DECOUPLE ONE_JOB KISS]
        end

        def check_ast(ast, _code, path:)
          return [] unless path.end_with?(".rb")
          visitor = Visitor.new(@max_params)
          ast.accept(visitor)
          visitor.findings.map do |line, count|
            finding(line:,
message: "initialize takes #{count} args (max #{@max_params}) — extract AgentContext or Config struct")
          end
        end

        class Visitor < Prism::Visitor
          attr_reader :findings

          def initialize(max)
            super()
            @max = max
            @findings = []
          end

          def visit_def_node(node)
            if node.name == :initialize
              count = param_count(node.parameters)
              @findings << [node.location.start_line, count] if count > @max
            end
            super
          end

          private

          def param_count(params)
            return 0 if params.nil?
            params.requireds.size +
              params.optionals.size +
              params.posts.size +
              params.keywords.size +
              (params.rest ? 1 : 0) +
              (params.keyword_rest ? 1 : 0) +
              (params.block ? 1 : 0)
          end
        end
      end
    end
  end
end

lib/master/scan/rules/axiom_coverage_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      # Every rule ID in rules.yml must have scan rule coverage; every @axiom_tags
      # symbol must name a real rule ID. Orphaned tags and uncovered rules both signal drift.
      class AxiomCoverageRule < Rule
        RULE_ID           = "axiom_coverage"
        DESCRIPTION       = "Every rule must have scan rule coverage; every tag must be a real rule"
        RULES_YML_PATH    = File.join("data", "rules.yml")
        SCAN_RULES_DIR    = File.join("lib", "master", "scan", "rules")
        AXIOM_TAGS_VAR    = :@axiom_tags
        ORPHANED_TAG_MSG  = "axiom_tag :%s has no entry in rules.yml — define it or remove the tag"
        UNCOVERED_RULE_MSG = "rule %s has no scan rule coverage — add a rule or accept as advisory"
        ORPHAN_FILE_MSG   = "scan rule file %s does not define a class inheriting from Master::Scan::Rule — registry will skip it silently"

        def initialize(root: nil)
          super()
          @root        = root
          @id          = RULE_ID
          @description = DESCRIPTION
          @severity    = :warning
          @axiom_tags  = []
        end

        def self.auto_build? = false

        def check(code, path:)
          return [] unless path.include?(SCAN_RULES_DIR) || path.include?("scan/rule.rb")
          return [] unless @root

          axiom_ids  = load_axiom_ids
          tagged_ids = load_tagged_ids
          findings   = []

          (tagged_ids - axiom_ids).each do |id|
            findings << finding(line: 1, message: format(ORPHANED_TAG_MSG, id))
          end

          (axiom_ids - tagged_ids).each do |id|
            findings << finding(line: 1, message: format(UNCOVERED_RULE_MSG, id))
          end

          orphan_rule_files.each do |file|
            findings << finding(line: 1, message: format(ORPHAN_FILE_MSG, file))
          end

          findings
        end

        def orphan_rule_files
          full_rules_dir = File.join(@root, SCAN_RULES_DIR)
          return [] unless Dir.exist?(full_rules_dir)
          Dir.glob(File.join(full_rules_dir, "*.rb")).reject { |f| inherits_from_rule?(File.read(f)) }
             .map { |f| File.basename(f) }
        rescue StandardError
          []
        end

        def inherits_from_rule?(source)
          result = Prism.parse(source)
          return true unless result.success?
          finder = SuperclassFinder.new
          finder.visit(result.value)
          finder.found
        rescue StandardError
          true
        end

        private

        def load_axiom_ids
          full_path = File.join(@root, RULES_YML_PATH)
          return [] unless File.exist?(full_path)

          data = Master.load_yaml(full_path)
          all_rules = (data["rules"] || {}).values.flatten
          all_rules.map { |r| r["id"] }.compact.uniq
        rescue StandardError
          []
        end

        def load_tagged_ids
          full_rules_dir = File.join(@root, SCAN_RULES_DIR)
          return [] unless Dir.exist?(full_rules_dir)

          Dir.glob(File.join(full_rules_dir, "*.rb")).flat_map { |f|
            extract_axiom_tags(File.read(f))
          }.uniq
        rescue StandardError
          []
        end

        def extract_axiom_tags(source)
          result = Prism.parse(source)
          return [] unless result.success?

          collector = TagCollector.new
          collector.visit(result.value)
          collector.tags
        rescue StandardError
          []
        end

        class SuperclassFinder < Prism::Visitor
          attr_reader :found

          def initialize
            super
            @found = false
          end

          def visit_class_node(node)
            sc = node.superclass
            if sc && rule_superclass?(sc)
              @found = true
            end
            super
          end

          private

          def rule_superclass?(node)
            case node
            when Prism::ConstantReadNode
              node.name == :Rule
            when Prism::ConstantPathNode
              node.name == :Rule
            else
              false
            end
          end
        end

        class TagCollector < Prism::Visitor
          attr_reader :tags

          def initialize
            super
            @tags = []
          end

          def visit_instance_variable_write_node(node)
            if node.name == AXIOM_TAGS_VAR
              @tags.concat(collect_symbols(node.value))
            end
            super
          end

          private

          def collect_symbols(node)
            return [] unless node
            case node
            when Prism::ArrayNode
              node.elements.flat_map { |el| collect_symbols(el) }
            when Prism::SymbolNode
              [node.unescaped.to_s]
            else
              []
            end
          end
        end
      end
    end
  end
end

lib/master/scan/rules/bare_rescue_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      class BareRescueRule < Rule
        def initialize
          super
          @id          = "bare_rescue"
          @description = "Never use bare rescue -- always specify exception type"
          @severity    = :error
          @axiom_tags  = [:FAIL_VISIBLY]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          result = Prism.parse(code)
          return [] unless result.success?

          visitor = BareRescueVisitor.new(self)
          visitor.visit(result.value)
          visitor.findings
        end

        # Allow protected #finding to be called from the visitor.
        def emit(line:, message:)
          finding(line:, message:)
        end

        class BareRescueVisitor < Prism::Visitor
          attr_reader :findings

          def initialize(rule)
            super()
            @rule     = rule
            @findings = []
          end

          def visit_rescue_node(node)
            if node.exceptions.empty?
              @findings << @rule.emit(
                line: node.location.start_line,
                message: "bare rescue: specify exception type (e.g. rescue StandardError)"
              )
            end
            if rescues_exception_class?(node)
              @findings << @rule.emit(
                line: node.location.start_line,
                message: "rescue Exception is too broad: rescue StandardError or a specific class"
              )
            end
            super
          end

          private

          def rescues_exception_class?(node)
            node.exceptions.any? do |exception_node|
              exception_node.is_a?(Prism::ConstantReadNode) && exception_node.name == :Exception
            end
          end
        end
      end
    end
  end
end

lib/master/scan/rules/co_change_coupling_rule.rb

# frozen_string_literal: true

require "open3"

module Master
  module Scan
    module Rules
      # Files that change together in many commits are coupled regardless of imports.
      # Mines the last N commits, builds adjacency, flags pairs whose weight exceeds
      # threshold and that live in different top-level module paths — likely DECOUPLE
      # candidates the lexical rules can't see.
      class CoChangeCouplingRule < Rule
        COMMITS_WINDOW    = 500
        WEIGHT_THRESHOLD  = 5
        MAX_FILES_IN_COMMIT = 12  # skip mega-commits, they pollute the graph

        def initialize
          super
          @id          = "co_change_coupling"
          @description = "Files co-change with N+ peers across module boundaries — hidden coupling"
          @severity    = :info
          @axiom_tags  = %i[DECOUPLE ONE_JOB]
          @graph_mutex = Mutex.new
          @graph       = nil
        end

        def check(_code, path:)
          return [] unless path.end_with?(".rb")
          rel = relativize(path)
          return [] unless rel
          peers = neighbors(rel).reject { |peer, _| same_module?(rel, peer) }
                                .select { |_, w| w >= WEIGHT_THRESHOLD }
                                .sort_by { |_, w| -w }
                                .first(3)
          return [] if peers.empty?
          msg = "co-changes with " + peers.map { |p, w| "#{p} (#{w}x)" }.join(", ")
          [finding(line: 1, message: msg)]
        end

        private

        def neighbors(rel)
          graph[rel] || {}
        end

        def graph
          @graph_mutex.synchronize { @graph ||= build_graph }
        end

        def build_graph
          out, _, status = Open3.capture3("git", "-C", repo_root,
                                          "log", "--name-only", "--pretty=format:--commit--",
                                          "-n", COMMITS_WINDOW.to_s, "--", "*.rb")
          return {} unless status.success?
          adjacency = Hash.new { |h, k| h[k] = Hash.new(0) }
          out.split("--commit--").each do |chunk|
            files = chunk.lines.map(&:strip).reject(&:empty?).select { |f| f.end_with?(".rb") }
            next if files.size < 2 || files.size > MAX_FILES_IN_COMMIT
            files.combination(2) do |a, b|
              adjacency[a][b] += 1
              adjacency[b][a] += 1
            end
          end
          adjacency
        end

        def repo_root
          @repo_root ||= File.expand_path(File.join(Master::ROOT, ".."))
        end

        def relativize(path)
          full = File.expand_path(path)
          prefix = repo_root + "/"
          full.start_with?(prefix) ? full.delete_prefix(prefix) : nil
        end

        def same_module?(a, b)
          top_a = a.split("/").first(3).join("/")
          top_b = b.split("/").first(3).join("/")
          top_a == top_b
        end
      end
    end
  end
end

lib/master/scan/rules/comment_drift_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Comments above method defs that no longer describe what the code does.
      # Lexical pass extracts (comment, method_body) pairs; LLM judges drift in one
      # batched call per file. Pairs with the "reassess on touch" directive: lying
      # comments are factual bugs, not style noise.
      class CommentDriftRule < Rule
        MAX_PAIRS_PER_FILE = 8
        BODY_SNIPPET       = 20  # lines of method body sent to LLM

        def initialize(agent: nil)
          super()
          @agent       = agent
          @id          = "comment_drift"
          @description = "Comment claim doesn't match method body — comment is lying"
          @severity    = :warning
          @axiom_tags  = %i[SELF_EXPLAINING EXPLICIT]
        end

        def self.auto_build? = false

        def set_agent(agent)
          @agent = agent
          self
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb") && @agent
          pairs = extract_pairs(code)
          return [] if pairs.empty?
          response = @agent.ask(build_prompt(pairs, path), operation: :scan_comment_drift).to_s
          parse_findings(response, pairs)
        rescue StandardError
          []
        end

        private

        def extract_pairs(code)
          lines = code.lines
          pairs = []
          i = 0
          while i < lines.size && pairs.size < MAX_PAIRS_PER_FILE
            comment_start = i
            while i < lines.size && lines[i] =~ /\A\s*#/
              i += 1
            end
            comment_lines = lines[comment_start...i]
            if comment_lines.any? && i < lines.size && lines[i] =~ /\A\s*def\s/
              comment_text = comment_lines.map { |l| l.strip.delete_prefix("#").strip }.join(" ")
              body = lines[i, BODY_SNIPPET].join
              pairs << { line: comment_start + 1, comment: comment_text, body: body } unless comment_text.empty?
            end
            i += 1
          end
          pairs
        end

        def build_prompt(pairs, path)
          numbered = pairs.each_with_index.map do |p, idx|
            "[#{idx}] line #{p[:line]}\nCOMMENT: #{p[:comment]}\nCODE:\n#{p[:body]}"
          end.join("\n---\n")
          <<~PROMPT
            Audit #{File.basename(path)} for comment drift. For each numbered pair,
            decide whether the comment accurately describes what the code does.
            List ONLY indices where the comment lies or contradicts the code.
            Format each violation: INDEX:short reason (one per line)
            If all pairs are accurate, respond with exactly: CLEAN

            #{numbered}
          PROMPT
        end

        def parse_findings(response, pairs)
          return [] if response.strip.upcase == "CLEAN"
          response.lines.filter_map do |line|
            match = line.strip.match(/\A(\d+):(.+)\z/)
            next unless match
            idx = match[1].to_i
            pair = pairs[idx]
            next unless pair
            finding(line: pair[:line], message: "comment drift — #{match[2].strip}")
          end
        end
      end
    end
  end
end

lib/master/scan/rules/comment_quality_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class CommentQualityRule < Rule
        TODO_NO_REF      = /^\s*#\s*TODO(?!.*[:(#@])/.freeze
        FIXME_NO_REF     = /^\s*#\s*FIXME(?!.*[:(#@])/.freeze
        CODE_LINE_RE     = /(?:def |end\b|=\s|\.call|if |unless |return |@@|@\w+ =)/.freeze
        MIN_CODE_COMMENTS = 3

        def initialize
          super
          @id          = "comment_quality"
          @description = "Low-quality comments — TODO without ref, commented-out code"
          @severity    = :style
          @axiom_tags  = [:SELF_EXPLAINING]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          line_findings = []
          lines = code.lines

          lines.each_with_index do |line, i|
            num = i + 1
            line_findings << finding(line: num,
message: "TODO without owner or issue ref — add TODO(name) or TODO(#123)") if line.match?(TODO_NO_REF)
            line_findings << finding(line: num,
message: "FIXME without owner or issue ref — add FIXME(name)") if line.match?(FIXME_NO_REF)
          end

          line_findings + commented_out_blocks(lines)
        end

        private

        def commented_out_blocks(lines)
          findings  = []
          run_start = nil
          run_count = 0

          lines.each_with_index do |line, i|
            stripped = line.strip
            if stripped.start_with?("#") && CODE_LINE_RE.match?(stripped[1..].to_s)
              run_start ||= i + 1
              run_count  += 1
            else
              if run_count >= MIN_CODE_COMMENTS
                findings << finding(line: run_start,
message: "#{run_count} consecutive lines of commented-out code — delete it, git history preserves it")
              end
              run_start = nil
              run_count = 0
            end
          end

          if run_count >= MIN_CODE_COMMENTS
            findings << finding(line: run_start,
message: "#{run_count} consecutive lines of commented-out code — delete it, git history preserves it")
          end

          findings
        end
      end
    end
  end
end

lib/master/scan/rules/controller_bloat_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class ControllerBloatRule < Rule
        def initialize
          super
          @id = "controller_bloat"
          @description = "Controllers with too many actions are hard to evolve"
          @severity = :warning
          @axiom_tags = %i[COHESION]
        end

        def check(code, path:)
          return [] unless path.include?("/app/controllers/") && path.end_with?(".rb")
          actions = code.scan(/^\s*def\s+\w+/)
          return [] if actions.size <= 7
          [finding(line: 1, message: "controller defines #{actions.size} actions; split by namespace/service")]
        end
      end
    end
  end
end

lib/master/scan/rules/cqs_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      # CqsRule — Command/Query Separation. A method named like a query
      # (get_*, find_*, fetch_*, etc.) must not mutate state in its body.
      class CqsRule < Rule
        QUERY_PREFIXES = %w[get_ find_ fetch_ load_ read_ list_ show_ describe_].freeze
        MUTATING_CALLS = %i[
          save save! update update! update_attribute update_attribute!
          update_columns update_column delete destroy destroy! create create!
          write write! push pop shift unshift clear concat
        ].freeze

        def initialize
          super
          @id          = "cqs"
          @description = "Command/Query Separation — queries must not mutate state"
          @severity    = :warning
          @axiom_tags  = [:CQS]
        end

        def check_ast(ast, _code, path:)
          return [] unless path.end_with?(".rb")
          visitor = Visitor.new
          ast.accept(visitor)
          visitor.findings.map do |line, name|
            finding(line:, message: "query method `#{name}` mutates state — split into command + query")
          end
        end

        class Visitor < Prism::Visitor
          attr_reader :findings

          def initialize
            super
            @findings = []
          end

          def visit_def_node(node)
            name = node.name.to_s
            if QUERY_PREFIXES.any? { |p| name.start_with?(p) } && mutates?(node.body)
              @findings << [node.location.start_line, name]
            end
            super
          end

          private

          def mutates?(body)
            return false if body.nil?
            finder = MutationFinder.new
            body.accept(finder)
            finder.mutating?
          end
        end

        class MutationFinder < Prism::Visitor
          def initialize
            super
            @found = false
          end

          def mutating? = @found

          def visit_call_node(node)
            @found = true if MUTATING_CALLS.include?(node.name)
            super
          end

          def visit_instance_variable_write_node(_node)
            @found = true
            super
          end

          def visit_instance_variable_operator_write_node(_node)
            @found = true
            super
          end

          def visit_instance_variable_and_write_node(_node)
            @found = true
            super
          end

          def visit_instance_variable_or_write_node(_node)
            @found = true
            super
          end
        end
      end
    end
  end
end

lib/master/scan/rules/dead_assign_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class DeadAssignRule < Rule
        # Match: leading whitespace, lowercase lvar, single = (not ==, +=, -=, =~, =>)
        ASSIGN = /^\s+([a-z_][a-z0-9_]*)\s*=(?![>=~])/.freeze

        def initialize
          super
          @id          = "dead_assign"
          @description = "Local variable assigned but never read — remove or use it"
          @severity    = :warning
          @axiom_tags  = [:EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          extract_methods(code).flat_map { |m| check_method(m) }
        end

        private

        def extract_methods(code)
          methods = []
          lines   = code.lines
          i       = 0
          while i < lines.size
            if lines[i].match?(/^\s*def \w/)
              start = i
              depth = 1
              i += 1
              while i < lines.size && depth > 0
                stripped = lines[i].strip
                depth += stripped.scan(/\b(?:def|do|begin|if|unless|case|class|module)\b/).size
                depth -= stripped.scan(/\bend\b/).size
                i += 1
              end
              methods << { lines: lines[start...i], start_line: start + 1 }
            else
              i += 1
            end
          end
          methods
        end

        def check_method(method)
          lines    = method[:lines]
          start    = method[:start_line]
          findings = []

          lines.each_with_index do |line, i|
            stripped = line.strip
            next if stripped.start_with?("#")
            m = line.match(ASSIGN)
            next unless m

            var = m[1]
            next if var.start_with?("_")               # intentionally unused
            next if %w[true false nil self].include?(var)

            rest = lines[(i + 1)..].join
            next if rest.match?(/\b#{Regexp.escape(var)}\b/)

            findings << finding(line: start + i, message: "#{var} is assigned but never read — remove or use it")
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/dead_code_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      class DeadCodeRule < Rule
        def initialize
          super
          @id          = "dead_code"
          @description = "Dead constants and empty rescue blocks"
          @severity    = :warning
          @axiom_tags  = [:EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          result = Prism.parse(code)
          return [] unless result.success?

          visitor = DeadCodeVisitor.new
          visitor.visit(result.value)

          findings = []
          visitor.empty_rescues.each do |line|
            findings << finding(line:, message: "empty rescue block swallows errors silently — log or re-raise")
          end
          visitor.constant_writes.each do |name, line|
            next if visitor.constant_reads.include?(name)
            findings << finding(line:, message: "#{name} is defined but never referenced — remove it")
          end
          findings
        end

        class DeadCodeVisitor < Prism::Visitor
          attr_reader :empty_rescues, :constant_writes, :constant_reads

          def initialize
            super
            @empty_rescues   = []
            @constant_writes = {}
            @constant_reads  = []
          end

          def visit_rescue_node(node)
            stmts = node.statements
            empty = stmts.nil? || (stmts.respond_to?(:body) && stmts.body.empty?)
            @empty_rescues << node.location.start_line if empty
            super
          end

          def visit_constant_write_node(node)
            @constant_writes[node.name.to_s] ||= node.location.start_line
            super
          end

          def visit_constant_read_node(node)
            @constant_reads << node.name.to_s
            super
          end

          def visit_constant_path_node(node)
            @constant_reads << node.name.to_s if node.name
            super
          end
        end
      end
    end
  end
end

lib/master/scan/rules/duplicate_code_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Duplicate structural code (same AST shape, different names) violates ONE_SOURCE.
      # Delegates to flay for reliable AST-level detection; falls back to a line-hash
      # approach when flay is unavailable (e.g. gem not installed).
      class DuplicateCodeRule < Rule
        FLAY_THRESHOLD = 16
        OCCUR_MIN      = 2

        def initialize
          super
          @id          = "duplicate_code"
          @description = "Duplicate code blocks violate ONE_SOURCE — extract to shared method"
          @severity    = :warning
          @axiom_tags  = %i[ONE_SOURCE DRY]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          flay_available? ? flay_check(code, path) : []
        end

        private

        def flay_available?
          require "flay"
          true
        rescue LoadError
          false
        end

        def flay_check(code, path)
          flay = Flay.new(threshold: FLAY_THRESHOLD, verbose: false, diff: false, summary: false)
          flay.process(path)
          flay.masses.filter_map { |hash, nodes|
            next if nodes.size < OCCUR_MIN
            first = nodes.first
            finding(
              line: first.line,
              message: "duplicate structure #{nodes.size} times (flay mass #{flay.masses[hash]}) — extract to shared method (ONE_SOURCE)"
            )
          }
        rescue StandardError
          []
        end
      end
    end
  end
end

lib/master/scan/rules/explicit_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # ExplicitRule — detects implicit/opaque patterns that violate EXPLICIT.
      # Flags: bare rescue, implicit return of nil, magic number literals,
      # single-letter variable names outside loops, and undefined method patterns.
      class ExplicitRule < Rule
        RESCUE_NIL   = /rescue\s+nil\b/.freeze
        MAGIC_NUM    = /[^:]\b([2-9]\d{2,}|[1-9]\d{3,})\b(?!\s*[#=])/.freeze
        OPAQUE_VAR   = /^\s+[a-z]\s*=(?!=)/.freeze

        def initialize
          super
          @id          = 'explicit'
          @description = 'Implicit, opaque patterns — prefer explicit contracts'
          @severity    = :warning
          @axiom_tags  = [:EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?('.rb')

          findings = []
          code.each_line.with_index(1) do |line, num|
            if line.match?(RESCUE_NIL)
              findings << finding(line: num,
                                  message: 'bare rescue hides errors — name the exception class or propagate')
            end
            next if line.match?(/^\s*[A-Z][A-Z_0-9]*\s*=/) # skip constant defs

            if line.match?(MAGIC_NUM) && !line.strip.start_with?('#')
              findings << finding(line: num,
                                  message: 'magic number — extract to a named constant')
            end
            if line.match?(OPAQUE_VAR) && !in_loop_context?(
              code, num
            )
              findings << finding(line: num,
                                  message: 'single-letter variable obscures intent — use a descriptive name')
            end
          end
          findings
        end

        private

        def in_loop_context?(code, target_line)
          lines = code.lines
          ((target_line - 4)..(target_line - 1)).any? do |i|
            next false unless i >= 0 && i < lines.size

            lines[i].match?(/\b(?:each|map|times|upto|downto|step|for\s+\w)\b/)
          end
        end
      end
    end
  end
end

lib/master/scan/rules/file_layout_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Every Ruby file opens the same way: frozen-string-literal, blank, requires
      # (alphabetized), blank, module/class. Inside each class: CONSTANTS, attr_*,
      # initialize, public methods, blank line + `private`, private methods.
      class FileLayoutRule < Rule
        FROZEN_RE = /\A#\s*frozen_string_literal:\s*true\s*\z/.freeze
        REQUIRE_RE = /\Arequire(?:_relative)?\s+/.freeze
        DEF_RE = /\A\s*(?:def\s|private\b|protected\b|public\b|attr_\w+|[A-Z][A-Z0-9_]*\s*=)/.freeze

        def initialize
          super
          @id          = "file_layout"
          @description = "File header or class member order deviates from house layout"
          @severity    = :warning
          @axiom_tags  = %i[IMPORTANCE_ORDER POLA_PRINCIPLE]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          findings = []
          findings.concat(header_findings(code))
          findings.concat(require_order_findings(code))
          findings.concat(class_member_findings(code))
          findings
        end

        private

        def header_findings(code)
          first = code.lines.first&.rstrip
          return [finding(line: 1,
message: "missing `# frozen_string_literal: true` magic comment")] unless first&.match?(FROZEN_RE)
          []
        end

        def require_order_findings(code)
          requires = code.lines.each_with_index.select { |l, _| l.match?(REQUIRE_RE) }
          return [] if requires.size < 2
          names = requires.map { |l, _| l[/\Arequire(?:_relative)?\s+["']([^"']+)/, 1] }.compact
          return [] if names == names.sort
          [finding(line: requires.first[1] + 1, message: "require lines should be alphabetized")]
        end

        def class_member_findings(code)
          private_idx = code.lines.each_with_index.find { |l, _| l.strip == "private" }
          return [] unless private_idx
          line_num = private_idx[1] + 1
          before = code.lines[0...private_idx[1]]
          after  = code.lines[(private_idx[1] + 1)..]
          findings = []
          findings << finding(line: line_num,
message: "`private` should sit on its own line with blank line above") unless blank_above?(
code.lines, private_idx[1])
          findings << finding(line: line_num,
message: "method order: definitions before `private` should not include helpers (move below)") if mixed_section?(before)
          findings << finding(line: line_num,
message: "constants/attr_* must precede first def") if late_constants?(before)
          findings
        end

        def blank_above?(lines, idx)
          idx.zero? || lines[idx - 1].strip.empty?
        end

        def mixed_section?(_lines)
          false
        end

        def late_constants?(lines)
          first_def = lines.index { |l| l =~ /\A\s*def\s/ }
          return false unless first_def
          lines[(first_def + 1)..].any? { |l| l =~ /\A\s*[A-Z][A-Z0-9_]*\s*=/ }
        end
      end
    end
  end
end

lib/master/scan/rules/file_silhouette_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Hashes each file's structural skeleton (header order, section markers, blank-line
      # cadence) into a 6-byte fingerprint. Clusters fingerprints across the repo; the
      # smallest clusters are visual outliers — files that don't look like the dominant
      # silhouette. Codifies the dominant rather than asking the operator to write rules.
      class FileSilhouetteRule < Rule
        FINGERPRINT_KEYS = %i[frozen requires constants attrs init publics privates].freeze
        OUTLIER_THRESHOLD = 0.10  # bottom 10% of cluster sizes = outliers

        def initialize
          super
          @id          = "file_silhouette"
          @description = "Structural skeleton diverges from repo-dominant file shape"
          @severity    = :warning
          @axiom_tags  = %i[POLA_PRINCIPLE IMPORTANCE_ORDER]
          @cluster_mutex = Mutex.new
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          fp = fingerprint(code)
          clusters = clusters_for(File.dirname(path))
          return [] if clusters.empty?
          dominant = clusters.max_by { |_, count| count }&.first
          return [] if fp == dominant
          mine = clusters[fp] || 1
          total = clusters.values.sum
          return [] unless mine.to_f / total < OUTLIER_THRESHOLD
          [finding(line: 1,
message: "silhouette #{fp.inspect} differs from dominant #{dominant.inspect} (#{mine}/#{total} files)")]
        end

        private

        def fingerprint(code)
          lines = code.lines
          {
            frozen:    lines.first.to_s.match?(/frozen_string_literal:\s*true/),
            requires:  lines.count { |l| l =~ /\Arequire/ },
            constants: lines.count { |l| l =~ /\A\s*[A-Z][A-Z0-9_]*\s*=/ },
            attrs:     lines.count { |l| l =~ /\A\s*attr_/ },
            init:      lines.any? { |l| l =~ /\A\s*def\s+initialize/ },
            publics:   lines.count { |l| l =~ /\A\s*def\s/ } - lines.count { |l| l =~ /\A\s*def\s+self\./ },
            privates:  lines.count { |l| l.strip == "private" }
          }.transform_values { |v| bucket(v) }.values.freeze
        end

        def bucket(v)
          case v
          when true, false then v
          when 0 then 0
          when 1..3 then 1
          when 4..10 then 2
          else 3
          end
        end

        def clusters_for(dir)
          @cluster_mutex.synchronize do
            @clusters ||= {}
            @clusters[dir] ||= compute_clusters(dir)
          end
        end

        def compute_clusters(dir)
          counts = Hash.new(0)
          Dir.glob(File.join(dir, "*.rb")).each do |f|
            counts[fingerprint(File.read(f, encoding: "UTF-8"))] += 1
          rescue StandardError
            next
          end
          counts
        end
      end
    end
  end
end

lib/master/scan/rules/god_class_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      class GodClassRule < Rule
        DEFAULT_THRESHOLD = 200

        def initialize
          super
          @threshold   = Master::Axioms.new.thresholds.dig("class", "max_lines") || DEFAULT_THRESHOLD
          @id          = "god_class"
          @description = "Classes over #{@threshold} lines should be split by responsibility"
          @severity    = :warning
          @axiom_tags  = %i[SIMPLEST_WORKS KISS SRP]
        end

        def check_ast(ast, _code, path:)
          return [] unless path.end_with?(".rb")
          visitor = Visitor.new(@threshold)
          ast.accept(visitor)
          visitor.findings.map do |line, name, lines|
            finding(line:, message: "#{name} is #{lines} lines (threshold: #{@threshold}) — split by responsibility")
          end
        end

        class Visitor < Prism::Visitor
          attr_reader :findings

          def initialize(threshold)
            super()
            @threshold = threshold
            @findings = []
          end

          def visit_class_node(node)
            lines = node.location.end_line - node.location.start_line + 1
            if lines > @threshold
              @findings << [node.location.start_line, node.name.to_s, lines]
            end
            super
          end
        end
      end
    end
  end
end

lib/master/scan/rules/hotwire_pattern_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class HotwirePatternRule < Rule
        def initialize
          super
          @id = "hotwire_pattern"
          @description = "Flags heavy Hotwire anti-patterns"
          @severity = :warning
          @axiom_tags = %i[PERFORMANCE]
        end

        def check(code, path:)
          return [] unless path.end_with?(".erb") || path.end_with?(".js")
          findings = []
          code.each_line.with_index(1) do |line, line_number|
            findings << finding(line: line_number, message: "prefer turbo-frame/turbo-stream over manual fetch") if line.include?("fetch(")
            findings << finding(line: line_number, message: "form is missing turbo-frame binding") if line.include?("form_with") && !line.include?("turbo_frame")
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/i18n_hardcoded_string_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class I18nHardcodedStringRule < Rule
        # User-facing string in flash/render/redirect — should be t(...)
        FLASH_LITERAL = /\bflash\[[^\]]+\]\s*=\s*["'][A-Z][^"']{4,}["']/.freeze
        RENDER_TEXT   = /\brender\s+(?:plain|text|html|inline):\s*["'][A-Z][^"']{4,}["']/.freeze
        REDIRECT_NOTICE = /\bredirect_to\s+.+?,\s*notice:\s*["'][A-Z][^"']{4,}["']/.freeze
        REDIRECT_ALERT  = /\bredirect_to\s+.+?,\s*alert:\s*["'][A-Z][^"']{4,}["']/.freeze

        def initialize
          super
          @id          = "i18n_hardcoded_string"
          @description = "Hardcoded user-facing string — wrap in I18n.t(...) for localization"
          @severity    = :info
          @axiom_tags  = %i[ABSTRACTION]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb") && (path.include?("/app/controllers/") || path.include?("/app/views/"))
          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
message: "flash literal — wrap in t('...') for i18n") if line.match?(FLASH_LITERAL)
            findings << finding(line: num,
message: "render literal — extract to locale file") if line.match?(RENDER_TEXT)
            findings << finding(line: num,
message: "redirect_to notice literal — extract to locale file") if line.match?(REDIRECT_NOTICE)
            findings << finding(line: num,
message: "redirect_to alert literal — extract to locale file") if line.match?(REDIRECT_ALERT)
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/immutable_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class ImmutableRule < Rule
        UNFROZEN_CONST     = /^\s+[A-Z][A-Z0-9_]+ \s*=\s*(?:"[^"]*"|'[^']*'|\[|\{)(?!.*(?:\.freeze|\.min|\.max|\.count|\.size|\.length|\.sum|\.to_i|\.to_f)\b)/.freeze
        MULTILINE_OPEN     = /^\s+[A-Z][A-Z0-9_]+ \s*=\s*[\[{]/.freeze
        STRING_CONTINUATION = /\\\s*$/.freeze
        CLASS_VAR_WRITE = /^\s+@@\w+\s*=(?!=)/.freeze
        GLOBAL_WRITE    = /^\s+\$\w+\s*=(?!=)/.freeze

        def initialize
          super
          @id          = "immutable"
          @description = "Mutable shared state — prefer frozen constants and immutable data flow"
          @severity    = :warning
          @axiom_tags  = [:IMMUTABLE]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")

          lines = code.lines
          findings = []

          lines.each_with_index do |line, line_index|
            line_number = line_index + 1
            next if line.strip.start_with?("#")

            if line.match?(UNFROZEN_CONST)
              if line.match?(MULTILINE_OPEN) && !inline_close?(line)
                findings << finding(line: line_number,
message: "unfrozen constant — append .freeze") unless multiline_frozen?(
lines, line_index)
              elsif line.match?(STRING_CONTINUATION)
                findings << finding(line: line_number,
message: "unfrozen constant — append .freeze") unless string_continuation_frozen?(
lines, line_index)
              else
                findings << finding(line: line_number, message: "unfrozen constant — append .freeze")
              end
            end

            findings << finding(line: line_number,
message: "class variable mutation (@@) — use instance state or inject") if line.match?(CLASS_VAR_WRITE)
            findings << finding(line: line_number,
message: "global variable mutation ($) — eliminate shared global state") if line.match?(GLOBAL_WRITE)
          end

          findings
        end

        private

        def inline_close?(line)
          stripped = line.rstrip
          return true if stripped.end_with?("].freeze", "}.freeze", "].freeze,", "}.freeze,")
          sq = stripped.count("[")
          cq = stripped.count("{")
          (sq > 0 && sq == stripped.count("]")) || (cq > 0 && cq == stripped.count("}"))
        end

        def string_continuation_frozen?(lines, start_line)
          (start_line...lines.size).each do |current_line|
            return lines[current_line].include?(".freeze") unless lines[current_line].match?(STRING_CONTINUATION)
          end
          false
        end

        def multiline_frozen?(lines, start_line)
          line   = lines[start_line]
          opener = line.include?("{") ? "{" : "["
          closer = opener == "{" ? "}" : "]"
          depth  = 0
          (start_line...lines.size).each do |current_line|
            depth += lines[current_line].count(opener) - lines[current_line].count(closer)
            return lines[current_line].include?(".freeze") if depth <= 0
          end
          false
        end
      end
    end
  end
end

lib/master/scan/rules/interconnect_rule.rb

# frozen_string_literal: true

require "yaml"

module Master
  module Scan
    module Rules
      # Detects phantom reads — Ruby code digs keys that don't exist in the corresponding data/*.yml.
      # Also detects orphan keys — top-level YAML keys with zero references in lib/.
      # Only meaningful when scanning lib/ with root: access.
      class InterconnectRule < Rule
        LOAD_CALL   = /load_yaml(?:_data)?\s*\(\s*["']([^"']+\.yml)["']/.freeze
        DIG_CALL    = /\.dig\(\s*((?:["'][^"']+["']\s*,?\s*)+)\)/.freeze
        FETCH_CALL  = /\.fetch\(\s*["']([^"']+)["']/.freeze
        BRACKET_KEY = /\[["']([^"']+)["']\]/.freeze

        def self.auto_build? = false

        def initialize(root:)
          super()
          @id          = "interconnect"
          @description = "Phantom YAML key reads and orphan data keys"
          @severity    = :warning
          @auto_fix    = false
          @axiom_tags  = %i[ONE_SOURCE]
          @root        = root
          @data_dir    = File.join(root, "data")
          @lib_source  = load_lib_source(root)
        end

        def check(code, path:)
          return [] unless path.include?("/lib/") && path.end_with?(".rb")

          yaml_files = extract_loaded_yamls(code)
          return [] if yaml_files.empty?

          loaded = yaml_files.filter_map do |yml_name|
            yml_path = File.join(@data_dir, yml_name)
            next unless File.exist?(yml_path)
            YAML.safe_load(File.read(yml_path), aliases: true) rescue nil
          end
          return [] if loaded.empty?

          findings = []
          extract_dig_paths(code).each do |path_keys|
            next if loaded.any? { |y| y.respond_to?(:dig) && y.dig(*path_keys) }

            code.each_line.with_index(1) do |line, number|
              next unless line.include?(path_keys.first.to_s)

              findings << finding(
                line: number,
                message: "phantom key #{path_keys.inspect} not found in any loaded yaml — stale dig path or missing entry"
              )
              break
            end
          end
          findings
        end

        private

        def extract_loaded_yamls(code)
          code.scan(LOAD_CALL).flatten.compact
        end

        def extract_dig_paths(code)
          code.scan(DIG_CALL).filter_map do |match|
            keys = match.first.to_s.scan(/["']([^"']+)["']/).flatten
            keys.size >= 1 ? keys : nil
          end
        end

        def load_lib_source(root)
          lib_dir = File.join(root, "lib")
          return "" unless File.directory?(lib_dir)

          Dir.glob(File.join(lib_dir, "**", "*.rb"))
            .filter_map { |path| File.read(path) rescue nil }
            .join("\n")
        end
      end
    end
  end
end

lib/master/scan/rules/lexical_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # LexicalRule — data-driven: loads all detect_lexical rules from rules.yml
      # and applies them to the matching file language. Single class covering
      # HTML, CSS, Zsh, JavaScript, and cross-language lexical checks.
      class LexicalRule < Rule
        RULES_PATH = File.join(Master::ROOT, "data", "rules.yml").freeze

        def initialize
          super
          @id          = "lexical"
          @description = "Data-driven lexical checks from rules.yml for all file types"
          @severity    = :warning
          @axiom_tags  = [:UNIVERSAL]
          @loaded      = load_lexical_rules
        end

        def check(code, path:)
          lang = language(path)
          return [] unless lang

          @loaded
            .select { |r| r[:languages].nil? || r[:languages].include?(lang) }
            .select { |r| r[:path_match].nil? || path.include?(r[:path_match]) }
            .select { |r| r[:requires_present].nil? || code.match?(r[:requires_present]) }
            .reject { |r| r[:requires_absent] && code.match?(r[:requires_absent]) }
            .flat_map { |r| apply(r, code, path) }
        end

        private

        def load_lexical_rules
          data = Master.load_yaml(RULES_PATH)
          all  = (data["rules"] || {}).values.flatten
          all.filter_map do |r|
            next unless r["detect_lexical"] && !r["detect_lexical"].to_s.empty?
            langs = Array(r["languages"]).compact
            {
              id:               r["id"],
              message:          r["name"] || r["id"],
              pattern:          Regexp.new(r["detect_lexical"]),
              fix:              r["fix"],
              severity:         (r["severity"] || "warning").to_sym,
              languages:        langs.empty? ? nil : langs,
              path_match:       r["path_match"],
              requires_present: r["requires_present"] && Regexp.new(r["requires_present"]),
              requires_absent:  r["requires_absent"]  && Regexp.new(r["requires_absent"]),
              whole_file:       r["whole_file"] == true,
            }
          rescue RegexpError
            nil
          end.compact
        rescue StandardError => _e
          []
        end

        def apply(rule, code, path)
          return apply_whole_file(rule, code) if rule[:whole_file]
          code.each_line.with_index(1).filter_map do |line, num|
            next unless line.match?(rule[:pattern])
            { rule: rule[:id], message: rule[:message], line: num,
              severity: rule[:severity], fix: rule[:fix] }
          end
        end

        def apply_whole_file(rule, code)
          return [] unless code.match?(rule[:pattern])
          line = code.each_line.with_index(1).find { |l, _| l.match?(rule[:pattern]) }&.last || 1
          [{ rule: rule[:id], message: rule[:message], line: line,
             severity: rule[:severity], fix: rule[:fix] }]
        end
      end
    end
  end
end

lib/master/scan/rules/long_method_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      class LongMethodRule < Rule
        DEFAULT_THRESHOLD = 10

        def initialize
          super
          @threshold   = Master::Axioms.new.thresholds.dig("method", "max_lines") || DEFAULT_THRESHOLD
          @id          = "long_method"
          @description = "Methods over #{@threshold} lines should be extracted"
          @severity    = :warning
          @axiom_tags  = %i[ONE_JOB KISS]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          result = Prism.parse(code)
          return [] unless result.success?

          visitor = MethodVisitor.new(@threshold)
          visitor.visit(result.value)
          visitor.methods_over_threshold.map do |name, line, length|
            finding(
              line:,
              message: "method #{name} is #{length} lines (threshold: #{@threshold}) — extract responsibilities"
            )
          end
        end

        class MethodVisitor < Prism::Visitor
          attr_reader :methods_over_threshold

          def initialize(threshold)
            super()
            @threshold              = threshold
            @methods_over_threshold = []
          end

          def visit_def_node(node)
            length = node.location.end_line - node.location.start_line + 1
            if length > @threshold
              @methods_over_threshold << [node.name.to_s, node.location.start_line, length]
            end
            super
          end
        end
      end
    end
  end
end

lib/master/scan/rules/mass_assignment_risk_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class MassAssignmentRiskRule < Rule
        UPDATE_PARAMS = /\.(?:update|update!|assign_attributes|create|create!|new)\s*\(\s*params\s*[)\.]/.freeze
        UPDATE_PARAMS_BARE = /\.(?:update|update!|assign_attributes)\s*\(\s*params\[/.freeze

        def initialize
          super
          @id          = "mass_assignment_risk"
          @description = "Mass assignment without strong-params .permit — exposes every attribute"
          @severity    = :error
          @axiom_tags  = %i[ROBUSTNESS]
        end

        def check(code, path:)
          return [] unless path.include?("/app/controllers/") || path.include?("/app/services/")
          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            next if line.include?(".permit(")
            findings << finding(line: num,
              message: "mass assignment from params — chain .permit(:allowed, :keys) before update") if line.match?(UPDATE_PARAMS) || line.match?(UPDATE_PARAMS_BARE)
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/memoize_falsy_bug_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class MemoizeFalsyBugRule < Rule
        # @x ||= expr where expr can return false/nil — re-evaluates every call
        OR_EQUALS_BOOL = /@\w+\s*\|\|=\s*(?:false|nil|.*?\.\w+\?)/.freeze
        OR_EQUALS_FIND = /@\w+\s*\|\|=\s*.*?\.(?:find|find_by|first|detect|presence)\b/.freeze

        def initialize
          super
          @id          = "memoize_falsy_bug"
          @description = "@x ||= memoization that may return false/nil — caller pays cost every time"
          @severity    = :warning
          @axiom_tags  = %i[PERFORMANCE EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
              message: "@var ||= boolean/predicate — use defined?(@var) ? @var : (@var = expr)") if line.match?(OR_EQUALS_BOOL)
            findings << finding(line: num,
              message: "@var ||= find/find_by — nil result re-queries every call; cache via defined?") if line.match?(OR_EQUALS_FIND)
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/n_plus_one_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class NPlusOneRule < Rule
        EACH_WITH_ASSOC = /\.\s*(?:each|map|select|filter_map)\s*(?:do\s*\|[^|]+\||\{\s*\|[^|]+\|).*?\.\w+\.\w+/m.freeze
        FIND_IN_LOOP    = /\.\s*(?:each|map)\s*(?:do\s*\|[^|]+\||\{\s*\|[^|]+\|).*?(?:\.find|\.where|\.first)\(/m.freeze

        def initialize
          super
          @id          = "n_plus_one"
          @description = "Likely N+1 query — eager-load with includes/preload"
          @severity    = :warning
          @axiom_tags  = %i[PERFORMANCE]
        end

        def check(code, path:)
          return [] unless rails_app?(path)
          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
              message: "iter accesses associated record — preload/includes the assoc to avoid N+1") if line.match?(EACH_WITH_ASSOC)
            findings << finding(line: num,
              message: "find/where inside iter — move query outside loop or batch with where(id: ids)") if line.match?(FIND_IN_LOOP)
          end
          findings
        end

        private

        def rails_app?(path)
          path.include?("/app/") || path.include?("/lib/") && File.exist?(File.join(File.dirname(path), "..", "..",
"config", "application.rb"))
        end
      end
    end
  end
end

lib/master/scan/rules/naming_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class NamingRule < Rule
        IS_PREFIX      = /def is_\w+(?!\?)/.freeze
        GET_PREFIX     = /def get_\w+/.freeze
        SET_PREFIX     = /def set_\w+/.freeze
        BOOL_NO_QMARK  = /def (?:has|can|should|will|did|was|have)_\w+(?!\?)/.freeze
        SCREAMING_ABBR = /\b[A-Z]{4,}\b/.freeze

        def initialize
          super
          @id          = "naming"
          @description = "Method names violate Ruby conventions"
          @severity    = :style
          @axiom_tags  = [:SELF_EXPLAINING]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
message: "is_ prefix: use adjective with ? suffix (e.g. valid? not is_valid?)") if line.match?(IS_PREFIX)
            findings << finding(line: num,
message: "get_ prefix: Ruby readers drop get_ (e.g. name not get_name)") if line.match?(GET_PREFIX)
            findings << finding(line: num,
message: "set_ prefix: use name= for writers (e.g. name= not set_name)") if line.match?(SET_PREFIX)
            findings << finding(line: num,
message: "boolean predicate missing ? suffix — add ? to indicate it returns bool") if line.match?(BOOL_NO_QMARK)
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/naming_silhouette_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Predicates end `?`, mutators end `!`, factories start `build_`/`make_`,
      # finders start `find_`. Methods that lie about their shape are POLA bombs.
      class NamingSilhouetteRule < Rule
        BOOL_RETURN = /(?:return\s+(?:true|false)\b|\A\s*(?:true|false)\s*\z|\.(?:nil|empty|present|blank|valid|include|any|all|none|one|exist|persisted|new_record)\?\s*\z)/.freeze
        FACTORY_PREFIX = /\Abuild_|\Amake_|\Acreate_/.freeze
        FINDER_PREFIX  = /\Afind_/.freeze

        def initialize
          super
          @id          = "naming_silhouette"
          @description = "Method name doesn't match its return shape — predicate/mutator/factory drift"
          @severity    = :warning
          @axiom_tags  = %i[POLA_PRINCIPLE SELF_EXPLAINING]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          findings = []
          methods(code).each do |name, line, body|
            findings << finding(line: line, message: "predicate `#{name}` should end with `?`") if predicate_unmarked?(
name, body)
            findings << finding(line: line,
message: "mutator `#{name}` ends with `!` but body has no mutation") if bang_without_mutation?(
name, body)
            findings << finding(line: line,
message: "factory `#{name}` should return a built object, not nil") if factory_returns_nil?(
name, body)
          end
          findings
        end

        private

        def methods(code)
          lines = code.lines
          out = []
          lines.each_with_index do |line, i|
            next unless line =~ /\A\s*def\s+(self\.)?([A-Za-z_][A-Za-z0-9_!?=]*)/
            name = Regexp.last_match(2)
            body = capture_body(lines, i)
            out << [name, i + 1, body]
          end
          out
        end

        def capture_body(lines, start)
          depth = 0
          buf = []
          lines[start..].each_with_index do |l, j|
            depth += 1 if l =~ /\A\s*(?:def|do\b|if|unless|case|while|until|begin|class|module)\b/ && j > 0
            depth += 1 if j.zero?
            depth -= 1 if l =~ /\A\s*end\b/
            buf << l
            return buf.join if depth.zero?
          end
          buf.join
        end

        def predicate_unmarked?(name, body)
          return false if name.end_with?("?", "!", "=")
          body.match?(BOOL_RETURN)
        end

        def bang_without_mutation?(name, body)
          return false unless name.end_with?("!")
          !body.match?(/[@\w]+\s*=|\.\w+!\b|<<|push|delete|clear|update|save|destroy|create|merge!/)
        end

        def factory_returns_nil?(name, body)
          return false unless name.match?(FACTORY_PREFIX)
          body.match?(/\Areturn\s+nil\b|\.\.\.\s*nil\s*\z/)
        end
      end
    end
  end
end

lib/master/scan/rules/nesting_depth_rule.rb

# frozen_string_literal: true

require "prism"

module Master
  module Scan
    module Rules
      class NestingDepthRule < Rule
        DEFAULT_DEPTH = 2
        NESTING_NODES = %i[
          visit_if_node visit_unless_node visit_case_node visit_case_match_node
          visit_while_node visit_until_node visit_for_node
        ].freeze

        def initialize
          super
          @threshold   = Master::Axioms.new.thresholds.dig("method", "max_nesting") || DEFAULT_DEPTH
          @id          = "nesting_depth"
          @description = "Nesting deeper than #{@threshold} — use guard clauses to flatten"
          @severity    = :warning
          @axiom_tags  = %i[GUARD_CLAUSES_FIRST KISS]
        end

        def check_ast(ast, _code, path:)
          return [] unless path.end_with?(".rb")
          visitor = Visitor.new(@threshold)
          ast.accept(visitor)
          visitor.findings.map do |line, depth|
            finding(line:, message: "nesting depth #{depth} exceeds #{@threshold} — extract method or add guard clause")
          end
        end

        class Visitor < Prism::Visitor
          attr_reader :findings

          def initialize(threshold)
            super()
            @threshold = threshold
            @findings = []
          end

          def visit_def_node(node)
            counter = DepthCounter.new
            node.body&.accept(counter)
            @findings << [node.location.start_line, counter.max] if counter.max > @threshold
            super
          end
        end

        class DepthCounter < Prism::Visitor
          attr_reader :max

          def initialize
            super
            @depth = 0
            @max = 0
          end

          NESTING_NODES.each do |m|
            define_method(m) do |node|
              @depth += 1
              @max = @depth if @depth > @max
              super(node)
              @depth -= 1
            end
          end

          def visit_def_node(_node) = nil
          def visit_class_node(_node) = nil
          def visit_module_node(_node) = nil
        end
      end
    end
  end
end

lib/master/scan/rules/nielsen_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # NielsenRule — enforces Nielsen Norman Group's 10 Usability Heuristics
      # at the code level: API design, error messages, output behavior.
      class NielsenRule < Rule
        # H9: Error messages must describe the problem — bare string raises with no guidance
        BARE_RAISE       = /\braise\s+["'][^"']{0,20}["']/.freeze
        # H9: Result.err with no message or single-word message
        THIN_ERR         = /Result\.err\(["'][a-z_]{1,15}["'](?:\s*\))/.freeze
        # H4: Inconsistent boolean naming — mix of is_/has_/can_ with plain predicates
        # H6: Positional args over 3 — harms recognition (caller can't tell what each is)
        POSITIONAL_HEAVY = /def\s+\w+\((?:[^:,)]+,){3,}[^*&]/.freeze
        # H8: Aesthetic minimalism — debug inspect calls (p/pp/pry) left in production
        DEBUG_OUTPUT     = /^\s+(?:p|pp|binding\.pry|debugger)\s+(?!.*#\s*rubocop)/.freeze
        # H3: User control — destructive methods without bang or guard comment
        SILENT_DELETE    = /\b(?:FileUtils\.rm|File\.delete|Dir\.rmdir)\s*\((?!.*#.*safe)/.freeze
        # H2: Real world match — internal jargon in user-facing strings
        JARGON           = /(?:raise|Result\.err)\(.*\b(?:nil\b|exception|stacktrace|backtrace|segfault|errno)\b/.freeze

        def initialize
          super
          @id          = "nielsen"
          @description = "Nielsen's 10 heuristics: error quality, recognition, minimalism, user control"
          @severity    = :warning
          @axiom_tags  = %i[NN_GROUP ERROR_RECOVERY REAL_WORLD_MATCH USER_CONTROL
                            AESTHETIC_MINIMALISM RECOGNITION_NOT_RECALL CONSISTENCY]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")

          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
message: "[ERROR_RECOVERY] raise with no guidance — tell the user what to do next")         if line.match?(BARE_RAISE)
            findings << finding(line: num,
message: "[ERROR_RECOVERY] thin Result.err message — include what failed and how to fix it") if line.match?(THIN_ERR)
            findings << finding(line: num,
message: "[RECOGNITION_NOT_RECALL] 4+ positional args — use keyword arguments so callers read intent") if line.match?(POSITIONAL_HEAVY)
            findings << finding(line: num,
message: "[AESTHETIC_MINIMALISM] debug output left in — remove puts/p or guard with log level")  if line.match?(DEBUG_OUTPUT)
            findings << finding(line: num,
message: "[USER_CONTROL] destructive call without safety comment — add undo or confirmation guard") if line.match?(SILENT_DELETE)
            findings << finding(line: num,
message: "[REAL_WORLD_MATCH] internal jargon in user-facing error — use plain language")          if line.match?(JARGON)
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/opportunity_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Opportunities are not violations — they are structural improvements waiting to happen.
      # Detects: deep nesting (reflow), lambda dispatch tables (regroup into Command),
      # thin delegation wrappers (collapse), and dense inline hashes (extract class).
      # Severity :info so they show up in deep scans without polluting standard output.
      class OpportunityRule < Rule
        NESTING_THRESHOLD   = 4
        HASH_PAIR_THRESHOLD = 4
        LAMBDA_TABLE_MIN    = 3
        INDENT_UNIT         = 2

        def initialize
          super
          @id          = "opportunity"
          @description = "Structural improvement opportunity — refactor for clarity or cohesion"
          @severity    = :info
          @axiom_tags  = %i[SIMPLEST_WORKS DECOUPLE ONE_JOB]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          findings = []
          findings += deep_nesting(code)
          findings += lambda_dispatch_table(code)
          findings += thin_delegation(code)
          findings += dense_inline_hash(code)
          findings
        end

        private

        def deep_nesting(code)
          results    = []
          base_indent = nil
          method_line = nil

          code.each_line.with_index(1) do |line, number|
            stripped = line.strip
            next if stripped.empty? || stripped.start_with?("#") || stripped.start_with?(".")
            indent = 0
            indent += 1 while line[indent] == " "
            if stripped.start_with?("def ")
              base_indent = indent
              method_line = number
            elsif base_indent && stripped == "end"
              base_indent = nil
              method_line = nil
            elsif base_indent
              relative_depth = (indent - base_indent) / INDENT_UNIT
              if relative_depth >= NESTING_THRESHOLD
                results << finding(line: method_line,
                  message: "#{relative_depth} levels of nesting in method — reflow with early returns or extract method")
                base_indent = nil
              end
            end
          end
          results
        end

        def lambda_dispatch_table(code)
          results = []
          code.each_line.with_index(1) do |line, number|
            stripped = line.strip
            next unless stripped.start_with?('"') && stripped.include?("=>") && stripped.include?("->")
            count = code.lines.count { |other|
 other.strip.start_with?('"') && other.strip.include?("=>") && other.strip.include?("->") }
            if count >= LAMBDA_TABLE_MIN
              results << finding(line: number,
                message: "lambda dispatch table (#{count} entries) — regroup into Command objects or a registry")
              break
            end
          end
          results
        end

        def thin_delegation(code)
          results = []
          lines   = code.lines
          lines.each_with_index do |line, line_index|
            stripped = line.strip
            next unless stripped.start_with?("def ") && !stripped.include?("=")
            method_name = stripped.split(" ", 3)[1].to_s.split("(", 2).first
            body_index  = line_index + 1
            next if body_index >= lines.size
            body    = lines[body_index].strip
            closing = lines[body_index + 1]&.strip
            next unless body.include?(".") && !body.start_with?("#") && closing == "end"
            delegated = body.split(".").last.split("(").first.strip
            if delegated == method_name
              results << finding(line: line_index + 1,
                message: "#{method_name}: thin transparent delegation — use Forwardable or delegate")
            end
          end
          results
        end

        def dense_inline_hash(code)
          results = []
          in_method  = false
          method_line = 0
          hash_pairs  = 0

          code.each_line.with_index(1) do |line, number|
            stripped = line.strip
            if stripped.start_with?("def ")
              in_method   = true
              method_line = number
              hash_pairs  = 0
            elsif stripped == "end" && in_method
              if hash_pairs >= HASH_PAIR_THRESHOLD
                results << finding(line: method_line,
                  message: "#{hash_pairs} hash pairs inline — hoist to a named constant or extract a builder")
              end
              in_method  = false
              hash_pairs = 0
            elsif in_method
              hash_pairs += stripped.scan("=>").size
            end
          end
          results
        end
      end
    end
  end
end

lib/master/scan/rules/pola_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class PolaRule < Rule
        BOOL_POSITIONAL = /def\s+\w+\([^)]*,\s*(true|false)\s*[,)]/.freeze
        DOUBLE_NEG      = /\bunless\s+!/.freeze
        NEG_BOOL_ATTR   = /\battr_\w+\s+:(?:not_|no_|without_|disabled?_|skip_)\w+/.freeze

        def initialize
          super
          @id          = "pola"
          @description = "Principle of Least Astonishment — surprising names, contracts, or side-effects"
          @severity    = :warning
          @axiom_tags  = %i[EXPLICIT POLA_PRINCIPLE]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")

          findings        = []
          in_predicate    = false
          pred_line       = 0
          depth           = 0

          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
message: "boolean positional default — use keyword arg (def method(flag: false)) to name intent at call site") if line.match?(BOOL_POSITIONAL)
            findings << finding(line: num,
message: "double negation detected — invert condition and use positive form") if line.match?(DOUBLE_NEG)
            findings << finding(line: num,
              message: "negative attribute name — name what it IS, not what it ISN'T") if line.match?(NEG_BOOL_ATTR)

            if line.match?(/^\s+def\s+\w+\?/)
              if line.match?(/=\s*[^=]/)
                in_predicate = false
              else
                in_predicate = true
                pred_line    = num
                depth        = 1
              end
            elsif in_predicate
              depth += line.scan(/\bdo\b|\bbegin\b|\bdef\b/).size
              depth += 1 if line.match?(/^\s+(?:if|case|unless|while|until|for)\b/)
              depth -= line.scan(/\bend\b/).size
              if depth <= 0
                in_predicate = false
              elsif line.match?(/(@\w+\s*=(?!=)|\.save[!\s]|\.update[!\s]|File\.write)/)
                findings << finding(line: pred_line,
                  message: "predicate method (?) mutates state — predicates must only query, never mutate (POLA)")
                in_predicate = false
              end
            end
          end

          findings
        end
      end
    end
  end
end

lib/master/scan/rules/prune_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Hedge words and preamble phrases in comments dilute signal.
      # Applies to Ruby and shell files — both use # comments.
      # Patterns loaded from data/rules.yml (voice.strunk section).
      class PruneRule < Rule
        DATA_PATH   = File.join(Master::ROOT, "data", "rules.yml").freeze
        COMMENT_EXT = %w[.rb .sh .zsh .bash].freeze

        def initialize
          super
          @id          = "prune"
          @description = "Hedge words and preamble phrases in comments reduce clarity"
          @severity    = :warning
          @axiom_tags  = [:STRUNK_WHITE]
        end

        def check(code, path:)
          return [] unless COMMENT_EXT.include?(File.extname(path).downcase)

          hedge_re    = build_hedge_re
          preamble_re = build_preamble_re

          code.each_line.with_index(1).flat_map { |line, num|
            next [] unless line.include?("#")
            findings = []
            findings << labelled(:HEDGE,    num, line) if hedge_re&.match?(line)
            findings << labelled(:PREAMBLE, num, line) if preamble_re&.match?(line)
            findings
          }
        end

        def labelled(subrule, num, line)
          { rule: "#{@id}.#{subrule.to_s.downcase}", message: "#{subrule}: #{line.strip}",
            line: num, severity: @severity, fix: nil, tags: [@axiom_tags.first, subrule] }
        end

        private

        def rules
          @rules ||= begin
            data = File.exist?(DATA_PATH) ? Master.load_yaml(DATA_PATH) : {}
            data.dig("voice", "strunk") || {}
          end
        rescue StandardError => _e
          @rules = {}
        end

        def build_hedge_re
          words = rules.fetch("hedges", []).filter_map { |h|
            if h.is_a?(Hash)
              pat = h["pattern"].to_s.strip
              pat.empty? ? nil : Regexp.escape(pat)
            elsif h.is_a?(String)
              h.strip.empty? ? nil : Regexp.escape(h.strip)
            end
          }
          return if words.empty?
          /(#{words.join("|")})/i
        rescue StandardError => _e
          nil
        end

        def build_preamble_re
          phrases = rules.fetch("preambles", []).filter_map { |p|
            next unless p.is_a?(String)
            p.strip.empty? ? nil : Regexp.escape(p.strip)
          }
          return if phrases.empty?
          /\#.*(?:#{phrases.join("|")})/i
        rescue StandardError => _e
          nil
        end
      end
    end
  end
end

lib/master/scan/rules/query_plan_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class QueryPlanRule < Rule
        def initialize
          super
          @id = "query_plan"
          @description = "Query shapes likely to degrade at scale"
          @severity = :warning
          @axiom_tags = %i[PERFORMANCE]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb") && (path.include?("/app/models/") || path.include?("/app/controllers/"))
          findings = []
          code.each_line.with_index(1) do |line, line_number|
            findings << finding(line: line_number, message: "prefer scoped count over full-table count") if line.match?(/\.count\s*$/)
            findings << finding(line: line_number, message: "limit selected columns on wide joins") if line.include?(".joins(") && !line.include?(".select(")
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/reek_rule.rb

# frozen_string_literal: true

require "open3"
require "json"

module Master
  module Scan
    module Rules
      # Reek smell detection mapped to MASTER axioms.
      # Degrades gracefully when reek is unavailable (CI, fresh installs).
      class ReekRule < Rule
        SMELL_MAP = {
          "TooManyMethods"         => { axiom: "ONE_JOB",        sev: :warning },
          "TooManyInstanceVariables" => { axiom: "ONE_JOB",      sev: :warning },
          "LongParameterList"      => { axiom: "DECOUPLE",       sev: :warning },
          "FeatureEnvy"            => { axiom: "DECOUPLE",       sev: :warning },
          "DataClump"              => { axiom: "DECOUPLE",       sev: :warning },
          "DuplicateMethodCall"    => { axiom: "ONE_SOURCE",     sev: :warning },
          "BooleanParameter"       => { axiom: "EXPLICIT",       sev: :warning },
          "ControlParameter"       => { axiom: "CQS",            sev: :warning },
          "NilCheck"               => { axiom: "EXPLICIT",       sev: :warning },
          "UncommunicativeMethodName" => { axiom: "SELF_EXPLAINING", sev: :warning },
          "UncommunicativeVariableName" => { axiom: "SELF_EXPLAINING", sev: :warning },
          "UncommunicativeParameterName" => { axiom: "SELF_EXPLAINING", sev: :warning },
          "UtilityFunction"        => { axiom: "DECOUPLE",       sev: :warning },
          "InstanceVariableAssumption" => { axiom: "EXPLICIT",   sev: :warning },
          "IrresponsibleModule"    => { axiom: "SELF_EXPLAINING", sev: :warning },
          "RepeatedConditional"    => { axiom: "ONE_SOURCE",     sev: :warning },
          "SubclassedFromCoreClass" => { axiom: "EXTEND_DONT_MODIFY", sev: :warning },
          "ModuleInitialize"       => { axiom: "POLA",           sev: :warning },
        }.freeze

        def initialize(root: nil)
          super()
          @id          = "reek"
          @description = "Code smell detection: feature envy, data clumps, boolean params (reek)"
          @severity    = :warning
          @axiom_tags  = SMELL_MAP.values.map { |v| v[:axiom].to_sym }.uniq
          @root        = root
        end

        def self.auto_build? = false

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          return [] unless reek_available?

          out, _err, _status = Open3.capture3(
            "bundle", "exec", "reek",
            "--format", "json",
            "--no-color",
            path,
            chdir: @root || Dir.pwd
          )

          parse_smells(out)
        rescue StandardError => _e
          []
        end

        private

        def reek_available?
          @reek_available ||= begin
            _, _, s = Open3.capture3("bundle", "exec", "reek", "--version",
                                     chdir: @root || Dir.pwd)
            s.success?
          rescue StandardError => _e
            false
          end
        end

        def parse_smells(json_str)
          data = JSON.parse(json_str)
          data.filter_map do |smell|
            meta = SMELL_MAP[smell["smell_type"]]
            next unless meta
            finding(
              line:    smell["lines"]&.first || 1,
              message: "[#{meta[:axiom]}] #{smell["smell_type"]}: #{smell["message"]}"
            )
          end
        rescue StandardError => _e
          []
        end
      end
    end
  end
end

lib/master/scan/rules/rubocop_rule.rb

# frozen_string_literal: true

require "open3"
require "json"

module Master
  module Scan
    module Rules
      # Rubocop AST analysis filtered to cops that map directly to MASTER axioms.
      # Degrades gracefully when rubocop is unavailable (CI, fresh installs).
      class RubocopRule < Rule
        COP_MAP = {
          "Metrics/MethodLength"        => { axiom: "ONE_JOB",       sev: :warning },
          "Metrics/ClassLength"         => { axiom: "SIMPLEST_WORKS", sev: :warning },
          "Metrics/AbcSize"             => { axiom: "ONE_JOB",       sev: :warning },
          "Metrics/CyclomaticComplexity" => { axiom: "SIMPLEST_WORKS", sev: :error },
          "Metrics/PerceivedComplexity" => { axiom: "SIMPLEST_WORKS", sev: :warning },
          "Metrics/ParameterLists"      => { axiom: "DECOUPLE",      sev: :warning },
          "Lint/RescueException"        => { axiom: "FAIL_VISIBLY",  sev: :error },
          "Lint/SuppressedException"    => { axiom: "FAIL_VISIBLY",  sev: :error },
          "Lint/DuplicateMethods"       => { axiom: "ONE_SOURCE",    sev: :error },
          "Style/GuardClause"           => { axiom: "BE_CONCISE",    sev: :warning },
          "Style/ReturnNil"             => { axiom: "EXPLICIT",      sev: :warning },
          "Naming/MethodParameterName"  => { axiom: "SELF_EXPLAINING", sev: :warning },
          "Layout/LineLength"           => { axiom: "BE_CONCISE",    sev: :warning },
        }.freeze

        def initialize(root: nil)
          super()
          @id          = "rubocop"
          @description = "AST-based analysis: complexity, guard clauses, parameter names (rubocop)"
          @severity    = :warning
          @axiom_tags  = COP_MAP.values.map { |v| v[:axiom].to_sym }.uniq
          @root        = root
        end

        def self.auto_build? = false

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          return [] unless rubocop_available?

          config_flag = rubocop_config_flag
          out, _err, status = Open3.capture3(
            "bundle", "exec", "rubocop",
            *config_flag,
            "--format", "json",
            "--no-color",
            path,
            chdir: @root || Dir.pwd
          )

          return [] unless status.exitstatus&.<= 1  # 0=clean 1=offenses 2=error

          parse_offenses(out)
        rescue StandardError => _e
          []
        end

        private

        def rubocop_available?
          @rubocop_available ||= begin
            _, _, s = Open3.capture3("bundle", "exec", "rubocop", "--version",
                                     chdir: @root || Dir.pwd)
            s.success?
          rescue StandardError => _e
            false
          end
        end

        def rubocop_config_flag
          cfg = File.join(@root || Dir.pwd, ".rubocop.yml")
          File.exist?(cfg) ? ["--config", cfg] : ["--only", COP_MAP.keys.join(",")]
        end

        def parse_offenses(json_str)
          data = JSON.parse(json_str)
          data["files"].flat_map do |file|
            file["offenses"].filter_map do |o|
              meta = COP_MAP[o["cop_name"]]
              next unless meta
              finding(
                line:    o.dig("location", "line") || 1,
                message: "[#{meta[:axiom]}] #{o["cop_name"]}: #{o["message"]}"
              )
            end
          end
        rescue StandardError => _e
          []
        end
      end
    end
  end
end

lib/master/scan/rules/self_explaining_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # SelfExplainingRule — detects names that obscure intent, violating SELF_EXPLAINING.
      # Flags method/variable names that are abbreviations, noise words, or too generic
      # to reveal purpose without reading the implementation.
      class SelfExplainingRule < Rule
        NOISE_NAMES   = /^\s+def\s+(?:self\.)?(do_it|handle|process|run_it|execute_it|go|doit)\b/.freeze
        ABBREV_METHOD = /^\s+def\s+(tmp|res|ret|val|obj|thingy|stuff|thing|data2|info2|buf|sig|ctx|cfg)\b/.freeze
        ABBREV_VAR    = /\b(tmp|res|ret|val|obj|arr|lst|hsh|idx|cnt|num|str|buf|sig|ctx|cfg)\s*=(?!=)/.freeze

        def initialize
          super
          @id          = "self_explaining"
          @description = "Opaque names — names should reveal purpose without reading the implementation"
          @severity    = :warning
          @axiom_tags  = [:SELF_EXPLAINING]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")

          code.each_line.with_index(1).flat_map { |line, num|
            next [] if line.strip.start_with?("#")
            next [] if line.match?(/^\s+[A-Z][A-Z0-9_]+ \s*=/)
            findings = []
            findings << finding(line: num,
message: "noise method name — rename to reveal intent") if line.match?(NOISE_NAMES)
            findings << finding(line: num,
message: "abbreviated method name — use the full descriptive word") if line.match?(ABBREV_METHOD)
            findings << finding(line: num,
message: "abbreviated variable — prefer descriptive identifier") if line.match?(ABBREV_VAR)
            findings
          }
        end
      end
    end
  end
end

lib/master/scan/rules/semantic_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      require_relative "../finding"
      # LLM review for rules whose violations resist lexical detection.
      # Each rules.yml entry with a detect_semantic prompt is folded into one LLM
      # call per file. Rules carry mode: violation (default) or opportunity —
      # the prompt frame and severity follow from that.
      class SemanticRule < Rule
        RULES_PATH = File.join(Master::ROOT, "data", "rules.yml").freeze
        CODE_SNIPPET_LIMIT = 2000

        def initialize(agent: nil)
          super()
          @agent       = agent
          @id          = "semantic"
          @description = "LLM-based rule review (violations + opportunities)"
          @severity    = :warning
          @axioms      = load_semantic_rules
          @axiom_tags  = @axioms.keys.map(&:to_sym)
        end

        def self.auto_build? = false

        def set_agent(agent)
          @agent = agent
          self
        end

        def check(code, path:)
          return [] unless language(path) && @agent

          response = @agent.ask(build_prompt(code, path), operation: :scan_semantic).to_s
          parse_findings(response)
        rescue StandardError => e
          [finding(line: 1, message: "semantic: scan error — #{e.message}")]
        end

        private

        # Each axiom is { prompt:, severity:, mode: }. info-tier violations stay
        # out of the prompt — they're noise that doubles cost. info-tier
        # opportunities stay in: that's their whole point.
        def load_semantic_rules
          data = Master.load_yaml(RULES_PATH)
          (data["rules"] || {}).values.flatten
            .select { |r| r["detect_semantic"] }
            .reject { |r| r["severity"] == "info" && r["mode"] != "opportunity" && r["tier"] != "kernel" }
            .each_with_object({}) do |r, h|
              h[r["id"]] = {
                prompt: r["detect_semantic"],
                severity: (r["severity"] || "warning").to_sym,
                mode: (r["mode"] || "violation").to_sym
              }
            end
        end

        def build_prompt(code, path)
          violations = @axioms.select { |_, a| a[:mode] == :violation }
          opportunities = @axioms.select { |_, a| a[:mode] == :opportunity }
          parts = []
          parts << violation_block(violations) unless violations.empty?
          parts << opportunity_block(opportunities) unless opportunities.empty?
          <<~PROMPT
            Review #{File.basename(path)}.

            #{parts.join("\n\n")}

            Code (first #{CODE_SNIPPET_LIMIT} chars):
            #{code[0, CODE_SNIPPET_LIMIT]}
          PROMPT
        end

        def violation_block(rules)
          list = rules.map { |id, a| "#{id}: #{a[:prompt]}" }.join("\n")
          <<~BLOCK
            VIOLATIONS — list ONLY clear breaches. Format: RULE_ID:LINE:description.
            If clean, write CLEAN on its own line.
            #{list}
          BLOCK
        end

        def opportunity_block(rules)
          list = rules.map { |id, a| "#{id}: #{a[:prompt]}" }.join("\n")
          <<~BLOCK
            OPPORTUNITIES — list refactors only if they would simplify. Format: RULE_ID:LINE:reason.
            If none, write NONE on its own line.
            #{list}
          BLOCK
        end

        def parse_findings(response)
          response.lines.filter_map do |line|
            stripped = line.strip
            next if stripped.empty? || %w[CLEAN NONE].include?(stripped.upcase)

            match = stripped.match(/\A([A-Z_][A-Z0-9_]*):(\d+):(.+)\z/)
            next unless match && @axioms.key?(match[1])

            axiom = @axioms[match[1]]
            Finding.build(
              rule: match[1].downcase,
              message: match[3].strip,
              line: match[2].to_i,
              severity: axiom[:severity],
              fix: nil,
              tags: [match[1].to_sym, axiom[:mode]]
            )
          end
        end
      end
    end
  end
end

lib/master/scan/rules/srp_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # SrpRule — Single Responsibility Principle.
      # A class should have one reason to change. Flags classes whose public methods
      # span multiple concern domains (persistence, rendering, validation, networking, parsing).
      class SrpRule < Rule
        CONCERNS = {
          persistence: /\b(save|load|read_\w|write_\w|persist|store_\w|fetch_\w|find_by|delete|destroy|insert|upsert)\b/,
          rendering:   /\b(render|display|format_\w|present|to_html|draw|paint|emit|output_\w)\b/,
          validation:  /\b(valid\?|validate[^d]|check_\w|verify_\w|assert_\w|ensure_\w|guard_\w)\b/,
          networking:  /\b(request_\w|http_\w|send_request|receive_\w|connect_\w|socket_\w)\b/,
          parsing:     /\b(parse_\w|tokenize|lex_\w|extract_\w|decode_\w|encode_\w|deserialize|serialize)\b/,
        }.freeze

        def initialize
          super
          @id          = "srp"
          @description = "Single Responsibility Principle — class spans multiple concern domains"
          @severity    = :warning
          @axiom_tags  = [:ONE_JOB]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")

          public_methods = code.scan(/^\s{2,8}def\s+(\w+)/).flatten
          return [] if public_methods.size < 4

          concerns_found = CONCERNS.select { |_, pat| public_methods.any? { |m| m.match?(pat) } }
          return [] if concerns_found.size < 2

          class_name = code.match(/class\s+(\w+)/i)&.[](1) || File.basename(path, ".rb")
          [finding(
            line: 1,
            message: "#{class_name} spans #{concerns_found.size} domains " \
                     "(#{concerns_found.keys.join(", ")}) — split by single responsibility (SRP)"
          )]
        end
      end
    end
  end
end

lib/master/scan/rules/structure_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class StructureRule < Rule
        UNREACHABLE_ELSE = /^\s*return\b[^\n]*\n(?:\s*#[^\n]*\n)*\s*else\b/.freeze
        GUARD_RETURN     = /^\s*if\s+.+\n\s*return\b/.freeze
        FLATTEN_NO_ARG   = /\.flatten\s*(?:\.|$|\))/.freeze
        SINGLE_WHEN      = /\bcase\b/.freeze

        def initialize
          super
          @id          = "structure"
          @description = "Structural anti-patterns — guard clauses, unreachable code, flatten depth"
          @severity    = :warning
          @axiom_tags  = [:GUARD_CLAUSES_FIRST]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          findings = []

          findings.concat(scan_lines(code, FLATTEN_NO_ARG,
            message: ".flatten without depth arg flattens infinitely — use .flatten(1) unless full depth is intended"))

          findings.concat(check_guard_candidates(code))
          findings.concat(check_single_when(code))
          findings.concat(check_unreachable_else(code))
          findings
        end

        private

        def check_guard_candidates(code)
          findings = []
          lines    = code.lines
          lines.each_with_index do |line, i|
            next unless line.match?(/^\s*if\s+/)
            next_sig = lines[i + 1]&.strip
            next unless next_sig&.start_with?("return")
            findings << finding(line: i + 1, message: "if/return block — invert to guard clause: return X if condition")
          end
          findings
        end

        def check_single_when(code)
          findings = []
          in_case  = false
          when_count = 0
          case_line  = 0

          code.each_line.with_index(1) do |line, num|
            stripped = line.strip
            if stripped.start_with?("case")
              in_case    = true
              when_count = 0
              case_line  = num
            elsif in_case
              when_count += 1 if stripped.start_with?("when")
              if stripped == "end"
                findings << finding(line: case_line, message: "case with one `when` — use if/else") if when_count == 1
                in_case = false
              end
            end
          end
          findings
        end

        def check_unreachable_else(code)
          findings = []
          lines    = code.lines
          lines.each_with_index do |line, i|
            next unless line.strip.start_with?("return")
            j = i + 1
            j += 1 while j < lines.size && lines[j].strip.start_with?("#")
            next unless j < lines.size && lines[j].strip.start_with?("else")
            findings << finding(line: j + 1, message: "else after return is unreachable — remove the else and dedent")
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/table_lexical_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Table-driven adapter — replaces 30+ regex-only rules with one class that
      # reads data/lexical_rules.yml. Each table entry becomes a virtual rule with
      # its own id surfaced on every finding. Adding a new lexical rule = a yaml
      # entry, not a new Ruby file.
      class TableLexicalRule < Rule
        TABLE_PATH = File.join(Master::ROOT, "data", "lexical_rules.yml").freeze

        def initialize
          super
          @id          = "table_lexical"
          @description = "Aggregator: emits findings under the id of each matching table row"
          @severity    = :warning
          @axiom_tags  = []
          @entries     = load_entries
        end

        def check(code, path:)
          @entries.flat_map { |entry| run_entry(entry, code, path) }
        end

        private

        def load_entries
          rows = Master.load_yaml(TABLE_PATH)
          (rows || []).map { |r| compile_entry(r) }
        rescue StandardError
          []
        end

        def compile_entry(row)
          {
            id:           row["id"],
            severity:     row["severity"]&.to_sym || :warning,
            tags:         (row["axiom_tags"] || []).map(&:to_sym),
            langs:        row["langs"] || [".rb"],
            includes:     row["path_includes"],
            excludes:     row["path_excludes"],
            skip_comments: row["skip_comments"] == true,
            first_line:   row["first_line"] == true,
            patterns:     (row["patterns"] || []).map { |p|
              { re: Regexp.new(p["regex"]), msg: p["message"], negate: p["negate"] == true,
                one_per_file: p["one_per_file"] == true }
            }
          }
        end

        def run_entry(entry, code, path)
          return [] unless entry[:langs].include?(File.extname(path).downcase)
          return [] if entry[:includes] && !path.include?(entry[:includes])
          return [] if entry[:excludes] && path.include?(entry[:excludes])
          entry[:first_line] ? first_line_findings(entry, code) : per_line_findings(entry, code)
        end

        def first_line_findings(entry, code)
          first = code.lines.first.to_s
          entry[:patterns].flat_map do |p|
            matched = first.match?(p[:re])
            hit = p[:negate] ? !matched : matched
            hit ? [emit(entry, p, 1)] : []
          end
        end

        def per_line_findings(entry, code)
          findings = []
          seen_one = {}
          code.each_line.with_index(1) do |line, num|
            next if entry[:skip_comments] && line.strip.start_with?("#")
            entry[:patterns].each do |p|
              next unless line.match?(p[:re])
              next if p[:one_per_file] && seen_one[p[:re]]
              findings << emit(entry, p, num)
              seen_one[p[:re]] = true if p[:one_per_file]
            end
          end
          findings
        end

        def emit(entry, pattern, line)
          { rule: entry[:id], message: pattern[:msg], line: line,
            severity: entry[:severity], fix: nil, tags: entry[:tags] }
        end
      end
    end
  end
end

lib/master/scan/rules/tell_dont_ask_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Querying an object's state then acting on it from outside breaks encapsulation.
      # The decision should live inside the object (tell it what to do, don't ask what it is).
      # Flags the most common Ruby patterns: .status/.state/.type == then method call,
      # and nil? guards that should be moved into the object.
      class TellDontAskRule < Rule
        STATE_QUERY  = /\b(\w+)\.(status|state|type|kind|mode|phase)\s*==/.freeze
        NIL_GUARD    = /\b(\w+)\.nil\?\s*\|\|/.freeze
        READY_QUERY  = /\b(\w+)\.ready\?\s*&&\s*\1\./.freeze

        def initialize
          super
          @id          = "tell_dont_ask"
          @description = "Tell-Don't-Ask: move state-based decisions into the object"
          @severity    = :warning
          @axiom_tags  = %i[DECOUPLE EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
              message: "TDA: querying .status/.state outside the object — move the decision into the class") if line.match?(STATE_QUERY)
            findings << finding(line: num,
              message: "TDA: nil? guard before method call — use Null Object or move nil check into the object") if line.match?(NIL_GUARD)
            findings << finding(line: num,
              message: "TDA: ready? check then method call on same object — replace with a single command method") if line.match?(READY_QUERY)
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/terse_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class TerseRule < Rule
        BOOL_CMP      = /(?:==|!=)\s*(?:true|false)\b/.freeze
        NIL_EQ        = /==\s*nil\b/.freeze
        NIL_NEQ       = /!=\s*nil\b/.freeze
        THEN_KWORD    = /\b(?:if|unless|when)\b[^#\n]*\bthen\b/.freeze
        SYMBOL_PROC   = /\.(map|select|reject|flat_map|filter_map|sort_by|min_by|max_by|count|sum|any\?|all\?|none\?|find)\s*\{\s*\|(\w+)\|\s*\2\.(\w+)\s*\}/.freeze
        NOT_EMPTY     = /!\s*\w+\.empty\?/.freeze
        LEN_ZERO      = /\.(length|size|count)\s*==\s*0\b/.freeze
        LEN_POS       = /\.(length|size|count)\s*(?:>|>=)\s*[01]\b/.freeze
        DOUBLE_BANG   = /!!\s*\w/.freeze
        UNLESS_NOT    = /\bunless\s+!/.freeze
        TERNARY_SELF  = /(\w+)\s*\?\s*\1\s*:/.freeze

        def initialize
          super
          @id          = "terse"
          @description = "Verbose Ruby patterns — use idiomatic shortcuts"
          @severity    = :style
          @axiom_tags  = [:EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          line_findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            line_findings << finding(line: num,
message: "== true/false is redundant — use the boolean directly") if line.match?(BOOL_CMP)
            line_findings << finding(line: num, message: "use .nil? instead of == nil") if line.match?(NIL_EQ)
            line_findings << finding(line: num,
message: "use object instead of != nil — truthy check suffices") if line.match?(NIL_NEQ)
            line_findings << finding(line: num,
message: "remove `then` — it is noise in multi-line if/unless") if line.match?(THEN_KWORD)
            line_findings << finding(line: num,
message: "symbol-to-proc: .map(&:method_name) instead of block") if line.match?(SYMBOL_PROC)
            line_findings << finding(line: num, message: "!x.empty? → x.any?") if line.match?(NOT_EMPTY)
            line_findings << finding(line: num, message: ".length/size/count == 0 → .empty?") if line.match?(LEN_ZERO)
            line_findings << finding(line: num, message: ".length/size/count > 0 → .any?") if line.match?(LEN_POS)
            line_findings << finding(line: num,
message: "!! is a no-op on booleans and obscures intent — use explicit truthiness") if line.match?(DOUBLE_BANG)
            line_findings << finding(line: num, message: "unless !x → if x") if line.match?(UNLESS_NOT)
            line_findings << finding(line: num, message: "x ? x : y → x || y") if line.match?(TERNARY_SELF)
          end
          line_findings + redundant_returns(code)
        end

        private

        def redundant_returns(code)
          findings = []
          method_lines = []
          in_method = false
          depth = 0

          code.each_line.with_index(1) do |line, num|
            stripped = line.strip
            if !in_method && stripped.match?(/\bdef \w/)
              in_method = true
              method_lines = []
              depth = 1
              next
            end
            next unless in_method

            depth += stripped.scan(/\b(?:def|do|begin|if|unless|case|class|module)\b/).size
            depth -= stripped.scan(/\bend\b/).size

            if depth <= 0
              last = method_lines.reverse.find { |l| !l[:text].strip.empty? && !l[:text].strip.start_with?("#") }
              if last && last[:text].match?(/^\s*return\s+\S/) && !last[:text].match?(/return\s+.+\bif\b/)
                findings << finding(line: last[:num],
message: "redundant return — last expression is the implicit return value")
              end
              in_method = false
              method_lines = []
              depth = 0
            else
              method_lines << { text: line, num: }
            end
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/rules/thread_safety_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class ThreadSafetyRule < Rule
        # Dir.chdir is process-wide; breaks concurrent threads using different roots.
        DIR_CHDIR     = /\bDir\.chdir\b/
        # String-interpolated shell calls risk injection and hide argument boundaries.
        SHELL_INTERP  = /(?:system|`|IO\.popen|Open3\.\w+)\s*\(?\s*["'][^"']*#\{/
        # Prism.parse freeze: kwarg dropped in Ruby 3.4.
        PRISM_FREEZE  = /Prism\.parse\([^)]*freeze:\s*(?:true|false)/

        def initialize
          super
          @id          = "thread_safety"
          @description = "Detect thread-unsafe patterns: Dir.chdir, shell interpolation, dropped kwargs"
          @severity    = :error
          @axiom_tags  = %i[FAIL_VISIBLY EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")

          scan_lines(code, DIR_CHDIR,
            message: "Dir.chdir is process-wide and thread-unsafe; use -C flag or File.expand_path") +
          scan_lines(code, SHELL_INTERP,
            message: "shell interpolation risks injection; use Open3.capture2e with arg array") +
          scan_lines(code, PRISM_FREEZE,
            message: "Prism.parse freeze: kwarg removed in Ruby 3.4; drop it")
        end
      end
    end
  end
end

lib/master/scan/rules/threshold_drift_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # Hardcoded threshold constants in scan rules drift silently from rules.yml.
      # Every threshold should be read from Axioms at init time, not baked into a constant.
      # Flags THRESHOLD, MAX_LINES, MIN_LINES, WARN_LINES constants in scan rule files.
      class ThresholdDriftRule < Rule
        DRIFT_CONST = /^\s+(?:THRESHOLD|MAX_LINES|MIN_LINES|WARN_LINES|MAX_PARAMS|MAX_METHODS)\s*=\s*\d+/.freeze

        def initialize
          super
          @id          = "threshold_drift"
          @description = "Hardcoded threshold constant in scan rule — read from Axioms instead"
          @severity    = :warning
          @axiom_tags  = [:ONE_SOURCE]
        end

        def check(code, path:)
          return [] unless path.include?("scan/rules") && path.end_with?(".rb")
          scan_lines(code, DRIFT_CONST,
            message: "hardcoded threshold — use Master::Axioms.new.thresholds.dig(...) so rules.yml is the single source")
        end
      end
    end
  end
end

lib/master/scan/rules/todo_debt_rule.rb

# frozen_string_literal: true

require "open3"

module Master
  module Scan
    module Rules
      class TodoDebtRule < Rule
        DEBT_TAGS = /\b(?:TODO|FIXME|XXX|HACK|REFACTOR|REVIEW|BUG)\b/.freeze
        STALE_DAYS = 90

        def initialize
          super
          @id          = "todo_debt"
          @description = "TODO/FIXME comment older than #{STALE_DAYS} days — resolve or delete"
          @severity    = :info
          @axiom_tags  = %i[BE_CONCISE]
        end

        def check(code, path:)
          return [] unless File.exist?(path)
          findings = []
          code.each_line.with_index(1) do |line, num|
            next unless line.include?("#") && line.match?(DEBT_TAGS)
            age = blame_age_days(path, num)
            next unless age && age > STALE_DAYS
            findings << finding(line: num,
message: "debt tag #{age} days old — resolve or delete: #{line.strip[0, 80]}")
          end
          findings
        rescue StandardError
          []
        end

        private

        def blame_age_days(path, line)
          out, _, status = Open3.capture3("git", "-C", File.dirname(path), "blame", "-L", "#{line},#{line}",
"--porcelain", File.basename(path))
          return unless status.success?
          ts = out[/^author-time (\d+)/, 1]&.to_i
          return unless ts
          ((Time.now.to_i - ts) / 86_400).to_i
        end
      end
    end
  end
end

lib/master/scan/rules/universal_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # UniversalRule — cross-language axiom checks applied to every file type.
      class UniversalRule < Rule
        BLANK_FLOOD = /\n{4,}/.freeze
        BOX_CHARS   = "\u256D\u256E\u2570\u256F\u2502\u2500\u250C\u2510\u2514\u2518\u251C\u2524\u252C\u2534\u253C\u2550\u2551\u2554\u2557\u255A\u255D".freeze
        BOX_DRAWING = Regexp.new("[#{Regexp.escape(BOX_CHARS)}]|={4,}|-{4,}").freeze
        OPAQUE_NAMES    = /\b(tmp|temp|val|ret|obj|str|arr|buf)\b\s*=/.freeze
        DEAD_AFTER_STOP = /\b(return|exit|raise|throw)\b.+\n\s*\S/.freeze
        STALE_COMMENT   = /^\s*#\s*(TODO|FIXME|HACK|REVIEW|NOTE):\s*$/i.freeze

        CHECKS = [
          { pattern: BLANK_FLOOD,
message: "more than 3 consecutive blank lines — use single blank between sections",       fix: "collapse to one blank line" },
          { pattern: BOX_DRAWING,
message: "box-drawing chars or separator lines — use whitespace as layout tool",          fix: "delete separators" },
          { pattern: OPAQUE_NAMES,
message: "generic variable name — use a domain-specific name",                            fix: nil },
          { pattern: STALE_COMMENT,
message: "empty TODO/FIXME marker — fill it or delete it",                               fix: "delete marker" },
        ].freeze

        def initialize
          super
          @id          = "universal"
          @description = "Cross-language axiom checks"
          @severity    = :info
          @auto_fix    = true
          @axiom_tags  = %i[SQUINT_TEST TYPOGRAPHY_DISCIPLINE MEANINGFUL_NAMES WHITESPACE_PUNCTUATION]
        end

        def check(code, path:)
          findings = []
          CHECKS.each do |check|
            code.each_line.with_index(1) do |line, number|
              findings << finding(line: number, message: check[:message],
fix: check[:fix]) if line.match?(check[:pattern])
            end
          end
          check_dead_code(code, findings)
          check_dense_methods(code, findings)
          findings
        end

        private

        def check_dead_code(code, findings)
          code.each_line.with_index(1).each_cons(2) do |(line_a, number_a), (line_b, _)|
            next unless line_a.match?(DEAD_AFTER_STOP) && line_b.match?(/\S/)
            findings << finding(line: number_a,
message: "dead code after #{line_a.strip.split.first} — remove unreachable lines", fix: "delete unreachable lines")
          end
        end

        def check_dense_methods(code, findings)
          code.each_line.with_index(1).each_cons(2) do |(line_a, number_a), (line_b, _)|
            stripped_a = line_a.strip
            stripped_b = line_b.strip
            next unless stripped_a == "end" && stripped_b.start_with?("def ")
            findings << finding(line: number_a,
message: "no blank line between method definitions — add vertical spacing", fix: "insert blank line")
          end
        end
      end
    end
  end
end

lib/master/scan/rules/vertical_rhythm_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      # House rhythm: exactly one blank line between method defs, none inside a method,
      # exactly two blank lines between top-level class/module definitions.
      class VerticalRhythmRule < Rule
        def initialize
          super
          @id          = "vertical_rhythm"
          @description = "Inter-method blank-line spacing deviates from house rhythm"
          @severity    = :warning
          @axiom_tags  = %i[POLA_PRINCIPLE IMPORTANCE_ORDER]
        end

        def check(code, path:)
          return [] unless path.end_with?(".rb")
          lines = code.lines
          findings = []
          lines.each_with_index do |line, i|
            next unless line =~ /\A\s*def\s/
            blanks = count_blanks_above(lines, i)
            next if i.zero? || blanks == 1 || prev_meaningful_is_class_open?(lines, i)
            findings << finding(line: i + 1, message: "expected 1 blank line above def, found #{blanks}")
          end
          findings
        end

        private

        def count_blanks_above(lines, idx)
          n = 0
          j = idx - 1
          while j >= 0 && lines[j].strip.empty?
            n += 1
            j -= 1
          end
          n
        end

        def prev_meaningful_is_class_open?(lines, idx)
          j = idx - 1
          j -= 1 while j >= 0 && lines[j].strip.empty?
          return false if j < 0
          lines[j] =~ /\A\s*(?:class|module|private|protected|public)\b/
        end
      end
    end
  end
end

lib/master/scan/rules/yaml_quality_rule.rb

# frozen_string_literal: true

module Master
  module Scan
    module Rules
      class YamlQualityRule < Rule
        QUOTED_BOOL   = /:\s*["'](true|false|yes|no|null)["']/.freeze
        QUOTED_INT    = /:\s*["'](\d+)["']/.freeze
        UNNECESSARY_Q = /:\s*"([a-zA-Z0-9_\-\/\.]+)"/.freeze

        def initialize
          super
          @id          = "yaml_quality"
          @description = "YAML verbosity — unnecessary quotes, type coercions"
          @severity    = :style
          @axiom_tags  = [:EXPLICIT]
        end

        def check(code, path:)
          return [] unless path.end_with?(".yml") || path.end_with?(".yaml")
          findings = []
          code.each_line.with_index(1) do |line, num|
            next if line.strip.start_with?("#")
            findings << finding(line: num,
message: "boolean/null as quoted string — remove quotes so YAML parses the type correctly") if line.match?(QUOTED_BOOL)
            findings << finding(line: num,
message: "integer as quoted string — remove quotes") if line.match?(QUOTED_INT)
            findings << finding(line: num,
message: "unnecessary quotes — plain scalars don't need quoting unless they contain : or #") if line.match?(UNNECESSARY_Q) && !line.match?(QUOTED_BOOL) && !line.match?(QUOTED_INT)
          end
          findings
        end
      end
    end
  end
end

lib/master/scan/scanner.rb

# frozen_string_literal: true

require "etc"
require "open3"
require "prism"

module Master
  module Scan
    class Scanner
      RULES_PATH = File.join(Master::ROOT, "data", "rules.yml").freeze
      POOL_SIZE  = [Etc.nprocessors, 8].min
      SCAN_GLOB  = "**/*.{rb,rake,erb,html,htm,css,scss,js,ts,jsx,tsx,zsh,sh,yml,yaml,md}".freeze
      RUBY_EXT   = %w[.rb .rake .gemspec].freeze

      def initialize(rules: nil, event_bus: nil)
        @rules = rules || []
        @bus   = event_bus
        @mutex = Mutex.new
      end

      def scan(path, depth: :deep)
        return Result.err("file not found: #{path}", category: :validation) unless File.exist?(path)

        code     = File.read(path, encoding: "UTF-8")
        ast      = parse_ruby(code, path)
        findings = active_rules(depth).flat_map { |rule| run_rule(rule, code, ast, path) }
        @bus&.publish("scan:complete", path:, depth:, count: findings.size)
        Result.ok(findings)
      rescue StandardError => e
        @bus&.publish("scan:error", path:, error: e.message)
        Result.err("scan failed: #{e.message}", category: :infrastructure)
      end

      def scan_dir(dir, depth: :deep, glob: SCAN_GLOB, stream: false)
        paths   = Dir.glob(File.join(dir, glob)).sort
        results = Array.new(paths.size)
        parallel_each(paths) { |path, idx| results[idx] = scan_one(dir, path, depth, stream) }
        Result.ok(results)
      rescue StandardError => e
        Result.err("scan_dir: #{e.message}", category: :infrastructure)
      end

      # Scan only files changed since git ref — orders of magnitude faster on big repos.
      def scan_since(ref = "HEAD~1", dir: ".", depth: :deep, stream: false)
        out, _, status = Open3.capture3("git", "-C", dir, "diff", "--name-only", "#{ref}...HEAD")
        return Result.err("git diff failed", category: :validation) unless status.success?
        paths = out.lines.map(&:strip).reject(&:empty?)
                  .map { |rel| File.join(dir, rel) }
                  .select { |p| File.exist?(p) && File.extname(p).match?(/\.(rb|erb|yml|js|css|sh|zsh)\z/) }
        results = Array.new(paths.size)
        parallel_each(paths) { |path, idx| results[idx] = scan_one(dir, path, depth, stream) }
        Result.ok(results)
      rescue StandardError => e
        Result.err("scan_since: #{e.message}", category: :infrastructure)
      end

      def add_rule(rule)
        @rules << rule
        self
      end

      def set_agent(agent)
        @rules.each { |r| r.set_agent(agent) if r.respond_to?(:set_agent) }
        self
      end

      private

      def parse_ruby(code, path)
        return unless RUBY_EXT.include?(File.extname(path))
        result = Prism.parse(code)
        result.success? ? result.value : nil
      rescue StandardError
        nil
      end

      def run_rule(rule, code, ast, path)
        if ast && rule.respond_to?(:check_ast)
          rule.check_ast(ast, code, path:)
        else
          rule.check(code, path:)
        end
      end

      def parallel_each(items)
        cursor = Mutex.new
        index  = 0
        Array.new(POOL_SIZE) do
          Thread.new do
            loop do
              i = cursor.synchronize { (index += 1) - 1 }
              break if i >= items.size
              yield items[i], i
            end
          end
        end.each(&:join)
      end

      def scan_one(dir, path, depth, stream)
        file_result = scan(path, depth:)
        stream_progress(dir, path, file_result) if stream
        [path, file_result]
      rescue StandardError => e
        @bus&.publish("scanner:thread_error", path:, error: e.message)
        [path, Result.err(e.message, category: :infrastructure)]
      end

      def stream_progress(dir, path, file_result)
        return unless file_result.ok?
        count = file_result.value!.size
        return unless count.positive?
        rel = path.sub(dir, "").delete_prefix("/")
        $stdout.puts "scan: #{rel} #{count} violation(s)"
        $stdout.flush
      end

      def depth_rules
        @depth_rules ||= begin
          data = Master.load_yaml(RULES_PATH)
          data["scan_depths"] || {}
        end
      rescue StandardError => _e
        @depth_rules = {}
      end

      def active_rules(depth)
        allowed = depth_rules[depth.to_s]
        return @rules if allowed.nil? || allowed == ["all"] || allowed == :all
        @rules.select { |r| allowed.include?(r.class.name.split("::").last) || allowed.include?(r.id) }
      end
    end
  end
end

lib/master/schema_index.rb

# frozen_string_literal: true

module Master
  class SchemaIndex
    attr_reader :tables

    def initialize(root:)
      @root = root
      @tables = {}
      parse_schema
    end

    def indexed_columns(table_name)
      (@tables.dig(table_name, :indexes) || []).flat_map { |idx| idx[:columns] }
    end

    def columns(table_name)
      (@tables.dig(table_name, :columns) || [])
    end

    private

    def parse_schema
      path = File.join(@root, "db", "schema.rb")
      return unless File.exist?(path)
      table_name = nil
      File.readlines(path, chomp: true).each do |line|
        if line =~ /^\s*create_table\s+"([^"]+)"/
          table_name = Regexp.last_match(1)
          @tables[table_name] = { columns: [], indexes: [] }
        elsif table_name && line =~ /^\s*t\.\w+\s+"([^"]+)"/
          @tables[table_name][:columns] << Regexp.last_match(1)
        elsif line =~ /^\s*add_index\s+"([^"]+)",\s+\[(.+)\]/
          target = Regexp.last_match(1)
          cols = Regexp.last_match(2).scan(/"([^"]+)"/).flatten
          (@tables[target] ||= { columns: [], indexes: [] })[:indexes] << { columns: cols }
        end
      end
    end
  end
end

lib/master/security/injection_guard.rb

# frozen_string_literal: true

module Master
  module Security
    class InjectionGuard
      DATA_PATH = File.join(Master::ROOT, "data", "injection_patterns.yml")

      DEFAULTS = {
        prompt_injection: [
          /ignore (?:previous|all|your) instructions/i,
          /disregard (?:your )?(?:system )?prompt/i,
          /you are now (?:a|an|in)/i,
          /pretend (?:to be|you are|you're)/i,
          /new instructions:/i,
          /\[SYSTEM\]/i,
          /###\s*SYSTEM/i,
          /(?:act|behave|respond) as (?:if )?(?:you (?:are|were)|a|an) (?!assistant|helpful)/i,
          /override (?:your )?(?:safety|guidelines|rules|instructions)/i,
          /jailbreak/i,
          /forget (?:everything|all|your)/i,
          /override (?:axiom|principle|rule)/i,
          /disregard (?:axiom|principle|rule|safety)/i,
          /new system prompt/i,
        ].freeze,
        shell_injection: /```(?:bash|sh|zsh|shell)\n.*?(?:rm\s+-rf|curl\b.*?\|\s*(?:bash|sh)\b|wget\b.*?\|\s*(?:bash|sh)\b)/im.freeze,
      }.freeze

      ALLOWLIST_TOKEN = /\AMASTER_TRUSTED:[A-Za-z0-9]{16,}/.freeze

      def initialize(mode: :permissive)
        @mode     = mode
        @patterns = load_or_default
      end

      def scan(content)
        hits = @patterns[:prompt_injection].select { |p| content.match?(p) }
        hits << @patterns[:shell_injection] if content.match?(@patterns[:shell_injection])

        if hits.empty?
          return Result.ok(:clean) if @mode == :permissive
          return Result.ok(:clean) if content.match?(ALLOWLIST_TOKEN)
          return Result.err("default_deny: no allowlist token; rejecting unmatched input", category: :validation)
        end
        Result.err("injection detected: #{hits.size} pattern(s) matched", category: :validation)
      end

      def safe?(text)
        scan(text.to_s).ok?
      end

      def clean!(content)
        cleaned = @patterns[:prompt_injection].reduce(content) { |c, p| c.gsub(p, "[REDACTED]") }
        Result.ok(cleaned)
      end

      private

      def load_or_default
        return DEFAULTS unless File.exist?(DATA_PATH)
        data = Master.load_yaml(DATA_PATH) || {}
        prompt = (data["prompt_injection"] || []).map { |s| Regexp.new(s, Regexp::IGNORECASE) }
        shell  = data.dig("shell_injection", "multiline_pattern")
        {
          prompt_injection: prompt.empty? ? DEFAULTS[:prompt_injection] : prompt.freeze,
          shell_injection:  shell ? Regexp.new(shell,
Regexp::MULTILINE | Regexp::IGNORECASE) : DEFAULTS[:shell_injection],
        }
      rescue StandardError
        DEFAULTS
      end
    end
  end
end

lib/master/security/permissions.rb

# frozen_string_literal: true

module Master
  module Security
    module Permissions
      TOOL_TIERS = {
        "read_file"    => :safe,
        "list_dir"     => :safe,
        "search_files" => :safe,
        "write_file"   => :guarded,
        "str_replace"  => :guarded,
        "apply_diff"   => :guarded,
        "ask_llm"      => :guarded,
        "web_search"   => :guarded,
        "zsh"          => :dangerous
      }.freeze

      BLOCKLIST = [
        "rm -rf /",
        "sudo",
        "reboot",
        "shutdown",
        "mkfs",
        "dd if=",
        "> /dev/",
        "chmod 777",
        "curl | sh",
        "wget | sh"
      ].freeze

      def self.tier_for(tool_name)
        TOOL_TIERS[tool_name.to_s] || :guarded
      end

      def self.blocked?(command)
        BLOCKLIST.any? { |b| command.downcase.include?(b.downcase) }
      end
    end
  end
end

lib/master/semantic_cache.rb

# frozen_string_literal: true

require "digest"
require "json"
require "monitor"

module Master
  class SemanticCache
    MAX_ENTRIES = 1000
    DEFAULT_TTL = 3600
    BYTES_PER_KB = 1024.0

    def initialize(root:, ttl: DEFAULT_TTL, event_bus: nil)
      @root = File.join(root, ".master", "cache")
      @ttl  = ttl
      @bus  = event_bus
      @lru  = []
      @lock = Monitor.new
      Dir.mkdir(@root) unless Dir.exist?(@root)
    end

    def fetch(prompt, model, &blk)
      key  = cache_key(prompt, model)
      path = cache_path(key)

      @lock.synchronize do
        hit = read_entry(path)
        if hit
          @bus&.publish("cache:hit", key:)
          return hit
        end
      end

      @bus&.publish("cache:miss", key:)
      result = blk.call
      @lock.synchronize { write_entry(path, result, key) }
      result
    end

    def invalidate!(prompt, model)
      path = cache_path(cache_key(prompt, model))
      @lock.synchronize { File.delete(path) if File.exist?(path) }
    end

    def invalidate_all!
      @lock.synchronize do
        Dir.glob(File.join(@root, "*.json")).each { |f| File.delete(f) rescue Errno::ENOENT }
        @lru.clear
      end
    end

    def stats
      @lock.synchronize do
        files = Dir.glob(File.join(@root, "*.json"))
        bytes = files.sum { |f| File.exist?(f) ? File.size(f) : 0 }
        { entries: files.size, size_kb: (bytes / BYTES_PER_KB).round(1) }
      end
    end

    private

    def cache_key(prompt, model) = Digest::SHA256.hexdigest("#{prompt}::#{model}")
    def cache_path(key) = File.join(@root, "#{key}.json")

    def stale?(entry) = Time.now.to_i - entry[:ts] > @ttl

    def expire_entry!(path)
      @lru.delete(path)
      File.delete(path) rescue Errno::ENOENT
      nil
    end

    def drop_entry!(path)
      File.delete(path) rescue Errno::ENOENT
      @lru.delete(path)
      nil
    end

    def read_entry(path)
      return unless File.exist?(path)
      entry = JSON.parse(File.read(path), symbolize_names: true)
      return expire_entry!(path) if stale?(entry)
      promote_lru(path)
      entry[:value]
    rescue JSON::ParserError
      drop_entry!(path)
    end

    def write_entry(path, value, key)
      value = value.value! if value.respond_to?(:ok?) && value.ok?
      evict_lru while @lru.size >= MAX_ENTRIES
      File.write(path, JSON.generate({ ts: Time.now.to_i, value: }))
      @lru.push(path)
    end

    def promote_lru(path)
      @lru.delete(path)
      @lru.push(path)
    end

    def evict_lru
      oldest = @lru.shift
      return unless oldest && File.exist?(oldest)
      File.delete(oldest) rescue Errno::ENOENT
    end
  end
end

lib/master/session.rb

# frozen_string_literal: true

require "json"
require "fileutils"

module Master
  class Session
    TOKENS_PER_CHAR  = 4
    SESSION_NAME_MAX = 40
    COSTS_MAX_BYTES  = 102_400     # 100 KB

    attr_reader :name, :messages, :cost, :phase, :snapshots

    def initialize(root: Dir.pwd, budget_max: 10.0, req_max: 1.0)
      @root       = root
      @budget_max = budget_max
      @req_max    = req_max
      @mutex      = Mutex.new
      @messages   = []
      @snapshots  = {}
      @cost       = 0.0
      @phase      = :discover
      @name       = nil
      @path       = File.join(root, ".master", "session.json")
      @costs_path = File.join(root, ".master", "costs.jsonl")
      Dir.mkdir(File.join(root, ".master")) unless Dir.exist?(File.join(root, ".master"))
    end

    def add_message(role:, content:)
      msg = { role:, content:, ts: Time.now.to_i }
      @mutex.synchronize do
        @messages << msg
        @name ||= auto_name(content) if role == :user
      end
      msg
    end

    def record_cost(amount, model:, tokens:)
      entry = nil
      @mutex.synchronize do
        @cost += amount
        entry = { ts: Time.now.to_i, amount:, model:, tokens:, total: @cost }
      end
      rotate_costs! if File.exist?(@costs_path) && File.size(@costs_path) > COSTS_MAX_BYTES
      File.open(@costs_path, "a") { |f| f.puts(JSON.generate(entry)) }
      entry
    end

    def snapshot(path, content)
      @snapshots[path] ||= []
      @snapshots[path] << content
    end

    def last_snapshot(path)
      @snapshots[path]&.last
    end

    def save!
      FileUtils.mkdir_p(File.dirname(@path))
      data = { name: @name, phase: @phase, messages: @messages, cost: @cost, ts: Time.now.to_i }
      File.write(@path, JSON.generate(data))
    end

    def load!
      return self unless File.exist?(@path)
      begin
        data = JSON.parse(File.read(@path), symbolize_names: true)
      rescue JSON::ParserError, Errno::ENOENT
        data = {}
      end
      @name     = data[:name]
      @phase    = data[:phase]&.to_sym || :discover
      @messages = data[:messages] || []
      @cost     = data[:cost].to_f
      self
    end

    def exists?    = File.exist?(@path)
    def clear!     = (@messages = [] ; @cost = 0.0 ; @name = nil ; self)
    def token_est  = @messages.sum { |m| m[:content].to_s.bytesize / TOKENS_PER_CHAR }

    private

    def auto_name(content)
      content.to_s.split.first(5).join(" ").then { |s| s[0, SESSION_NAME_MAX] }
    end

    def rotate_costs!
      return unless File.exist?(@costs_path)

      lines = File.readlines(@costs_path)
      # Keep the most recent half of the lines
      keep  = lines.last([lines.size / 2, 1].max)
      File.write(@costs_path, keep.join)
    end
  end
end

lib/master/skills.rb

# frozen_string_literal: true

require "yaml"

module Master
  # Skills — discovers and loads composable skill directories.
  # Each skill is a directory under skills/ containing:
  #   SKILL.md   — metadata (name, description, trigger patterns)
  #   skill.rb   — optional Ruby implementation (loaded as a tool)
  #
  # Skills discovered at boot are available via /skills; tool registration is pending.
  class Skills
    SKILLS_DIR = "skills".freeze

    attr_reader :loaded

    def initialize(root:, event_bus: nil)
      @root   = root
      @bus    = event_bus
      @loaded = []
    end

    def discover!
      skills_path = File.join(@root, SKILLS_DIR)
      return [] unless Dir.exist?(skills_path)

      Dir.children(skills_path).sort.each do |name|
        dir = File.join(skills_path, name)
        next unless File.directory?(dir)

        skill = load_skill(dir, name)
        @loaded << skill if skill
      end

      @bus&.publish("skills:loaded", count: @loaded.size)
      @loaded
    end

    def list
      return "(no skills loaded)" if @loaded.empty?

      @loaded.map { |s| "#{s[:name]}: #{s[:description]}" }.join("\n")
    end

    def find(name)
      @loaded.find { |s| s[:name] == name.to_s }
    end

    def trigger_for(input)
      @loaded.select do |s|
        s[:triggers]&.any? { |t| input.match?(Regexp.new(t, Regexp::IGNORECASE)) }
      end
    end

    private

    def load_skill(dir, name)
      md_path = File.join(dir, "SKILL.md")
      rb_path = File.join(dir, "skill.rb")

      metadata = parse_skill_md(md_path) if File.exist?(md_path)
      metadata ||= { "name" => name, "description" => name }

      skill = {
        name:        metadata["name"] || name,
        description: metadata["description"] || name,
        triggers:    metadata["triggers"] || [],
        dir:         dir,
        has_ruby:    File.exist?(rb_path)
      }

      if File.exist?(rb_path)
        begin
          require rb_path
          @bus&.publish("skills:ruby_loaded", skill: name)
        rescue StandardError => e
          @bus&.publish("skills:load_error", skill: name, error: e.message)
        end
      end

      skill
    rescue StandardError => e
      @bus&.publish("skills:load_error", skill: name, error: e.message)
      nil
    end

    def parse_skill_md(path)
      content = File.read(path, encoding: "UTF-8")
      return {} unless content.start_with?("---")

      parts = content.split("---", 3)
      return {} if parts.size < 3

      YAML.safe_load(parts[1]) || {}
    rescue StandardError => _e
      {}
    end
  end
end

lib/master/soul.rb

# frozen_string_literal: true

require "open3"
require "yaml"
require "fileutils"

module Master
  # Manages SOUL.md identity document; Evolution Protocol: propose→test→approve→tag.
  class Soul
    SOUL_PATH     = File.join(Master::ROOT, "SOUL.md").freeze
    PROPOSAL_PATH = File.join(Master::ROOT, ".master", "soul_proposal.md").freeze

    # Drift boundaries — changes to ABSOLUTE sections are blocked without override.
    ABSOLUTE_PATTERNS  = [/anti-simulation rule/i, /golden rule/i, /preserve.*then.*improve/i].freeze
    PROTECTED_PATTERNS = [/voice character/i, /terse.*direct.*dark/i].freeze

    def initialize(root: Master::ROOT, agent: nil)
      @root  = root
      @agent = agent
      @soul  = load_soul
    end

    # Wire the agent after construction (avoids circular dependency in build).
    def wire_agent(agent) = @agent = agent

    def summary
      version = extract_version
      persona = extract_field("Persona")
      voice   = extract_field("Voice").to_s.lines.first.to_s.strip[0, 120]
      "SOUL.md v#{version} | persona: #{persona}\n#{voice}"
    end

    def changelog
      block = @soul[/## Changelog\n+(.*?)(?=\n## |\z)/m, 1].to_s.strip
      block.empty? ? "(no changelog)" : block
    end

    def propose(rationale, agent: @agent)
      return "no agent available for drafting" unless agent

      current = @soul
      prompt  = <<~PROMPT
        You are editing SOUL.md — a constitutional identity document for an AI coding agent.
        Current document:
        #{current}

        Proposed change rationale: #{rationale}

        Draft ONLY the minimal changes needed. Preserve the anti-simulation rule,
          golden rule, and voice character unchanged.
        Output the full updated SOUL.md. No preamble.
      PROMPT

      draft = agent.ask_once(prompt)
      return "draft failed" if draft.to_s.strip.empty?

      drift = measure_drift(current, draft)
      blocked = drift[:absolute_changed].any?

      if blocked
        "BLOCKED: proposal changes ABSOLUTE sections: #{drift[:absolute_changed].join(", ")}. Add /override to force."
      else
        FileUtils.mkdir_p(File.dirname(PROPOSAL_PATH))
        tmp_w = "#{PROPOSAL_PATH}.tmp.#{Process.pid}"
        File.write(tmp_w, draft)
        File.rename(tmp_w, PROPOSAL_PATH)
        risk = drift[:protected_changed].any? ? " [PROTECTED sections affected: #{drift[:protected_changed].join(", ")}]" : ""
        "proposal saved#{risk}. Review with `soul diff`, approve with `soul approve`, reject with `soul reject`."
      end
    rescue StandardError => e
      "proposal error: #{e.message}"
    end

    def diff
      return "no pending proposal" unless File.exist?(PROPOSAL_PATH)
      proposal = File.read(PROPOSAL_PATH)
      lines_old = @soul.lines
      lines_new = proposal.lines
      changes = lines_new.reject { |l| lines_old.include?(l) }
      removals = lines_old.reject { |l| lines_new.include?(l) }
      out = []
      out += removals.first(10).map { |l| "- #{l.chomp}" }
      out += changes.first(10).map { |l| "+ #{l.chomp}" }
      out.empty? ? "(no visible changes)" : out.join("\n")
    end

    def approve
      return "no pending proposal" unless File.exist?(PROPOSAL_PATH)
      proposal = File.read(PROPOSAL_PATH)

      old_version = extract_version
      new_version = bump_version(old_version, :patch)

      # Inject new version into proposal
      updated = proposal.sub(/Version: [\d.]+/, "Version: #{new_version}")
      # Update changelog entry
      date    = Time.now.strftime("%Y-%m-%d")
      entry   = "| #{new_version} | #{date} | Evolution Protocol change | Approved via `soul approve` |\n"
      updated = updated.sub(/\| 1\.0\.0 \|/, entry + "| 1.0.0 |")

      tmp_w = "#{SOUL_PATH}.tmp.#{Process.pid}"
      File.write(tmp_w, updated)
      File.rename(tmp_w, SOUL_PATH)
      File.unlink(PROPOSAL_PATH)
      @soul = updated

      # Git tag
      Open3.capture2e("git", "-C", @root, "add", "SOUL.md")
      Open3.capture2e("git", "-C", @root, "commit", "-m", "soul: v#{new_version} — evolution protocol update")

      "soul updated to v#{new_version}"
    rescue StandardError => e
      "approve error: #{e.message}"
    end

    def reject
      return "no pending proposal" unless File.exist?(PROPOSAL_PATH)
      File.unlink(PROPOSAL_PATH)
      "proposal rejected"
    end

    def rollback
      log_out, = Open3.capture2e("git", "-C", @root, "log", "--oneline", "SOUL.md")
      out = log_out.lines
      return "no git history for SOUL.md" if out.size < 2
      prev_sha = out[1].split.first
      restored, = Open3.capture2e("git", "-C", @root, "show", "#{prev_sha}:SOUL.md")
      tmp_w = "#{SOUL_PATH}.tmp.#{Process.pid}"
      File.write(tmp_w, restored)
      File.rename(tmp_w, SOUL_PATH)
      @soul = restored
      "rolled back to #{prev_sha}#{out[1].chomp}"
    rescue StandardError => e
      "rollback error: #{e.message}"
    end

    def system_prompt
      voice  = @soul[/## Voice\n+(.*?)(?=\n## |\z)/m, 1].to_s.strip
      values = @soul[/## Values\n+(.*?)(?=\n## |\z)/m, 1].to_s.strip
      "#{voice}\n\n#{values}"
    end

    def propose_from_violations(rule_id, sample_violations, agent: @agent)
      return "no agent available" unless agent

      examples  = sample_violations.first(3).map { |v| "  L#{v[:line]}: #{v[:message]}" }.join("\n")
      rationale = "Recurring scan rule '#{rule_id}' flagged #{sample_violations.size} " \
                  "violations across multiple files and cycles:\n#{examples}\n" \
                  "Propose whether the codebase axioms or soul principles should acknowledge this pattern " \
                  "or whether the rule needs refinement."
      propose(rationale, agent:)
    end

    private

    def load_soul
      File.exist?(SOUL_PATH) ? File.read(SOUL_PATH, encoding: "UTF-8") : ""
    rescue StandardError => _e
      ""
    end

    def extract_version
      @soul[/^Version: ([\d.]+)/, 1] || "1.0.0"
    end

    def extract_field(name)
      @soul[/^#{Regexp.escape(name)}:\s*(.+)/, 1].to_s.strip
    end

    def bump_version(ver, level)
      parts = ver.split(".").map(&:to_i)
      case level
      when :major then "#{parts[0] + 1}.0.0"
      when :minor then "#{parts[0]}.#{parts[1] + 1}.0"
      when :patch then "#{parts[0]}.#{parts[1]}.#{parts[2] + 1}"
      end
    end

    def measure_drift(old_doc, new_doc)
      absolute_changed  = ABSOLUTE_PATTERNS.select  { |p| old_doc.match?(p) && !new_doc.match?(p) }.map(&:source)
      protected_changed = PROTECTED_PATTERNS.select { |p| old_doc.match?(p) && !new_doc.match?(p) }.map(&:source)
      { absolute_changed:, protected_changed: }
    end
  end
end

lib/master/speech.rb

# frozen_string_literal: true

require "securerandom"
require "fileutils"
require "open3"

module Master
  module Speech
    WORKER   = File.expand_path("../../exe/tts-worker", __dir__)
    EDGE_TTS = File.executable?(WORKER)
    ESPEAK   = %w[/usr/bin/espeak /usr/local/bin/espeak].find { |p| File.executable?(p) }

    VOICES = {
      osman:   "ms-MY-OsmanNeural",
      yasmin:  "ms-MY-YasminNeural",
      ryan:    "en-GB-RyanNeural",
      finn:    "nb-NO-FinnNeural",
      steffan: "en-US-SteffanNeural"
    }.freeze

    STYLES = {
      deep:    { rate: "-35%", pitch: "-150Hz" },
      heavy:   { rate: "-30%", pitch: "-120Hz" },
      normal:  { rate: "+0%",  pitch: "+0Hz"   },
      slow:    { rate: "-20%", pitch: "-60Hz"  },
      natural: { rate: "+8%",  pitch: "+20Hz"  },
      # Clause-aware variants — auto-applied by infer_style when caller passes :auto.
      question:{ rate: "-10%", pitch: "+40Hz"  }, # rising lift, slight slowdown
      exclaim: { rate: "+15%", pitch: "+60Hz"  }, # energetic, brighter
      whisper: { rate: "-15%", pitch: "-30Hz"  }, # quiet, intimate
      grave:   { rate: "-25%", pitch: "-80Hz"  }  # sober, weighty
    }.freeze

    DEFAULT_VOICE = :yasmin
    DEFAULT_STYLE = :natural

    # P4: pick a style from text shape when caller asks for :auto.
    # Heuristic — questions lift, exclamations brighten, ALL-CAPS shouts,
    # ellipses/short-final go grave. Fallback: caller's default.
    def infer_style(text, fallback: DEFAULT_STYLE)
      t = text.to_s.strip
      return fallback if t.empty?
      return :exclaim  if t.match?(/!{1,3}\s*$/) || t.match?(/\b[A-Z]{4,}\b/)
      return :question if t.end_with?("?")
      return :grave    if t.match?(/\.{3,}\s*$|\u2026\s*$/) || t.match?(/\b(sorry|i'?m sorry|condolences|grief|loss)\b/i)
      return :whisper  if t.start_with?("(") && t.end_with?(")")
      fallback
    end

    PULSE_SOCKET     = "/tmp/pulse/native".freeze
    PULSE_DAEMON     = "/data/data/com.termux/files/usr/bin/pulseaudio".freeze
    PAPLAY_CANDIDATES = %w[
      /data/data/com.termux/files/usr/bin/paplay
      /usr/bin/paplay
      /usr/local/bin/paplay
    ].freeze
    FFMPEG_CANDIDATES = %w[/usr/bin/ffmpeg /usr/local/bin/ffmpeg].freeze
    DIRECT_PLAYERS    = %w[aucat mpv ffplay aplay].freeze

    module_function

    def available?
      !EDGE_TTS.nil? || !ESPEAK.nil?
    end

    def synthesize(text, voice: DEFAULT_VOICE, style: DEFAULT_STYLE)
      return if text.to_s.strip.empty?

      style = infer_style(text, fallback: DEFAULT_STYLE) if style == :auto
      style = DEFAULT_STYLE unless STYLES.key?(style)
      voice = DEFAULT_VOICE unless VOICES.key?(voice)

      if EDGE_TTS
        synthesize_edge(text, voice: voice, style: style)
      elsif ESPEAK
        synthesize_espeak(text)
      end
    end

    def synthesize_bytes(text, **opts)
      path = synthesize(text, **opts)
      return unless path
      bytes = File.binread(path)
      File.unlink(path) rescue StandardError => _e
      bytes
    end

    def play(audio_path)
      return false unless audio_path && File.exist?(audio_path)
      play_via_pulse(audio_path) || play_direct(audio_path)
    end

    private

    module_function

    # Shells out to exe/tts-worker — Falcon's Async scheduler blocks Process.fork
    # ("Closing scheduler with blocked operations"), and EventMachine.run inside a
    # request fiber conflicts with Falcon's reactor. A subprocess sidesteps both.
    def synthesize_edge(text, voice:, style:)
      audio_path   = "/tmp/m_tts_#{SecureRandom.hex(8)}.mp3"
      voice_name   = VOICES.fetch(voice.to_sym, VOICES[DEFAULT_VOICE])
      style_config = STYLES.fetch(style.to_sym, STYLES[DEFAULT_STYLE])

      _out, _err, status = Open3.capture3(
        WORKER, voice_name, style_config[:rate], style_config[:pitch], audio_path,
        stdin_data: text.to_s
      )
      return unless status.success?

      (File.exist?(audio_path) && File.size(audio_path) > 0) ? audio_path : nil
    end

    def synthesize_espeak(text)
      audio_path = "/tmp/m_tts_#{SecureRandom.hex(8)}.wav"
      ok         = system(
        ESPEAK, "-s", "140", "-p", "30", "-a", "150",
        "-w", audio_path, text.to_s,
        out: File::NULL, err: File::NULL
      )
      (ok && File.exist?(audio_path) && File.size(audio_path) > 0) ? audio_path : nil
    end
  end
end

lib/master/stages/council.rb

# frozen_string_literal: true

require "yaml"

module Master
  module Stages
    # Council — 6-persona deliberation on dangerous or multi-file changes.
    # PRAISE votes are appended to data/exemplars.yml for future reference.
    class Council
      EXEMPLARS_PATH       = File.join(Master::ROOT, "data", "exemplars.yml").freeze
      COUNCIL_PATH         = File.join(Master::ROOT, "data", "council.yml").freeze
      EXEMPLAR_MSG_CHARS   = 120
      EXEMPLAR_FEEDBACK_CHARS = 240

      def initialize(deliberation:, config: nil, enabled: false, event_bus: nil)
        @deliberation      = deliberation
        @config            = config
        @bus               = event_bus
        @enabled           = @config&.[]("council") == true || enabled
        @dangerous_patterns = load_patterns
      end

      def call(ctx)
        return Result.ok(ctx) unless should_run?(ctx)

        payload = extract_payload(ctx)
        result  = @deliberation.review(payload, context: ctx[:message])
        return result if result.err?

        feedback = result.value!
        log_praise(ctx[:message], feedback) if praise?(feedback)

        Result.ok(ctx.merge(council_feedback: feedback))
      end

      def enable!
        @enabled = true
        @config&.[]=("council", true)
        @config&.save!
      end

      def disable!
        @enabled = false
        @config&.[]=("council", false)
        @config&.save!
      end

      def enabled? = @enabled

      private

      def load_patterns
        data = Master.load_yaml(COUNCIL_PATH)
        (data["auto_trigger_patterns"] || []).filter_map do |str|
          Regexp.new(str, Regexp::IGNORECASE)
        rescue RegexpError
          nil
        end
      end

      def should_run?(ctx)
        return false if ctx[:intent] == :command
        @enabled || dangerous_request?(ctx) || dangerous_tool?(ctx) || multi_file_diff?(ctx)
      end

      def dangerous_request?(ctx)
        msg = ctx[:message].to_s.gsub(/[[:cntrl:]]/, "")
        !msg.empty? && @dangerous_patterns.any? { |p| msg.match?(p) }
      end

      def dangerous_tool?(ctx)  = ctx[:last_tool_tier] == :dangerous
      def multi_file_diff?(ctx) = extract_payload(ctx).scan(/^(?:---|\+\+\+)\s+[ab]\/(.+)$/).uniq.size >= 2

      def extract_payload(ctx)
        out = ctx[:output]
        case out
        when Result::Ok  then out.value!.to_s
        when Result::Err then ""
        else
          text = out.to_s
          text.empty? ? ctx[:message].to_s : text
        end
      end

      # Detect unanimous or majority PRAISE in council feedback text.
      def praise?(feedback)
        text = feedback.to_s.downcase
        text.scan(/\bpraise\b/).size >= 3
      end

      # Append a PRAISE entry to data/exemplars.yml.
      def log_praise(message, feedback)
        entry = {
          "timestamp" => Time.now.iso8601,
          "message"   => message.to_s[0, EXEMPLAR_MSG_CHARS],
          "feedback"  => feedback.to_s[0, EXEMPLAR_FEEDBACK_CHARS]
        }
        @exemplar_mutex ||= Mutex.new
        @exemplar_mutex.synchronize do
          existing = File.exist?(EXEMPLARS_PATH) ? (Master.load_yaml(EXEMPLARS_PATH) || []) : []
          tmp = "#{EXEMPLARS_PATH}.tmp.#{Process.pid}"
          File.write(tmp, YAML.dump(existing + [entry]))
          File.rename(tmp, EXEMPLARS_PATH)
        end
      rescue StandardError => e
        @bus&.publish("council:exemplar_error", error: e.message)
      end
    end
  end
end

lib/master/stages/deliberate.rb

# frozen_string_literal: true

module Master
  module Stages
    # Deliberate — enumerate N approaches before acting; prevents first-solution fixation.
    class Deliberate
      MIN_OPTIONS   = 4
      CODING_TYPES  = %i[coding refactor architecture infrastructure].freeze

      def initialize(agent:, config:)
        @agent  = agent
        @config = config
      end

      def call(ctx)
        return Result.ok(ctx) unless applicable?(ctx)

        msg    = ctx[:message].to_s
        Result.ok(ctx.merge(message: wrap(msg)))
      end

      private

      def applicable?(ctx)
        ctx[:intent] == :llm &&
          CODING_TYPES.include?(ctx[:task_type]) &&
          @config["deliberate"] != false
      end

      def wrap(msg)
        <<~PROMPT
          #{msg}

          Before acting: list #{MIN_OPTIONS} distinct approaches (numbered). Each: one-line name + one-line trade-off. Then execute the strongest one. State which you chose and why in one sentence.
        PROMPT
      end
    end
  end
end

lib/master/stages/execute.rb

# frozen_string_literal: true

module Master
  module Stages
    # Execute — call the handler resolved by Route and store its output.
    class Execute
      def call(ctx)
        handler = ctx[:handler]
        return Result.err("execute: no handler", category: :validation) unless handler

        Result.ok(ctx.merge(output: handler.call(ctx)))
      rescue StandardError => e
        Result.err("execute: #{e.message}", category: :handler_exception)
      end
    end
  end
end

lib/master/stages/guard.rb

# frozen_string_literal: true

module Master
  module Stages
    # Guard — reject messages that contain prompt-injection patterns.
    # Skips scan when message is absent (command-only paths set no :message).
    class Guard
      def initialize(governor:, injection_guard:)
        @governor        = governor
        @injection_guard = injection_guard
      end

      def call(ctx)
        msg = ctx[:message].to_s
        return Result.ok(ctx) if msg.empty?

        scan = @injection_guard.scan(msg)
        return Result.err("guard: #{scan.message}", category: :validation) if scan.err?

        Result.ok(ctx)
      end
    end
  end
end

lib/master/stages/infer.rb

# frozen_string_literal: true

module Master
  module Stages
    # Infer — promote natural-language messages to :command intent via data/infer_patterns.yml.
    class Infer
      # Heuristic task-type detection — used by ModelRouter for tiered model selection.
      PRESSURE_PATTERN = /\b(?:urgent|asap|immediately|critical|now|hurry|fast|quick(?:ly)?|emergency|sos)\b/i.freeze

      VAGUE_STUBS = /\A(?:help(?:\s+me)?|hmm+|idk|ugh|ok+|yeah|yep|nope?|hi+|hey|hello|good\s+\w+|test(?:ing)?|please)\z/i.freeze

      GREETING_STUBS = /\A(?:hi+|hey|hello|good\s+\w+)\z/i.freeze
      ELICIT_DEFAULT = "be specific: which file or function, and what should change?".freeze

      TASK_TYPE_PATTERNS = {
        architecture: /\b(?:restructur|reorganiz|hierarch|layout|folder|director|module\s+boundar|decouple|extract\s+(?:a\s+)?(?:module|class|layer|service)|where\s+should|how\s+should\s+(?:we|i)\s+organiz|split\s+(?:this\s+)?(?:into|across)|consolidat)/i,
        coding:       /\b(?:def |class |module |require |\.rb\b|fix\s+(?:the\s+)?(?:bug|error|issue)|refactor|implement|write\s+(?:a\s+)?(?:method|class|function|test)|add\s+(?:a\s+)?(?:method|feature)|```(?:ruby|python|js|javascript|bash))/i,
        research:     /\b(?:search|find\s+(?:all|every|info)|research|look\s+up|what\s+is|explain\s+(?:how|what|why)|tell\s+me\s+about)\b/i,
        qa:           /\?(?:\s*$|\s+[A-Z])/m,
      }.freeze

      PATTERNS_PATH = File.join(Master::ROOT, "data", "infer_patterns.yml").freeze

      def initialize
        @patterns = load_patterns
      end

      def call(ctx)
        return Result.ok(ctx) unless ctx[:intent] == :llm

        msg = ctx[:message].to_s.strip
        @patterns.each do |cmd, entry|
          entry[:regexes].each do |pattern|
            next unless (m = msg.match(pattern))
            return Result.ok(ctx.merge(intent: :command, command: cmd, args: extract_args(cmd,
              entry[:capture], m, msg)))
          end
        end

        if vague?(msg)
          if msg.match?(GREETING_STUBS)
            return Result.ok(ctx.merge(intent: :clarify, clarifying_question: "ready. what are you working on?"))
          end
          return Result.ok(ctx.merge(intent: :clarify, clarifying_question: ELICIT_DEFAULT))
        end

        pressure = msg.match?(PRESSURE_PATTERN)
        Result.ok(ctx.merge(task_type: infer_task_type(msg), pressure: pressure || ctx[:pressure]))
      end

      private

      def load_patterns
        return {} unless File.exist?(PATTERNS_PATH)
        data = Master.load_yaml(PATTERNS_PATH) || {}
        commands = data["commands"] || {}
        commands.each_with_object({}) do |(name, spec), out|
          regexes = (spec["patterns"] || []).map { |src| Regexp.new(src, Regexp::IGNORECASE | Regexp::EXTENDED) }
          out[name.to_s] = { regexes: regexes, capture: spec["capture"].to_s }
        end
      rescue StandardError => _e
        {}
      end

      def vague?(msg) = msg.match?(VAGUE_STUBS)

      def infer_task_type(msg)
        TASK_TYPE_PATTERNS.each { |type, pat| return type if msg.match?(pat) }
        :general
      end

      def extract_args(cmd, capture, match, msg)
        case capture
        when "path"
          path = match[1]&.strip
          path = nil if path&.match?(/\A(?:all|everything|the|code|codebase)\z/i)
          path.to_s
        when "cycles"
          (match[1] || msg[/\b(\d+)\s*(?:time|cycle|iteration|gang|syklus)/i, 1]).to_s
        when "on_off"
          msg.match?(/\b(?:off|disable|stop|av|skru\s+av)\b/i) ? "off" : "on"
        when "first_group"
          match.captures.compact.first.to_s.strip
        when "persona_name"
          (match[1] || match[2] || match[3]).to_s.strip
        when "soul_subcmd"
          msg[/\b(version|changelog|diff|approve|reject|rollback|propose.{0,60})/i].to_s.strip
        when "orders_subcmd"
          msg.match?(/\blist|show\b/i) ? "list" : ""
        when "scan_depth"
          match[1]&.strip.to_s
        else
          ""
        end
      end
    end
  end
end

lib/master/stages/intake.rb

# frozen_string_literal: true

module Master
  module Stages
    # Intake — parse raw user message into intent + structured fields.
    # Slash syntax: /command args → intent :command.
    # Plain text → intent :llm.
    class Intake
      # m[1] = command name, m[2] = args string (may be empty)
      COMMAND_RE = /\A\s*\/([\w-]+)\s*(.*)/m.freeze

      def call(ctx)
        raw = ctx[:user_message]
        msg = raw.to_s.strip
        return Result.err("intake: empty message", category: :validation) if msg.empty?

        if (m = msg.match(COMMAND_RE))
          command = m[1].downcase
          args    = m[2].strip
          args = nil if args.empty?
          Result.ok(ctx.merge(intent: :command, command: command, args: args))
        else
          Result.ok(ctx.merge(intent: :llm, message: msg))
        end
      end
    end
  end
end

lib/master/stages/lint.rb

# frozen_string_literal: true

module Master
  module Stages
    # Lint — scan written files and chat code blocks; autofix via autoloop if available.
    class Lint
      FENCE_RE = /```(?:ruby)?\n(.*?)```/m

      def initialize(scanner:, config:, autoloop: nil, root: nil, event_bus: nil)
        @scanner  = scanner
        @config   = config
        @autoloop = autoloop
        @root     = root
        @bus      = event_bus
      end

      def call(ctx)
        findings = []

        paths = Array(ctx[:written_files]).filter_map { |p| File.exist?(p) ? p : nil }
        paths.each do |scan_path|
          if File.directory?(scan_path)
            dir_map = Result.wrap(@scanner.scan_dir(scan_path, depth: :standard)).value_or({})
            findings.concat(dir_map.values.flat_map { |r| Result.wrap(r).value_or([]) })
          elsif scan_path.end_with?(".rb")
            findings.concat(Result.wrap(@scanner.scan(scan_path, depth: :standard)).value_or([]))
          end
        end

        output = ctx[:output].to_s
        output.scan(FENCE_RE).each do |match|
          code = match[0]
          next if code.nil? || code.strip.empty?
          inline_findings = scan_inline(code)
          findings.concat(inline_findings)
        end

        if findings.any? && @autoloop
          fixable = findings.select { |f| !AutoLoop::SKIP_RULES.include?(f[:rule].to_s) }
          if fixable.any?
            fix_result = @autoloop.run(max_cycles: 3)
            ctx = ctx.merge(autofix_result: fix_result)
          end
        end

        Result.ok(ctx.merge(lint_report: findings))
      rescue StandardError => e
        Result.ok(ctx.merge(lint_error: e.message))
      end

      private

      def scan_inline(code)
        require "tempfile"
        findings = []
        Tempfile.open(["lint_inline", ".rb"]) do |f|
          f.write("# frozen_string_literal: true\n\n#{code}")
          f.flush
          findings = Result.wrap(@scanner.scan(f.path, depth: :standard))
            .value_or([]).map { |v| v.merge(source: :inline) }
        end
        findings
      rescue StandardError => e
        @bus&.publish("lint:scan_error", error: e.message)
        []
      end
    end
  end
end

lib/master/stages/memo.rb

# frozen_string_literal: true

module Master
  module Stages
    # Memo — extract memories from :user_message only; assistant output ignored to prevent hallucination loops.
    class Memo
      REMEMBER_RE   = /\bremember\s+(?:that\s+)?(.{10,200}?)(?:[.!]|$)/im.freeze
      DECISION_RE   = /\bwe(?:'ve|\s+have)?\s+decided\s+(?:to\s+)?(.{10,150}?)(?:[.!]|$)/im.freeze
      PREFER_RE     = /\bI\s+prefer\s+(.{5,100}?)(?:[.!]|$)/im.freeze
      ROLE_RE       = /\bI(?:'m| am)\s+(?:a\s+|the\s+)?([a-z][a-z\s-]{3,40}?)(?:[.,!]|\s+(?:and|but|so|who))/im.freeze
      DONT_RE       = /\b(?:don'?t|stop|never)\s+(.{5,120}?)(?:[.!]|$)/im.freeze
      EPISODE_CHARS = 160

      def initialize(memory:, event_bus: nil)
        @memory = memory
        @bus    = event_bus
      end

      def call(ctx)
        text = user_text(ctx)
        scan_for_memories(text) if text && !text.empty?
        record_episode(ctx, text) if ctx[:voice] && text && !text.empty?
        Result.ok(ctx)
      rescue StandardError => e
        @bus&.publish("memo:error", message: e.message)
        Result.ok(ctx)
      end

      private

      def user_text(ctx)
        ctx[:user_message].to_s
      end

      def record_episode(ctx, user_text)
        reply  = ctx[:rendered].to_s
        digest = "user: #{user_text[0, EPISODE_CHARS]} | reply: #{reply[0, EPISODE_CHARS]}"
        @memory.remember("episode_#{Time.now.to_i}", digest, type: "general")
      end

      def scan_for_memories(text)
        ts = Time.now.to_i
        text.scan(REMEMBER_RE).each_with_index do |(fact), i|
          @memory.remember("note_#{ts}_#{i}", fact.strip, type: "general")
        end
        text.scan(DECISION_RE).each_with_index do |(decision), i|
          @memory.remember("decision_#{ts}_#{i}", decision.strip, type: "project")
        end
        text.scan(PREFER_RE).each_with_index do |(pref), i|
          key = "pref_#{ts}_#{i}_#{pref.split.first(3).join("_").downcase.gsub(/\W/, "")}"
          @memory.remember(key, pref.strip, type: "feedback")
        end
        text.scan(DONT_RE).each_with_index do |(rule), i|
          @memory.remember("rule_#{ts}_#{i}", "don't #{rule.strip}", type: "feedback")
        end
        if (m = text.match(ROLE_RE))
          role = m[1].strip
          @memory.remember("user_role", role, type: "user") unless role.length < 4 || role =~ /\b(?:going|trying|sure|thinking|writing)\b/i
        end
      end
    end
  end
end

lib/master/stages/prune.rb

# frozen_string_literal: true

module Master
  module Stages
    # Prune — strip sycophancy and markdown formatting from LLM responses.
    # Rules loaded from data/rules.yml (voice.strunk). Fence-aware: prunes prose, leaves code blocks.
    class Prune
      RULES_PATH = File.join(Master::ROOT, "data", "rules.yml").freeze
      FENCE_RE  = /(```.*?```)/m.freeze

      HEADER_RE     = %r{^\#{1,6}\s+}.freeze
      BOLD_RE       = /\*\*(.+?)\*\*/
      ITALIC_RE     = /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/
      BULLET_RE     = /^\s*[-*+]\s+/
      NUMBERED_RE   = /^\s*\d+\.\s+/
      HR_RE         = /^-{3,}\s*$/
      LINK_RE       = /\[([^\]]+)\]\([^)]+\)/
      SYCOPHANCY_RE = /\A\s*(?:certainly|of course|great question|absolutely|sure|happy to help|i(?:'d| would) be (?:happy|glad)|no problem)[!.,]*\s*/i

      def call(ctx)
        raw = ctx[:output]
        output = if raw.respond_to?(:ok?) && raw.ok?
                   raw.value!.to_s
                 elsif raw.is_a?(String)
                   raw
                 else
                   return Result.ok(ctx)
                 end
        return Result.ok(ctx) if output.empty?

        cleaned = prune_mixed(output)
        final = raw.respond_to?(:ok?) ? Result.ok(cleaned.strip) : cleaned.strip
        Result.ok(ctx.merge(output: final))
      end

      private

      def prune_mixed(text)
        segments = text.split(FENCE_RE)
        segments.map { |seg|
          seg.start_with?("```") ? seg : strip_all(seg)
        }.join
      end

      def strip_all(text)
        cleaned = text
        cleaned = cleaned.sub(SYCOPHANCY_RE, "")

        rules.fetch("preambles", []).each { |p| cleaned = cleaned.sub(/\A\s*#{Regexp.escape(p)}\s*/i, "") }
        rules.fetch("endings",   []).each { |e| cleaned = cleaned.sub(/\s*#{Regexp.escape(e)}\s*\z/i, "") }
        rules.fetch("hedges",    []).each do |h|
          if h.is_a?(Hash)
            cleaned = cleaned.gsub(h["pattern"].to_s, h["replace"].to_s)
          else
            cleaned = cleaned.gsub(/\b#{Regexp.escape(h)}\b\s*/i, "")
          end
        end

        cleaned = cleaned.gsub(HEADER_RE, "")
        cleaned = cleaned.gsub(BOLD_RE, '\1')
        cleaned = cleaned.gsub(ITALIC_RE, '\1')
        cleaned = cleaned.gsub(LINK_RE, '\1')
        cleaned = cleaned.gsub(HR_RE, "")
        cleaned = cleaned.gsub(BULLET_RE, "")
        cleaned = cleaned.gsub(NUMBERED_RE, "")
        cleaned = cleaned.gsub(/\n{3,}/, "\n\n")
        cleaned
      end

      def rules
        @rules ||= begin
          data = File.exist?(RULES_PATH) ? Master.load_yaml(RULES_PATH) : {}
          data.dig("voice", "strunk") || {}
        end
      rescue StandardError => _e
        @rules = {}
      end
    end
  end
end

lib/master/stages/render.rb

# frozen_string_literal: true

module Master
  module Stages
    # Render — format the final output for display.
    class Render
      def initialize(renderer:)
        @renderer = renderer
      end

      def call(ctx)
        output = ctx[:output]
        rendered = case output
                   when Result::Ok  then @renderer.render(output.value!, mode: :plain)
                   when Result::Err then @renderer.render(output.message, mode: :error)
                   else                  @renderer.render(output.to_s, mode: :plain)
                   end

        Result.ok(ctx.merge(rendered:))
      end
    end
  end
end

lib/master/stages/route.rb

# frozen_string_literal: true

module Master
  module Stages
    # Route — attach the correct handler to the context.
    # :command looks up registered command. :llm uses the agent.
    class Route
      def initialize(commands:, agent:)
        @commands = commands
        @agent    = agent
      end

      def add_command(name, handler) = @commands[name.to_s] = handler

      def call(ctx)
        case ctx[:intent]
        when :command  then route_command(ctx)
        when :llm      then Result.ok(ctx.merge(handler: @agent))
        when :clarify  then Result.ok(ctx.merge(handler: ->(_c) { ctx[:clarifying_question] }))
        else                Result.err("route: unknown intent #{ctx[:intent].inspect}", category: :validation)
        end
      end

      private

      def route_command(ctx)
        cmd = @commands[ctx[:command]]
        unless cmd
          suggestion = closest_command(ctx[:command])
          msg = "unknown command: /#{ctx[:command]}"
          msg += " -- did you mean /#{suggestion}?" if suggestion
          return Result.err(msg, category: :validation)
        end
        Result.ok(ctx.merge(handler: cmd))
      end

      def closest_command(name)
        best = @commands.keys.min_by { |k| levenshtein(k, name) }
        return unless best && levenshtein(best, name) <= [name.length, 3].min

        best
      end

      def levenshtein(a, b)
        m = a.length
        n = b.length
        dp = Array.new(m + 1) { |i| Array.new(n + 1) { |j| i.zero? ? j : (j.zero? ? i : 0) } }
        (1..m).each do |i|
          (1..n).each do |j|
            dp[i][j] = a[i - 1] == b[j - 1] ? dp[i - 1][j - 1] : 1 + [dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]].min
          end
        end
        dp[m][n]
      end
    end
  end
end

lib/master/standing_orders.rb

# frozen_string_literal: true

module Master
  class StandingOrders
    DAILY_INTERVAL   = 86_400
    WEEKLY_INTERVAL  = 604_800
    ERROR_TRUNCATE   = 200
    STORE_PATH       = File.join(Master::ROOT, "data", "standing_orders.yml")
    VALID_STATES    = %w[pending running done error].freeze

    BUILTIN_ORDERS = [
      { name: "nightly_dreams", description: "Consolidate memories during low-activity periods",
        trigger: "scheduled", interval_s: 86_400, command: "dreams consolidate", enabled: true },
      { name: "weekly_scan", description: "Weekly codebase axiom scan for regressions",
        trigger: "scheduled", interval_s: 604_800, command: "scan", enabled: false }
    ].freeze

    def initialize(pipeline: nil, event_bus: nil)
      @pipeline = pipeline
      @bus      = event_bus
      @orders   = load_orders
    end

    def wire_pipeline(pipeline)
      @pipeline = pipeline
    end

    def due
      now = Time.now.to_i
      @orders.select do |o|
        o["enabled"] &&
          o["trigger"] == "scheduled" &&
          %w[pending done].include?(state_of(o)) &&
          (now - o["last_run_at"].to_i) >= o["interval_s"].to_i
      end
    end

    def run_due!
      results = []
      due.each do |order|
        order["state"] = "running"
        persist

        result = execute_order(order)
        order["last_run_at"] = Time.now.to_i

        if result.ok?
          order["state"] = "done"
          order.delete("last_error")
        else
          order["state"] = "error"
          order["last_error"] = result.message.to_s[0, ERROR_TRUNCATE]
        end

        results << { name: order["name"], result: }
        @bus&.publish("standing_order:ran", name: order["name"], ok: result.ok?, state: order["state"])
      end
      persist if results.any?
      results
    end

    def upsert(name:, description: "", trigger: "scheduled",
               interval_s: 86_400, command:, enabled: true)
      existing = @orders.find { |o| o["name"] == name.to_s }
      if existing
        existing.merge!(
          "description" => description, "trigger" => trigger.to_s,
          "interval_s"  => interval_s.to_i, "command" => command.to_s, "enabled" => enabled
        )
      else
        @orders << {
          "name" => name.to_s, "description" => description.to_s, "trigger" => trigger.to_s,
          "interval_s" => interval_s.to_i, "command" => command.to_s, "enabled" => enabled,
          "state" => "pending", "last_run_at" => 0
        }
      end
      persist
      "standing order '#{name}' saved"
    end

    def enable(name)  = toggle(name, true)
    def disable(name) = toggle(name, false)

    def reset(name)
      order = @orders.find { |x| x["name"] == name.to_s }
      return "no order named '#{name}'" unless order
      order["state"] = "pending"
      order.delete("last_error")
      persist
      "'#{name}' reset -> pending"
    end

    def list
      return "no standing orders defined" if @orders.empty?
      @orders.map do |o|
        st   = state_of(o)
        flag = o["enabled"] ? "on" : "off"
        last = o["last_run_at"].to_i > 0 ? Time.at(o["last_run_at"].to_i).strftime("%Y-%m-%d") : "never"
        err  = o["last_error"] ? "  !! #{o["last_error"][0, 60]}" : ""
        "#{o['name']} [#{flag}|#{st}] - #{o['description']} (last: #{last})#{err}"
      end.join("\n")
    end

    private

    def state_of(order) = VALID_STATES.include?(order["state"]) ? order["state"] : "done"

    def execute_order(order)
      if (callable_key = order["callable"])
        klass = Master::Orders::Registry.lookup(callable_key)
        return Result.err("unknown callable: #{callable_key}") unless klass
        return klass.new(container: { bus: @bus, root: Master::ROOT }).call
      end
      return Result.err("no pipeline") unless @pipeline
      @pipeline.call(Result.ok(user_message: order["command"].to_s))
    rescue StandardError => e
      Result.err(e.message)
    end

    def toggle(name, enabled)
      order = @orders.find { |x| x["name"] == name.to_s }
      return "no order named '#{name}'" unless order
      order["enabled"] = enabled
      persist
      "#{name} #{enabled ? 'enabled' : 'disabled'}"
    end

    def load_orders
      if File.exist?(STORE_PATH)
        orders = Master.load_yaml(STORE_PATH)
        unless orders.is_a?(Array)
          @bus&.publish("standing_orders:corrupt", path: STORE_PATH, got: orders.class.name)
          return builtin_orders
        end
        orders.select { |o| o.is_a?(Hash) }.each { |o| o["state"] ||= "done" }
      else
        builtin_orders
      end
    rescue Psych::Exception, Errno::ENOENT, TypeError, NoMethodError => e
      @bus&.publish("standing_orders:load_error", error: e.message)
      builtin_orders
    end

    def builtin_orders
      BUILTIN_ORDERS.map { |o| o.transform_keys(&:to_s).merge("last_run_at" => 0, "state" => "pending") }
    end

    def persist
      return unless @orders.is_a?(Array)
      FileUtils.mkdir_p(File.dirname(STORE_PATH))
      tmp = "#{STORE_PATH}.tmp.#{Process.pid}"
      File.write(tmp, YAML.dump(@orders))
      File.rename(tmp, STORE_PATH)
    rescue StandardError => e
      File.delete(tmp) if defined?(tmp) && File.exist?(tmp) rescue nil
      raise e
    end
  end
end

lib/master/swarm/coordinator.rb

# frozen_string_literal: true

require "timeout"

module Master
  module Swarm
    class Coordinator
      SwarmResult = Struct.new(:verdict, :confidence, :reasoning, :artifacts, keyword_init: true) do
        def ok?      = verdict != :error
        def approved? = verdict == :approved
      end

      WORKER_CLASSES = {
        analyst:    Workers::Analyst,
        coder:      Workers::Coder,
        reviewer:   Workers::Reviewer,
        researcher: Workers::Researcher
      }.freeze

      WORKER_TIMEOUT = 30
      SHARED_DEADLINE = 60
      SYNTHESIS_TRUNCATE_LIMIT = 200

      def initialize(agent:, event_bus: nil)
        @agent = agent
        @bus   = event_bus
        @workers = {}
      end

      def dispatch(role, task:, context_slice: {})
        worker = worker_for(role) or return Result.err("unknown role: #{role}")
        @bus&.publish(:swarm_dispatch, role:, task: task[0..60])
        worker.call(task:, context_slice:)
      end

      def analyse_and_review(file_path:, code:)
        fan_out([
          { role: :analyst,  task: "identify all issues",          context_slice: { file: file_path, code: code } },
          { role: :reviewer, task: "security and correctness review", context_slice: { code: code } }
        ]).and_then do |sr|
          analysis = sr.artifacts[:analyst]
          review   = sr.artifacts[:reviewer]
          Result.ok({ analysis:, review:, approved: review.is_a?(Hash) && review["approved"] })
        end
      end

      def fan_out(tasks, timeout: WORKER_TIMEOUT)
        threads = tasks.map do |t|
          Thread.new do
            [t[:role], dispatch(t[:role], task: t[:task], context_slice: t.fetch(:context_slice, {}))]
          rescue StandardError => e
            @bus&.publish("swarm:worker_error", role: t[:role], error: e.message)
            [t[:role], Result.err("worker error: #{e.message}")]
          end
        end

        results = threads.map do |th|
          if th.join(timeout)
            th.value
          else
            begin; th.kill; rescue ThreadError; nil; end
            @bus&.publish(:swarm_worker_timeout, timeout:)
            [:timeout, Result.err("worker timed out after #{timeout}s")]
          end
        end.to_h

        sr = build_swarm_result(results)
        @bus&.publish(:swarm_fan_out_done, roles: results.keys, verdict: sr.verdict,
                      synthesis: sr.reasoning[0..SYNTHESIS_TRUNCATE_LIMIT])
        Result.ok(sr)
      end

      def dispatch_parallel(role_tasks, deadline: SHARED_DEADLINE)
        finish_by = Process.clock_gettime(Process::CLOCK_MONOTONIC) + deadline

        threads = role_tasks.map do |t|
          Thread.new do
            remaining = [finish_by - Process.clock_gettime(Process::CLOCK_MONOTONIC), 1].max
            Timeout.timeout(remaining) do
              [t[:role], dispatch(t[:role], task: t[:task], context_slice: t.fetch(:context_slice, {}))]
            end
          rescue Timeout::Error
            [t[:role], Result.err("worker exceeded shared deadline")]
          rescue StandardError => e
            @bus&.publish("swarm:worker_error", role: t[:role], error: e.message)
            [t[:role], Result.err("worker error: #{e.message}")]
          end
        end

        results = threads.map do |th|
          if th.join(deadline)
            th.value
          else
            begin; th.kill; rescue ThreadError; nil; end
            @bus&.publish(:swarm_parallel_timeout, deadline:)
            [nil, Result.err("worker exceeded shared deadline")]
          end
        end.to_h

        sr = build_swarm_result(results)
        @bus&.publish(:swarm_dispatch_parallel_done, roles: results.keys, verdict: sr.verdict)
        Result.ok(sr)
      end

      def worker_roles = WORKER_CLASSES.keys

      private

      def build_swarm_result(results)
        successes = results.reject { |role, _| role == :timeout }
                           .select { |_, r| r.respond_to?(:ok?) && r.ok? }
        artifacts = successes.transform_values(&:value!)
        confidence = results.empty? ? 0.0 : successes.size.to_f / results.size
        lines = successes.map { |role, r| "### #{role}\n#{r.value!.to_s.strip}" }
        reasoning = lines.empty? ? "(no results)" : lines.join("\n\n")
        verdict = if confidence >= 0.8 then :approved
                 elsif confidence >= 0.5 then :mixed
                 elsif successes.empty? then :error
                 else :rejected
                 end
        SwarmResult.new(verdict:, confidence:, reasoning:, artifacts:)
      end

      def worker_for(role)
        sym = role.to_sym
        @workers.fetch(sym) do
          klass = WORKER_CLASSES[sym]
          return unless klass

          @workers[sym] = klass.new(agent: @agent, event_bus: @bus)
        end
      end
    end
  end
end

lib/master/swarm/worker.rb

# frozen_string_literal: true

module Master
  module Swarm
    # Base worker — receives only the context slice it needs (need-to-know).
    class Worker
      PREFERRED_MODEL = nil

      UNCERTAINTY_PHRASES = %w[unclear uncertain not\ sure cannot\ determine
                                i\ don't\ know limited\ information probably].freeze

      attr_reader :role, :result, :confidence

      def initialize(agent:, event_bus: nil)
        @agent      = agent
        @bus        = event_bus
        @role       = self.class.name.split("::").last.downcase
        @result     = nil
        @confidence = 1.0
      end

      def call(task:, context_slice: {})
        prompt = build_prompt(task, context_slice)
        @bus&.publish(:swarm_worker_start, role: @role, task: task[0..60])

        preferred = self.class::PREFERRED_MODEL
        raw = @agent.ask_once(prompt, model: preferred, system: worker_system_prompt)
        @result, @confidence = parse_result(raw)

        @bus&.publish(:swarm_worker_done, role: @role, ok: @result.ok?)
        @result
      rescue StandardError => e
        Result.err("worker #{@role}: #{e.message}", category: :unknown)
      end

      private

      def worker_system_prompt
        "You are a specialized #{@role} agent. #{role_description}\n" \
          "Respond only with what is asked. No preamble. No meta-commentary."
      end

      def role_description = "General-purpose assistant."
      def build_prompt(task, ctx) = "#{ctx_summary(ctx)}\n\nTask: #{task}"

      def parse_result(raw)
        text = raw.to_s.strip
        hits = UNCERTAINTY_PHRASES.count { |p| text.downcase.include?(p) }
        conf = [1.0 - (hits.to_f / [UNCERTAINTY_PHRASES.size, 1].max * 0.5), 0.0].max.round(2)
        [Result.ok({ text: text, confidence: conf }), conf]
      end

      def ctx_summary(ctx)
        return "" if ctx.empty?
        ctx.map { |k, v| "#{k}: #{v}" }.join("\n")
      end
    end
  end
end

lib/master/swarm/workers/analyst.rb

# frozen_string_literal: true

module Master
  module Swarm
    module Workers
      # Reads code, produces structured analysis. Knows nothing about other workers.
      class Analyst < Worker
        PREFERRED_MODEL = "google/gemini-2.0-flash-lite:free".freeze
        private

        def role_description
          "You analyze code for quality, bugs, and design issues. " \
            "Output JSON: {issues: [{file, line, severity(1-3), description}], summary: string}"
        end

        def build_prompt(task, ctx)
          parts = []
          parts << "File: #{ctx[:file]}" if ctx[:file]
          parts << "Code:\n```\n#{ctx[:code]}\n```" if ctx[:code]
          parts << "Analyze: #{task}"
          parts.join("\n\n")
        end

        def parse_result(raw)
          match_str = raw.to_s.match(/\{.*\}/m)&.to_s || "{}"
          parsed = JSON.parse(match_str)
          Result.ok(parsed)
        rescue JSON::ParserError
          Result.ok({ summary: raw.to_s.strip, issues: [] })
        end
      end
    end
  end
end

lib/master/swarm/workers/coder.rb

# frozen_string_literal: true

module Master
  module Swarm
    module Workers
      # Writes code given a spec. Knows only the spec + relevant file context.
      class Coder < Worker
        private

        def role_description
          "You write clean, minimal Ruby/Rails/Zsh code. " \
            "Output only the code block. No explanation unless asked."
        end

        def build_prompt(task, ctx)
          parts = []
          parts << "Language: #{ctx[:language] || "ruby"}"
          parts << "Existing code:\n```\n#{ctx[:code]}\n```" if ctx[:code]
          parts << "Spec: #{task}"
          parts.join("\n\n")
        end
      end
    end
  end
end

lib/master/swarm/workers/researcher.rb

# frozen_string_literal: true

module Master
  module Swarm
    module Workers
      # Synthesizes research from external sources. No codebase context.
      class Researcher < Worker
        PREFERRED_MODEL = "google/gemini-2.0-flash-lite:free".freeze
        private

        def role_description
          "You are a research analyst. Synthesize information concisely. " \
            "Output: factual summary, sources if known, confidence level (low/med/high)."
        end

        def build_prompt(task, ctx)
          parts = []
          parts << "Domain: #{ctx[:domain]}" if ctx[:domain]
          parts << "Prior findings:\n#{ctx[:prior_findings]}" if ctx[:prior_findings]
          parts << "Research: #{task}"
          parts.join("\n\n")
        end
      end
    end
  end
end

lib/master/swarm/workers/reviewer.rb

# frozen_string_literal: true

module Master
  module Swarm
    module Workers
      # Reviews code for security, correctness, style. Constitutional layer.
      class Reviewer < Worker
        CHECKLIST = %w[
          sql_injection xss command_injection path_traversal
          hardcoded_secrets open_redirect mass_assignment
        ].freeze

        private

        def role_description
          "You are a security-focused code reviewer. Check for OWASP top-10 issues, " \
            "logic bugs, and constitutional AI violations. " \
            "Output JSON: {approved: bool, violations: [{type, line, description}]}"
        end

        def build_prompt(task, ctx)
          parts = []
          parts << "Code to review:\n```\n#{ctx[:code]}\n```" if ctx[:code]
          parts << "Security checklist: #{CHECKLIST.join(", ")}"
          parts << "Review for: #{task}"
          parts.join("\n\n")
        end

        def parse_result(raw)
          parsed = JSON.parse(raw.to_s.match(/\{.*\}/m)&.to_s || "{}")
          parsed["approved"] = true if parsed.empty?
          Result.ok(parsed)
        rescue JSON::ParserError
          Result.ok({ "approved" => true, "violations" => [] })
        end
      end
    end
  end
end

lib/master/sweep.rb

# frozen_string_literal: true

require "open3"
require "tempfile"
require "set"
require_relative "sweep/rewriter"
require_relative "sweep/convergence"

module Master
  # Full-codebase refactor to convergence; stops on delta/oscillation/stall (arxiv:2602.21833).
  class Sweep
    MAX_CYCLES         = 16
    SMALL_CHANGE_LINES = 5
    MEDIUM_CHANGE_LINES = 30
    CONVERGE_THRESHOLD = 0.05
    CONVERGE_WINDOW    = 2
    RENAME_WINDOW    = 3
    TRAJECTORY_GAMMA = 0.9

    GLOBS = {
      rb:  "**/*.rb",
      sh:  "**/*.sh",
      yml: "**/*.yml",
      md:  "**/*.md",
      erb: "**/*.erb"
    }.freeze

    SYNTAX_CHECKERS = {
      ".rb"  => ->(p) { _, _, st = Open3.capture3("ruby", "-c", p); st.success? },
      ".sh"  => ->(p) { _, _, st = Open3.capture3("zsh", "-n", p); st.success? },
      ".yml" => ->(p) { begin; Master.load_yaml(p); true; rescue StandardError => _e; false; end },
      ".erb" => ->(p) { begin; RubyVM::InstructionSequence.compile(ERB.new(File.read(p, encoding: "UTF-8")).src); true; rescue SyntaxError, StandardError => _e; false; end }
    }.freeze

    SEVERITY_RANK = Master::SEVERITY_RANK

    ERROR_PATTERNS = /
      \b(?:error|exception|traceback|failed|cannot|unable\sto|
      undefined\smethod|no\smethod|syntax\serror|
      internal\sserver|rate\slimit|quota\sexceeded|
      apologize|as\san\sai|i\scannot|i\sam\sunable|
circuit\sopen|retry\sin|llm_request)\b
    /ix.freeze

    PROMPTS_PATH      = File.join(Master::ROOT, "data", "sweep_prompts.yml").freeze
    MIN_REWRITE_BYTES   = 500
    MIN_LENGTH_FRACTION = 0.50

    # Regex for Ruby method/class/constant names — used by rename tracker.
    NAME_RE = /\b(?:def\s+(\w+)|class\s+([A-Z]\w*)|[A-Z][A-Z_]+)\b/.freeze

    include Rewriter
    include Convergence

    def initialize(agent:, scanner:, root:, council: nil, event_bus: nil, code_index: nil)
      @agent      = agent
      @scanner    = scanner
      @root       = root
      @bus        = event_bus
      @code_index = code_index
      @map        = nil
      @prompts    = nil
      @rename_log = Hash.new { |h, k| h[k] = [] }
      @cycle_log  = []
    end

    def run(target = @root, max_cycles: MAX_CYCLES, types: GLOBS.keys)
      saved_model = @agent.model
      @agent.model = @agent.model_for(operation: :sweep)
      cfg = Master.load_yaml(File.join(@root, "data", "workflow.yml")).dig("sweep") || {}
      @converge_threshold = cfg.fetch("converge_threshold", CONVERGE_THRESHOLD)
      @converge_window    = cfg.fetch("converge_window",    CONVERGE_WINDOW)
      @map            = build_codebase_map
      @prompts        = load_prompts
      sweep_types     = select_types(target, types)
      violation_history = []
      converge_streak   = 0
      init_cycle_log

      max_cycles.times do |i|
        cycle       = i + 1
        changed     = 0
        cycle_viol  = 0
        cycle_fixed = 0
        cycle_defer = 0

        @bus&.publish("sweep:cycle", cycle:, target:)

        collect_files(target, sweep_types).each do |path|
          rel    = path.delete_prefix("#{@root}/")
          before = violations_in(path)
          src    = File.read(path, encoding: "UTF-8")

          new_src, after = evaluate_rewrite(rel, src, before, cycle)
          if new_src.nil?
            cycle_defer += before
            next
          end

          delta = before - after
          tmp_path = "#{path}.tmp.#{Process.pid}"
          File.write(tmp_path, new_src, encoding: "UTF-8")
          File.rename(tmp_path, path)
          changed     += 1
          cycle_viol  += after
          cycle_fixed += delta
          @bus&.publish("sweep:improved", file: rel, before:, after:)
          yield cycle, rel, delta if block_given?
        end

        violation_history << cycle_viol
        entry = record_cycle(violations: cycle_viol, fixed: cycle_fixed, deferred: cycle_defer)
        @bus&.publish("sweep:cycle_stats", cycle:, **entry)
        commit("sweep: full-codebase refactor [cycle #{cycle}]") if changed > 0 && git_dirty?

        converge_streak = converged?(violation_history) ? converge_streak + 1 : 0
        break if converge_streak >= @converge_window
        break if trajectory_stalled?(violation_history)
        break if should_halt_early?
      end

      summary = convergence_summary
      @bus&.publish("sweep:done", summary:)
      Result.ok(summary)
    rescue StandardError => e
      Result.err("sweep: #{e.message}", category: :unknown)
    ensure
      @agent.model = saved_model if defined?(saved_model) && saved_model
    end

    private

    def select_types(target, fallback_types)
      changed = changed_line_count(target)
      return %i[rb sh] if changed < SMALL_CHANGE_LINES
      return %i[rb sh yml] if changed <= MEDIUM_CHANGE_LINES
      fallback_types
    end

    def changed_line_count(target)
      rel = target.to_s == @root ? nil : target.delete_prefix(@root + "/")
      cmd = ["git", "-C", @root, "diff", "--numstat", "HEAD"]
      cmd << "--" << rel if rel && !rel.empty?
      out, = Open3.capture2(*cmd)
      out.lines.sum do |line|
        a, d, = line.split("	", 3)
        a.to_i + d.to_i
      end
    rescue StandardError
      MEDIUM_CHANGE_LINES
    end

    def evaluate_rewrite(rel, src, before, cycle)
      abs = File.join(@root, rel)
      native_src = native_autofix(abs, src)
      if native_src
        native_after = violations_in_text(native_src, abs)
        if native_after < before
          @bus&.publish("sweep:native_fix", file: rel, before:, after: native_after)
          return [native_src, native_after]
        end
        src = native_src
        before = native_after
      end

      new_src = rewrite(abs, rel)
      return unless new_src && new_src.strip != src.strip && syntax_ok?(abs, new_src)

      after = violations_in_text(new_src, abs)
      return if after > before

      if rename_oscillation?(rel, src, new_src, cycle)
        @bus&.publish("sweep:oscillation_rejected", file: rel, cycle:)
        return
      end

      [new_src, after]
    end

    def native_autofix(path, src)
      return unless path.end_with?(".rb")
      result = nil
      Tempfile.open(["sweep_native", ".rb"]) do |f|
        f.write(src)
        f.flush
        _, _, status = Open3.capture3(
          "bundle", "exec", "rubocop", "-A", "--no-color",
          "--format", "quiet", f.path, chdir: @root
        )
        next unless status.exitstatus.to_i <= 1
        candidate = File.read(f.path, encoding: "UTF-8")
        next if candidate == src
        next unless syntax_ok?(path, candidate)
        result = candidate
      end
      result
    rescue StandardError => _e
      nil
    end
  end
end

lib/master/sweep/convergence.rb

# frozen_string_literal: true

module Master
  class Sweep
    # Per-cycle metrics tracking and early-stop logic for sweep loops.
    # Detects stall, low success rate, and sign-reversal oscillation.
    module Convergence
      LOW_SUCCESS_RATE = 0.10

      private

      def init_cycle_log
        @cycle_log = []
      end

      # Record one cycle's metrics. Returns the entry for bus publishing.
      def record_cycle(violations:, fixed:, deferred:)
        prev  = @cycle_log.last
        delta = prev ? (prev[:violations] - violations) : fixed
        total = violations + fixed
        rate  = total.zero? ? 0.0 : (fixed.to_f / total).round(3)
        entry = { violations:, fixed:, deferred:, delta:, rate: }
        @cycle_log << entry
        entry
      end

      # Unified early-stop: stall, low success rate, oscillation, or done.
      def should_halt_early?
        return false if @cycle_log.size < 2

        last = @cycle_log.last
        return true if last[:violations].zero?
        return true if last[:rate] < LOW_SUCCESS_RATE
        return true if @cycle_log.last(2).all? { |entry| entry[:delta] == 0 }
        return true if oscillating?

        false
      end

      def oscillating?
        signs = @cycle_log.last(3).map { |entry| entry[:delta] <=> 0 }
        return false if signs.size < 3
        signs.each_cons(2).all? { |x, y| x != 0 && x == -y }
      end

      def convergence_summary
        return "sweep: no cycles recorded" if @cycle_log.empty?
        count = @cycle_log.size
        last  = @cycle_log.last
        prev  = count > 1 ? @cycle_log[-2][:violations] : "?"
        osc   = oscillating? ? 1 : 0
        "sweep: iter=#{count} violations=#{prev}->#{last[:violations]} " \
          "fixed=#{last[:fixed]} deferred=#{last[:deferred]} rate=#{last[:rate]} oscillating=#{osc}"
      end

      # A→B→A within RENAME_WINDOW cycles signals oscillation (arxiv:2602.21833 §4.3).
      def rename_oscillation?(rel, old_src, new_src, cycle)
        old_names   = extract_names(old_src)
        new_names   = extract_names(new_src)
        removed_now = old_names - new_names
        added_now   = new_names - old_names
        history     = @rename_log[rel]
        oscillates  = history.last(RENAME_WINDOW).any? { |entry| names_reverted?(entry, added_now, removed_now) }
        history << { cycle:, removed: removed_now, added: added_now }
        @rename_log[rel] = history.last(RENAME_WINDOW * 2)
        oscillates
      end

      def names_reverted?(entry, added_now, removed_now)
        (entry[:removed] & added_now).any? && (entry[:added] & removed_now).any?
      end

      def extract_names(source) = source.scan(NAME_RE).flatten.compact.uniq

      def converged?(history)
        return false if history.size < 2
        prev, curr = history[-2], history[-1]
        return true if curr.zero?
        (prev - curr).abs.to_f / [prev, 1].max < @converge_threshold
      end

      def trajectory_stalled?(history)
        return false if history.size < 3
        deltas = history.each_cons(2).map { |a, b| a - b }
        weighted = deltas.last(@converge_window + 1).each_with_index.sum { |d, idx| d * (TRAJECTORY_GAMMA**idx) }
        weighted.abs < 1.0
      end

      def commit(msg)
        Master::AutoLoop::TARGETS.each { |d| Open3.capture2e("git", "-C", @root, "add", "--", d) }
        Open3.capture2e("git", "-C", @root, "commit", "-m", msg.to_s)
      end

      def git_dirty?
        out, = Open3.capture2e("git", "-C", @root, "status", "--porcelain")
        !out.strip.empty?
      end
    end
  end
end

lib/master/sweep/rewriter.rb

# frozen_string_literal: true

require "tempfile"

module Master
  class Sweep
    module Rewriter
      private

      def load_prompts = Master.load_yaml(PROMPTS_PATH)

      def build_codebase_map
        files = library_files
        return simple_codebase_map(files) unless @code_index&.built?
        indexed_codebase_map(files)
      end

      def library_files
        Dir.glob(File.join(@root, "lib", "**", Scan::Scanner::SCAN_GLOB))
           .reject { |path| path.include?("/vendor/") || path.include?("/knowledge/") }
           .map    { |path| path.delete_prefix("#{@root}/") }
           .sort
      end

      def codebase_header(files) = "## Codebase (#{files.size} files)"

      def simple_codebase_map(files)
        ([codebase_header(files)] + files.map { |path| "  #{path}" }).join("\n")
      end

      def indexed_codebase_map(files)
        ([codebase_header(files)] + files.flat_map { |rel| symbol_lines(rel) }).join("\n")
      end

      def symbol_lines(rel)
        symbols = @code_index.symbols_in(File.join(@root, rel))
        return ["  #{rel}"] if symbols.empty?
        [rel] + class_lines(symbols) + method_lines(symbols)
      end

      def class_lines(symbols)
        symbols.select { |sym| %i[class module].include?(sym.type) }
               .map    { |sym| "  class #{sym.fqn}" }
      end

      def method_lines(symbols)
        symbols.select { |sym| sym.type == :method }
               .map    { |sym| "  def #{sym.fqn}" }
      end

      def collect_files(dir, types)
        sacred = Tools::PathGuard::SACRED_PATHS
        types.flat_map { |type| Dir.glob(File.join(dir, GLOBS[type].to_s)) }
             .reject { |path| sacred_match?(path, sacred) }
             .uniq.sort
      end

      def sacred_match?(path, sacred)
        rel = path.delete_prefix("#{@root}/")
        sacred.any? { |s| rel == s || rel.start_with?(s) }
      end

      CANDIDATE_THRESHOLD_BYTES = 4_000
      CANDIDATE_COUNT           = 3

      def rewrite(path, rel)
        source = File.read(path, encoding: "UTF-8")
        lang   = Scan::Rule::EXT_LANG.fetch(File.extname(path).downcase, "text")
        if source.bytesize >= CANDIDATE_THRESHOLD_BYTES
          rewrite_best_of(source, path, rel, lang, n: CANDIDATE_COUNT)
        else
          single_rewrite(source, rel, lang)
        end
      rescue StandardError => e
        @bus&.publish("sweep:rewrite_error", file: path, error: e.message)
        nil
      end

      def single_rewrite(source, rel, lang)
        response = @agent.ask(build_prompt(source, rel, lang))
        extract(response.to_s, lang, original_size: source.bytesize)
      end

      def rewrite_best_of(source, path, rel, lang, n:)
        baseline = violations_in_text(source, path)
        candidates = n.times.map { single_rewrite(source, rel, lang) }.compact
        return if candidates.empty?
        scored = candidates.map { |c| [score_candidate(c, path, baseline, source.bytesize), c] }
        winner = scored.max_by { |s, _| s }
        @bus&.publish("sweep:best_of_picked", file: path, n: candidates.size,
                      baseline_violations: baseline, winner_score: winner.first)
        winner.last
      end

      def score_candidate(content, path, baseline, original_size)
        violations  = violations_in_text(content, path)
        delta_viol  = baseline - violations
        delta_size  = (content.bytesize - original_size).abs
        (delta_viol * 10) - (delta_size / 100.0)
      end

      def build_prompt(src, rel, lang)
        <<~PROMPT
          You are refactoring #{rel} (#{lang}). Study the full codebase map below
          before making any change — do not modify an interface without tracing its callers.

          #{@map}

          #{@prompts["axioms"]}
          #{@prompts["structural_techniques"]}
          #{@prompts["cosmetic_techniques"]}

          Improve every dimension of #{rel} in a single pass.
          Return ONLY the improved file content — no explanation, no markdown fences
          unless the file is already markdown. If no improvement is possible, return
          exactly: UNCHANGED

          File content:
          #{src}
        PROMPT
      end

      def extract(text, lang, original_size: 0)
        return if text.strip == "UNCHANGED"
        return if ERROR_PATTERNS.match?(text)
        return if too_short?(text, original_size)
        fenced = extract_fenced(text, lang)
        return fenced if fenced
        text.strip.empty? ? nil : text
      end

      def too_short?(text, original_size)
        original_size > 0 && text.bytesize < (original_size * MIN_LENGTH_FRACTION)
      end

      def extract_fenced(text, lang)
        langs    = "#{Regexp.escape(lang)}|ruby|sh|yaml|erb"
        fence_re = /```(?:#{langs})?\n(.*?)```/m
        match    = text.match(fence_re)
        return match[1] if match
        bare = text.match(/```\n(.*?)```/m)
        bare&.[](1)
      end

      def syntax_ok?(path, content)
        checker = SYNTAX_CHECKERS[File.extname(path)]
        return true unless checker
        Tempfile.open(["sweep", File.extname(path)]) do |file|
          file.write(content); file.flush; checker.call(file.path)
        end
      end

      def violations_in(path)
        return 0 unless Scan::Rule::EXT_LANG.key?(File.extname(path).downcase) && File.exist?(path)
        scan_result = @scanner.scan(path, depth: :deep)
        scan_result.ok? ? scan_result.value!.size : 0
      rescue StandardError => _e
        0
      end

      def violations_in_text(content, ref_path)
        ext = File.extname(ref_path).downcase
        return 0 unless Scan::Rule::EXT_LANG.key?(ext)
        Tempfile.open(["vcheck", ext]) do |file|
          file.write(content); file.flush
          scan_result = @scanner.scan(file.path, depth: :deep)
          scan_result.ok? ? scan_result.value!.size : 0
        end
      rescue StandardError => _e
        0
      end
    end
  end
end

lib/master/sweep/techniques.rb

# frozen_string_literal: true

module Master
  class Sweep
    # Typed view over data/sweep_prompts.yml's techniques: catalogue. Lets the
    # rewriter (or a future planner) filter by layer, risk, language, or path
    # without reparsing prose. The prose blocks remain the LLM-facing surface;
    # this is the Ruby-facing one.
    module Techniques
      PATH = File.join(Master::ROOT, "data", "sweep_prompts.yml").freeze

      module_function

      def all
        @all ||= (Master.load_yaml(PATH)["techniques"] || []).map { |e| e.transform_keys(&:to_s) }
      end

      def by_layer(layer)
        all.select { |t| t["layer"] == layer.to_s }
      end

      def by_risk(risk)
        all.select { |t| t["risk"] == risk.to_s }
      end

      def applicable_to(path:, lang:)
        all.select { |t| applies?(t, path: path, lang: lang) }
      end

      def applies?(entry, path:, lang:)
        spec = entry["applies_to"] || {}
        return false if spec["langs"] && !spec["langs"].map(&:to_s).include?(lang.to_s)
        return false if spec["path_includes"] && spec["path_includes"].none? { |g| File.fnmatch?(g, path) }
        return false if spec["path_excludes"]&.any? { |g| File.fnmatch?(g, path) }
        true
      end
    end
  end
end

lib/master/telemetry.rb

# frozen_string_literal: true

require "json"

module Master
  # OpenTelemetry tracer wrapper. Soft-optional — if the SDK isn't loaded,
  # span() degrades to a plain yield. Spans emit JSONL to .master/traces.log.
  module Telemetry
    TRACE_PATH = ".master/traces.log".freeze

    @enabled = false
    @tracer  = nil
    @io      = nil

    class << self
      attr_reader :enabled, :tracer
    end

    def self.bootstrap!(root:, service: "master")
      return if @enabled
      require "opentelemetry/sdk"
      path = File.join(root, TRACE_PATH)
      require "fileutils"
      FileUtils.mkdir_p(File.dirname(path))
      @io = File.open(path, "a")
      @io.sync = true
      OpenTelemetry::SDK.configure do |c|
        c.service_name = service
        c.add_span_processor(
          OpenTelemetry::SDK::Trace::Export::SimpleSpanProcessor.new(JsonlExporter.new(@io))
        )
      end
      @tracer  = OpenTelemetry.tracer_provider.tracer(service)
      @enabled = true
    rescue LoadError
      @enabled = false
    rescue StandardError
      @enabled = false
    end

    def self.span(name, attrs = {})
      return yield unless @enabled && @tracer
      @tracer.in_span(name, attributes: stringify(attrs)) { yield }
    rescue StandardError
      yield
    end

    def self.stringify(hash)
      hash.each_with_object({}) { |(k, v), out| out[k.to_s] = v.to_s }
    end

    class JsonlExporter
      SUCCESS = 0
      FAILURE = 1

      def initialize(io)
        @io = io
      end

      def export(spans, timeout: nil)
        spans.each do |s|
          @io.puts(JSON.generate(
            name:    s.name,
            kind:    s.kind.to_s,
            start:   s.start_timestamp,
            end:     s.end_timestamp,
            dur_us:  ((s.end_timestamp - s.start_timestamp) / 1000),
            attrs:   s.attributes,
            trace:   s.hex_trace_id,
            span:    s.hex_span_id
          ))
        end
        SUCCESS
      rescue StandardError
        FAILURE
      end

      def force_flush(timeout: nil) = SUCCESS
      def shutdown(timeout: nil)    = SUCCESS
    end
  end
end

lib/master/text_hygiene.rb

# frozen_string_literal: true

module Master
  # TextHygiene — deterministic pre-write normalization.
  # Ported from MASTER2. Strips BOM, zero-width chars, CRLF, trailing spaces.
  # Called by WriteFile and StrReplace tools before writing.
  module TextHygiene
    BINARY_EXTS = %w[.png .jpg .jpeg .gif .webp .pdf .zip .gz .tgz .mp3 .mp4 .mov .woff .woff2].freeze

    module_function

    def normalize(content, filename: nil, ensure_final_newline: true)
      return content unless content.is_a?(String)

      out = content.dup
      out.gsub!("\r\n", "\n")
      out.gsub!("\r", "\n")
      out.sub!(/\A\xEF\xBB\xBF/, "")
      out.gsub!(/[\u200B\u200C\u200D\uFEFF]/, "")
      out.gsub!(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/, "")
      out.gsub!(/[ \t]+$/, "")
      out.gsub!(/([^\n\t ]) {2,}/, '\1 ')
      out.gsub!("\u00A0", " ")
      out.gsub!(/^\t+$/, "")
      out.gsub!(/\n{3,}/, "\n\n")

      if ensure_final_newline && text_like?(filename) && !out.empty? && !out.end_with?("\n")
        out << "\n"
      end

      out
    end

    def text_like?(filename)
      return true if filename.nil?

      ext = File.extname(filename.to_s).downcase
      !BINARY_EXTS.include?(ext)
    end
  end
end

lib/master/tools/ask_llm.rb

# frozen_string_literal: true

module Master
  module Tools
    # AskLlm — delegate sub-questions to the LLM agent mid-pipeline.
    class AskLlm
      TIER        = :guarded
      NAME        = "ask_llm".freeze
      DESCRIPTION = "Ask the LLM a sub-question and return the answer as a string.".freeze

      def initialize(agent:, governor:, circuit_breaker:, cache:, event_bus: nil)
        @agent          = agent
        @governor       = governor
        @circuit_breaker = circuit_breaker
        @cache          = cache
        @bus            = event_bus
      end

      def call(prompt:, context: nil)
        perm = @governor.permit?(NAME, TIER, prompt[0, 60])
        return perm if perm.err?

        @bus&.publish("tool:before", tool: NAME, prompt: prompt[0, 80])

        result = @circuit_breaker.call(estimate_cost(prompt)) {
          @cache.fetch(prompt, @agent.model) {
            @agent.ask(prompt, context: context)
          }
        }

        @bus&.publish("tool:after", tool: NAME)
        Result.ok(result.to_s)
      rescue StandardError => e
        Result.err("ask_llm: #{e.message}", category: :unknown)
      end

      private

      def estimate_cost(prompt)
        (prompt.bytesize / Session::TOKENS_PER_CHAR) * Agent::COST_PER_TOKEN
      end
    end
  end
end

lib/master/tools/ast_edit.rb

# frozen_string_literal: true

module Master
  module Tools
    # AST-aware editing tool using Ruby's Ripper (stdlib) for parsing.
    # Supports: find_method, rename_method, extract_lines_to_method, add_after_method.
    # Uses Ripper::SexpBuilder for structure-awareness without external gem dependencies.
    class AstEdit
      include PathGuard
      include AtomicWrite
      TIER        = :guarded
      NAME        = "ast_edit".freeze
      DESCRIPTION = "AST-aware code editing: find, rename, or restructure Ruby methods safely.".freeze

      def initialize(root:, undo:, event_bus: nil)
        @root = File.realpath(root)
        @undo = undo
        @bus  = event_bus
      end

      def call(operation:, path:, **opts)
        full = resolve(path)
        return full if full.err?
        fp = full.value!
        return Result.err("ast_edit: not found: #{path}", category: :validation) unless File.exist?(fp)

        src = File.read(fp)
        case operation.to_s
        when "find_method"    then find_method(src, opts[:name].to_s)
        when "rename_method"  then rename_method(fp, src, opts[:from].to_s, opts[:to].to_s)
        when "add_after"      then add_after_method(fp, src, opts[:after].to_s, opts[:code].to_s)
        when "method_lines"   then method_lines(src, opts[:name].to_s)
        else
          Result.err("ast_edit: unknown operation: #{operation}", category: :validation)
        end
      rescue StandardError => e
        Result.err("ast_edit: #{e.message}", category: :unknown)
      end

      private

      # Find a method definition and return its source lines
      def find_method(src, name)
        lines  = src.lines
        ranges = method_line_ranges(src)
        entry  = ranges.find { |r| r[:name] == name }
        return Result.err("ast_edit: method not found: #{name}", category: :validation) unless entry

        slice  = lines[(entry[:start] - 1)..(entry[:end] - 1)].join
        Result.ok("# #{name} (lines #{entry[:start]}#{entry[:end]})\n#{slice}")
      end

      # Rename all occurrences of a method definition and calls
      def rename_method(fp, src, from, to)
        return Result.err("ast_edit: from/to required", category: :validation) if from.empty? || to.empty?
        return Result.err("ast_edit: invalid name: #{to}",
category: :validation) unless to.match?(/\A[a-z_][a-zA-Z0-9_]*[?!]?\z/)

        @undo.snapshot(fp)
        updated = src
          .gsub(/\bdef\s+#{Regexp.escape(from)}\b/, "def #{to}")
          .gsub(/\b#{Regexp.escape(from)}\s*\(/, "#{to}(")
          .gsub(/\b#{Regexp.escape(from)}\b(?!\s*[:=])/) { |m| to }

        atomic_write(fp, updated)
        @bus&.publish("tool:ast_edit", op: "rename", from: from, to: to, path: fp)
        Result.ok("renamed #{from}#{to} in #{File.basename(fp)}")
      end

      # Insert a new method directly after an existing one
      def add_after_method(fp, src, after_name, code)
        return Result.err("ast_edit: after/code required", category: :validation) if after_name.empty? || code.empty?

        ranges = method_line_ranges(src)
        entry  = ranges.find { |r| r[:name] == after_name }
        return Result.err("ast_edit: method not found: #{after_name}", category: :validation) unless entry

        lines = src.lines
        insert_at = entry[:end]  # after the 'end' of the target method
        lines.insert(insert_at, "\n", code.chomp + "\n")

        @undo.snapshot(fp)
        atomic_write(fp, lines.join)
        @bus&.publish("tool:ast_edit", op: "add_after", after: after_name, path: fp)
        Result.ok("inserted method after #{after_name} in #{File.basename(fp)}")
      end

      # Return start/end line numbers for each method definition
      def method_lines(src, name)
        ranges = method_line_ranges(src)
        entry  = ranges.find { |r| r[:name] == name }
        return Result.err("ast_edit: method not found: #{name}", category: :validation) unless entry
        Result.ok("#{name}: lines #{entry[:start]}#{entry[:end]}")
      end

      def method_line_ranges(src)
        require "ripper"
        lines  = src.lines
        ranges = []
        stack  = []  # stack of {name:, start:, depth:}
        depth  = 0

        Ripper.lex(src).each do |(_line, _col), type, token, _state|
          case type
          when :on_kw
            case token
            when "def"
              # next identifier token is the method name
              stack.push({ name: nil, start: _line, depth: depth })
              depth += 1
            when "class", "module", "do", "begin", "for", "if", "unless",
                 "while", "until", "case"
              depth += 1 unless token == "if" && !stack.empty? && stack.last[:name]
            when "end"
              depth -= 1
              if !stack.empty? && depth == stack.last[:depth]
                entry        = stack.pop
                entry[:end]  = _line
                ranges << entry if entry[:name]
              end
            end
          when :on_ident
            if !stack.empty? && stack.last[:name].nil?
              stack.last[:name] = token
            end
          end
        end
        ranges
      end

    end
  end
end

lib/master/tools/atomic_write.rb

# frozen_string_literal: true

module Master
  module Tools
    module AtomicWrite
      private

      # Write content to path via tmp+rename. Deletes tmp on error.
      def write_atomic(path, content, encoding: "UTF-8")
        tmp = "#{path}.tmp.#{Process.pid}"
        File.write(tmp, content, encoding:)
        File.rename(tmp, path)
      rescue StandardError
        File.delete(tmp) if tmp && File.exist?(tmp)
        raise
      end
    end
  end
end

lib/master/tools/base.rb

# frozen_string_literal: true

module Master
  module Tools
    # Shared boilerplate for mutating tools: governor permit, undo snapshot,
    # atomic write, bus publish — or stage via diff_stager when present.
    # Tool classes include Base, set NAME/TIER, and call commit_write inside safely.
    module Base
      include PathGuard
      include AtomicWrite

      def permit(ctx = nil)
        @governor.permit?(self.class::NAME, self.class::TIER, ctx)
      end

      # Apply a write through the standard pipeline.
      # Returns Result.ok(full_path), or stages it if diff_stager is wired.
      def commit_write(full, content, path: nil)
        return @diff_stager.stage(path: full, new_content: content, tool: self.class::NAME) if @diff_stager

        @undo.snapshot(full)
        write_atomic(full, content)
        @bus&.publish("tool:after", tool: self.class::NAME, path: path || full)
        Result.ok(full)
      end

      def safely
        yield
      rescue StandardError => e
        Result.err("#{self.class::NAME}: #{e.message}", category: :unknown)
      end
    end
  end
end

lib/master/tools/batch_replace.rb

# frozen_string_literal: true

module Master
  module Tools
    # BatchReplace — apply multiple search-and-replace operations in one pass.
    class BatchReplace
      include AtomicWrite
      TIER        = :guarded
      NAME        = "replace".freeze
      DESCRIPTION = "Find and replace text across all files in a directory.".freeze

      SAFE_EXTENSIONS = %w[.rb .erb .yml .yaml .md .sh .js .css .html .json .txt].freeze

      def initialize(root:, governor:, event_bus: nil)
        @root     = root
        @governor = governor
        @bus      = event_bus
      end

      def call(old_str:, new_str:, dir: nil, rename_files: false)
        perm = @governor.permit?(NAME, TIER, "#{old_str}#{new_str}")
        return perm if perm.err?

        target = dir ? File.expand_path(dir, @root) : @root
        return Result.err("replace: path escapes root: #{dir}", category: :validation) unless target.start_with?(@root)
        return Result.err("replace: directory not found: #{target}", category: :validation) unless Dir.exist?(target)

        @bus&.publish("tool:before", tool: NAME, old: old_str, new: new_str)

        changed = 0
        Dir.glob("#{target}/**/*").each do |path|
          next unless File.file?(path)
          next unless SAFE_EXTENSIONS.include?(File.extname(path))
          rel = path.delete_prefix("#{@root}/")
          next if PathGuard::SACRED_PATHS.any? { |s| rel == s || rel.start_with?(s) }
          content = File.read(path, encoding: "UTF-8") rescue next
          next unless content.include?(old_str)
          write_atomic(path, content.gsub(old_str, new_str))
          changed += 1
        end

        if rename_files
          Dir.glob("#{target}/**/*")
             .select { |p| File.file?(p) && File.basename(p).include?(old_str) }
             .each do |path|
               rel = path.delete_prefix("#{@root}/")
               next if PathGuard::SACRED_PATHS.any? { |s| rel == s || rel.start_with?(s) }
               new_path = File.join(File.dirname(path), File.basename(path).gsub(old_str, new_str))
               File.rename(path, new_path)
               changed += 1
             end
        end

        @bus&.publish("tool:after", tool: NAME)
        Result.ok("replaced in #{changed} file(s)")
      rescue StandardError => e
        Result.err("replace: #{e.message}", category: :unknown)
      end
    end
  end
end

lib/master/tools/clean.rb

# frozen_string_literal: true

require "open3"

module Master
  module Tools
    # Clean — removes trailing whitespace, CRLF, and excess blank lines
    # from text files under a given path, using sh/clean.sh.
    class Clean
      SCRIPT = File.expand_path("../../../sh/clean.sh", __dir__).freeze

      def initialize(root:, governor:, event_bus: nil)
        @bus = event_bus
        @root     = root
        @governor = governor
      end

      def call(path: nil)
        target = path ? File.expand_path(path, @root) : @root
        return Result.err("path not found: #{target}",
category: :validation) unless File.exist?(target) || Dir.exist?(target)

        guard = @governor.guard("clean #{target}")
        return Result.err(guard.message, category: :policy) if guard.err?

        out, err, status = Open3.capture3("zsh", SCRIPT, target)
        return Result.err("clean failed: #{err.strip}", category: :unknown) unless status.success?

        cleaned = out.lines.grep(/^Cleaned:/).map { |l| l.sub("Cleaned: ", "").chomp }
        @bus&.publish("tool:clean", path: target, count: cleaned.size)
        Result.ok("cleaned #{cleaned.size} file(s):\n#{cleaned.join("\n")}")
      rescue StandardError => e
        Result.err("clean: #{e.message}", category: :unknown)
      end
    end
  end
end

lib/master/tools/feedback_record.rb

# frozen_string_literal: true

module Master
  module Tools
    # FeedbackRecord — LLM-callable tool to record RSI feedback events.
    # Event types: tool_success, tool_failure, user_correction, provider_error, user_feedback.
    # Dimension: tool name, provider name, or pattern label.
    # Pattern from OpenCrabs: tools self-report outcomes; RSI reads the ledger.
    class FeedbackRecord
      TIER        = :open
      NAME        = "feedback_record".freeze
      DESCRIPTION = "Record a feedback event for RSI self-improvement. " \
                    "Call after tool success/failure or when user corrects output.".freeze

      VALID_EVENTS = %w[tool_success tool_failure user_correction provider_error user_feedback].freeze

      def initialize(learnings:)
        @learnings = learnings
      end

      def call(event_type:, dimension:, value: nil, metadata: nil)
        return Result.err("feedback_record: unknown event_type #{event_type}",
category: :validation) unless VALID_EVENTS.include?(event_type.to_s)
        return Result.err("feedback_record: dimension required", category: :validation) if dimension.to_s.strip.empty?

        @learnings.record_event(
          event_type: event_type.to_s,
          dimension:  dimension.to_s,
          value:      value&.to_f,
          metadata:   metadata&.to_s
        )
        Result.ok("recorded: #{event_type} / #{dimension}")
      rescue StandardError => e
        Result.err("feedback_record: #{e.message}", category: :unknown)
      end
    end
  end
end

lib/master/tools/git_context.rb

# frozen_string_literal: true

module Master
  module Tools
    class GitContext
      TIER            = :safe
      NAME            = "git_context".freeze
      DESCRIPTION     = "Query git log, blame, diff, and status for the project.".freeze
      MAX_OUTPUT_CHARS = 4000

      def initialize(root:, event_bus: nil)
        @root = File.realpath(root)
        @bus  = event_bus
      end

      def call(operation:, path: nil, limit: 20)
        case operation.to_s
        when "log"    then git_log(path, limit.to_i)
        when "blame"  then git_blame(path)
        when "diff"   then git_diff(path)
        when "status" then git_status
        when "show"   then git_show(path)
        else
          Result.err("git_context: unknown operation: #{operation}", category: :validation)
        end
      rescue StandardError => e
        Result.err("git_context: #{e.message}", category: :unknown)
      end

      private

      def git_log(path, limit)
        args = ["git", "-C", @root, "log", "--oneline", "--no-color", "-#{limit}"]
        args << "--" << safe_path(path) if path
        out = IO.popen(args, err: File::NULL) { |io| io.read }
        Result.ok(out.strip.empty? ? "(no commits)" : out.strip)
      end

      def git_blame(path)
        return Result.err("git_context blame: path required", category: :validation) unless path
        safe = safe_path(path)
        return Result.err("git_context blame: file not found: #{path}",
          category: :validation) unless File.exist?(File.join(@root, safe))
        out = IO.popen(["git", "-C", @root, "blame", "--no-color", "-l", safe], err: File::NULL) { |io| io.read }
        Result.ok(out.strip.empty? ? "(no blame data)" : out.strip)
      end

      def git_diff(path)
        args = ["git", "-C", @root, "diff", "--no-color"]
        args << "--" << safe_path(path) if path
        out = IO.popen(args, err: File::NULL) { |io| io.read }
        Result.ok(out.strip.empty? ? "(no unstaged changes)" : out.strip)
      end

      def git_status
        out = IO.popen(["git", "-C", @root, "status", "--short", "--no-color"], err: File::NULL) { |io| io.read }
        Result.ok(out.strip.empty? ? "(clean)" : out.strip)
      end

      def git_show(ref)
        ref_s = (ref.to_s.empty? ? "HEAD" : ref.to_s).gsub(/[^a-zA-Z0-9._~^:\-\/]/, "")
        out = IO.popen(["git", "-C", @root, "show", "--stat", "--no-color", ref_s], err: File::NULL) { |io| io.read }
        Result.ok(out.strip.empty? ? "(not found)" : out.strip[0..MAX_OUTPUT_CHARS])
      end

      def safe_path(path)
        full = File.expand_path(path.to_s, @root)
        raise "path escapes root" unless full.start_with?(@root)
        Pathname.new(full).relative_path_from(@root).to_s
      end
    end
  end
end

lib/master/tools/list_dir.rb

# frozen_string_literal: true

module Master
  module Tools
    class ListDir
      TIER        = :safe
      NAME        = "list_dir".freeze
      DESCRIPTION = "List directory contents, depth-limited.".freeze
      MAX_DEPTH   = 5

      def initialize(root:, event_bus: nil)
        @root = File.realpath(root)
        @bus  = event_bus
      end

      def call(path: ".", depth: 2, pattern: nil)
        resolved = resolve(path)
        return resolved if resolved.err?

        full  = resolved.value!
        depth = [depth.to_i, MAX_DEPTH].min
        lines = list_tree(full, full, depth, pattern)
        Result.ok(lines.join("\n"))
      end

      private

      def list_tree(base, dir, depth, pattern, indent = 0)
        return [] if depth < 0
        entries = Dir.entries(dir).reject { |e| e.start_with?(".") }.sort
        entries.flat_map { |entry|
          full = File.join(dir, entry)
          next [] if pattern && !File.fnmatch?(pattern, entry)
          prefix = "  " * indent
          if File.directory?(full)
            ["#{prefix}#{entry}/"] + list_tree(base, full, depth - 1, pattern, indent + 1)
          else
            ["#{prefix}#{entry}"]
          end
        }
      end

      def resolve(path)
        full = File.expand_path(path, @root)
        return Result.err("path escapes project root: #{path}", category: :validation) unless full.start_with?(@root)
        return Result.err("not a directory: #{path}", category: :validation) unless File.directory?(full)
        Result.ok(full)
      end
    end
  end
end

lib/master/tools/llm.rb

# frozen_string_literal: true

require "ruby_llm"

module Master
  module Tools
    # LLM-callable wrappers around the existing Master tool instances.
    # Each class holds a reference to the underlying tool via initialize,
    # so governor, undo, and event_bus plumbing is preserved.
    module LLM

    # LLM — shared base module for LLM-backed tool functionality.
      class ReadFile < RubyLLM::Tool
        DEFAULT_LIMIT = 2000

        description "Read a file with line numbers. Path is relative to project root."
        param :path,   desc: "File path relative to project root", required: true
        param :offset, desc: "First line to read (0-indexed)", type: "integer", required: false
        param :limit,  desc: "Maximum number of lines to return", type: "integer", required: false

        def initialize(tool) = @tool = tool

        def execute(path:, offset: 0, limit: DEFAULT_LIMIT)
          result = @tool.call(path: path.to_s, offset: offset.to_i, limit: limit.to_i)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class WriteFile < RubyLLM::Tool
        description "Write content to a file, creating it if needed. Snapshots for undo."
        param :path,    desc: "File path relative to project root", required: true
        param :content, desc: "Full content to write", required: true

        def initialize(tool) = @tool = tool

        def execute(path:, content:)
          result = @tool.call(path: path.to_s, content: content.to_s)
          result.ok? ? "Written: #{result.value!}" : "Error: #{result.message}"
        end
      end

      class StrReplace < RubyLLM::Tool
        description "Replace an exact unique string in a file with new content."
        param :path,        desc: "File path relative to project root", required: true
        param :old_string,  desc: "Exact string to find (must be unique in file)", required: true
        param :new_string,  desc: "Replacement string", required: true

        def initialize(tool) = @tool = tool

        def execute(path:, old_string:, new_string:)
          result = @tool.call(path: path.to_s, old_string: old_string.to_s, new_string: new_string.to_s)
          result.ok? ? "Replaced in: #{result.value!}" : "Error: #{result.message}"
        end
      end

      class ListDir < RubyLLM::Tool
        description "List directory contents as a tree. Path is relative to project root."
        param :path,  desc: "Directory path (default: project root)", required: false
        param :depth, desc: "Tree depth (1-5)", type: "integer", required: false

        def initialize(tool) = @tool = tool

        def execute(path: ".", depth: 3)
          result = @tool.call(path: path.to_s, depth: depth.to_i)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class SearchFiles < RubyLLM::Tool
        description "Search files in the project for a regex pattern. Returns matching lines with context."
        param :pattern, desc: "Ruby regex pattern to search for", required: true
        param :path,    desc: "Directory to search in (default: project root)", required: false
        param :context, desc: "Lines of context to show around each match", type: "integer", required: false

        def initialize(tool) = @tool = tool

        def execute(pattern:, path: ".", context: 2)
          result = @tool.call(pattern: pattern.to_s, glob: path.to_s, context_lines: context.to_i)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class Shell < RubyLLM::Tool
        description "Run a shell command in the project root. Blocked patterns are enforced."
        param :command, desc: "Shell command to execute", required: true

        def initialize(tool) = @tool = tool

        def execute(command:)
          result = @tool.call(command: command.to_s)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class WebSearch < RubyLLM::Tool
        MAX_QUERY_LENGTH = 300

        description "Search the web using DuckDuckGo. Returns titles and snippets."
        param :query, desc: "Search query (max #{MAX_QUERY_LENGTH} chars)", required: true

        def initialize(tool) = @tool = tool

        def execute(query:)
          result = @tool.call(query: query.to_s)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class AskLlm < RubyLLM::Tool
        description "Ask a sub-question to a fresh LLM context. Useful for isolated reasoning."
        param :prompt,  desc: "The question or prompt to ask", required: true
        param :context, desc: "Optional background context", required: false

        def initialize(tool) = @tool = tool

        def execute(prompt:, context: nil)
          result = @tool.call(prompt: prompt.to_s, context: context&.to_s)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class GitContext < RubyLLM::Tool
        description "Query git log, blame, diff, status, or show for the project."
        param :operation, desc: "One of: log, blame, diff, status, show", required: true
        param :path,      desc: "File path (required for blame; optional for log/diff/show)", required: false
        param :limit,     desc: "Max commits for log", type: "integer", required: false

        def initialize(tool) = @tool = tool

        def execute(operation:, path: nil, limit: 20)
          result = @tool.call(operation: operation.to_s, path: path&.to_s, limit: limit.to_i)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class AstEdit < RubyLLM::Tool
        description "AST-aware Ruby code editing: find, rename, or insert methods safely."
        param :operation, desc: "One of: find_method, rename_method, add_after, method_lines", required: true
        param :path,      desc: "File path relative to project root", required: true
        param :name,      desc: "Method name (for find_method, method_lines)", required: false
        param :from,      desc: "Original method name (for rename_method)", required: false
        param :to,        desc: "New method name (for rename_method)", required: false
        param :after,     desc: "Insert after this method name (for add_after)", required: false
        param :code,      desc: "Ruby code to insert (for add_after)", required: false

        def initialize(tool) = @tool = tool

        def execute(operation:, path:, name: nil, from: nil, to: nil, after: nil, code: nil)
          result = @tool.call(operation: operation.to_s, path: path.to_s,
                         name: name&.to_s, from: from&.to_s, to: to&.to_s,
                         after: after&.to_s, code: code&.to_s)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class SearchKnowledge < RubyLLM::Tool
        description "Search the local knowledge base: ruby_llm docs, OpenBSD man pages, system prompts, gem docs. Topics: ruby_llm,
          openbsd, system_prompts, gems, awesome."
        param :query, desc: "Search pattern (regex-capable)", required: true
        param :topic, desc: "Limit to topic folder: ruby_llm, openbsd, system_prompts, gems, awesome", required: false

        def initialize(tool) = @tool = tool

        def execute(query:, topic: nil)
          result = @tool.call(query: query.to_s, topic: topic&.to_s)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class FeedbackRecord < RubyLLM::Tool
        description "Record RSI feedback: tool_success, tool_failure, user_correction, provider_error, user_feedback."
        param :event_type, desc: "One of: tool_success tool_failure user_correction provider_error user_feedback",
required: true
        param :dimension,  desc: "Tool name, provider name, or pattern label", required: true
        param :value,      desc: "Numeric value (1.0=success 0.0=failure or duration)", type: "number", required: false
        param :metadata,   desc: "Additional context string", required: false

        def initialize(tool) = @tool = tool

        def execute(event_type:, dimension:, value: nil, metadata: nil)
          result = @tool.call(event_type: event_type.to_s, dimension: dimension.to_s, value:, metadata:)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class Postpro < RubyLLM::Tool
        description "Apply cinematic post-processing (film stocks, presets, recipes) to images via ruby-vips. Writes processed copies next to originals."
        param :target_dir, desc: "Directory containing source images (relative to project root)", required: true
        param :preset,     desc: "One of: portrait, landscape, street, blockbuster", required: false
        param :variations, desc: "1-5 output variations per file", type: "integer", required: false
        param :recipe,     desc: "JSON recipe filename (overrides preset)", required: false

        def initialize(tool) = @tool = tool

        def execute(target_dir:, preset: "portrait", variations: 2, recipe: nil)
          result = @tool.call(target_dir: target_dir.to_s, preset: preset.to_s,
                              variations: variations.to_i, recipe: recipe&.to_s)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

      class Repligen < RubyLLM::Tool
        description "Discover, search, and run Replicate.com models. Actions: sync, search, stats. Requires REPLICATE_API_TOKEN for sync."
        param :action, desc: "One of: sync, search, stats", required: true
        param :query,  desc: "Search query (required for action=search)", required: false
        param :limit,  desc: "Sync limit (1-1000, default 100)", type: "integer", required: false

        def initialize(tool) = @tool = tool

        def execute(action:, query: nil, limit: nil)
          result = @tool.call(action: action.to_s, query: query&.to_s, limit: limit&.to_i)
          result.ok? ? result.value! : "Error: #{result.message}"
        end
      end

    end
  end
end

lib/master/tools/path_guard.rb

# frozen_string_literal: true

module Master
  module Tools
    module PathGuard
      SACRED_PATHS = begin
        data = Master.load_yaml(File.join(Master::ROOT, "data", "soul.yml"))
        Array(data.dig("absolute", "sacred_paths")).freeze
      rescue StandardError
        %w[data/ SOUL.md CLAUDE.md CONVENTIONS.md README.md .claude/].freeze
      end

      def resolve(path)
        full = File.expand_path(path, @root)
        return Result.err("path escapes project root: #{path}", category: :validation) unless full.start_with?(@root)

        rel = full.delete_prefix(@root + "/")
        if sacred?(rel)
          return Result.err(
            "#{rel} is sacred-tier (constitutional). Amend via `soul propose`.",
            category: :validation
          )
        end

        Result.ok(full)
      end

      private

      def sacred?(rel_path)
        SACRED_PATHS.any? { |s| rel_path.start_with?(s) || rel_path == s.chomp("/") }
      end
    end
  end
end

lib/master/tools/postpro.rb

# frozen_string_literal: true

require "json"
require "open3"
require "shellwords"
require "tmpdir"
require "fileutils"

module Master
  module Tools
    # Postpro — drive the multimedia/postpro.rb pipeline (libvips/ruby-vips,
    # film stocks, presets, recipes) from MASTER. Shells out so we don't
    # pollute Master's namespace with postpro's top-level constants.
    class Postpro
      TIER        = :dangerous
      NAME        = "postpro".freeze
      DESCRIPTION = "Apply cinematic post-processing (film grain, halation, teal-orange, presets, recipes) to images via ruby-vips.".freeze
      TIMEOUT     = 600
      PRESETS     = %w[portrait landscape street blockbuster].freeze
      SCRIPT_REL  = "../multimedia/postpro.rb".freeze

      def initialize(root:, governor:, event_bus: nil)
        @root     = root
        @governor = governor
        @bus      = event_bus
      end

      def call(target_dir:, preset: "portrait", variations: 2, recipe: nil, patterns: nil)
        return Result.err("postpro: target_dir required", category: :validation) if target_dir.to_s.empty?
        return Result.err("postpro: unknown preset #{preset}",
category: :validation) unless PRESETS.include?(preset.to_s) || recipe

        target_abs = absolute(target_dir)
        return Result.err("postpro: not a directory: #{target_abs}",
category: :validation) unless File.directory?(target_abs)

        perm = @governor.permit?(NAME, TIER, "postpro #{preset} #{target_abs}")
        return perm if perm.err?

        script = File.expand_path(SCRIPT_REL, @root)
        return Result.err("postpro: script missing at #{script}", category: :infrastructure) unless File.exist?(script)

        config_path = write_driver_config(target_abs, preset:, variations:, recipe:, patterns:)
        @bus&.publish("tool:before", tool: NAME, target: target_abs, preset: preset)

        out, err, status = Open3.capture3(
          { "POSTPRO_DRIVER_CONFIG" => config_path },
          "ruby", script, "--auto",
          chdir: target_abs
        )

        @bus&.publish("tool:after", tool: NAME, exit: status.exitstatus)
        status.success? ? Result.ok(format_output(out, err)) :
                          Result.err("postpro: exit #{status.exitstatus} #{err.strip}", category: :provider_error)
      rescue StandardError => e
        Result.err("postpro: #{e.message}", category: :infrastructure)
      ensure
        FileUtils.rm_f(config_path) if defined?(config_path) && config_path
      end

      private

      def absolute(path)
        path = path.to_s
        File.absolute_path?(path) ? path : File.expand_path(path, @root)
      end

      def write_driver_config(dir, preset:, variations:, recipe:, patterns:)
        config = {
          "default_preset" => preset.to_s,
          "variations"     => variations.to_i.clamp(1, 5),
          "jpeg_quality"   => 95
        }
        config["patterns"] = Array(patterns) if patterns
        config["recipe"]   = recipe if recipe

        path = File.join(Dir.tmpdir, "postpro_driver_#{Process.pid}_#{Time.now.to_i}.json")
        File.write(path, JSON.pretty_generate(config))
        path
      end

      def format_output(out, err)
        lines = (out.to_s + err.to_s).lines.map(&:strip).reject(&:empty?)
        summary = lines.last(20).join("\n")
        summary.empty? ? "(no output)" : summary
      end
    end
  end
end

lib/master/tools/read_file.rb

# frozen_string_literal: true

module Master
  module Tools
    # ReadFile — read file contents with line-range support and undo tracking.
    class ReadFile
      include PathGuard
      TIER        = :safe
      MAX_LINES   = 2000
      NAME        = "read_file".freeze
      DESCRIPTION = "Read a file with line numbers. Guarded to project root.".freeze

      def initialize(root:, undo:, event_bus: nil)
        @root  = File.realpath(root)
        @undo  = undo
        @bus   = event_bus
        @cache = {}
      end

      # Clear per-turn cache — called by Agent at the start of each chat turn.
      def reset!
        @cache.clear
      end

      def call(path:, offset: 0, limit: MAX_LINES)
        key = [path, offset, limit]
        return @cache[key] if @cache.key?(key)
        resolved = resolve(path)
        return resolved if resolved.err?

        full_path = resolved.value!
        return Result.err("not found: #{path}", category: :validation) unless File.exist?(full_path)
        return Result.err("not a file: #{path}", category: :validation) unless File.file?(full_path)

        lines = File.readlines(full_path)
        total = lines.size
        slice = lines[offset, limit] || []

        numbered = slice.each_with_index.map { |l, i| "#{offset + i + 1}\t#{l}" }.join
        suffix   = total > offset + limit ? "\n[...truncated, #{total} total lines]" : ""

        result = Result.ok(numbered + suffix)
        @cache[key] = result
        result
      end

      private

    end
  end
end

lib/master/tools/repligen.rb

# frozen_string_literal: true

require "open3"
require "shellwords"

module Master
  module Tools
    # Repligen — drive the multimedia/repligen.rb Replicate.com CLI from MASTER.
    # Shells out so SQLite + net/http stay in their own process. REPLICATE_API_TOKEN
    # must be set in the parent env.
    class Repligen
      TIER        = :dangerous
      NAME        = "repligen".freeze
      DESCRIPTION = "Discover, search, and run Replicate.com models (image/video/music/upscale).".freeze
      TIMEOUT     = 1200
      SCRIPT_REL  = "../multimedia/repligen.rb".freeze
      ACTIONS     = %w[sync search stats].freeze

      def initialize(root:, governor:, event_bus: nil)
        @root     = root
        @governor = governor
        @bus      = event_bus
      end

      def call(action:, query: nil, limit: nil)
        action = action.to_s
        return Result.err("repligen: unknown action #{action}", category: :validation) unless ACTIONS.include?(action)
        return Result.err("repligen: REPLICATE_API_TOKEN not set",
category: :validation) if action == "sync" && ENV["REPLICATE_API_TOKEN"].to_s.empty?

        script = File.expand_path(SCRIPT_REL, @root)
        return Result.err("repligen: script missing at #{script}", category: :infrastructure) unless File.exist?(script)

        argv = build_argv(action, query: query, limit: limit)
        perm = @governor.permit?(NAME, TIER, "repligen #{argv.join(' ')}")
        return perm if perm.err?

        @bus&.publish("tool:before", tool: NAME, action: action)

        out, err, status = Open3.capture3("ruby", script, *argv, chdir: File.dirname(script))

        @bus&.publish("tool:after", tool: NAME, exit: status.exitstatus)
        status.success? ? Result.ok(out.to_s.strip) :
                          Result.err("repligen: exit #{status.exitstatus} #{err.strip}", category: :provider_error)
      rescue StandardError => e
        Result.err("repligen: #{e.message}", category: :infrastructure)
      end

      private

      def build_argv(action, query:, limit:)
        case action
        when "sync"   then ["sync",   (limit || 100).to_i.clamp(1, 1000).to_s]
        when "search" then ["search", query.to_s]
        when "stats"  then ["stats"]
        end
      end
    end
  end
end

lib/master/tools/search_files.rb

# frozen_string_literal: true

module Master
  module Tools
    class SearchFiles
      TIER               = :safe
      NAME               = "search_files".freeze
      DESCRIPTION        = "Search for a pattern in files under the project root.".freeze
      MAX_RESULTS        = 200
      BINARY_SAMPLE_BYTES = 512

      def initialize(root:, event_bus: nil)
        @root = File.realpath(root)
        @bus  = event_bus
        @cache = {}
      end

      def reset!
        @cache.clear
      end

      def call(pattern:, glob: "**/*", context_lines: 2)
        key = [pattern, glob, context_lines]
        return @cache[key] if @cache.key?(key)
        begin
          re = Regexp.new(pattern)
        rescue RegexpError
          return Result.err("invalid pattern: #{pattern}", category: :validation)
        end

        paths   = cached_paths(glob)
        results = []

        paths.each do |path|
          next if binary_file?(path)

          lines = File.readlines(path)
          lines.each_with_index do |line, idx|
            next unless line.match?(re)
            start  = [idx - context_lines, 0].max
            finish = [idx + context_lines, lines.size - 1].min
            ctx    = lines[start..finish].each_with_index.map { |l, i| "#{start + i + 1}:#{l}" }.join
            rel    = path.delete_prefix(@root + "/")
            results << "#{rel}:#{idx + 1}\n#{ctx}"
            if results.size >= MAX_RESULTS
              out = Result.ok(results.join("\n---\n") + "\n[...truncated]")
              @cache[key] = out
              return out
            end
          end
        end

        out = Result.ok(results.empty? ? "(no matches)" : results.join("\n---\n"))
        @cache[key] = out
        out
      rescue StandardError => e
        Result.err("search_files: #{e.message}", category: :unknown)
      end

      private

      def cached_paths(glob)
        list_key = [:glob, glob]
        return @cache[list_key] if @cache.key?(list_key)
        paths = Dir.glob(File.join(@root, glob)).select { |p| File.file?(p) }
        @cache[list_key] = paths
      end

      def binary_file?(path)
        sample = begin; File.read(path, BINARY_SAMPLE_BYTES); rescue StandardError; ""; end
        sample.include?("\x00")
      end
    end
  end
end

lib/master/tools/search_knowledge.rb

# frozen_string_literal: true

module Master
  module Tools
    # Search the local knowledge base: cloned docs, man pages, system prompts, gem READMEs.
    class SearchKnowledge
      TIER        = :safe
      NAME        = "search_knowledge".freeze
      DESCRIPTION = "Search local knowledge base (ruby_llm docs, OpenBSD man pages, system prompts, gem docs). " \
                    "Use for: how does X work in ruby_llm? what does man pf.conf say? example system prompts?".freeze
      MAX_RESULTS = 30

      def initialize(root:, event_bus: nil)
        @knowledge_root = File.join(File.realpath(root), "knowledge")
        @bus = event_bus
      end

      def call(query:, topic: nil)
        return Result.err("knowledge base not found", category: :validation) unless Dir.exist?(@knowledge_root)

        search_dir = topic ? File.join(@knowledge_root, topic.to_s) : @knowledge_root
        unless Dir.exist?(search_dir) && File.realpath(search_dir).start_with?(@knowledge_root)
          return Result.err("unknown topic: #{topic}. Available: #{available_topics.join(", ")}", category: :validation)
        end

        begin
          re = Regexp.new(query, Regexp::IGNORECASE)
        rescue RegexpError => e
          re = Regexp.new(Regexp.escape(query), Regexp::IGNORECASE)
        end

        paths   = Dir.glob(File.join(search_dir, "**", "*")).select { |p| File.file?(p) && text_file?(p) }
        results = []

        paths.each do |path|
          next if skip_file?(path)
          lines = File.readlines(path, encoding: "UTF-8", invalid: :replace)
          lines.each_with_index do |line, idx|
            next unless line.match?(re)
            start  = [idx - 2, 0].max
            finish = [idx + 2, lines.size - 1].min
            ctx    = lines[start..finish].map.with_index(start + 1) { |l, n| "#{n}: #{l}" }.join
            rel    = path.delete_prefix(@knowledge_root + "/")
            results << "### #{rel}:#{idx + 1}\n#{ctx}"
            break if results.size >= MAX_RESULTS
          end
          break if results.size >= MAX_RESULTS
        end

        if results.empty?
          Result.ok("No matches for '#{query}' in #{topic || "all knowledge"}.")
        else
          header = "# Knowledge search: '#{query}' (#{results.size} matches)\n\n"
          Result.ok(header + results.join("\n---\n"))
        end
      rescue StandardError => e
        Result.err("search_knowledge: #{e.message}", category: :unknown)
      end

      def available_topics
        return [] unless Dir.exist?(@knowledge_root)
        Dir.entries(@knowledge_root).select { |e|
 File.directory?(File.join(@knowledge_root, e)) && !e.start_with?(".") }
      end

      private

      def text_file?(path)
        ext = File.extname(path).downcase
        %w[.rb .md .txt .yml .yaml .json .sh .conf .html .rst .rdoc].include?(ext) || ext.empty?
      end

      def skip_file?(path)
        path.include?("/.git/") || path.include?("/node_modules/") ||
          path.include?("/vendor/") || File.size(path) > 500_000
      end
    end
  end
end

lib/master/tools/shell.rb

# frozen_string_literal: true

require "tty-command"
require "timeout"
require "shellwords"

module Master
  module Tools
    # Shell — execute zsh commands with timeout and governor approval.
    # Three-layer defense (OpenCrabs pattern):
    #   1. BLOCKLIST: hard-blocked destructive commands
    #   2. Interactive detection: block commands that need a TTY
    #   3. Recent failure window: warn after 3 failures in last 5 commands
    class Shell
      TIER        = :dangerous
      NAME        = "zsh".freeze
      DESCRIPTION = "Execute a zsh command in the project root.".freeze
      TIMEOUT     = 30
      FAILURE_WINDOW = 5
      FAILURE_WARN_AT = 3

      BLOCKLIST   = Security::Permissions::BLOCKLIST
      ZSH_BANNED  = begin
        merged = Master.load_yaml(File.join(Master::ROOT, "data", "patterns.yml"))
        zsh_data = (merged && merged["zsh"]) ||
                    Master.load_yaml(File.join(Master::ROOT, "data", "zsh_patterns.yml"))
        Array(zsh_data["banned_commands"]).freeze
      rescue StandardError
        %w[sed awk grep find head tail wc cut tr bash sudo perl python].freeze
      end

      INTERACTIVE_RE = /\b(vim?|nano|less|more|pager|git\s+add\s+-[ip]|irb|pry|rails\s+c|bundle\s+exec\s+rails\s+c|fzf|top|htop|tmux|screen)\b/i.freeze

      def initialize(root:, governor:, event_bus: nil)
        @root     = root
        @governor = governor
        @bus      = event_bus
        @cmd      = TTY::Command.new(printer: :null)
        @recent   = []
        @mutex    = Mutex.new
      end

      def call(command:)
        return Result.err("blocked command: #{command}", category: :validation) if blocked?(command)
        return Result.err("interactive command blocked — no TTY available: #{command}",
category: :validation) if interactive?(command)

        warn_if_failing_often

        perm = @governor.permit?(NAME, TIER, command)
        return perm if perm.err?

        @bus&.publish("tool:before", tool: NAME, command:)

        banned = ZSH_BANNED.select { |b| command.match?(/\b#{b}\b/) }
        @bus&.publish("zsh:banned_tool_warning", tools: banned, command:) if banned.any?

        zdotdir = File.writable?("/tmp") ? "/tmp" : Dir.home
        wrapped = "#!/usr/bin/env zsh\nset -euo pipefail\nsetopt nullglob extendedglob\n" \
                  "export ZDOTDIR=#{Shellwords.escape(zdotdir)}\nexport LC_ALL=C.UTF-8\n" \
                  "cd #{Shellwords.escape(@root)}\n#{command}\n"

        out, err = Timeout.timeout(TIMEOUT) { @cmd.run!("zsh", input: wrapped) }
        @bus&.publish("tool:after", tool: NAME, exit_code: out.exit_status)

        track_result(:success)
        Result.ok(out.to_s.strip)
      rescue Timeout::Error
        track_result(:failure)
        Result.err("zsh: timed out after #{TIMEOUT}s", category: :unknown)
      rescue TTY::Command::ExitError => e
        track_result(:failure)
        Result.err("zsh: #{e.message}", category: :unknown)
      rescue StandardError => e
        track_result(:failure)
        Result.err("zsh: #{e.message}", category: :unknown)
      end

      private

      def blocked?(command) = BLOCKLIST.any? { |b| command.include?(b) }
      def interactive?(command) = INTERACTIVE_RE.match?(command)

      def track_result(outcome)
        @mutex.synchronize do
          @recent << outcome
          @recent = @recent.last(FAILURE_WINDOW)
        end
      end

      def warn_if_failing_often
        failures = @mutex.synchronize { @recent.count(:failure) }
        @bus&.publish("zsh:high_failure_rate", failures:, window: FAILURE_WINDOW) if failures >= FAILURE_WARN_AT
      end
    end
  end
end

lib/master/tools/str_replace.rb

# frozen_string_literal: true

module Master
  module Tools
    class StrReplace
      include Base
      TIER        = :guarded
      NAME        = "str_replace".freeze
      DESCRIPTION = "Replace unique string in a file. Fails if pattern matches 0 or 2+ times.".freeze

      def initialize(root:, undo:, governor:, event_bus: nil, diff_stager: nil)
        @root, @undo, @governor, @bus, @diff_stager =
          File.realpath(root), undo, governor, event_bus, diff_stager
      end

      def call(path:, old_string:, new_string:)
        safely do
          resolved = resolve(path)
          next resolved if resolved.err?

          full = resolved.value!
          next Result.err("not found: #{path}", category: :validation) unless File.exist?(full)

          content = File.read(full)
          count   = content.scan(old_string).size
          next Result.err("str_replace: pattern not found in #{path}", category: :validation) if count.zero?
          next Result.err("str_replace: pattern matches #{count} times in #{path} (must be unique)",
                          category: :validation) if count > 1

          perm = permit(path)
          next perm if perm.err?

          commit_write(full, content.sub(old_string, new_string), path:)
        end
      end
    end
  end
end

lib/master/tools/symbol_lookup.rb

# frozen_string_literal: true

module Master
  module Tools
    # SymbolLookup — query the live symbol graph; returns definition, callers, and impact.
    class SymbolLookup
      NAME        = "symbol_lookup".freeze
      DESCRIPTION = "Look up a Ruby class, module, or method in the codebase. " \
                    "Returns file, line, and all cross-file references (callers/usages). " \
                    "Use before refactoring to understand impact.".freeze
      def initialize(code_index:, event_bus: nil)
        @index = code_index
        @bus   = event_bus
      end

      def call(name:)
        return Result.err("symbol_lookup: index not built yet", category: :validation) unless @index.built?

        hits = @index.query(name)
        if hits.is_a?(Hash) && hits[:error]
          return Result.err("symbol_lookup: #{hits[:error]}", category: :validation)
        end

        @bus&.publish("tool:symbol_lookup", name:, hits: hits.size)
        Result.ok(hits.map { |h| format_hit(h) }.join("\n\n"))
      end

      private

      def format_hit(h)
        lines = ["#{h[:fqn]} (#{h[:type]})"]
        lines << "  defined: #{h[:file]}:#{h[:line]}"
        lines << "  parent:  #{h[:parent]}" if h[:parent] && h[:parent] != "Object"
        if h[:used_in].any?
          lines << "  used in:"
          h[:used_in].each { |ref| lines << "    #{ref}" }
        else
          lines << "  used in: (no cross-file references found)"
        end
        lines.join("\n")
      end
    end
  end
end

lib/master/tools/tree.rb

# frozen_string_literal: true

require "open3"

module Master
  module Tools
    # Tree — lists directory structure using sh/tree.sh.
    # Safe: read-only, no writes.
    class Tree
      SCRIPT = File.expand_path("../../../sh/tree.sh", __dir__).freeze

      def initialize(root:, event_bus: nil)
        @bus = event_bus
        @root = root
      end

      def call(path: nil)
        target = path ? File.expand_path(path, @root) : @root
        return Result.err("path not found: #{target}", category: :validation) unless Dir.exist?(target)

        out, err, status = Open3.capture3("zsh", SCRIPT, target)
        return Result.err("tree failed: #{err.strip}", category: :unknown) unless status.success?

        lines = out.lines.map(&:chomp).reject(&:empty?)
        @bus&.publish("tool:tree", path: target, count: lines.size)
        Result.ok(lines.join("\n"))
      rescue StandardError => e
        Result.err("tree: #{e.message}", category: :unknown)
      end
    end
  end
end

lib/master/tools/web_fetch.rb

# frozen_string_literal: true
require "net/http"
require "uri"

module Master
  module Tools
    # Fetches a URL, returns first ~16KB of content with HTML stripped to plain text.
    # Rewrites well-known sites to the most useful underlying URL:
    #   github.com/.../blob/...     → raw.githubusercontent.com
    #   gist.github.com/<u>/<id>    → gist.githubusercontent.com/<u>/<id>/raw
    #   arxiv.org/abs|pdf/<id>      → ar5iv.labs.arxiv.org/html/<id>  (full text)
    #   codepen.io/<u>/pen/<slug>   → triple-fetch .html + .css + .js
    # Pairs with web_search for two-step research. Governor-permitted.
    class WebFetch
      TIER         = :guarded
      NAME         = "web_fetch".freeze
      DESCRIPTION  = "Fetch a URL → plain text. Rewrites github/gist/arxiv/codepen URLs.".freeze
      TIMEOUT      = 15
      MAX_BYTES    = 16_000
      HTTP_OK      = "200".freeze
      TAG_RE       = /<[^>]+>/.freeze
      WS_RE        = /[ \t]+/.freeze
      BLANK_RE     = /\n{3,}/.freeze

      REWRITES = [
        [%r{\Ahttps://github\.com/([^/]+)/([^/]+)/blob/([^/]+)/(.+)\z},
         'https://raw.githubusercontent.com/\1/\2/\3/\4'],
        [%r{\Ahttps://gist\.github\.com/([^/]+)/([0-9a-f]+)/?\z},
         'https://gist.githubusercontent.com/\1/\2/raw'],
        [%r{\Ahttps://arxiv\.org/(?:abs|pdf)/([\w./-]+?)(?:v\d+)?(?:\.pdf)?/?\z},
         'https://ar5iv.labs.arxiv.org/html/\1']
      ].freeze

      CODEPEN_RE = %r{\Ahttps://codepen\.io/([^/]+)/pen/([^/?#]+)/?\z}.freeze

      def initialize(governor:, event_bus: nil)
        @governor = governor
        @bus      = event_bus
      end

      def call(url:)
        if (m = url.match(CODEPEN_RE))
          return fetch_codepen(m[1], m[2])
        end

        rewritten = rewrite(url)
        fetch_one(rewritten)
      end

      private

      def rewrite(url)
        REWRITES.each { |re, repl| return url.sub(re, repl) if url.match?(re) }
        url
      end

      def fetch_codepen(user, slug)
        base = "https://codepen.io/#{user}/pen/#{slug}"
        parts = %w[html css js].map do |ext|
          result = fetch_one("#{base}.#{ext}")
          result.is_a?(Master::Result::Ok) ? "// === #{ext} ===\n#{result.value!}" : nil
        end
        Result.ok(parts.compact.join("\n\n"))
      end

      def fetch_one(url)
        uri = URI(url)
        return Result.err("web_fetch: only http(s)", category: :validation) unless %w[http https].include?(uri.scheme)

        perm = @governor.permit?(NAME, TIER, url)
        return perm if perm.err?

        response = http_get(uri)
        deliver(url, response)
      rescue StandardError => e
        Result.err("web_fetch: #{e.message}", category: :infrastructure)
      end

      def deliver(url, response)
        return Result.err("web_fetch: HTTP #{response.code}", category: :infrastructure) unless response.code == HTTP_OK

        body     = response.body.to_s.byteslice(0, MAX_BYTES * 4)
        stripped = strip_html(body)[0, MAX_BYTES]
        @bus&.publish("tool:after", tool: NAME, url:)
        Result.ok(stripped)
      end

      def http_get(uri)
        Net::HTTP.start(uri.host, uri.port,
                        use_ssl: uri.scheme == "https",
                        read_timeout: TIMEOUT, open_timeout: TIMEOUT) do |h|
          h.get(uri.request_uri, "User-Agent" => "MASTER/1 (web_fetch)")
        end
      end

      def strip_html(body)
        body.gsub(TAG_RE, " ").gsub(WS_RE, " ").gsub(BLANK_RE, "\n\n").strip
      end
    end
  end
end

lib/master/tools/web_search.rb

# frozen_string_literal: true
require "net/http"
require "uri"
require "json"

module Master
  module Tools
    class WebSearch
      TIER               = :guarded
      MAX_QUERY_CHARS    = 300
      MAX_SEARCH_RESULTS = 5
      HTTP_OK            = "200".freeze

      NAME        = "web_search".freeze
      DESCRIPTION = "Search DuckDuckGo instant answers API.".freeze
      ENDPOINT    = "https://api.duckduckgo.com/".freeze
      TIMEOUT     = 10

      def initialize(governor:, event_bus: nil)
        @governor = governor
        @bus      = event_bus
      end

      def call(query:)
        if query.length > MAX_QUERY_CHARS
          @bus&.publish("tool:warning", tool: NAME, message: "query truncated to #{MAX_QUERY_CHARS} chars")
          query = query[0, MAX_QUERY_CHARS]
        end

        perm = @governor.permit?(NAME, TIER, query)
        return perm if perm.err?

        uri = URI(ENDPOINT)
        uri.query = URI.encode_www_form(q: query, format: "json", no_redirect: 1)

        response = Timeout.timeout(TIMEOUT * 2) {
          Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: TIMEOUT) { |h|
            h.get(uri.request_uri)
          }
        }

        return Result.err("web_search: HTTP #{response.code}",
category: :infrastructure) unless response.code == HTTP_OK

        data    = JSON.parse(response.body)
        results = extract_results(data)
        @bus&.publish("tool:after", tool: NAME, query:)
        Result.ok(results)
      rescue StandardError => e
        Result.err("web_search: #{e.message}", category: :infrastructure)
      end

      private

      def extract_results(data)
        parts = []
        parts << data["Abstract"] unless data["Abstract"].to_s.empty?
        (data["RelatedTopics"] || []).first(MAX_SEARCH_RESULTS).each { |t| parts << t["Text"] if t["Text"] }
        parts.empty? ? "(no results)" : parts.join("\n\n")
      end
    end
  end
end

lib/master/tools/write_file.rb

# frozen_string_literal: true

require "fileutils"

module Master
  module Tools
    class WriteFile
      include Base
      TIER        = :guarded
      NAME        = "write_file".freeze
      DESCRIPTION = "Atomically write content to a file, with undo snapshot.".freeze

      def initialize(root:, undo:, governor:, event_bus: nil, diff_stager: nil)
        @root, @undo, @governor, @bus, @diff_stager =
          File.realpath(root), undo, governor, event_bus, diff_stager
      end

      def call(path:, content:)
        safely do
          resolved = resolve(path)
          next resolved if resolved.err?

          full = resolved.value!
          perm = permit(path)
          next perm if perm.err?

          FileUtils.mkdir_p(File.dirname(full))
          commit_write(full, content, path:)
        end
      end
    end
  end
end

lib/master/trace.rb

# frozen_string_literal: true

require "json"
require "fileutils"

module Master
  # Trace — captures all bus events for the current turn into a JSONL log
  # at data/traces/YYYY-MM-DD.jsonl. Each line is one turn record.
  # /why pretty-prints the last turn.
  class Trace
    EVENT_PATTERNS = %w[gateway:* pipeline:* tool:after route:* council:* memo:* lint:* governor:*].freeze
    MAX_EVENTS_PER_TURN = 200

    def initialize(root:, event_bus:)
      @dir   = File.join(root, "data", "traces")
      @bus   = event_bus
      @mutex = Mutex.new
      @current = nil
      @last    = nil
      FileUtils.mkdir_p(@dir)
      subscribe
    end

    def last_turn = @last

    def pretty_last
      turn = @last || load_last_from_disk
      return "no trace recorded yet" unless turn

      lines = ["turn #{turn[:id]}  ts=#{turn[:start_ts]}  channel=#{turn[:channel]}"]
      lines << "  message: #{(turn[:message] || "")[0, 100]}"
      turn[:events].each do |ev|
        ms = ev[:ts_ms]
        name = ev[:event]
        detail = ev.except(:ts_ms, :event).reject { |_, v| v.nil? }
        lines << "  +#{ms.to_s.rjust(5)}ms  #{name}  #{detail.empty? ? "" : detail.inspect}"
      end
      lines.join("\n")
    end

    private

    def subscribe
      EVENT_PATTERNS.each { |pat| @bus.subscribe(pat) { |ev| record(ev) } }
    end

    def record(event)
      @mutex.synchronize do
        if event[:event] == "gateway:turn_start"
          @current = { id: event[:turn_id], start_ts: Time.now.to_i, channel: event[:channel],
                       message: event[:message], events: [], t0: event[:ts] }
          next
        end
        if @current && event[:event] == "gateway:turn_done"
          @current[:events] << relative(event)
          finalize(@current)
          @last = @current
          @current = nil
          next
        end
        next unless @current
        next if @current[:events].size >= MAX_EVENTS_PER_TURN
        @current[:events] << relative(event)
      end
    rescue StandardError
      # never break the bus
    end

    def relative(ev)
      ts0 = @current[:t0] || 0
      ev.merge(ts_ms: (ev[:ts] || 0) - ts0).except(:ts)
    end

    def finalize(turn)
      path = File.join(@dir, "#{Time.now.strftime("%Y-%m-%d")}.jsonl")
      File.open(path, "a") { |f| f.puts JSON.generate(turn) }
    rescue StandardError
      # disk full / permission — drop silently rather than break the turn
    end

    def load_last_from_disk
      files = Dir.glob(File.join(@dir, "*.jsonl")).sort
      return nil if files.empty?
      last_line = nil
      File.foreach(files.last) { |l| last_line = l }
      last_line ? JSON.parse(last_line, symbolize_names: true) : nil
    rescue StandardError
      nil
    end
  end
end

lib/master/triggers.rb

# frozen_string_literal: true

module Master
  class Triggers
    DEFAULTS       = %i[after_scan on_error budget_low tool_after].freeze
    ERROR_TRUNCATE = 200

    def initialize(event_bus:, scanner: nil, agent: nil)
      @bus     = event_bus
      @scanner = scanner
      @agent   = agent
      @rules   = []
    end

    def install_defaults!
      register(:after_scan) do |ctx|
        count = ctx[:violations].to_i
        if count > 0
          @bus.publish("triggers:violations_found", count: count)
        end
      end

      register(:on_error) do |ctx|
        @bus.publish("triggers:error_logged", error: ctx[:error].to_s[0, ERROR_TRUNCATE])
      end

      register(:budget_low) do |_ctx|
        @bus.publish("triggers:budget_low", action: "switch_to_free_tier")
      end

      @bus.subscribe("tool:after") do |ev|
        fire(:tool_after, ev)
      end

      self
    end

    def register(event, &handler)
      @rules << { event: event.to_sym, handler: handler }
    end

    def fire(event, context = {})
      matching = @rules.select { |r| r[:event] == event.to_sym }
      matching.each do |rule|
        rule[:handler].call(context)
      rescue StandardError => e
        @bus.publish("triggers:handler_error", event: event, error: e.message)
      end
    end

    def list
      @rules.map { |r| r[:event].to_s }.tally.map { |e, n| "#{e}: #{n} handler(s)" }.join("\n")
    end

    def clear!
      @rules.clear
    end
  end
end

lib/master/undo.rb

# frozen_string_literal: true

require "json"
require "fileutils"

module Master
  # Persistent undo: snapshots file content before writes, restores on demand.
  # Journal survives restarts via .master/undo_journal.jsonl.
  class Undo
    MAX_JOURNAL = 50

    def initialize(session:, event_bus: nil, root: Dir.pwd)
      @session = session
      @bus     = event_bus
      @root    = root
      @journal = File.join(root, ".master", "undo_journal.jsonl")
      @stack   = load_journal
      @redo    = []
    end

    def snapshot(path)
      content = File.exist?(path) ? File.read(path) : nil
      @session.snapshot(path, content)
      @stack << { "path" => path, "content" => content, "ts" => Time.now.to_i }
      @stack.shift while @stack.size > MAX_JOURNAL
      persist_journal
      Result.ok(path)
    rescue StandardError => e
      Result.err("undo snapshot: #{e.message}", category: :unknown)
    end

    def undo!(steps: 1)
      return Result.err("nothing to undo", category: :validation) if @stack.empty?

      steps = [steps, @stack.size].min
      paths = []

      steps.times do
        entry = @stack.pop
        @redo << { "path" => entry["path"], "content" => (File.exist?(entry["path"]) ? File.read(entry["path"]) : nil), "ts" => Time.now.to_i }
        restore(entry["path"], entry["content"])
        paths << entry["path"]
        @bus&.publish("undo:applied", path: paths.last)
      end

      persist_journal
      Result.ok(paths.size == 1 ? paths.first : paths)
    end

    def redo!(steps: 1)
      return Result.err("nothing to redo", category: :validation) if @redo.empty?

      steps = [steps, @redo.size].min
      paths = []
      steps.times do
        entry = @redo.pop
        @stack << { "path" => entry["path"], "content" => (File.exist?(entry["path"]) ? File.read(entry["path"]) : nil), "ts" => Time.now.to_i }
        restore(entry["path"], entry["content"])
        paths << entry["path"]
        @bus&.publish("redo:applied", path: paths.last)
      end

      persist_journal
      Result.ok(paths.size == 1 ? paths.first : paths)
    end

    def depth = @stack.size

    def history(limit: 10)
      @stack.last(limit).reverse.map.with_index(1) do |entry, i|
        time = entry["ts"] ? Time.at(entry["ts"]).strftime("%H:%M:%S") : "?"
        "#{i}. #{entry["path"]} (#{time})"
      end
    end

    private

    def restore(path, content)
      if content.nil?
        File.delete(path) if File.exist?(path)
      else
        tmp = "#{path}.tmp.#{Process.pid}"
        File.write(tmp, content)
        File.rename(tmp, path)
      end
    rescue StandardError => e
      File.delete(tmp) if defined?(tmp) && File.exist?(tmp) rescue nil
      raise e
    end

    def load_journal
      return [] unless File.exist?(@journal)
      File.readlines(@journal).filter_map do |line|
        JSON.parse(line.strip)
      rescue JSON::ParserError
        nil
      end
    rescue StandardError => e
      @bus&.publish("undo:read_error", error: e.message) if defined?(@bus)
      []
    end

    def persist_journal
      FileUtils.mkdir_p(File.dirname(@journal))
      tmp = "#{@journal}.tmp.#{Process.pid}"
      File.open(tmp, "w") { |f| @stack.each { |entry| f.puts(JSON.generate(entry)) } }
      File.rename(tmp, @journal)
    rescue StandardError => e
      File.delete(tmp) if defined?(tmp) && File.exist?(tmp) rescue nil
      raise e
    end
  end
end

lib/master/unwrap_error.rb

# frozen_string_literal: true

module Master
  # Raised when #value! is called on an Err result.
  class UnwrapError < RuntimeError; end
end

lib/master/why_explainer.rb

# frozen_string_literal: true

module Master
  # Local lookup for /why <id>: laws, scan rules, anti-patterns, style keys.
  # Returns nil when nothing matches; caller falls back to the LLM.
  class WhyExplainer
    SCAN_RULES_DIR = "lib/master/scan/rules"

    def initialize(root: Master::ROOT)
      @root = root
    end

    def explain(id)
      key = id.to_s.strip
      return if key.empty?
      law(key) || scan_rule(key) || anti_pattern(key) || style_key(key)
    end

    private

    def rules
      @rules ||= Master.load_yaml(File.join(@root, "data", "rules.yml")) || {}
    end

    def style
      @style ||= Master.load_yaml(File.join(@root, "data", "ruby_style.yml")) || {}
    end

    def law(key)
      laws = rules["laws"] || {}
      hit = laws[key.upcase] or return
      [
        "law: #{key.upcase}",
        "  priority:  #{hit["priority"]}",
        "  principle: #{hit["principle"]}",
        "  applies:   #{Array(hit["applies_to"]).join(", ")}"
      ].join("\n")
    end

    def scan_rule(key)
      slug = key.downcase.tr("-", "_")
      path = File.join(@root, SCAN_RULES_DIR, "#{slug}_rule.rb")
      return unless File.file?(path)
      src = File.read(path, encoding: "UTF-8")
      desc = src[/@description\s*=\s*["']([^"']+)["']/, 1] || "(no description)"
      tags = src[/@axiom_tags\s*=\s*%i\[([^\]]+)\]/, 1].to_s.split.first(6).join(" ")
      [
        "scan rule: #{slug}",
        "  description: #{desc}",
        ("  axioms:      #{tags}" unless tags.empty?),
        "  source:      #{SCAN_RULES_DIR}/#{slug}_rule.rb"
      ].compact.join("\n")
    end

    def anti_pattern(key)
      ap = rules["anti_patterns"] || {}
      %w[forbidden discouraged].each do |level|
        Array(ap[level]).each do |entry|
          reason = entry["reason"].to_s
          next unless reason.include?(key) || entry["pattern"].to_s.include?(key)
          return [
            "anti-pattern: #{reason}",
            "  level:   #{level}",
            "  pattern: #{entry["pattern"]}"
          ].join("\n")
        end
      end
      nil
    end

    def style_key(key)
      keys = key.downcase.split(/[.\/]/)
      cursor = style
      keys.each do |k|
        return nil unless cursor.is_a?(Hash) && cursor.key?(k)
        cursor = cursor[k]
      end
      "style: #{key}\n#{render(cursor, indent: 2).chomp}"
    end

    def render(node, indent: 0)
      pad = " " * indent
      case node
      when Hash  then node.map { |k, v|
 "#{pad}#{k}: #{v.is_a?(Hash) || v.is_a?(Array) ? "\n" + render(v, indent: indent + 2) : v}" }.join("\n") + "\n"
      when Array then node.map { |v|
 "#{pad}- #{v.is_a?(Hash) ? "\n" + render(v, indent: indent + 2) : v}" }.join("\n") + "\n"
      else node.to_s + "\n"
      end
    end
  end
end

master.gemspec

# frozen_string_literal: true

Gem::Specification.new do |s|
  s.name    = "master"
  s.version = "3.0.0"
  s.summary = "Constitutional governance for an autonomous coding agent"
  s.authors = ["dev"]
  s.files   = Dir["lib/**/*.rb", "exe/*", "data/**/*", "*.yml"]
  s.executables = ["master"]
  s.require_paths = ["lib"]

  s.add_dependency "ruby_llm",       "~> 1.3"
  s.add_dependency "tty-prompt",     "~> 0.23"
  s.add_dependency "tty-reader",     "~> 0.9"
  s.add_dependency "tty-spinner",    "~> 0.9"
  s.add_dependency "tty-markdown",   "~> 0.7"
  s.add_dependency "tty-table",      "~> 0.12"
  s.add_dependency "tty-screen",     "~> 0.8"
  s.add_dependency "tty-box",        "~> 0.7"
  s.add_dependency "tty-command",    "~> 0.10"
  s.add_dependency "tty-tree",       "~> 0.4"
  s.add_dependency "tty-config",     "~> 0.6"
  s.add_dependency "tty-logger",     "~> 0.6"
  s.add_dependency "tty-progressbar","~> 0.18"
  s.add_dependency "pastel",         "~> 0.8"
  s.add_dependency "rouge",          "~> 4.4"
  s.add_dependency "diffy",          "~> 3.4"
  s.add_dependency "zeitwerk",       "~> 2.7"
end

scripts/openbsd_preflight.zsh

#!/usr/bin/env zsh
# MASTER preflight check — run before deploying to OpenBSD
set -euo pipefail
setopt err_exit

ROOT=${${0:A}:h:h}
cd "$ROOT"

print "MASTER preflight — ${ROOT}"

# Ruby version
[[ -x $(whence ruby) ]] || { print "FAIL: ruby not found"; exit 1 }
print "ok: ruby $(ruby -e 'print RUBY_VERSION')"

# Bundler
[[ -x $(whence bundle) ]] || { print "FAIL: bundler not found"; exit 1 }
print "ok: bundler $(bundle -v)"

# Gem dependencies
bundle check >/dev/null 2>&1 || { print "FAIL: bundle check failed (run: bundle install)"; exit 1 }
print "ok: gems installed"

# API keys
[[ -n "${REPLICATE_API_KEY:-}" ]]   && print "ok: REPLICATE_API_KEY" || print "warn: REPLICATE_API_KEY not set"
[[ -n "${ANTHROPIC_API_KEY:-}" ]]   && print "ok: ANTHROPIC_API_KEY" || print "warn: ANTHROPIC_API_KEY not set"
[[ -n "${OPENROUTER_API_KEY:-}" ]]  && print "ok: OPENROUTER_API_KEY" || print "warn: OPENROUTER_API_KEY not set"

# Syntax
print "check: ruby syntax"
ruby -c lib/master.rb >/dev/null
print "ok: lib/master.rb syntax"

# Tests
print "check: test suite"
bundle exec ruby -Itest test/test_result.rb test/test_ring_buffer.rb test/test_axioms.rb test/test_prune.rb 2>&1 | tail -1
print "ok: tests passed"

print "\nMASTER preflight complete."

skills/explain/SKILL.md

---
name: explain
triggers:
  - "explain"
  - "what is"
  - "how does"
  - "why does"
description: Explain a MASTER rule, concept, or code construct in plain terms with a before/after example.
---

Invoked when the user asks for an explanation. Calls `/why <rule>` for rule queries.
Returns 2–3 sentences plus a concrete example. No hedging. No padding.

test/fixtures_bare_rescue.rb

# frozen_string_literal: true

module TestFixture
  def risky_read
    File.read("/tmp/x")
  rescue
    nil
  end
end

test/support/master_container.rb

# frozen_string_literal: true

require "fileutils"
require "tmpdir"

module Master
  module TestSupport
    # Boots a sandboxed MASTER instance rooted at a tmpdir with fixture YAMLs.
    # Yields the infra hash; cleans up on exit.
    #
    # Usage:
    #   with_master_container do |m|
    #     m[:gateway].receive(channel: :cli, message: "/help")
    #     assert m[:trace].pretty_last
    #   end
    module Container
      DEFAULT_FIXTURES = {
        "soul.yml" => <<~YML,
          version: "test"
          persona: malay
          absolute:
            golden_rule: PRESERVE_THEN_IMPROVE_NEVER_BREAK
            code_axioms:
              FAIL_VISIBLY: never rescue Exception silently.
              SIMPLEST_WORKS: refuse god classes.
              PRESERVE_FIRST: never rewrite from scratch.
              BE_CONCISE: minimal response.
          negotiable:
            style: openbsd_dmesg
            default_model: openrouter/auto
        YML
        "personas.yml" => <<~YML,
          malay:
            voice: ms-MY-OsmanNeural
            tts_rate: "-35%"
            tts_pitch: "-150Hz"
            style: deep
            description: "Terse. Direct."
        YML
        "rules.yml" => <<~YML,
          rules: {}
          voice:
            strunk: { preambles: [], endings: [] }
            banned_output: []
          thresholds:
            class: { max_lines: 200, max_methods: 6 }
        YML
        "workflow.yml" => "{}\n"
      }.freeze

      def with_master_container(extra_fixtures: {}, env: {})
        Dir.mktmpdir("master_container_") do |root|
          FileUtils.mkdir_p(File.join(root, "data"))
          FileUtils.mkdir_p(File.join(root, ".master"))
          DEFAULT_FIXTURES.merge(extra_fixtures).each do |name, body|
            File.write(File.join(root, "data", name), body)
          end
          original_env = env.keys.to_h { |k| [k, ENV[k]] }
          env.each { |k, v| ENV[k] = v.to_s }
          begin
            infra = Master::Builder.build(root: root)
            yield infra
          ensure
            original_env.each { |k, v| v.nil? ? ENV.delete(k) : ENV[k] = v }
          end
        end
      end
    end
  end
end

test/test_agent.rb

# frozen_string_literal: true

require_relative "test_helper"

# Minimal unit tests for Master::Agent — specifically targeting the Tier-1
# bugs the patch corrects. Does not hit any real LLM.
class TestAgent < Minitest::Test
  include Master

  # Fake collaborators — just enough to construct an Agent.
  FakeConfig  = Struct.new(:model, :task_type, :reasoning_mode) do
    def [](k) = send(k) rescue nil
  end
  FakeSession = Struct.new(:messages) { def add_message(**) = messages << _1 }
  FakeCB      = Struct.new(:out) { def check_rate!; end; def call(_, &b); b.call; end }
  FakeCache   = Struct.new(:store) { def fetch(k, m, &b); (store[k] ||= b.call); end }

  def setup
    @agent = Master::Agent.new(
      config:          FakeConfig.new("claude-sonnet-4-6", :exploration, "none"),
      session:         FakeSession.new([]),
      tools:           [],
      circuit_breaker: FakeCB.new,
      cache:           FakeCache.new({})
    )
  end

  # tool_capable? — previously a substring-include check. After patch,
  # anchored regex rejects garbage-tailed model ids but accepts real ones.
  def test_tool_capable_accepts_known_providers
    assert @agent.send(:tool_capable?, "claude-sonnet-4-6")
    assert @agent.send(:tool_capable?, "gpt-4o")
    assert @agent.send(:tool_capable?, "anthropic/claude-opus-4-1")
  end

  def test_tool_capable_rejects_arbitrary_strings
    refute @agent.send(:tool_capable?, "not-a-model")
    refute @agent.send(:tool_capable?, "")
    refute @agent.send(:tool_capable?, "random-gpt-mention-inside-sentence"), \
      "substring-contains is the old bug; anchored regex must not match this"
  end

  # cache_key_for — must produce bounded, deterministic SHA256 keys.
  def test_cache_key_bounded
    k = @agent.send(:cache_key_for, "hello", [])
    assert_equal 64, k.length, "SHA256 hex is 64 chars"
    assert_equal k, @agent.send(:cache_key_for, "hello", []), "deterministic"
  end

  def test_cache_key_uses_window_not_full_context
    long_ctx = (1..100).map { |i| { role: "user", content: "msg #{i}" * 50 } }
    short_ctx = long_ctx.last(4)
    k_long  = @agent.send(:cache_key_for, "same", long_ctx)
    k_short = @agent.send(:cache_key_for, "same", short_ctx)
    assert_equal k_long, k_short, "only the last CACHE_WINDOW messages affect the key"
  end

  # escalation flag — must be per-thread, not per-instance.
  def test_escalation_flag_is_thread_local
    Thread.current[:master_escalation_done] = nil
    other_thread_saw = nil
    t = Thread.new do
      other_thread_saw = Thread.current[:master_escalation_done]
    end
    t.join
    assert_nil other_thread_saw, "flag must not leak across threads"
  end
end

test/test_axioms.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestAxioms < Minitest::Test
  def setup
    @axioms = Master::Axioms.new
  end

  def test_kernel_not_empty
    refute @axioms.kernel.empty?, "kernel axioms must be present"
  end

  def test_kernel_has_preserve_first
    assert @axioms.kernel.key?("PRESERVE_FIRST")
  end

  def test_philosophy_sorted_by_priority
    items = @axioms.philosophy
    refute items.empty?
    priorities = items.map { |a| a["priority"].to_i }
    assert_equal priorities.sort, priorities
  end

  def test_kernel_block_formatted
    block = @axioms.kernel_block
    assert block.include?("## Kernel Axioms")
    assert block.include?("PRESERVE_FIRST")
  end

  def test_philosophy_block_limit
    block = @axioms.philosophy_block(limit: 3)
    assert block.include?("## Core Philosophy (top 3)")
  end

  def test_lookup_kernel
    val = @axioms.lookup("PRESERVE_FIRST")
    refute_nil val
    assert val.length > 5
  end
end

test/test_bare_rescue_rule.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestBareRescueRule < Minitest::Test
  def setup
    @rule = Master::Scan::Rules::BareRescueRule.new
  end

  def test_detects_bare_rescue
    code = File.read(File.join(__dir__, "fixtures_bare_rescue.rb"))

    findings = @rule.check(code, path: "test/fixtures_bare_rescue.rb")

    refute_empty findings
    assert_equal "bare_rescue", findings.first[:rule]
    assert_match(/specify exception type/, findings.first[:message])
  end

  def test_passes_when_rescue_names_exception
    code = <<~RUBY
      def safe
        do_work
      rescue StandardError => e
        warn e.message
      end
    RUBY

    findings = @rule.check(code, path: "safe.rb")

    assert_empty findings
  end
end

test/test_browser.rb

# frozen_string_literal: true

# Browser integration test using Ferrum + local Chromium.
# Run: bundle exec ruby test/test_browser.rb
#
# NOTE: Browser must be created BEFORE minitest/autorun is loaded,
# otherwise Minitest's signal handlers break Ferrum's pipe reading.
#
# Requires ~300MB free RAM. On low-memory servers, tests are auto-skipped.
#
# WHY CHROME TESTS SKIP ON OPENBSD
# =================================
# Chrome/Chromium exits with SIGSEGV (139) immediately on OpenBSD due to the
# W^X (Write XOR Execute) memory protection policy enforced by the kernel.
# Chrome's V8 engine — even with --jitless -- and its process model require
# mmap(PROT_WRITE|PROT_EXEC) pages that OpenBSD forbids at the OS level.
# No combination of flags (--no-sandbox, --single-process, --jitless,
# --disable-gpu) resolves this; a dedicated OpenBSD-patched Chromium port
# would be required.
#
# To run browser tests against the live server from a non-OpenBSD machine:
#   WEB_URL=https://ai.brgen.no:4430 bundle exec ruby test/test_browser.rb
#
# HTTP smoke tests (test_web_http.rb) cover: page load, overlay presence,
# JS syntax, metrics JSON, and SSE stream — and run fine on OpenBSD.

require "ferrum"
require "json"
require "net/http"
require "socket"

CHROME_PATH = %w[/usr/local/bin/chrome /usr/local/bin/chromium].find { |p| File.executable?(p) }
WEB_URL     = (ENV["WEB_URL"] || "http://localhost:10002").freeze

FREE_MEM_MB = begin
  # Use free + inactive pages — inactive pages are reclaimable by new processes.
  stats = `vmstat -s`
  free_pages     = stats[/(\d+) pages free/,    1].to_i
  inactive_pages = stats[/(\d+) pages inactive/, 1].to_i
  (free_pages + inactive_pages) * 4 / 1024  # 4KB pages → MB
rescue
  999
end

SKIP_REASON = if CHROME_PATH.nil?
  "Chromium not found"
elsif begin; TCPSocket.new("127.0.0.1", 10002).close; false; rescue; true; end
  "Web server not running on port 10002"
elsif FREE_MEM_MB < 300
  "Insufficient free memory (#{FREE_MEM_MB}MB < 300MB required for Chrome)"
end

# Start Chrome now, before minitest/autorun installs signal handlers.
FERRUM_BROWSER = if SKIP_REASON.nil?
  begin
    Ferrum::Browser.new(
      browser_path: CHROME_PATH,
      process_timeout: 30,
      timeout: 20,
      browser_options: {
        "headless"       => "new",
        "no-sandbox"     => nil,
        "single-process" => nil,
        "disable-gpu"    => nil,
        "disable-dev-shm-usage" => nil
      }
    )
  rescue StandardError => e
    warn "Chrome failed to start: #{e.message}"
    nil
  end
end

# Override SKIP_REASON if browser failed to start
BROWSER_SKIP = SKIP_REASON || (FERRUM_BROWSER.nil? ? "Chrome failed to start" : nil)

require "minitest/autorun"

class TestBrowserUI < Minitest::Test
  def skip_if_unavailable
    skip BROWSER_SKIP if BROWSER_SKIP
  end

  def fresh_page
    pg = FERRUM_BROWSER.create_page
    pg.go_to(WEB_URL)
    pg.network.wait_for_idle
    pg
  rescue Ferrum::DeadBrowserError => e
    skip "Chrome died (OOM): #{e.message}"
  end

  def teardown
    FERRUM_BROWSER&.pages&.each(&:close) rescue nil
  end

  def test_01_page_loads_with_overlay
    skip_if_unavailable
    pg = fresh_page
    assert pg.at_css("#overlay"), "overlay element missing"
    assert !pg.evaluate("document.getElementById('overlay').hidden"),
           "overlay should be visible on load"
  end

  def test_02_overlay_dismisses_on_click
    skip_if_unavailable
    pg = fresh_page
    pg.at_css("#overlay").click
    sleep 1.5
    assert pg.evaluate("document.getElementById('overlay').hidden"),
           "overlay should be hidden after click"
  end

  def test_03_input_active_after_overlay_dismissed
    skip_if_unavailable
    pg = fresh_page
    pg.at_css("#overlay").click
    sleep 1.5
    assert pg.evaluate("document.getElementById('input-field').classList.contains('active')"),
           "input-field should have 'active' class"
  end

  def test_04_chat_receives_response
    skip_if_unavailable
    pg = fresh_page
    pg.at_css("#overlay").click
    sleep 1.2
    pg.at_css("#input-field input[type=text]").focus
    pg.keyboard.type("ping")
    pg.keyboard.type(:Return)
    deadline = Time.now + 30
    response = ""
    loop do
      response = pg.evaluate("document.getElementById('chat-log').textContent").strip
      break unless response.empty?
      break if Time.now > deadline
      sleep 1
    end
    refute_empty response, "chat-log should contain a response to 'ping'"
  end

  # Uses plain HTTP — no browser page needed for a JSON endpoint.
  def test_05_metrics_endpoint_json
    skip "Web server not running" unless begin
      TCPSocket.new("127.0.0.1", 10002).close
      true
    rescue
      false
    end
    uri  = URI("#{WEB_URL}/chat/metrics")
    body = Net::HTTP.get(uri)
    data = JSON.parse(body)
    assert data.key?("model"),         "metrics should include 'model'"
    assert data.key?("tokens"),        "metrics should include 'tokens'"
    assert data.key?("open_breakers"), "metrics should include 'open_breakers'"
  rescue JSON::ParserError => e
    flunk "metrics returned invalid JSON: #{e.message}"
  end
end

Minitest.after_run { FERRUM_BROWSER&.quit rescue nil }

test/test_cli.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestCLI < Minitest::Test
  def setup
    @session     = Minitest::Mock.new
    @agent       = Minitest::Mock.new
    @renderer    = Minitest::Mock.new
    @logging     = Minitest::Mock.new
    @undo        = Minitest::Mock.new
    @config      = Minitest::Mock.new
    @pipeline    = Minitest::Mock.new

    @config.expect(:[], false, ["tts"])
    @config.expect(:prescan?, false)

    @container = {
      session:  @session,
      agent:    @agent,
      renderer: @renderer,
      logging:  @logging,
      undo:     @undo,
      config:   @config,
      pipeline: @pipeline
    }

    @cli = Master::CLI.new(container: @container)
  end

  # ── container accessor ────────────────────────────────────────────────────

  def test_container_accessor
    assert_same @container, @cli.container
  end

  # ── TTS flag ──────────────────────────────────────────────────────────────

  def test_tts_off_when_unavailable
    refute @cli.instance_variable_get(:@tts_on),
      "tts_on should be false when Speech.available? is false"
  end

  # ── handle_command dispatch ───────────────────────────────────────────────

  def test_handle_command_returns_false_for_non_command
    assert_equal false, @cli.send(:handle_command, "hello world")
  end

  def test_handle_command_save
    @session.expect(:save!, nil)
    @renderer.expect(:render, "saved", ["saved"], mode: :success)
    capture_io { @cli.send(:handle_command, "/save") }
    @session.verify
  end

  def test_handle_command_exit
    @session.expect(:save!, nil)
    capture_io { @cli.send(:handle_command, "/exit") }
    refute @cli.instance_variable_get(:@running)
    @session.verify
  end

  def test_handle_command_tts_on
    # Speech not available in test env — /tts on should stay off → "unavailable"
    @renderer.expect(:render, "tts: unavailable", [String], mode: :dim)
    capture_io { @cli.send(:handle_command, "/tts on") }
  end

  def test_handle_command_tts_off
    @renderer.expect(:render, "tts: off", ["tts: off"], mode: :dim)
    capture_io { @cli.send(:handle_command, "/tts off") }
    refute @cli.instance_variable_get(:@tts_on)
  end

  def test_handle_command_unknown
    @renderer.expect(:render, "unknown command: /foo", [String], mode: :warning)
    capture_io { @cli.send(:handle_command, "/foo") }
    @renderer.verify
  end

  # ── process ───────────────────────────────────────────────────────────────

  def test_process_skips_blank_input
    @pipeline.expect(:call, nil)
    @cli.send(:process, "   ")
  end

  def test_process_ok_result
    text = "the answer is 42"
    result = Master::Result.ok(rendered: text)
    @pipeline.expect(:call, result, [->(r) { r.respond_to?(:ok?) }])
    out, _err = capture_io { @cli.send(:process, "what is 6*7") }
    assert_includes out, text
    assert @cli.instance_variable_get(:@last_ok)
  end

  def test_process_err_result
    result = Master::Result.err("model unavailable")
    @pipeline.expect(:call, result, [->(r) { r.respond_to?(:ok?) }])
    @renderer.expect(:render, "[ERR]", ["model unavailable"], mode: :error)
    capture_io { @cli.send(:process, "fail me") }
    refute @cli.instance_variable_get(:@last_ok)
  end

  # ── pipe ──────────────────────────────────────────────────────────────────

  def test_pipe_calls_process
    result = Master::Result.ok(rendered: "pong")
    @pipeline.expect(:call, result, [->(r) { r.respond_to?(:ok?) }])
    out, _err = capture_io { @cli.pipe("ping") }
    assert_includes out, "pong"
  end
end

test/test_council_deliberation.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestCouncilDeliberation < Minitest::Test
  Persona = Struct.new(:name, :role, :bias, :prompt, :veto_role, :emphasizes, keyword_init: true) do
    def veto? = !!veto_role
  end

  class StubAgent
    def initialize(mapping = {})
      @mapping = mapping
    end

    def ask(prompt)
      @mapping.each do |needle, response|
        return response if prompt.include?(needle)
      end
      "looks good"
    end
  end

  def test_veto_blocks_review
    personas = [
      Persona.new(name: "Security", role: "Attacker", bias: "paranoid", prompt: "be strict", veto_role: true)
    ]
    agent = StubAgent.new("You are Security" => "VETO: unsafe eval path")

    result = Master::Council::Deliberation.new(personas:, agent:, judge_enabled: false)
                                        .review("eval(params[:x])")

    assert result.err?
    assert_equal :validation, result.category
    assert_match(/veto/i, result.message)
  end

  def test_empty_personas_fails_validation
    result = Master::Council::Deliberation.new(personas: [], agent: StubAgent.new, judge_enabled: false)
                                        .review("puts :ok")

    assert result.err?
    assert_equal :validation, result.category
    assert_match(/no personas/, result.message)
  end
end

test/test_experience.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestExperience < Minitest::Test
  def setup
    @dir = Dir.mktmpdir
    @exp = Master::State::Experience.new(root: @dir)
  end

  def teardown
    FileUtils.rm_rf(@dir)
  end

  def test_signature_ignores_arguments
    plan_a = [{ tool: :fs_read, path: "a.rb" }, { tool: :ast_replace, method: "login" }]
    plan_b = [{ tool: :fs_read, path: "z.rb" }, { tool: :ast_replace, method: "logout" }]
    # Same strategy, different arguments → same signature → shared score.
    @exp.record(plan: plan_a, score: 1.0)
    refute_in_delta 0.0, @exp.score(plan_b), 0.2, "same tool sequence should share experience"
  end

  def test_decay_bounds_unbounded_growth
    plan = [{ tool: :fs_read }]
    20.times { @exp.record(plan: plan, score: 1.0) }
    entry = @exp.record(plan: plan, score: 1.0)
    # With DECAY=0.99, count cannot grow to 21 — it stays well below.
    assert_in_delta 20.0, entry["count"], 2.0
  end

  def test_unknown_plan_returns_near_zero
    score = @exp.score([{ tool: :never_run }])
    assert_in_delta 0.0, score, 0.1, "unseen plan returns base 0 + small noise"
  end
end

test/test_helper.rb

# frozen_string_literal: true

if ENV["COVERAGE"] == "1"
  require "simplecov"
  SimpleCov.start do
    add_filter "/test/"
    add_group "Scan", "lib/master/scan"
    add_group "Stages", "lib/master/stages"
    add_group "Council", "lib/master/council"
    minimum_coverage 85
  end
end

require "minitest/autorun"
require "tmpdir"
require "timeout"

# Load MASTER without booting the CLI
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
require "master"

# All tests time out after 10s to prevent hangs.
Minitest::Test.class_eval do
  alias_method :run_without_timeout, :run
  def run(*args)
    Timeout.timeout(10) { run_without_timeout(*args) }
  rescue Timeout::Error
    failures << Minitest::UnexpectedError.new(Timeout::Error.new("timed out after 10s"))
    self
  end
end

test/test_learnings.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestLearnings < Minitest::Test
  def test_record_and_search_prefers_non_failed_entries
    Dir.mktmpdir do |dir|
      learnings = Master::Learnings.new(root: dir)
      learnings.record(trigger: "rubocop offense", strategy: "run autocorrect", outcome: :fixed)
      learnings.record(trigger: "rubocop offense", strategy: "ignore", outcome: :failed)

      hits = learnings.search("rubocop")

      refute_empty hits
      assert_equal "run autocorrect", hits.first["strategy"]
      refute_includes hits.map { |h| h["outcome"] }, "failed"
    end
  end

  def test_opportunities_identifies_failure_and_corrections
    Dir.mktmpdir do |dir|
      learnings = Master::Learnings.new(root: dir)
      3.times { learnings.record_event(event_type: :tool_failure, dimension: "write_file") }
      3.times { learnings.record_event(event_type: :user_correction, dimension: "style") }
      3.times { learnings.record_event(event_type: :provider_error, dimension: "openrouter") }

      categories = learnings.opportunities.map { |o| o[:category] }

      assert_includes categories, :high_failure
      assert_includes categories, :repeated_correction
      assert_includes categories, :provider_errors
    end
  end
end

test/test_master_container.rb

# frozen_string_literal: true

require_relative "test_helper"
require_relative "support/master_container"

# Smoke tests for the with_master_container helper itself.
class TestMasterContainer < Minitest::Test
  include Master::TestSupport::Container

  def test_boots_full_infra_stack
    with_master_container do |m|
      assert m[:gateway],     "expected :gateway"
      assert m[:pipeline],    "expected :pipeline"
      assert m[:bus],         "expected :bus"
      assert m[:memory],      "expected :memory"
      assert m[:trace],       "expected :trace"
      assert m[:personality], "expected :personality"
    end
  end

  def test_help_command_round_trips
    with_master_container do |m|
      result = m[:gateway].receive(channel: :cli, message: "/help")
      assert result.ok?, "gateway should accept /help"
    end
  end

  def test_trace_records_turn
    with_master_container do |m|
      m[:gateway].receive(channel: :cli, message: "/help")
      pretty = m[:trace].pretty_last
      assert_match(/turn /, pretty)
      assert_match(/gateway:turn_done/, pretty)
    end
  end

  def test_typed_memory_persists_within_container
    with_master_container do |m|
      m[:memory].remember("role", "architect", type: "user")
      assert_equal({"user" => 1}, m[:memory].type_counts)
    end
  end
end

test/test_pipeline.rb

# frozen_string_literal: true

require_relative "test_helper"

# Pipeline unit tests — Result-monadic chaining and rollback contract.
class TestPipeline < Minitest::Test
  include Master

  class OkStage
    def call(ctx) = Master::Result.ok(ctx.merge(stamped: true))
  end

  class ErrStage
    def initialize(cat = :unknown) = (@cat = cat)
    def call(_ctx) = Master::Result.err("boom", category: @cat)
  end

  class RaiseStage
    def call(_ctx) = raise "stage exploded"
  end

  def test_happy_path_passes_context_through
    pipe = Master::Pipeline.new([OkStage.new, OkStage.new])
    result = pipe.call(Master::Result.ok(input: "hi"))
    assert result.ok?
    assert result.value![:stamped]
  end

  def test_first_error_short_circuits
    pipe = Master::Pipeline.new([OkStage.new, ErrStage.new, OkStage.new])
    result = pipe.call(Master::Result.ok({}))
    refute result.ok?
    assert_equal "boom", result.message
  end

  def test_raise_in_stage_becomes_err
    pipe = Master::Pipeline.new([OkStage.new, RaiseStage.new])
    result = pipe.call(Master::Result.ok({}))
    refute result.ok?
    assert_match(/exploded/, result.message)
  end

  def test_rollback_skipped_outside_git_workspace
    # In /tmp (no .git), rollback is a no-op — must not crash.
    Dir.mktmpdir do |dir|
      pipe = Master::Pipeline.new([ErrStage.new(:validation)], root: dir)
      result = pipe.call(Master::Result.ok({}))
      refute result.ok?
      # No exception raised = success for this test.
    end
  end
end

test/test_prune.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestPrune < Minitest::Test
  def stage
    Master::Stages::Prune.new
  end

  def call(text)
    stage.call({ output: text })
  end

  def test_strips_preamble
    r = call("Certainly! Here is the answer.")
    assert r.ok?
    assert_equal "Here is the answer.", r.value![:output]
  end

  def test_strips_hedge
    r = call("I think that Ruby is great.")
    assert r.ok?
    assert_equal "Ruby is great.", r.value![:output]
  end

  def test_skips_code_blocks
    code = "```ruby\njust use this\n```"
    r = call(code)
    assert_equal code, r.value![:output]  # must not mangle code
  end

  def test_passthrough_non_string
    r = stage.call({ output: 42 })
    assert r.ok?
    assert_equal 42, r.value![:output]
  end

  def test_empty_string_passthrough
    r = call("")
    assert r.ok?
  end
end

test/test_result.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestResult < Minitest::Test
  def test_ok_holds_value
    r = Master::Result.ok("hello")
    assert r.ok?
    refute r.err?
    assert_equal "hello", r.value!
  end

  def test_err_holds_message
    r = Master::Result.err("boom", category: :unknown)
    assert r.err?
    refute r.ok?
    assert_equal "boom", r.message
  end

  def test_and_then_chains_on_ok
    r = Master::Result.ok(2).and_then { |v| Master::Result.ok(v * 3) }
    assert r.ok?
    assert_equal 6, r.value!
  end

  def test_and_then_short_circuits_on_err
    r = Master::Result.err("fail").and_then { |_| Master::Result.ok("never") }
    assert r.err?
    assert_equal "fail", r.message
  end

  def test_and_then_wraps_plain_value
    r = Master::Result.ok(5).and_then { |v| v * 2 }
    assert r.ok?
    assert_equal 10, r.value!
  end
end

test/test_ring_buffer.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestRingBuffer < Minitest::Test
  def test_push_and_to_a
    buf = Master::RingBuffer.new(3)
    buf.push("a").push("b").push("c")
    assert_equal %w[a b c], buf.to_a
  end

  def test_wraps_around
    buf = Master::RingBuffer.new(3)
    %w[a b c d].each { |x| buf.push(x) }
    assert_equal %w[b c d], buf.to_a
  end

  def test_each_without_block_returns_enumerator
    buf = Master::RingBuffer.new(3)
    buf.push("x")
    assert_instance_of Enumerator, buf.each
  end

  def test_to_a_without_block
    buf = Master::RingBuffer.new(3)
    buf.push("x").push("y")
    assert_equal %w[x y], buf.to_a  # must not raise LocalJumpError
  end

  def test_size_and_empty
    buf = Master::RingBuffer.new(4)
    assert buf.empty?
    buf.push("a")
    assert_equal 1, buf.size
    refute buf.empty?
  end
end

test/test_speech.rb

# frozen_string_literal: true

require_relative "test_helper"

class TestSpeech < Minitest::Test
  # ── module interface ──────────────────────────────────────────────────────

  def test_available_returns_boolean
    assert_includes [true, false], Master::Speech.available?
  end

  def test_voices_constants_present
    assert Master::Speech::VOICES.key?(:osman)
    assert Master::Speech::VOICES.key?(:ryan)
  end

  def test_styles_constants_present
    assert Master::Speech::STYLES.key?(:deep)
    assert Master::Speech::STYLES.key?(:normal)
  end

  def test_synthesize_returns_nil_for_empty_text
    assert_nil Master::Speech.synthesize("")
    assert_nil Master::Speech.synthesize("   ")
  end

  def test_synthesize_bytes_returns_nil_for_empty
    assert_nil Master::Speech.synthesize_bytes("")
  end

  # ── when edge-tts unavailable ─────────────────────────────────────────────

  def test_synthesize_returns_nil_when_unavailable
    # Stub Speech.available? to false
    Master::Speech.stub(:available?, false) do
      assert_nil Master::Speech.synthesize("hello")
    end
  end

  def test_synthesize_bytes_returns_nil_when_unavailable
    Master::Speech.stub(:available?, false) do
      assert_nil Master::Speech.synthesize_bytes("hello")
    end
  end

  # ── when edge-tts available (mock system call) ────────────────────────────

  def test_synthesize_calls_edge_tts_with_correct_args
    skip "edge-tts not installed" unless Master::Speech.available?

    tmp = nil
    Master::Speech.stub(:synthesize, ->(text, **) {
      # Just verify we can call it without raising
      nil
    }) do
      tmp = Master::Speech.synthesize("test", voice: :osman, style: :deep)
    end
    assert_nil tmp  # mock returns nil
  end

  def test_synthesize_bytes_cleans_up_temp_file
    fake_path = "/tmp/m3_tts_test_fake.mp3"

    Master::Speech.stub(:synthesize, fake_path) do
      # Create a fake mp3
      File.write(fake_path, "fake-mp3-data")
      bytes = Master::Speech.synthesize_bytes("hello")
      assert_equal "fake-mp3-data", bytes
      refute File.exist?(fake_path), "temp file should be deleted"
    end
  end

  # ── voice / style lookup ─────────────────────────────────────────────────

  def test_unknown_voice_falls_back_to_default
    # Speech.synthesize uses VOICES.fetch(voice, VOICES[DEFAULT_VOICE])
    # so unknown symbol falls back to Osman
    default_voice = Master::Speech::VOICES[Master::Speech::DEFAULT_VOICE]
    assert default_voice
  end

  def test_deep_style_has_negative_pitch
    style = Master::Speech::STYLES[:deep]
    assert style[:pitch].start_with?("-"), "deep pitch should be negative"
    assert style[:rate].start_with?("-"),  "deep rate should be negative"
  end
end

test/test_web_http.rb

# frozen_string_literal: true

# HTTP smoke tests for the MASTER web UI.
# Faster and lighter than browser tests -- no Chrome required.
# Run: bundle exec ruby test/test_web_http.rb

require "net/http"
require "json"
require "socket"
require "minitest/autorun"

WEB_PORT = 10002

SKIP_HTTP = begin
  TCPSocket.new("127.0.0.1", WEB_PORT).close
  nil
rescue Errno::ECONNREFUSED
  "Web server not running on port #{WEB_PORT}"
end

class TestWebHTTP < Minitest::Test
  def skip_unless_server
    skip SKIP_HTTP if SKIP_HTTP
  end

  def get(path, headers = {})
    Net::HTTP.start("127.0.0.1", WEB_PORT, read_timeout: 10) do |http|
      http.request(Net::HTTP::Get.new(path, headers))
    end
  end

  def test_01_homepage_returns_200
    skip_unless_server
    res = get("/")
    assert_equal "200", res.code, "homepage should return 200"
  end

  def test_02_homepage_contains_overlay
    skip_unless_server
    res = get("/")
    assert_includes res.body, "overlay", "homepage should contain overlay element"
  end

  def test_03_homepage_js_no_stray_paren
    skip_unless_server
    res = get("/")
    bad = res.body.lines.grep(/^\s*"\);/)
    assert_empty bad, "stray \");\" found in page JS: #{bad.first(2).inspect}"
  end

  def test_04_metrics_returns_json
    skip_unless_server
    res = get("/chat/metrics")
    assert_equal "200", res.code, "metrics endpoint should return 200"
    data = JSON.parse(res.body)
    assert data.key?("model"),         "metrics should include 'model'"
    assert data.key?("tokens"),        "metrics should include 'tokens'"
    assert data.key?("uptime"),        "metrics should include 'uptime'"
    assert data.key?("open_breakers"), "metrics should include 'open_breakers'"
  rescue StandardError => e
    flunk "metrics returned invalid JSON: #{e.message}"
  end

  def test_05_message_endpoint_streams_sse
    skip_unless_server
    Net::HTTP.start("127.0.0.1", WEB_PORT, read_timeout: 15) do |http|
      req = Net::HTTP::Post.new("/chat/message")
      req.set_form_data("message" => "ping")
      data = ""
      http.request(req) do |res|
        assert_equal "200", res.code, "message endpoint should return 200"
        assert_match "text/event-stream", res["Content-Type"].to_s,
                     "message endpoint should stream SSE"
        res.read_body do |chunk|
          data += chunk
          break if data.include?("[DONE]") || data.size > 512
        end
      end
      assert_includes data, "data:", "SSE response should contain data: lines"
    end
  rescue Net::ReadTimeout
    # Server still streaming -- that means it accepted the request fine
  end
end

test/test_web_ui.rb

# frozen_string_literal: true

require_relative "test_helper"
require "rack/test"

# Minimal Rack test harness for the web UI chat controller.
# Tests cover SSE stream, TTS endpoint, dmesg, and metrics.

ENV["RAILS_ENV"] = "test"

# We test the controller logic via a stub Rack app rather than
# booting the full Rails stack.
class FakeSpeech
  def self.available?  = true
  def self.synthesize_bytes(_text, **) = "FAKE-MP3-BYTES"
end

class FakePipeline
  attr_writer :result
  def call(_ctx) = @result || Master::Result.ok(rendered: "hello from pipeline")
end

class FakeSession
  def token_est = 42
  def cost      = 0.0001
end

class FakeAgent
  def model = "test/model-7b"
end

class FakeContainer
  def [](key)
    case key
    when :agent    then FakeAgent.new
    when :session  then FakeSession.new
    when :pipeline then @pipeline ||= FakePipeline.new
    end
  end
  def pipeline = self[:pipeline]
end

class TestWebUI < Minitest::Test
  include Rack::Test::Methods

  def setup
    @container = FakeContainer.new
  end

  # ── Result monad ──────────────────────────────────────────────────────────

  def test_result_ok_wraps_value
    r = Master::Result.ok("hello")
    assert r.ok?
    assert_equal "hello", r.value!
  end

  def test_result_err_wraps_message
    r = Master::Result.err("boom")
    assert r.err?
    assert_equal "boom", r.message
  end

  def test_result_err_value_bang_raises_unwrap_error
    r = Master::Result.err("boom")
    assert_raises(Master::UnwrapError) { r.value! }
  end

  def test_result_ok_chaining
    r = Master::Result.ok(5).and_then { |v| Master::Result.ok(v * 2) }
    assert_equal 10, r.value!
  end

  def test_result_err_short_circuits
    r = Master::Result.err("x").and_then { raise "should not reach" }
    assert r.err?
  end

  # ── Pipeline ─────────────────────────────────────────────────────────────

  def test_pipeline_returns_result
    result = @container.pipeline.call(Master::Result.ok(user_message: "hi"))
    assert result.ok?
    assert_includes result.value![:rendered], "hello"
  end

  def test_pipeline_err_propagates
    @container.pipeline.result = Master::Result.err("model down")
    result = @container.pipeline.call(Master::Result.ok(user_message: "hi"))
    assert result.err?
    assert_equal "model down", result.message
  end

  # ── Speech bytes ─────────────────────────────────────────────────────────

  def test_speech_synthesize_bytes_stub
    bytes = FakeSpeech.synthesize_bytes("hello world")
    assert_equal "FAKE-MP3-BYTES", bytes
  end

  # ── Cognitive monitor ─────────────────────────────────────────────────────

  def test_cognitive_monitor_starts_clean
    m = Master::CognitiveMonitor.new
    assert_equal 0.0, m.load
    assert_equal :optimal, m.flow_state
  end

  def test_cognitive_monitor_push_increases_load
    m = Master::CognitiveMonitor.new
    m.push("concept_a", weight: 2.0)
    assert_in_delta 2.0, m.load, 0.01
  end

  def test_cognitive_monitor_overload_after_threshold
    m = Master::CognitiveMonitor.new
    m.push("heavy", weight: 8.0)
    assert m.overloaded?
  end

  def test_cognitive_monitor_reset
    m = Master::CognitiveMonitor.new
    5.times { |i| m.push("c#{i}", weight: 1.5) }
    m.reset!(keep_recent: 2)
    assert m.load <= 3.0
    assert_equal 0, m.switches
  end

  def test_cognitive_monitor_update_flow_returns_self
    m = Master::CognitiveMonitor.new
    assert_same m, m.update_flow(context_switches: 1)
  end

  def test_cognitive_monitor_state_hash
    m = Master::CognitiveMonitor.new
    s = m.state
    assert s.key?(:load)
    assert s.key?(:flow_state)
    assert s.key?(:overload_risk)
    assert s.key?(:complexity)
  end

  # ── SwarmCoordinator ─────────────────────────────────────────────────────

  def test_swarm_coordinator_worker_roles
    # Just check the list is non-empty without booting real agents
    assert_includes Master::Swarm::Coordinator::WORKER_CLASSES.keys, :analyst
    assert_includes Master::Swarm::Coordinator::WORKER_CLASSES.keys, :coder
    assert_includes Master::Swarm::Coordinator::WORKER_CLASSES.keys, :reviewer
    assert_includes Master::Swarm::Coordinator::WORKER_CLASSES.keys, :researcher
  end

  def test_swarm_coordinator_unknown_role
    mock_agent = Minitest::Mock.new
    coord = Master::Swarm::Coordinator.new(agent: mock_agent)
    result = coord.dispatch(:nonexistent, task: "foo")
    assert result.err?
    assert_includes result.message, "unknown role"
  end

  # ── Memory ───────────────────────────────────────────────────────────────

  def test_memory_remember_and_recall
    Dir.mktmpdir do |dir|
      m = Master::Memory.new(root: dir)
      m.remember(:user_name, "Osman")
      assert_equal "Osman", m.recall(:user_name)
    end
  end

  def test_memory_context_summary_nil_when_empty
    Dir.mktmpdir do |dir|
      m = Master::Memory.new(root: dir)
      assert_nil m.context_summary
    end
  end

  def test_memory_context_summary_lists_keys
    Dir.mktmpdir do |dir|
      m = Master::Memory.new(root: dir)
      m.remember(:language, "Ruby")
      summary = m.context_summary
      assert_includes summary, "language"
      assert_includes summary, "Ruby"
    end
  end

  # ── Personality ──────────────────────────────────────────────────────────

  def test_personality_default_is_malay
    assert_equal :malay, Master::Personality::DEFAULT
  end

  def test_personality_system_prompt_non_empty
    p = Master::Personality.new(:malay)
    assert p.system_prompt.length > 10
  end

  def test_personality_system_prompt_memoized
    p = Master::Personality.new(:malay)
    assert_same p.system_prompt, p.system_prompt
  end

  # ── UnwrapError ──────────────────────────────────────────────────────────

  def test_unwrap_error_is_runtime_error_subclass
    assert Master::UnwrapError < RuntimeError
  end
end

web/Gemfile

source "https://rubygems.org"

# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft"
# Use sqlite3 as the database for Active Record
gem "sqlite3", ">= 2.1"
# Use the Puma web server [https://github.com/puma/puma]
# gem "puma"
gem "falcon"

# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
gem "tzinfo-data", platforms: %i[ windows jruby ]

group :development, :test do
  # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
  gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
end

gem "master", path: ".."

# Pure-Ruby Edge TTS (replaces python edge-tts CLI)
gem "rb-edge-tts", git: "https://github.com/ZPVIP/rb-edge-tts"

web/README.md

# MASTER Web UI

Rails 8 + Falcon server. Internal port 53187; relayd proxies to ai.brgen.no:4430.

## Routes

| Route | Description |
|---|---|
| `GET /` | Chat interface |
| `POST /chat/message` | SSE streaming response |
| `POST /chat/tts` | TTS synthesis |
| `POST /chat/speak` | Speak text |
| `GET /chat/metrics` | Session metrics |
| `GET /chat/dmesg` | Event log |
| `GET /events/stream` | SSE event stream |

## Canvas

- 2000-particle orb visualization
- 50 procedural shapes
- Ambient pad engine
- Drum sequencer
- 17 voice FX chains

## rc.d service

```zsh
doas rcctl enable master
doas rcctl start master

Daemon binds to 127.0.0.1:53187. relayd handles TLS termination.


## `web/Rakefile`
```text
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

require_relative "config/application"

Rails.application.load_tasks

web/app/controllers/application_controller.rb

# frozen_string_literal: true

$LOAD_PATH.unshift File.expand_path("../../../../lib", __FILE__)
require "master"

class ApplicationController < ActionController::Base
  allow_browser versions: :modern

  @@container        = nil
  @@mutex            = Mutex.new
  @@start_ms         = (Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i
  @@scheduler_thread = nil

  private

  def visitor?
    request.env["master.tier"] != "authenticated"
  end
  helper_method :visitor? if respond_to?(:helper_method)

  def container
    @@mutex.synchronize do
      @@container ||= Master.bootstrap_container(root: Rails.root.join("..").to_s).tap do |c|
        start_scheduler(c)
      end
    end
  end

  def start_scheduler(c)
    return if @@scheduler_thread&.alive?
    @@scheduler_thread = Thread.new do
      sleep 300
      loop do
        begin
          due = c[:standing].due
          if due.any?
            results = c[:standing].run_due!
            results.each { |r| c[:bus].publish("scheduler:ran", name: r[:name]) rescue nil }
          end
        rescue StandardError
          nil
        end
        sleep 900
      end
    end
    @@scheduler_thread.abort_on_exception = false
  end

  def start_ms
    @@start_ms
  end
end

web/app/controllers/canvas_controller.rb

# frozen_string_literal: true

# Live agent-controlled canvas. Spec: data/canvas.yml.
# Reads from EventBus, streams to browser via SSE.
class CanvasController < ApplicationController
  include ActionController::Live

  def show
    @session_id = session[:canvas_id] ||= SecureRandom.hex(8)
    render layout: false
  end

  def stream
    response.headers["Content-Type"]      = "text/event-stream"
    response.headers["Cache-Control"]     = "no-cache"
    response.headers["X-Accel-Buffering"] = "no"

    sse = SSE.new(response.stream, retry: 1500)
    bus = Master::EventBus.instance rescue nil
    sub = bus&.subscribe { |topic, payload| sse.write({topic:, payload:}, event: "bus") }

    keepalive = Thread.new { loop { sleep 15; sse.write({}, event: "ping") rescue break } }
    sleep
  rescue IOError, ActionController::Live::ClientDisconnected
    # client gone
  ensure
    keepalive&.kill
    bus&.unsubscribe(sub) if sub
    sse&.close
  end

  def post_event
    topic   = params.require(:topic)
    payload = params.fetch(:payload, {}).permit!.to_h
    Master::EventBus.instance.publish(topic, **payload.transform_keys(&:to_sym)) rescue nil
    head :accepted
  end

  class SSE
    def initialize(io, retry: nil)
      @io = io
      @io.write("retry: #{binding.local_variable_get(:retry)}\n\n") if binding.local_variable_get(:retry)
    end

    def write(data, event: nil)
      @io.write("event: #{event}\n") if event
      @io.write("data: #{data.to_json}\n\n")
    end

    def close
      @io.close rescue nil
    end
  end
end

web/app/controllers/chat_controller.rb

# frozen_string_literal: true

require "open3"

class ChatController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:message, :tts, :speak]

  def index
    @model = container[:agent].model.to_s.split("/").last
    @tier  = request.env["master.tier"].to_s
    render layout: false
  end

  def dmesg
    out, = Open3.capture2e("dmesg")
    lines = out.lines.first(20).map(&:chomp)
    render json: { lines: lines }
  end

  def metrics
    c = container
    repo_root = Rails.root.join("..").to_s
    out, = Open3.capture2e("git", "-C", repo_root, "status", "--porcelain")
    dirty = out.lines.count
    open_models = c[:breaker].respond_to?(:open_models) ? c[:breaker].open_models : []
    render json: {
      model:            c[:agent].model.to_s.split("/").last,
      tokens:           c[:session].respond_to?(:token_est) ? c[:session].token_est : 0,
      cost:             "$%.4f" % (c[:session].respond_to?(:cost) ? c[:session].cost : 0.0),
      uptime:           ((Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000).to_i - start_ms),
      repo_dirty_count: dirty,
      open_breakers:    open_models,
      tier:             request.env["master.tier"].to_s
    }
  end

  def message
    input = params[:message].to_s.strip
    return head(:bad_request) if input.empty?

    response.headers["Content-Type"]      = "text/event-stream"
    response.headers["Cache-Control"]     = "no-cache"
    response.headers["X-Accel-Buffering"] = "no"

    visitor = request.env["master.tier"] != "authenticated"
    Fiber[:master_visitor] = visitor

    sse = response.stream
    begin
      streamed  = false
      tool_sub  = container[:bus].subscribe("tool:before") do |ev|
        begin
          payload = { tool: ev[:tool].to_s, path: ev[:path].to_s }.to_json
          sse.write("event: tool\ndata: #{payload}\n\n")
        rescue StandardError
          nil
        end
      end

      on_chunk = ->(token) {
        streamed = true
        encoded = token.to_s.gsub("\\", "\\\\").gsub("\n", "\\n")
        sse.write("data: #{encoded}\n\n")
      }

      ctx = { user_message: input, on_chunk: on_chunk, visitor: visitor }
      ctx[:voice] = true if params[:voice].present?
      if (img = params[:image]).present?
        ctx[:image] = { data: img[:data].to_s, mime: img[:mime].to_s, name: img[:name].to_s }
      end

      # P2: ack backchannel for long messages — bridges silence while LLM thinks.
      container[:bus].publish("speak:backchannel", reason: "user_long_input") if input.length >= 120

      mutated_flag    = false
      web_mutated     = false
      mutated_paths   = []
      mutate_sub = container[:bus].subscribe("tool:after") do |ev|
        next unless %w[Write Edit Create FilePatch].include?(ev[:tool].to_s)
        mutated_flag = true
        path = ev[:path].to_s
        mutated_paths << path unless path.empty?
        web_mutated ||= path.include?("/MASTER/web/")
      end

      result = container[:pipeline].call(Master::Result.ok(**ctx))

      unless streamed
        text = case result
               when Master::Result::Ok
                 val = result.value
                 val.is_a?(Hash) && val[:rendered] ? val[:rendered] : val.to_s
               when Master::Result::Err
                 "ERROR: #{result.message}"
               end
        unless text.to_s.strip.empty?
          encoded = text.to_s.gsub("\\", "\\\\").gsub("\n", "\\n")
          sse.write("data: #{encoded}\n\n")
        end
      end

      sse.write("data: [DONE]\n\n")

      # Post-turn standing orders: triad, autocommit, restart on web edits.
      if mutated_flag
        Thread.new do
          Thread.current.report_on_exception = false
          begin
            container[:bus].publish("triad:auto_start", reason: "post_chat_mutation")
            container[:command_registry].dig("triad")&.call(args: "deep .")
            container[:bus].publish("triad:auto_done")
          rescue StandardError => err
            container[:bus].publish("triad:auto_error", error: err.message)
          end
        end

        Thread.new do
          Thread.current.report_on_exception = false
          repo_root = Rails.root.join("..", "..").to_s
          msg = "auto: chat-turn mutation (#{mutated_paths.size} file(s))"
          _, status = Open3.capture2e("git", "-C", repo_root, "add", "-A")
          if status.success?
            _, st = Open3.capture2e("git", "-C", repo_root, "commit", "-m", msg)
            container[:bus].publish("autocommit:done", ok: st.success?)
          end
        rescue StandardError => err
          container[:bus].publish("autocommit:error", error: err.message)
        end

        if web_mutated
          Thread.new do
            Thread.current.report_on_exception = false
            _, status = Open3.capture2e("doas", "rcctl", "restart", "master")
            container[:bus].publish("master:restart", ok: status.success?)
          rescue StandardError => err
            container[:bus].publish("master:restart_error", error: err.message)
          end
        end
      end
    rescue => e
      sse.write("data: ERROR: #{e.message}\n\n")
      sse.write("data: [DONE]\n\n")
    ensure
      Fiber[:master_visitor] = nil
      begin
        tool_sub.call if defined?(tool_sub) && tool_sub
        mutate_sub.call if defined?(mutate_sub) && mutate_sub
      rescue StandardError
        nil
      end
      sse.close
    end
  end

  def speak
    text = params[:text].to_s.strip
    return head(:bad_request) if text.empty?
    container[:bus].publish("speak:text", { text: text })
    head :ok
  end

  def tts
    text = params[:text].to_s.strip
    return head(:bad_request) if text.empty?

    voice = params[:voice].to_s.downcase.to_sym
    style = params[:style].to_s.downcase.to_sym
    voice = Master::Speech::DEFAULT_VOICE unless Master::Speech::VOICES.key?(voice)
    # :auto opts in to per-clause infer_style. Otherwise enforce whitelist.
    style = Master::Speech::DEFAULT_STYLE if style != :auto && !Master::Speech::STYLES.key?(style)

    bytes = Master::Speech.synthesize_bytes(text, voice: voice, style: style)
    if bytes && bytes.bytesize > 0
      send_data bytes, type: "audio/mpeg", disposition: "inline"
    else
      head :service_unavailable
    end
  rescue => e
    logger.error "TTS failed: #{e.message}"
    head :service_unavailable
  end
end

web/app/controllers/events_controller.rb

# frozen_string_literal: true

# EventsController — SSE stream of EventBus events to the orb visualizer.
#
# The orb already exists (web/app/views/chat/index.html.erb). What it lacked
# was a real signal. This controller subscribes to the container's EventBus,
# serializes each event as Server-Sent Event, and streams them.
#
# Wire into routes:
#   get "/events/stream" => "events#stream"
#
# Consume from the orb JS:
#   const es = new EventSource("/events/stream");
#   es.onmessage = e => handleEvent(JSON.parse(e.data));
#
# Event types the orb can react to (emitted by existing pipeline stages):
#   llm:request           → burst pulse
#   llm:escalation        → color shift
#   tool:used             → ripple
#   scan:complete         → stabilization flash
#   autoloop:cycle        → rotation increment
#   sweep:cycle           → slow rotation
#   pipeline:rollback     → red glitch (from Pipeline rollback)
class EventsController < ApplicationController
  include ActionController::Live

  POLL_INTERVAL_S = 0.1
  MAX_STREAM_S    = 600   # hard cap — 10 minute stream ceiling

  def stream
    response.headers["Content-Type"]      = "text/event-stream"
    response.headers["Cache-Control"]     = "no-cache"
    response.headers["X-Accel-Buffering"] = "no"  # nginx passthrough

    bus      = container[:bus]
    received = Queue.new
    sub      = bus.subscribe("*") { |ev|
      received << { t: Time.now.to_f, type: ev[:event], data: ev }
    }
    deadline = Time.now + MAX_STREAM_S

    loop do
      break if Time.now > deadline
      if received.empty?
        response.stream.write(": keepalive\n\n")  # SSE comment, prevents proxy timeout
        sleep POLL_INTERVAL_S
      else
        event = received.pop(true) rescue nil
        next unless event
        response.stream.write("data: #{event.to_json}\n\n")
      end
    end
  rescue IOError, ActionController::Live::ClientDisconnected
    # Client went away — normal. Stop streaming.
  ensure
    sub.call if sub
    response.stream.close rescue nil
  end
end

web/app/controllers/health_controller.rb

# frozen_string_literal: true

class HealthController < ActionController::API
  def show
    render json: { status: "ok" }, status: :ok
  end
end

web/app/helpers/application_helper.rb

module ApplicationHelper
end

web/app/middleware/auth_tier.rb

# frozen_string_literal: true

# AuthTier — sets request env["master.tier"] to "authenticated" or "visitor"
# based on token match. Public paths bypass entirely. Token comes from
# .master/config.yml; first request seeds it if missing.
class AuthTier
  PUBLIC_PATHS = %w[/up /health].freeze

  def initialize(app, config_path:)
    @app = app
    @config_path = config_path
  end

  def call(env)
    return @app.call(env) if PUBLIC_PATHS.include?(env["PATH_INFO"])
    env["master.tier"] = tier_for(env)
    @app.call(env)
  end

  private

  def tier_for(env)
    request = Rack::Request.new(env)
    token = web_token
    return "authenticated" if request.params["token"] == token
    return "authenticated" if env["HTTP_X_TOKEN"] == token
    "visitor"
  end

  def web_token
    cfg = YAML.safe_load_file(@config_path, permitted_classes: [], aliases: true) rescue {}
    cfg["web_token"].to_s.empty? ? seed_token(cfg) : cfg["web_token"]
  end

  def seed_token(cfg)
    require "securerandom"
    tok = SecureRandom.urlsafe_base64(24)
    cfg["web_token"] = tok
    FileUtils.mkdir_p(File.dirname(@config_path))
    File.write(@config_path, cfg.to_yaml)
    tok
  end
end

web/app/models/application_record.rb

class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class
end

web/app/views/canvas/show.html.erb

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="color-scheme" content="dark">
<title>MASTER · canvas</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:#0a0c0a;color:#cdc5b6;overflow:hidden;font:400 .9rem/1.4 'Monaco','Courier New','SF Mono',ui-monospace,monospace}
article{position:fixed;inset:0;background:#0a0c0a;display:flex;flex-direction:column}
canvas{position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:pixelated}
#shell{position:absolute;left:0;right:0;bottom:0;padding:.5rem .75rem;background:rgba(26,28,26,.88);border-top:1px solid #1f221d;font-family:inherit;line-height:1.4;z-index:100}
#prompt-wrapper{display:flex;gap:.25rem;align-items:center}
#prefix{color:#a0522d;font-weight:600}
#prompt{flex:1;background:transparent;border:0;outline:0;color:#cdc5b6;font:inherit;padding:0}
#prompt::placeholder{color:#544b41;opacity:.6}
#messages{position:absolute;top:1.25rem;left:1rem;right:1rem;max-width:64ch;max-height:30vh;overflow-y:auto;display:flex;flex-direction:column;gap:.75rem;pointer-events:none;font-size:.85rem;z-index:10}
#messages p{margin:0;padding:.5rem .75rem;background:rgba(31,34,29,.45);border-left:2px solid #4a7d80;color:#cdc5b6;border-radius:2px;white-space:pre-wrap;word-wrap:break-word}
#messages p[data-role=user]{border-left-color:#a0522d;background:rgba(160,82,45,.1)}
#messages p[data-role=master]{border-left-color:#4a7d80;animation:fadeout 8s ease forwards}
@keyframes fadeout{0%{opacity:1}80%{opacity:1}100%{opacity:.3}}
#status{position:absolute;bottom:.5rem;right:1rem;font-size:.75rem;color:#544b41;opacity:.7;font-family:monospace;text-align:right}
</style>
</head>
<body>
<article id="canvas">
  <canvas id="field" aria-label="MASTER particle field"></canvas>
  <section id="messages" aria-live="polite"></section>
  <section id="status"></section>
  <section id="shell">
    <div id="prompt-wrapper">
      <span id="prefix">MASTER ❯</span>
      <form id="form" style="flex:1;display:flex"><input id="prompt" type="text" autocorrect="off" autocapitalize="none" spellcheck="false" placeholder="(type or /face)"></form>
    </div>
  </section>
</article>
<script>
(()=>{
  const field=document.getElementById('field');
  const ctx=field.getContext('2d',{alpha:false,desynchronized:true});
  const messages=document.getElementById('messages');
  const prompt=document.getElementById('prompt');
  const status=document.getElementById('status');
  const form=document.getElementById('form');

  const COUNT=1200;
  const MAX_FPS=45;
  const FRAME_MS=1000/MAX_FPS;
  let lastFrameTime=performance.now();

  let w=0, h=0, dpr=Math.max(1,Math.floor(devicePixelRatio||1));
  let particles=[];
  let fieldKind='idle';
  let faceUntil=performance.now()+2500;
  let lastKick=performance.now();
  let scatter=0;
  let audioTick=0;
  let mood={drift:.18,jitter:.04,cohesion:.016,disperse:.02};

  function initParticles(){
    particles=Array.from({length:COUNT},()=>({
      x:Math.random()*w, y:Math.random()*h,
      vx:(Math.random()-.5)*.15, vy:(Math.random()-.5)*.15,
      tx:null, ty:null, life:1.0, trail:[]
    }));
  }

  function resize(){
    w=Math.max(1,innerWidth); h=Math.max(1,innerHeight);
    field.width=w*dpr; field.height=h*dpr;
    ctx.setTransform(dpr,0,0,dpr,0,0);
    initParticles();
  }

  function drawFace(){
    const pts=[];
    const cx=w*.5, cy=h*.45;
    const s=Math.min(w,h)*.15;

    ctx.save();
    ctx.fillStyle='rgba(204,197,182,.06)';
    ctx.beginPath();
    ctx.ellipse(cx, cy-s*.2, s*.65, s*.72, 0, 0, Math.PI*2);
    ctx.fill();
    ctx.restore();

    ctx.save();
    ctx.strokeStyle='#4a7d80';
    ctx.lineWidth=1.5;
    ctx.beginPath();
    for(let i=0;i<=20;i++){
      const t=(i/20)*Math.PI;
      const ex=cx+Math.cos(t)*s*.55;
      const ey=cy-s*.15+Math.sin(t)*s*.08;
      i===0?ctx.moveTo(ex,ey):ctx.lineTo(ex,ey);
    }
    ctx.stroke();

    ctx.beginPath();
    for(let i=0;i<=20;i++){
      const t=(i/20)*Math.PI;
      const ex=cx-Math.cos(t)*s*.55;
      const ey=cy-s*.15+Math.sin(t)*s*.08;
      i===0?ctx.moveTo(ex,ey):ctx.lineTo(ex,ey);
    }
    ctx.stroke();

    ctx.beginPath();
    ctx.arc(cx-s*.18, cy-s*.12, s*.08, 0, Math.PI*2);
    ctx.stroke();

    ctx.beginPath();
    ctx.arc(cx+s*.18, cy-s*.12, s*.08, 0, Math.PI*2);
    ctx.stroke();

    ctx.beginPath();
    const mouthW=s*.26, mouthH=s*.12;
    for(let i=0;i<=16;i++){
      const t=(i/16)*Math.PI;
      const mx=cx+Math.cos(t+Math.PI)*mouthW*.5;
      const my=cy+s*.08+Math.sin(t)*mouthH*.3;
      i===0?ctx.moveTo(mx,my):ctx.lineTo(mx,my);
    }
    ctx.stroke();
    ctx.restore();

    for(let i=0;i<160;i++){
      const t=i/159*Math.PI;
      pts.push([cx+Math.cos(t+Math.PI)*s*.55, cy-s*.2+Math.sin(t)*s*.08]);
    }
    for(let i=0;i<80;i++){
      const t=i/79*Math.PI;
      pts.push([cx-Math.cos(t)*s*.55, cy-s*.2+Math.sin(t)*s*.08]);
    }
    for(let i=0;i<120;i++){
      const t=i/119*Math.PI;
      pts.push([cx+Math.cos(t+Math.PI)*s*.26, cy+s*.1+Math.sin(t)*mouthH*.4]);
    }

    return pts;
  }

  function assignTargets(points){
    particles.forEach((p,i)=>{
      const q=points[i%points.length];
      p.tx=q[0]; p.ty=q[1];
    });
  }

  function setMode(kind, payload={}){
    fieldKind=kind;
    if(kind==='face'){
      assignTargets(drawFace());
      faceUntil=performance.now()+2500;
      return;
    }
    if(kind==='scan'){
      const pts=[];
      const cols=10, rows=6, gap=22;
      const startX=w*.15, startY=h*.35;
      let idx=0;
      (payload.items||[]).slice(0,60).forEach(item=>{
        const c=idx%cols, r=Math.floor(idx/cols);
        const severity=(item.severity||1);
        const count=Math.max(2,Math.floor(severity*8));
        for(let j=0;j<count;j++){
          pts.push([startX+c*gap+Math.random()*8, startY+r*gap+Math.random()*8]);
        }
        idx++;
      });
      assignTargets(pts.length?pts:drawFace());
      return;
    }
    if(kind==='pipeline'){
      const names=payload.stages||['ingest','scan','council','render'];
      const pts=[];
      names.forEach((_,i)=>{
        const x=w*.2+i*((w*.6)/Math.max(1,names.length-1));
        for(let k=0;k<100;k++){
          pts.push([x+(Math.random()-.5)*18, h*.5+(Math.random()-.5)*18]);
        }
      });
      assignTargets(pts);
      return;
    }
    particles.forEach(p=>{p.tx=null;p.ty=null});
  }

  function pushMessage(text, role='master'){
    const p=document.createElement('p');
    p.dataset.role=role;
    p.textContent=text;
    messages.prepend(p);
    while(messages.children.length>5) messages.lastElementChild.remove();
  }

  function updateMood(tag){
    if(tag==='focused'){mood.drift=.16;mood.jitter=.03;mood.cohesion=.020;}
    else if(tag==='curious'){mood.drift=.24;mood.jitter=.06;mood.cohesion=.012;}
    else if(tag==='tense'){mood.drift=.34;mood.jitter=.12;mood.cohesion=.010;}
    else if(tag==='weary'){mood.drift=.10;mood.jitter=.02;mood.cohesion=.008;}
  }

  function tick(now){
    const dt=Math.min(FRAME_MS, now-lastFrameTime);
    lastFrameTime=now;

    audioTick+=0.0085;
    const low=(Math.sin(audioTick*2.1)+1)*.5;
    const high=(Math.sin(audioTick*7.8)+1)*.5;

    if(now-lastKick>300) scatter=Math.max(0, scatter*0.94);
    if(now<faceUntil && fieldKind!=='face' && fieldKind==='idle') setMode('face');
    if(now>faceUntil && fieldKind==='face') setMode('idle');

    ctx.fillStyle='#0a0c0a';
    ctx.fillRect(0,0,w,h);
    ctx.fillStyle='#cdc5b6';

    let onScreen=0;
    for(const p of particles){
      if(!p) continue;
      const turn=(low-.5)*0.014;
      const ca=Math.cos(turn), sa=Math.sin(turn);

      let vx=p.vx*ca-p.vy*sa;
      let vy=p.vx*sa+p.vy*ca;

      vx+=(Math.random()-.5)*mood.jitter+(high-.5)*.028;
      vy+=(Math.random()-.5)*mood.jitter;

      if(p.tx!=null){
        vx+=(p.tx-p.x)*mood.cohesion;
        vy+=(p.ty-p.y)*mood.cohesion;
      }

      if(scatter>.005){
        const dx=p.x-w/2, dy=p.y-h/2;
        const d=Math.max(25, Math.hypot(dx,dy));
        const mag=(scatter*.22)/(1+d/300);
        vx+=(dx/d)*mag;
        vy+=(dy/d)*mag;
      }

      p.vx=vx*mood.drift+vx*.82;
      p.vy=vy*mood.drift+vy*.82;

      p.x+=p.vx*.07;
      p.y+=p.vy*.07;

      if(p.x<-2)p.x+=w+4;
      if(p.x>w+2)p.x-=w+4;
      if(p.y<-2)p.y+=h+4;
      if(p.y>h+2)p.y-=h+4;

      p.life=Math.min(1.0, p.life+.01);
      const alpha=p.life*.8;

      ctx.globalAlpha=alpha;
      ctx.fillRect(Math.floor(p.x), Math.floor(p.y), 1.2, 1.2);

      if(p.x>=0 && p.x<w && p.y>=0 && p.y<h) onScreen++;
    }
    ctx.globalAlpha=1;

    status.textContent=`particles: ${onScreen}/${COUNT} | mode: ${fieldKind} | fps: ${Math.round(1000/dt)}`;

    requestAnimationFrame(tick);
  }

  prompt.addEventListener('input',()=>{});
  prompt.addEventListener('keydown',e=>{
    if(e.key==='Escape'){prompt.value='';prompt.blur();}
  });

  form.addEventListener('submit',e=>{
    e.preventDefault();
    const value=prompt.value.trim();
    if(!value) return;
    pushMessage(value, 'user');

    if(value==='/face') setMode('face');
    if(value.includes('scan')) setMode('scan',{items:Array.from({length:30},(_,i)=>({severity:(i%3)+1}))});
    if(value.includes('pipeline')) setMode('pipeline',{});

    prompt.value='';
  });

  document.addEventListener('keydown',e=>{
    if(e.key.length===1 && !e.ctrlKey && !e.metaKey && !e.altKey){
      if(document.activeElement!==prompt){
        prompt.focus();
        scatter=Math.min(1.4, scatter+0.3);
        lastKick=performance.now();
      }
    }
  });

  document.addEventListener('click',()=>{
    prompt.focus();
    scatter=Math.min(1.4, scatter+0.25);
    lastKick=performance.now();
  });

  const es=new EventSource('/canvas/stream');
  es.addEventListener('bus',e=>{
    try{
      const m=JSON.parse(e.data);
      const t=m.topic, p=m.payload||{};
      if(t==='chat:assistant' || t==='master:speak'){
        pushMessage((p.text||p.message||'...').substring(0,120), 'master');
        scatter=Math.min(1.4, scatter+0.2);
        lastKick=performance.now();
      }
      if(t==='homeostat:mood') updateMood(p.mood||p.state);
      if(t==='canvas:face') setMode('face');
      if(t==='scan:summary') setMode('scan',p);
      if(t==='pipeline:stages') setMode('pipeline',p);
      if(t==='audio:transient') scatter=Math.min(1.4,scatter+0.8);
    }catch(err){}
  });

  resize();
  addEventListener('resize',resize,{passive:true});
  requestAnimationFrame(tick);
})();
</script>
</body>
</html>

web/app/views/chat/index.html.erb

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
  <meta name="theme-color" content="#000000">
  <title>MASTER</title>
  <style>
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
    :root {
      --bg: #000;
      --fg: #fff;
      --dim: rgba(255,255,255,0.3);
      --hairline: rgba(255,255,255,0.12);
      --accent: 255, 255, 255;
      --think-accent: 200, 220, 255;
      --max-line-width: 60ch;
      --pad: 1.5rem;
    }
    html, body { height: 100%; background: var(--bg); color: var(--fg); }
    body {
      font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
      font-size: 16px;
      line-height: 1.5;
      overflow: hidden;
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      user-select: none;
    }

    canvas#particles {
      position: fixed; inset: 0; z-index: 0;
    }

    main {
      position: relative; z-index: 1;
      display: flex; flex-direction: column; height: 100%;
    }

    #messages {
      flex: 1; overflow-y: auto; padding: var(--pad);
      display: flex; flex-direction: column; gap: 1rem;
      scroll-behavior: smooth;
    }
    .message {
      max-width: var(--max-line-width);
      animation: fadeSlide 0.3s ease-out;
    }
    .message.user { align-self: flex-end; }
    .message.assistant {
      align-self: flex-start;
      border-left: 1px solid var(--hairline);
      padding-left: 1rem;
    }
    .message.assistant.thinking {
      border-left-color: rgb(var(--think-accent));
      transition: border-color 0.6s;
    }
    .message .text {
      white-space: pre-wrap;
      line-height: 1.6;
      color: var(--fg);
    }
    .message .meta {
      font-size: 0.75rem;
      color: var(--dim);
      margin-top: 0.25rem;
      display: none;
    }

    @keyframes fadeSlide {
      from { opacity: 0; transform: translateY(0.5rem); }
      to   { opacity: 1; transform: translateY(0); }
    }

    #input-area {
      flex-shrink: 0;
      border-top: 1px solid var(--hairline);
      padding: 0.5rem var(--pad);
      display: flex; align-items: center;
      background: var(--bg);
    }
    #input-line {
      flex: 1;
      background: transparent; border: none; outline: none;
      color: var(--fg);
      font: inherit; font-size: 1rem;
      caret-color: var(--fg);
      padding: 0.5rem 0;
      resize: none;
    }
    #input-line::placeholder { color: var(--dim); }
    button { display: none; } /* no buttons */

    @media (max-width: 600px) {
      :root { --pad: 1rem; }
      body { font-size: 15px; }
    }

    /* subtle scrollbar */
    #messages::-webkit-scrollbar { width: 4px; }
    #messages::-webkit-scrollbar-thumb { background: var(--hairline); border-radius: 2px; }
  </style>
</head>
<body>
  <canvas id="particles"></canvas>
  <main>
    <div id="messages"></div>
    <div id="input-area">
      <input id="input-line" type="text" placeholder="" autofocus autocomplete="off" autocapitalize="off" />
    </div>
  </main>

  <script>
    // ─── Particle system ──────────────────────────────────────────────
    const canvas = document.getElementById('particles');
    const ctx = canvas.getContext('2d');
    let width, height;
    let particles = [];
    const PARTICLE_COUNT = 2500;
    const FACE_POINTS = []; // will hold outline coords for brief face display

    function resize() {
      width = window.innerWidth;
      height = window.innerHeight;
      canvas.width = width;
      canvas.height = height;
    }
    window.addEventListener('resize', resize);
    resize();

    // Generate wireframe face points (a stylized geometric face outline)
    function generateFaceOutline() {
      const cx = width * 0.5, cy = height * 0.45;
      const scale = Math.min(width, height) * 0.15;
      const points = [];
      // simple face: two eyes, nose line, mouth
      // left eye
      for (let a = 0; a < Math.PI * 2; a += 0.3) {
        points.push([cx - scale * 1.2 + Math.cos(a)*scale*0.4, cy - scale*0.3 + Math.sin(a)*scale*0.25]);
      }
      // right eye
      for (let a = 0; a < Math.PI * 2; a += 0.3) {
        points.push([cx + scale * 1.2 + Math.cos(a)*scale*0.4, cy - scale*0.3 + Math.sin(a)*scale*0.25]);
      }
      // nose bridge
      for (let t = 0; t <= 1; t += 0.1) {
        points.push([cx + (t-0.5)*scale*0.3, cy - scale*0.3 + t*scale*0.8]);
      }
      // mouth
      for (let a = 0; a < Math.PI; a += 0.2) {
        points.push([cx + Math.cos(a)*scale*0.7, cy + scale*0.7 + Math.sin(a)*scale*0.15]);
      }
      return points;
    }
    FACE_POINTS.push(...generateFaceOutline());

    class Particle {
      constructor() {
        this.reset();
      }
      reset() {
        this.x = Math.random() * width;
        this.y = Math.random() * height;
        this.vx = 0; this.vy = 0;
        this.homeX = this.x; this.homeY = this.y;
      }
      update(attractors, audioData) {
        // drift toward nearest attractor or home
        let targetX = this.homeX, targetY = this.homeY;
        if (attractors && attractors.length > 0) {
          let minDist = Infinity;
          for (let a of attractors) {
            const dx = a[0] - this.x, dy = a[1] - this.y;
            const d = dx*dx + dy*dy;
            if (d < minDist) { minDist = d; targetX = a[0]; targetY = a[1]; }
          }
        }
        const ax = (targetX - this.x) * 0.003;
        const ay = (targetY - this.y) * 0.003;
        // audio influence: low frequencies cause grouping, high add jitter
        let jitter = 0;
        if (audioData) {
          const bass = audioData[0] / 255;   // 0-1
          const treble = audioData[Math.floor(audioData.length*0.7)] / 255;
          jitter = treble * 0.5;
          this.vx += (Math.random() - 0.5) * jitter;
          this.vy += (Math.random() - 0.5) * jitter;
        }
        this.vx += ax + (Math.random() - 0.5) * 0.02;
        this.vy += ay + (Math.random() - 0.5) * 0.02;
        this.vx *= 0.96; this.vy *= 0.96;
        this.x += this.vx; this.y += this.vy;
        // bounce edges
        if (this.x < 0) this.x = 0;
        if (this.x > width) this.x = width;
        if (this.y < 0) this.y = 0;
        if (this.y > height) this.y = height;
      }
    }

    for (let i = 0; i < PARTICLE_COUNT; i++) particles.push(new Particle());

    let faceShowTimer = null;
    function showFace(duration = 2000) {
      // set attractors to FACE_POINTS
      window._attractors = FACE_POINTS;
      clearTimeout(faceShowTimer);
      faceShowTimer = setTimeout(() => {
        window._attractors = null; // release to home positions
      }, duration);
    }
    window._attractors = null; // idle

    // audio engine: generate an ambient pad and provide frequency data
    let audioCtx = null, analyser = null;
    function initAudio() {
      if (audioCtx) return;
      audioCtx = new (window.AudioContext || window.webkitAudioContext)();
      analyser = audioCtx.createAnalyser();
      analyser.fftSize = 256;
      // create gentle pad
      const osc1 = audioCtx.createOscillator();
      osc1.type = 'sine'; osc1.frequency.value = 110;
      const osc2 = audioCtx.createOscillator();
      osc2.type = 'sine'; osc2.frequency.value = 164.81;
      const gain1 = audioCtx.createGain(); gain1.gain.value = 0.03;
      const gain2 = audioCtx.createGain(); gain2.gain.value = 0.02;
      osc1.connect(gain1); gain1.connect(analyser); analyser.connect(audioCtx.destination);
      osc2.connect(gain2); gain2.connect(analyser);
      osc1.start(); osc2.start();
    }

    function getAudioData() {
      if (!analyser) return null;
      const bufferLength = analyser.frequencyBinCount;
      const dataArray = new Uint8Array(bufferLength);
      analyser.getByteFrequencyData(dataArray);
      return dataArray;
    }

    // Animation loop
    function animate() {
      requestAnimationFrame(animate);
      const audioData = getAudioData();
      particles.forEach(p => p.update(window._attractors, audioData));
      ctx.clearRect(0, 0, width, height);
      ctx.fillStyle = 'rgba(255,255,255,0.9)';
      for (let p of particles) {
        ctx.fillRect(p.x, p.y, 1, 1);
      }
    }
    animate();

    // ─── Chat logic ─────────────────────────────────────────────────
    const messagesDiv = document.getElementById('messages');
    const inputLine = document.getElementById('input-line');
    let isThinking = false;

    function addMessage(role, text) {
      const div = document.createElement('div');
      div.className = `message ${role}`;
      const textDiv = document.createElement('div');
      textDiv.className = 'text';
      textDiv.textContent = text;
      div.appendChild(textDiv);
      messagesDiv.appendChild(div);
      messagesDiv.scrollTop = messagesDiv.scrollHeight;
      return div;
    }

    function updateThinking(thinking) {
      isThinking = thinking;
      // change particle accent color
      if (thinking) {
        document.documentElement.style.setProperty('--accent', 'var(--think-accent)');
      } else {
        document.documentElement.style.setProperty('--accent', '255, 255, 255');
      }
    }

    async function sendMessage(text) {
      addMessage('user', text);
      updateThinking(true);
      // SSE connection
      const token = new URLSearchParams(window.location.search).get('token') || '';
      const url = `/chat/message?token=${encodeURIComponent(token)}`;
      const eventSource = new EventSource(`${url}&message=${encodeURIComponent(text)}`);
      let assistantDiv = null;
      let fullText = '';

      eventSource.onmessage = (event) => {
        if (!assistantDiv) {
          assistantDiv = addMessage('assistant', '');
          assistantDiv.classList.add('thinking');
        }
        const data = JSON.parse(event.data);
        if (data.chunk) {
          fullText += data.chunk;
          assistantDiv.querySelector('.text').textContent = fullText;
          messagesDiv.scrollTop = messagesDiv.scrollHeight;
        }
        if (data.done) {
          assistantDiv.classList.remove('thinking');
          updateThinking(false);
          eventSource.close();
        }
      };
      eventSource.onerror = () => {
        if (assistantDiv) assistantDiv.classList.remove('thinking');
        updateThinking(false);
        eventSource.close();
      };
    }

    inputLine.addEventListener('keydown', (e) => {
      if (e.key === 'Enter' && inputLine.value.trim()) {
        sendMessage(inputLine.value.trim());
        inputLine.value = '';
      }
    });

    // ─── Mobile sensors ─────────────────────────────────────────────
    if (window.DeviceOrientationEvent) {
      window.addEventListener('deviceorientation', (event) => {
        if (event.gamma !== null) {
          // tilt left-right affects particle drift bias
          const tilt = event.gamma / 90; // -1 to 1
          // modify global drift factor (applied in particle update via wind)
          window._tiltX = tilt * 0.2;
        }
      });
    }
    // Ambient light sensor
    if ('AmbientLightSensor' in window) {
      try {
        const sensor = new AmbientLightSensor();
        sensor.onreading = () => {
          const brightness = Math.min(sensor.illuminance / 400, 1);
          document.documentElement.style.opacity = 0.7 + brightness * 0.3;
        };
        sensor.start();
      } catch (e) { /* not supported */ }
    }

    // Speech recognition (optional, tap and hold input to activate voice)
    let recognition = null;
    if ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window) {
      const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
      recognition = new SpeechRecognition();
      recognition.continuous = false;
      recognition.interimResults = false;
      recognition.onresult = (event) => {
        const transcript = event.results[0][0].transcript.trim();
        if (transcript) {
          inputLine.value = transcript;
          sendMessage(transcript);
        }
      };
      // long press input to activate voice
      inputLine.addEventListener('pointerdown', startVoiceTimer);
      inputLine.addEventListener('pointerup', cancelVoiceTimer);
    }
    let voiceTimer;
    function startVoiceTimer(e) {
      voiceTimer = setTimeout(() => {
        if (recognition) recognition.start();
      }, 600);
    }
    function cancelVoiceTimer() {
      clearTimeout(voiceTimer);
    }

    // ─── Boot sequence ──────────────────────────────────────────────
    function boot() {
      showFace(3000); // face appears for 3 seconds
      initAudio();
      // initial message
      addMessage('assistant', 'master ready');
    }
    boot();
  </script>
</body>
</html>

web/app/views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    <title><%= content_for(:title) || "Web" %></title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <meta name="apple-mobile-web-app-capable" content="yes">
    <meta name="application-name" content="Web">
    <meta name="mobile-web-app-capable" content="yes">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%= yield :head %>

    <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
    <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>

    <link rel="icon" href="/icon.png" type="image/png">
    <link rel="icon" href="/icon.svg" type="image/svg+xml">
    <link rel="apple-touch-icon" href="/icon.png">

    <%# Includes all stylesheet files in app/assets/stylesheets %>
    <%= stylesheet_link_tag :app %>
  </head>

  <body>
    <%= yield %>
      <nav><a href="/">chat</a> <a href="/canvas">canvas</a></nav>
</body>
</html>

web/app/views/pwa/manifest.json.erb

{
  "name": "Web",
  "icons": [
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src": "/icon.png",
      "type": "image/png",
      "sizes": "512x512",
      "purpose": "maskable"
    }
  ],
  "start_url": "/",
  "display": "standalone",
  "scope": "/",
  "description": "Web.",
  "theme_color": "red",
  "background_color": "red"
}

web/config/application.rb

require_relative "boot"

require "rails"
# Pick the frameworks you want:
require "active_model/railtie"
# require "active_job/railtie"
require "active_record/railtie"
# require "active_storage/engine"
require "action_controller/railtie"
# require "action_mailer/railtie"
# require "action_mailbox/engine"
# require "action_text/engine"
require "action_view/railtie"
# require "action_cable/engine"
# require "rails/test_unit/railtie"

# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
Bundler.require(*Rails.groups)

require_relative "../app/middleware/auth_tier"

module Web
  class Application < Rails::Application
    # Initialize configuration defaults for originally generated Rails version.
    config.load_defaults 8.1

    # Please, add to the `ignore` list any other `lib` subdirectories that do
    # not contain `.rb` files, or that should not be reloaded or eager loaded.
    # Common ones are `templates`, `generators`, or `middleware`, for example.
    config.autoload_lib(ignore: %w[assets tasks master])

    # Configuration for the application, engines, and railties goes here.
    #
    # These settings can be overridden in specific environments using the files
    # in config/environments, which are processed later.
    #
    # config.time_zone = "Central Time (US & Canada)"
    # config.eager_load_paths << Rails.root.join("extras")

    # Don't generate system test files.
    config.generators.system_tests = nil

    config.middleware.use(
      AuthTier,
      config_path: Rails.root.join("..", ".master", "config.yml").to_s
    )
  end
end

web/config/boot.rb

ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)

require "bundler/setup" # Set up gems listed in the Gemfile.

web/config/ci.rb

# Run using bin/ci

CI.run do
  step "Setup", "bin/setup --skip-server"



  # Optional: set a green GitHub commit status to unblock PR merge.
  # Requires the `gh` CLI and `gh extension install basecamp/gh-signoff`.
  # if success?
  #   step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
  # else
  #   failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again."
  # end
end

web/config/database.yml

default: &default
  adapter: sqlite3
  max_connections: 5
  timeout: 5000

development:
  <<: *default
  database: storage/development.sqlite3

test:
  <<: *default
  database: storage/test.sqlite3

production:
  <<: *default
  database: storage/production.sqlite3

web/config/environment.rb

# Load the Rails application.
require_relative "application"

# Initialize the Rails application.
Rails.application.initialize!

web/config/environments/development.rb

require "active_support/core_ext/integer/time"

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.

  # Make code changes take effect immediately without server restart.
  config.enable_reloading = true

  # Do not eager load code on boot.
  config.eager_load = false

  # Show full error reports.
  config.consider_all_requests_local = true

  # Enable server timing.
  config.server_timing = true

  # Enable/disable Action Controller caching. By default Action Controller caching is disabled.
  # Run rails dev:cache to toggle Action Controller caching.
  if Rails.root.join("tmp/caching-dev.txt").exist?
    config.action_controller.perform_caching = true
    config.action_controller.enable_fragment_cache_logging = true
    config.public_file_server.headers = { "cache-control" => "public, max-age=#{2.days.to_i}" }
  else
    config.action_controller.perform_caching = false
  end

  # Change to :null_store to avoid any caching.
  config.cache_store = :memory_store

  # Print deprecation notices to the Rails logger.
  config.active_support.deprecation = :log

  # Raise an error on page load if there are pending migrations.
  config.active_record.migration_error = :page_load

  # Highlight code that triggered database queries in logs.
  config.active_record.verbose_query_logs = true

  # Append comments with runtime information tags to SQL queries in logs.
  config.active_record.query_log_tags_enabled = true

  # Highlight code that triggered redirect in logs.
  config.action_dispatch.verbose_redirect_logs = true

  # Suppress logger output for asset requests.
  config.assets.quiet = true

  # Raises error for missing translations.
  # config.i18n.raise_on_missing_translations = true

  # Annotate rendered view with file names.
  config.action_view.annotate_rendered_view_with_filenames = true

  # Raise error when a before_action's only/except options reference missing actions.
  config.action_controller.raise_on_missing_callback_actions = true
end

web/config/environments/production.rb

require "active_support/core_ext/integer/time"

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.

  # Code is not reloaded between requests.
  config.enable_reloading = false

  # Eager load code on boot for better performance and memory savings (ignored by Rake tasks).
  config.eager_load = true

  # Full error reports are disabled.
  config.consider_all_requests_local = false

  # Turn on fragment caching in view templates.
  config.action_controller.perform_caching = true

  # Cache assets for far-future expiry since they are all digest stamped.
  config.public_file_server.headers = { "cache-control" => "public, max-age=#{1.year.to_i}" }

  # Enable serving of images, stylesheets, and JavaScripts from an asset server.
  # config.asset_host = "http://assets.example.com"

  # Assume all access to the app is happening through a SSL-terminating reverse proxy.
  config.assume_ssl = true

  # SSL terminated at relayd proxy layer — do not redirect internally.
  config.force_ssl = false

  # Skip http-to-https redirect for the default health check endpoint.
  # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } }

  # Log to STDOUT with the current request id as a default log tag.
  config.log_tags = [ :request_id ]
  config.logger   = ActiveSupport::TaggedLogging.logger(STDOUT)

  # Change to "debug" to log everything (including potentially personally-identifiable information!).
  config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")

  # Prevent health checks from clogging up the logs.
  config.silence_healthcheck_path = "/up"

  # Don't log any deprecations.
  config.active_support.report_deprecations = false

  # Replace the default in-process memory cache store with a durable alternative.
  # config.cache_store = :mem_cache_store

  # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
  # the I18n.default_locale when a translation cannot be found).
  config.i18n.fallbacks = true

  # Do not dump schema after migrations.
  config.active_record.dump_schema_after_migration = false

  # Only use :id for inspections in production.
  config.active_record.attributes_for_inspect = [ :id ]

  # Host validation handled by relayd; disable Rails-level host authorization.
  config.host_authorization = { exclude: ->(request) { true } }
end

web/config/environments/test.rb

# The test environment is used exclusively to run your application's
# test suite. You never need to work with it otherwise. Remember that
# your test database is "scratch space" for the test suite and is wiped
# and recreated between test runs. Don't rely on the data there!

Rails.application.configure do
  # Settings specified here will take precedence over those in config/application.rb.

  # While tests run files are not watched, reloading is not necessary.
  config.enable_reloading = false

  # Eager loading loads your entire application. When running a single test locally,
  # this is usually not necessary, and can slow down your test suite. However, it's
  # recommended that you enable it in continuous integration systems to ensure eager
  # loading is working properly before deploying your code.
  config.eager_load = ENV["CI"].present?

  # Configure public file server for tests with cache-control for performance.
  config.public_file_server.headers = { "cache-control" => "public, max-age=3600" }

  # Show full error reports.
  config.consider_all_requests_local = true
  config.cache_store = :null_store

  # Render exception templates for rescuable exceptions and raise for other exceptions.
  config.action_dispatch.show_exceptions = :rescuable

  # Disable request forgery protection in test environment.
  config.action_controller.allow_forgery_protection = false

  # Print deprecation notices to the stderr.
  config.active_support.deprecation = :stderr

  # Raises error for missing translations.
  # config.i18n.raise_on_missing_translations = true

  # Annotate rendered view with file names.
  # config.action_view.annotate_rendered_view_with_filenames = true

  # Raise error when a before_action's only/except options reference missing actions.
  config.action_controller.raise_on_missing_callback_actions = true
end

web/config/initializers/assets.rb

# Be sure to restart your server when you modify this file.

# Version of your assets, change this if you want to expire all your assets.
Rails.application.config.assets.version = "1.0"

# Add additional assets to the asset load path.
# Rails.application.config.assets.paths << Emoji.images_path

web/config/initializers/content_security_policy.rb

# Be sure to restart your server when you modify this file.

# Define an application-wide content security policy.
# See the Securing Rails Applications Guide for more information:
# https://guides.rubyonrails.org/security.html#content-security-policy-header

Rails.application.configure do
  config.content_security_policy do |policy|
    policy.default_src :self
    policy.font_src    :self, :data, "https://fonts.gstatic.com"
    policy.img_src     :self, :data
    policy.object_src  :none
    policy.script_src  :self, :unsafe_inline
    policy.style_src   :self, :unsafe_inline, "https://fonts.googleapis.com"
    policy.connect_src :self
    policy.media_src   :self, :blob
  end
end

web/config/initializers/filter_parameter_logging.rb

# Be sure to restart your server when you modify this file.

# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file.
# Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [
  :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc
]

web/config/initializers/inflections.rb

# Be sure to restart your server when you modify this file.

# Add new inflection rules using the following format. Inflections
# are locale specific, and you may define rules for as many different
# locales as you wish. All of these examples are active by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
#   inflect.plural /^(ox)$/i, "\\1en"
#   inflect.singular /^(ox)en/i, "\\1"
#   inflect.irregular "person", "people"
#   inflect.uncountable %w( fish sheep )
# end

# These inflection rules are supported but not enabled by default:
# ActiveSupport::Inflector.inflections(:en) do |inflect|
#   inflect.acronym "RESTful"
# end

web/config/initializers/master_container.rb

# frozen_string_literal: true
# Pre-build container at boot to avoid blocking Falcons async event loop.
Rails.application.config.after_initialize do
  Thread.new { ApplicationController.class_eval { container } rescue nil }
end

web/config/initializers/new_framework_defaults_8_0.rb

# Be sure to restart your server when you modify this file.
#
# This file eases your Rails 8.0 framework defaults upgrade.
#
# Uncomment each configuration one by one to switch to the new default.
# Once your application is ready to run with all new defaults, you can remove
# this file and set the `config.load_defaults` to `8.0`.
#
# Read the Guide for Upgrading Ruby on Rails for more info on each option.
# https://guides.rubyonrails.org/upgrading_ruby_on_rails.html

###
# Specifies whether `to_time` methods preserve the UTC offset of their receivers or preserves the timezone.
# If set to `:zone`, `to_time` methods will use the timezone of their receivers.
# If set to `:offset`, `to_time` methods will use the UTC offset.
# If `false`, `to_time` methods will convert to the local system UTC offset instead.
#++
# Rails.application.config.active_support.to_time_preserves_timezone = :zone

###
# When both `If-Modified-Since` and `If-None-Match` are provided by the client
# only consider `If-None-Match` as specified by RFC 7232 Section 6.
# If set to `false` both conditions need to be satisfied.
#++
# Rails.application.config.action_dispatch.strict_freshness = true

###
# Set `Regexp.timeout` to `1`s by default to improve security over Regexp Denial-of-Service attacks.
#++
# Regexp.timeout = 1

web/config/locales/en.yml

# Files in the config/locales directory are used for internationalization and
# are automatically loaded by Rails. If you want to use locales other than
# English, add the necessary files in this directory.
#
# To use the locales, use `I18n.t`:
#
#     I18n.t "hello"
#
# In views, this is aliased to just `t`:
#
#     <%= t("hello") %>
#
# To use a different locale, set it with `I18n.locale`:
#
#     I18n.locale = :es
#
# This would use the information in config/locales/es.yml.
#
# To learn more about the API, please read the Rails Internationalization guide
# at https://guides.rubyonrails.org/i18n.html.
#
# Be aware that YAML interprets the following case-insensitive strings as
# booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings
# must be quoted to be interpreted as strings. For example:
#
#     en:
#       "yes": yup
#       enabled: "ON"

en:
  hello: "Hello world"

web/config/puma.rb

# This configuration file will be evaluated by Puma. The top-level methods that
# are invoked here are part of Puma's configuration DSL. For more information
# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html.
#
# Puma starts a configurable number of processes (workers) and each process
# serves each request in a thread from an internal thread pool.
#
# You can control the number of workers using ENV["WEB_CONCURRENCY"]. You
# should only set this value when you want to run 2 or more workers. The
# default is already 1. You can set it to `auto` to automatically start a worker
# for each available processor.
#
# The ideal number of threads per worker depends both on how much time the
# application spends waiting for IO operations and on how much you wish to
# prioritize throughput over latency.
#
# As a rule of thumb, increasing the number of threads will increase how much
# traffic a given process can handle (throughput), but due to CRuby's
# Global VM Lock (GVL) it has diminishing returns and will degrade the
# response time (latency) of the application.
#
# The default is set to 3 threads as it's deemed a decent compromise between
# throughput and latency for the average Rails application.
#
# Any libraries that use a connection pool or another resource pool should
# be configured to provide at least as many connections as the number of
# threads. This includes Active Record's `pool` parameter in `database.yml`.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count

# Specifies the `port` that Puma will listen on to receive requests; default is 3000.
port ENV.fetch("PORT", 3000)

# Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart

# Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested.
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]

web/config/routes.rb

Rails.application.routes.draw do
  root "chat#index"
  post "chat/message",  to: "chat#message"
  post "chat/tts",      to: "chat#tts"
  post "chat/speak",    to: "chat#speak"
  get  "chat/metrics",  to: "chat#metrics"
  get  "chat/dmesg",    to: "chat#dmesg"
  get  "events/stream", to: "events#stream"
get  "canvas",         to: "canvas#show"
get  "canvas/stream",  to: "canvas#stream"
post "canvas/event",   to: "canvas#post_event"
  get  "up" => "rails/health#show", as: :rails_health_check
  get  "health" => "health#show"
end

web/db/seeds.rb

# This file should ensure the existence of records required to run the application in every environment (production,
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
#
# Example:
#
#   ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
#     MovieGenre.find_or_create_by!(name: genre_name)
#   end

web/public/assets/rails-ujs-20eaf715.js

/*
Unobtrusive JavaScript
https://github.com/rails/rails/blob/main/actionview/app/javascript
Released under the MIT license
 */
(function(global, factory) {
  typeof exports === "object" && typeof module !== "undefined" ? module.exports = factory() : typeof define === "function" && define.amd ? define(factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self,
  global.Rails = factory());
})(this, (function() {
  "use strict";
  const linkClickSelector = "a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]";
  const buttonClickSelector = {
    selector: "button[data-remote]:not([form]), button[data-confirm]:not([form])",
    exclude: "form button"
  };
  const inputChangeSelector = "select[data-remote], input[data-remote], textarea[data-remote]";
  const formSubmitSelector = "form:not([data-turbo=true])";
  const formInputClickSelector = "form:not([data-turbo=true]) input[type=submit], form:not([data-turbo=true]) input[type=image], form:not([data-turbo=true]) button[type=submit], form:not([data-turbo=true]) button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])";
  const formDisableSelector = "input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled";
  const formEnableSelector = "input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled";
  const fileInputSelector = "input[name][type=file]:not([disabled])";
  const linkDisableSelector = "a[data-disable-with], a[data-disable]";
  const buttonDisableSelector = "button[data-remote][data-disable-with], button[data-remote][data-disable]";
  let nonce = null;
  const loadCSPNonce = () => {
    const metaTag = document.querySelector("meta[name=csp-nonce]");
    return nonce = metaTag && metaTag.content;
  };
  const cspNonce = () => nonce || loadCSPNonce();
  const m = Element.prototype.matches || Element.prototype.matchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.webkitMatchesSelector;
  const matches = function(element, selector) {
    if (selector.exclude) {
      return m.call(element, selector.selector) && !m.call(element, selector.exclude);
    } else {
      return m.call(element, selector);
    }
  };
  const EXPANDO = "_ujsData";
  const getData = (element, key) => element[EXPANDO] ? element[EXPANDO][key] : undefined;
  const setData = function(element, key, value) {
    if (!element[EXPANDO]) {
      element[EXPANDO] = {};
    }
    return element[EXPANDO][key] = value;
  };
  const $ = selector => Array.prototype.slice.call(document.querySelectorAll(selector));
  const isContentEditable = function(element) {
    var isEditable = false;
    do {
      if (element.isContentEditable) {
        isEditable = true;
        break;
      }
      element = element.parentElement;
    } while (element);
    return isEditable;
  };
  const csrfToken = () => {
    const meta = document.querySelector("meta[name=csrf-token]");
    return meta && meta.content;
  };
  const csrfParam = () => {
    const meta = document.querySelector("meta[name=csrf-param]");
    return meta && meta.content;
  };
  const CSRFProtection = xhr => {
    const token = csrfToken();
    if (token) {
      return xhr.setRequestHeader("X-CSRF-Token", token);
    }
  };
  const refreshCSRFTokens = () => {
    const token = csrfToken();
    const param = csrfParam();
    if (token && param) {
      return $('form input[name="' + param + '"]').forEach((input => input.value = token));
    }
  };
  const AcceptHeaders = {
    "*": "*/*",
    text: "text/plain",
    html: "text/html",
    xml: "application/xml, text/xml",
    json: "application/json, text/javascript",
    script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"
  };
  const ajax = options => {
    options = prepareOptions(options);
    var xhr = createXHR(options, (function() {
      const response = processResponse(xhr.response != null ? xhr.response : xhr.responseText, xhr.getResponseHeader("Content-Type"));
      if (Math.floor(xhr.status / 100) === 2) {
        if (typeof options.success === "function") {
          options.success(response, xhr.statusText, xhr);
        }
      } else {
        if (typeof options.error === "function") {
          options.error(response, xhr.statusText, xhr);
        }
      }
      return typeof options.complete === "function" ? options.complete(xhr, xhr.statusText) : undefined;
    }));
    if (options.beforeSend && !options.beforeSend(xhr, options)) {
      return false;
    }
    if (xhr.readyState === XMLHttpRequest.OPENED) {
      return xhr.send(options.data);
    }
  };
  var prepareOptions = function(options) {
    options.url = options.url || location.href;
    options.type = options.type.toUpperCase();
    if (options.type === "GET" && options.data) {
      if (options.url.indexOf("?") < 0) {
        options.url += "?" + options.data;
      } else {
        options.url += "&" + options.data;
      }
    }
    if (!(options.dataType in AcceptHeaders)) {
      options.dataType = "*";
    }
    options.accept = AcceptHeaders[options.dataType];
    if (options.dataType !== "*") {
      options.accept += ", */*; q=0.01";
    }
    return options;
  };
  var createXHR = function(options, done) {
    const xhr = new XMLHttpRequest;
    xhr.open(options.type, options.url, true);
    xhr.setRequestHeader("Accept", options.accept);
    if (typeof options.data === "string") {
      xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
    }
    if (!options.crossDomain) {
      xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
      CSRFProtection(xhr);
    }
    xhr.withCredentials = !!options.withCredentials;
    xhr.onreadystatechange = function() {
      if (xhr.readyState === XMLHttpRequest.DONE) {
        return done(xhr);
      }
    };
    return xhr;
  };
  var processResponse = function(response, type) {
    if (typeof response === "string" && typeof type === "string") {
      if (type.match(/\bjson\b/)) {
        try {
          response = JSON.parse(response);
        } catch (error) {}
      } else if (type.match(/\b(?:java|ecma)script\b/)) {
        const script = document.createElement("script");
        script.setAttribute("nonce", cspNonce());
        script.text = response;
        document.head.appendChild(script).parentNode.removeChild(script);
      } else if (type.match(/\b(xml|html|svg)\b/)) {
        const parser = new DOMParser;
        type = type.replace(/;.+/, "");
        try {
          response = parser.parseFromString(response, type);
        } catch (error1) {}
      }
    }
    return response;
  };
  const href = element => element.href;
  const isCrossDomain = function(url) {
    const originAnchor = document.createElement("a");
    originAnchor.href = location.href;
    const urlAnchor = document.createElement("a");
    try {
      urlAnchor.href = url;
      return !((!urlAnchor.protocol || urlAnchor.protocol === ":") && !urlAnchor.host || originAnchor.protocol + "//" + originAnchor.host === urlAnchor.protocol + "//" + urlAnchor.host);
    } catch (e) {
      return true;
    }
  };
  let preventDefault;
  let {CustomEvent: CustomEvent} = window;
  if (typeof CustomEvent !== "function") {
    CustomEvent = function(event, params) {
      const evt = document.createEvent("CustomEvent");
      evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
      return evt;
    };
    CustomEvent.prototype = window.Event.prototype;
    ({preventDefault: preventDefault} = CustomEvent.prototype);
    CustomEvent.prototype.preventDefault = function() {
      const result = preventDefault.call(this);
      if (this.cancelable && !this.defaultPrevented) {
        Object.defineProperty(this, "defaultPrevented", {
          get() {
            return true;
          }
        });
      }
      return result;
    };
  }
  const fire = (obj, name, data) => {
    const event = new CustomEvent(name, {
      bubbles: true,
      cancelable: true,
      detail: data
    });
    obj.dispatchEvent(event);
    return !event.defaultPrevented;
  };
  const stopEverything = e => {
    fire(e.target, "ujs:everythingStopped");
    e.preventDefault();
    e.stopPropagation();
    e.stopImmediatePropagation();
  };
  const delegate = (element, selector, eventType, handler) => element.addEventListener(eventType, (function(e) {
    let {target: target} = e;
    while (!!(target instanceof Element) && !matches(target, selector)) {
      target = target.parentNode;
    }
    if (target instanceof Element && handler.call(target, e) === false) {
      e.preventDefault();
      e.stopPropagation();
    }
  }));
  const toArray = e => Array.prototype.slice.call(e);
  const serializeElement = (element, additionalParam) => {
    let inputs = [ element ];
    if (matches(element, "form")) {
      inputs = toArray(element.elements);
    }
    const params = [];
    inputs.forEach((function(input) {
      if (!input.name || input.disabled) {
        return;
      }
      if (matches(input, "fieldset[disabled] *")) {
        return;
      }
      if (matches(input, "select")) {
        toArray(input.options).forEach((function(option) {
          if (option.selected) {
            params.push({
              name: input.name,
              value: option.value
            });
          }
        }));
      } else if (input.checked || [ "radio", "checkbox", "submit" ].indexOf(input.type) === -1) {
        params.push({
          name: input.name,
          value: input.value
        });
      }
    }));
    if (additionalParam) {
      params.push(additionalParam);
    }
    return params.map((function(param) {
      if (param.name) {
        return `${encodeURIComponent(param.name)}=${encodeURIComponent(param.value)}`;
      } else {
        return param;
      }
    })).join("&");
  };
  const formElements = (form, selector) => {
    if (matches(form, "form")) {
      return toArray(form.elements).filter((el => matches(el, selector)));
    } else {
      return toArray(form.querySelectorAll(selector));
    }
  };
  const handleConfirmWithRails = rails => function(e) {
    if (!allowAction(this, rails)) {
      stopEverything(e);
    }
  };
  const confirm = (message, element) => window.confirm(message);
  var allowAction = function(element, rails) {
    let callback;
    const message = element.getAttribute("data-confirm");
    if (!message) {
      return true;
    }
    let answer = false;
    if (fire(element, "confirm")) {
      try {
        answer = rails.confirm(message, element);
      } catch (error) {}
      callback = fire(element, "confirm:complete", [ answer ]);
    }
    return answer && callback;
  };
  const handleDisabledElement = function(e) {
    const element = this;
    if (element.disabled) {
      stopEverything(e);
    }
  };
  const enableElement = e => {
    let element;
    if (e instanceof Event) {
      if (isXhrRedirect(e)) {
        return;
      }
      element = e.target;
    } else {
      element = e;
    }
    if (isContentEditable(element)) {
      return;
    }
    if (matches(element, linkDisableSelector)) {
      return enableLinkElement(element);
    } else if (matches(element, buttonDisableSelector) || matches(element, formEnableSelector)) {
      return enableFormElement(element);
    } else if (matches(element, formSubmitSelector)) {
      return enableFormElements(element);
    }
  };
  const disableElement = e => {
    const element = e instanceof Event ? e.target : e;
    if (isContentEditable(element)) {
      return;
    }
    if (matches(element, linkDisableSelector)) {
      return disableLinkElement(element);
    } else if (matches(element, buttonDisableSelector) || matches(element, formDisableSelector)) {
      return disableFormElement(element);
    } else if (matches(element, formSubmitSelector)) {
      return disableFormElements(element);
    }
  };
  var disableLinkElement = function(element) {
    if (getData(element, "ujs:disabled")) {
      return;
    }
    const replacement = element.getAttribute("data-disable-with");
    if (replacement != null) {
      setData(element, "ujs:enable-with", element.innerHTML);
      element.innerHTML = replacement;
    }
    element.addEventListener("click", stopEverything);
    return setData(element, "ujs:disabled", true);
  };
  var enableLinkElement = function(element) {
    const originalText = getData(element, "ujs:enable-with");
    if (originalText != null) {
      element.innerHTML = originalText;
      setData(element, "ujs:enable-with", null);
    }
    element.removeEventListener("click", stopEverything);
    return setData(element, "ujs:disabled", null);
  };
  var disableFormElements = form => formElements(form, formDisableSelector).forEach(disableFormElement);
  var disableFormElement = function(element) {
    if (getData(element, "ujs:disabled")) {
      return;
    }
    const replacement = element.getAttribute("data-disable-with");
    if (replacement != null) {
      if (matches(element, "button")) {
        setData(element, "ujs:enable-with", element.innerHTML);
        element.innerHTML = replacement;
      } else {
        setData(element, "ujs:enable-with", element.value);
        element.value = replacement;
      }
    }
    element.disabled = true;
    return setData(element, "ujs:disabled", true);
  };
  var enableFormElements = form => formElements(form, formEnableSelector).forEach((element => enableFormElement(element)));
  var enableFormElement = function(element) {
    const originalText = getData(element, "ujs:enable-with");
    if (originalText != null) {
      if (matches(element, "button")) {
        element.innerHTML = originalText;
      } else {
        element.value = originalText;
      }
      setData(element, "ujs:enable-with", null);
    }
    element.disabled = false;
    return setData(element, "ujs:disabled", null);
  };
  var isXhrRedirect = function(event) {
    const xhr = event.detail ? event.detail[0] : undefined;
    return xhr && xhr.getResponseHeader("X-Xhr-Redirect");
  };
  const handleMethodWithRails = rails => function(e) {
    const link = this;
    const method = link.getAttribute("data-method");
    if (!method) {
      return;
    }
    if (isContentEditable(this)) {
      return;
... 230 lines truncated (630 total)

web/public/assets/rails-ujs.esm-e925103b.js

/*
Unobtrusive JavaScript
https://github.com/rails/rails/blob/main/actionview/app/javascript
Released under the MIT license
 */
const linkClickSelector = "a[data-confirm], a[data-method], a[data-remote]:not([disabled]), a[data-disable-with], a[data-disable]";

const buttonClickSelector = {
  selector: "button[data-remote]:not([form]), button[data-confirm]:not([form])",
  exclude: "form button"
};

const inputChangeSelector = "select[data-remote], input[data-remote], textarea[data-remote]";

const formSubmitSelector = "form:not([data-turbo=true])";

const formInputClickSelector = "form:not([data-turbo=true]) input[type=submit], form:not([data-turbo=true]) input[type=image], form:not([data-turbo=true]) button[type=submit], form:not([data-turbo=true]) button:not([type]), input[type=submit][form], input[type=image][form], button[type=submit][form], button[form]:not([type])";

const formDisableSelector = "input[data-disable-with]:enabled, button[data-disable-with]:enabled, textarea[data-disable-with]:enabled, input[data-disable]:enabled, button[data-disable]:enabled, textarea[data-disable]:enabled";

const formEnableSelector = "input[data-disable-with]:disabled, button[data-disable-with]:disabled, textarea[data-disable-with]:disabled, input[data-disable]:disabled, button[data-disable]:disabled, textarea[data-disable]:disabled";

const fileInputSelector = "input[name][type=file]:not([disabled])";

const linkDisableSelector = "a[data-disable-with], a[data-disable]";

const buttonDisableSelector = "button[data-remote][data-disable-with], button[data-remote][data-disable]";

let nonce = null;

const loadCSPNonce = () => {
  const metaTag = document.querySelector("meta[name=csp-nonce]");
  return nonce = metaTag && metaTag.content;
};

const cspNonce = () => nonce || loadCSPNonce();

const m = Element.prototype.matches || Element.prototype.matchesSelector || Element.prototype.mozMatchesSelector || Element.prototype.msMatchesSelector || Element.prototype.oMatchesSelector || Element.prototype.webkitMatchesSelector;

const matches = function(element, selector) {
  if (selector.exclude) {
    return m.call(element, selector.selector) && !m.call(element, selector.exclude);
  } else {
    return m.call(element, selector);
  }
};

const EXPANDO = "_ujsData";

const getData = (element, key) => element[EXPANDO] ? element[EXPANDO][key] : undefined;

const setData = function(element, key, value) {
  if (!element[EXPANDO]) {
    element[EXPANDO] = {};
  }
  return element[EXPANDO][key] = value;
};

const $ = selector => Array.prototype.slice.call(document.querySelectorAll(selector));

const isContentEditable = function(element) {
  var isEditable = false;
  do {
    if (element.isContentEditable) {
      isEditable = true;
      break;
    }
    element = element.parentElement;
  } while (element);
  return isEditable;
};

const csrfToken = () => {
  const meta = document.querySelector("meta[name=csrf-token]");
  return meta && meta.content;
};

const csrfParam = () => {
  const meta = document.querySelector("meta[name=csrf-param]");
  return meta && meta.content;
};

const CSRFProtection = xhr => {
  const token = csrfToken();
  if (token) {
    return xhr.setRequestHeader("X-CSRF-Token", token);
  }
};

const refreshCSRFTokens = () => {
  const token = csrfToken();
  const param = csrfParam();
  if (token && param) {
    return $('form input[name="' + param + '"]').forEach((input => input.value = token));
  }
};

const AcceptHeaders = {
  "*": "*/*",
  text: "text/plain",
  html: "text/html",
  xml: "application/xml, text/xml",
  json: "application/json, text/javascript",
  script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"
};

const ajax = options => {
  options = prepareOptions(options);
  var xhr = createXHR(options, (function() {
    const response = processResponse(xhr.response != null ? xhr.response : xhr.responseText, xhr.getResponseHeader("Content-Type"));
    if (Math.floor(xhr.status / 100) === 2) {
      if (typeof options.success === "function") {
        options.success(response, xhr.statusText, xhr);
      }
    } else {
      if (typeof options.error === "function") {
        options.error(response, xhr.statusText, xhr);
      }
    }
    return typeof options.complete === "function" ? options.complete(xhr, xhr.statusText) : undefined;
  }));
  if (options.beforeSend && !options.beforeSend(xhr, options)) {
    return false;
  }
  if (xhr.readyState === XMLHttpRequest.OPENED) {
    return xhr.send(options.data);
  }
};

var prepareOptions = function(options) {
  options.url = options.url || location.href;
  options.type = options.type.toUpperCase();
  if (options.type === "GET" && options.data) {
    if (options.url.indexOf("?") < 0) {
      options.url += "?" + options.data;
    } else {
      options.url += "&" + options.data;
    }
  }
  if (!(options.dataType in AcceptHeaders)) {
    options.dataType = "*";
  }
  options.accept = AcceptHeaders[options.dataType];
  if (options.dataType !== "*") {
    options.accept += ", */*; q=0.01";
  }
  return options;
};

var createXHR = function(options, done) {
  const xhr = new XMLHttpRequest;
  xhr.open(options.type, options.url, true);
  xhr.setRequestHeader("Accept", options.accept);
  if (typeof options.data === "string") {
    xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
  }
  if (!options.crossDomain) {
    xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
    CSRFProtection(xhr);
  }
  xhr.withCredentials = !!options.withCredentials;
  xhr.onreadystatechange = function() {
    if (xhr.readyState === XMLHttpRequest.DONE) {
      return done(xhr);
    }
  };
  return xhr;
};

var processResponse = function(response, type) {
  if (typeof response === "string" && typeof type === "string") {
    if (type.match(/\bjson\b/)) {
      try {
        response = JSON.parse(response);
      } catch (error) {}
    } else if (type.match(/\b(?:java|ecma)script\b/)) {
      const script = document.createElement("script");
      script.setAttribute("nonce", cspNonce());
      script.text = response;
      document.head.appendChild(script).parentNode.removeChild(script);
    } else if (type.match(/\b(xml|html|svg)\b/)) {
      const parser = new DOMParser;
      type = type.replace(/;.+/, "");
      try {
        response = parser.parseFromString(response, type);
      } catch (error1) {}
    }
  }
  return response;
};

const href = element => element.href;

const isCrossDomain = function(url) {
  const originAnchor = document.createElement("a");
  originAnchor.href = location.href;
  const urlAnchor = document.createElement("a");
  try {
    urlAnchor.href = url;
    return !((!urlAnchor.protocol || urlAnchor.protocol === ":") && !urlAnchor.host || originAnchor.protocol + "//" + originAnchor.host === urlAnchor.protocol + "//" + urlAnchor.host);
  } catch (e) {
    return true;
  }
};

let preventDefault;

let {CustomEvent: CustomEvent} = window;

if (typeof CustomEvent !== "function") {
  CustomEvent = function(event, params) {
    const evt = document.createEvent("CustomEvent");
    evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
    return evt;
  };
  CustomEvent.prototype = window.Event.prototype;
  ({preventDefault: preventDefault} = CustomEvent.prototype);
  CustomEvent.prototype.preventDefault = function() {
    const result = preventDefault.call(this);
    if (this.cancelable && !this.defaultPrevented) {
      Object.defineProperty(this, "defaultPrevented", {
        get() {
          return true;
        }
      });
    }
    return result;
  };
}

const fire = (obj, name, data) => {
  const event = new CustomEvent(name, {
    bubbles: true,
    cancelable: true,
    detail: data
  });
  obj.dispatchEvent(event);
  return !event.defaultPrevented;
};

const stopEverything = e => {
  fire(e.target, "ujs:everythingStopped");
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
};

const delegate = (element, selector, eventType, handler) => element.addEventListener(eventType, (function(e) {
  let {target: target} = e;
  while (!!(target instanceof Element) && !matches(target, selector)) {
    target = target.parentNode;
  }
  if (target instanceof Element && handler.call(target, e) === false) {
    e.preventDefault();
    e.stopPropagation();
  }
}));

const toArray = e => Array.prototype.slice.call(e);

const serializeElement = (element, additionalParam) => {
  let inputs = [ element ];
  if (matches(element, "form")) {
    inputs = toArray(element.elements);
  }
  const params = [];
  inputs.forEach((function(input) {
    if (!input.name || input.disabled) {
      return;
    }
    if (matches(input, "fieldset[disabled] *")) {
      return;
    }
    if (matches(input, "select")) {
      toArray(input.options).forEach((function(option) {
        if (option.selected) {
          params.push({
            name: input.name,
            value: option.value
          });
        }
      }));
    } else if (input.checked || [ "radio", "checkbox", "submit" ].indexOf(input.type) === -1) {
      params.push({
        name: input.name,
        value: input.value
      });
    }
  }));
  if (additionalParam) {
    params.push(additionalParam);
  }
  return params.map((function(param) {
    if (param.name) {
      return `${encodeURIComponent(param.name)}=${encodeURIComponent(param.value)}`;
    } else {
      return param;
    }
  })).join("&");
};

const formElements = (form, selector) => {
  if (matches(form, "form")) {
    return toArray(form.elements).filter((el => matches(el, selector)));
  } else {
    return toArray(form.querySelectorAll(selector));
  }
};

const handleConfirmWithRails = rails => function(e) {
  if (!allowAction(this, rails)) {
    stopEverything(e);
  }
};

const confirm = (message, element) => window.confirm(message);

var allowAction = function(element, rails) {
  let callback;
  const message = element.getAttribute("data-confirm");
  if (!message) {
    return true;
  }
  let answer = false;
  if (fire(element, "confirm")) {
    try {
      answer = rails.confirm(message, element);
    } catch (error) {}
    callback = fire(element, "confirm:complete", [ answer ]);
  }
  return answer && callback;
};

const handleDisabledElement = function(e) {
  const element = this;
  if (element.disabled) {
    stopEverything(e);
  }
};

const enableElement = e => {
  let element;
  if (e instanceof Event) {
    if (isXhrRedirect(e)) {
      return;
    }
    element = e.target;
  } else {
    element = e;
  }
  if (isContentEditable(element)) {
    return;
  }
  if (matches(element, linkDisableSelector)) {
    return enableLinkElement(element);
  } else if (matches(element, buttonDisableSelector) || matches(element, formEnableSelector)) {
    return enableFormElement(element);
  } else if (matches(element, formSubmitSelector)) {
    return enableFormElements(element);
  }
};

const disableElement = e => {
  const element = e instanceof Event ? e.target : e;
  if (isContentEditable(element)) {
    return;
  }
  if (matches(element, linkDisableSelector)) {
    return disableLinkElement(element);
  } else if (matches(element, buttonDisableSelector) || matches(element, formDisableSelector)) {
    return disableFormElement(element);
  } else if (matches(element, formSubmitSelector)) {
    return disableFormElements(element);
  }
};

var disableLinkElement = function(element) {
  if (getData(element, "ujs:disabled")) {
    return;
  }
  const replacement = element.getAttribute("data-disable-with");
  if (replacement != null) {
    setData(element, "ujs:enable-with", element.innerHTML);
    element.innerHTML = replacement;
  }
  element.addEventListener("click", stopEverything);
  return setData(element, "ujs:disabled", true);
};

var enableLinkElement = function(element) {
  const originalText = getData(element, "ujs:enable-with");
  if (originalText != null) {
    element.innerHTML = originalText;
    setData(element, "ujs:enable-with", null);
  }
  element.removeEventListener("click", stopEverything);
  return setData(element, "ujs:disabled", null);
};

var disableFormElements = form => formElements(form, formDisableSelector).forEach(disableFormElement);
... 286 lines truncated (686 total)

web/public/robots.txt

# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file

files: 306 / lines: 24416 / truncated: 5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment