rubyn-code 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +120 -3
  3. data/db/migrations/014_multi_agent_upgrade.rb +79 -0
  4. data/lib/rubyn_code/agent/conversation.rb +89 -3
  5. data/lib/rubyn_code/agent/llm_caller.rb +2 -2
  6. data/lib/rubyn_code/agent/loop.rb +49 -9
  7. data/lib/rubyn_code/agent/system_prompt_builder.rb +37 -2
  8. data/lib/rubyn_code/agent/tool_processor.rb +3 -1
  9. data/lib/rubyn_code/auth/oauth.rb +1 -1
  10. data/lib/rubyn_code/auth/token_store.rb +49 -4
  11. data/lib/rubyn_code/checkpoint/hook.rb +26 -0
  12. data/lib/rubyn_code/checkpoint/manager.rb +109 -0
  13. data/lib/rubyn_code/chisel/debt.rb +65 -0
  14. data/lib/rubyn_code/chisel/inspection.rb +93 -0
  15. data/lib/rubyn_code/chisel.rb +127 -0
  16. data/lib/rubyn_code/cli/commands/agents.rb +31 -0
  17. data/lib/rubyn_code/cli/commands/chisel.rb +52 -0
  18. data/lib/rubyn_code/cli/commands/chisel_audit.rb +19 -0
  19. data/lib/rubyn_code/cli/commands/chisel_debt.rb +28 -0
  20. data/lib/rubyn_code/cli/commands/chisel_gain.rb +30 -0
  21. data/lib/rubyn_code/cli/commands/chisel_review.rb +19 -0
  22. data/lib/rubyn_code/cli/commands/command_template.rb +50 -0
  23. data/lib/rubyn_code/cli/commands/context.rb +3 -1
  24. data/lib/rubyn_code/cli/commands/custom_command.rb +42 -0
  25. data/lib/rubyn_code/cli/commands/custom_loader.rb +69 -0
  26. data/lib/rubyn_code/cli/commands/goal.rb +87 -0
  27. data/lib/rubyn_code/cli/commands/learning.rb +62 -0
  28. data/lib/rubyn_code/cli/commands/loop.rb +58 -0
  29. data/lib/rubyn_code/cli/commands/mcp.rb +18 -5
  30. data/lib/rubyn_code/cli/commands/megaplan.rb +1 -1
  31. data/lib/rubyn_code/cli/commands/registry.rb +14 -9
  32. data/lib/rubyn_code/cli/commands/rewind.rb +65 -0
  33. data/lib/rubyn_code/cli/first_run.rb +1 -1
  34. data/lib/rubyn_code/cli/loop_runner.rb +98 -0
  35. data/lib/rubyn_code/cli/mention_expander.rb +92 -0
  36. data/lib/rubyn_code/cli/renderer.rb +3 -2
  37. data/lib/rubyn_code/cli/repl.rb +37 -14
  38. data/lib/rubyn_code/cli/repl_commands.rb +76 -2
  39. data/lib/rubyn_code/cli/repl_setup.rb +9 -1
  40. data/lib/rubyn_code/cli/stream_formatter.rb +3 -2
  41. data/lib/rubyn_code/cli/version_check.rb +10 -3
  42. data/lib/rubyn_code/config/defaults.rb +13 -1
  43. data/lib/rubyn_code/config/schema.json +4 -0
  44. data/lib/rubyn_code/config/settings.rb +17 -2
  45. data/lib/rubyn_code/context/manager.rb +29 -12
  46. data/lib/rubyn_code/debug.rb +11 -5
  47. data/lib/rubyn_code/goal/evaluator.rb +95 -0
  48. data/lib/rubyn_code/hooks/event_map.rb +56 -0
  49. data/lib/rubyn_code/hooks/external_dispatcher.rb +199 -0
  50. data/lib/rubyn_code/hooks/goal_hook.rb +88 -0
  51. data/lib/rubyn_code/hooks/response.rb +83 -0
  52. data/lib/rubyn_code/hooks/runner.rb +61 -3
  53. data/lib/rubyn_code/hooks/settings_json_loader.rb +109 -0
  54. data/lib/rubyn_code/hooks/subprocess_executor.rb +116 -0
  55. data/lib/rubyn_code/ide/handlers/plan_interview_answer_handler.rb +13 -13
  56. data/lib/rubyn_code/ide/handlers/plan_interview_cancel_handler.rb +1 -1
  57. data/lib/rubyn_code/ide/handlers/plan_interview_start_handler.rb +10 -10
  58. data/lib/rubyn_code/ide/handlers/plan_propose_handler.rb +1 -1
  59. data/lib/rubyn_code/ide/handlers/prompt_handler.rb +9 -1
  60. data/lib/rubyn_code/ide/handlers/recover_ci_handler.rb +27 -16
  61. data/lib/rubyn_code/ide/handlers/session_resume_handler.rb +1 -1
  62. data/lib/rubyn_code/index/codebase_index.rb +39 -1
  63. data/lib/rubyn_code/learning/porter.rb +129 -0
  64. data/lib/rubyn_code/llm/adapters/anthropic.rb +65 -16
  65. data/lib/rubyn_code/llm/adapters/openai.rb +1 -1
  66. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +5 -1
  67. data/lib/rubyn_code/llm/adapters/token_caching.rb +54 -0
  68. data/lib/rubyn_code/llm/model_router.rb +2 -2
  69. data/lib/rubyn_code/mcp/client.rb +59 -0
  70. data/lib/rubyn_code/mcp/server_extras_bridge.rb +110 -0
  71. data/lib/rubyn_code/mcp/sse_transport.rb +2 -1
  72. data/lib/rubyn_code/mcp/tool_bridge.rb +16 -14
  73. data/lib/rubyn_code/megaplan/ci_recovery.rb +3 -3
  74. data/lib/rubyn_code/megaplan/interview_session.rb +8 -3
  75. data/lib/rubyn_code/megaplan/plan_proposer.rb +3 -3
  76. data/lib/rubyn_code/memory/search.rb +9 -5
  77. data/lib/rubyn_code/memory/session_persistence.rb +159 -21
  78. data/lib/rubyn_code/observability/cost_calculator.rb +3 -1
  79. data/lib/rubyn_code/output/diff_renderer.rb +62 -7
  80. data/lib/rubyn_code/skills/auto_suggest.rb +70 -2
  81. data/lib/rubyn_code/skills/registry_client.rb +4 -3
  82. data/lib/rubyn_code/sub_agents/agent_type.rb +17 -0
  83. data/lib/rubyn_code/sub_agents/catalog.rb +124 -0
  84. data/lib/rubyn_code/teams/agent_registry.rb +120 -0
  85. data/lib/rubyn_code/teams/mailbox.rb +99 -10
  86. data/lib/rubyn_code/teams/manager.rb +83 -5
  87. data/lib/rubyn_code/teams/teammate.rb +5 -1
  88. data/lib/rubyn_code/tools/ask_user.rb +15 -1
  89. data/lib/rubyn_code/tools/executor.rb +5 -3
  90. data/lib/rubyn_code/tools/spawn_agent.rb +47 -62
  91. data/lib/rubyn_code/tools/spawn_teammate.rb +7 -2
  92. data/lib/rubyn_code/tools/web_fetch.rb +1 -1
  93. data/lib/rubyn_code/tools/web_search.rb +4 -1
  94. data/lib/rubyn_code/version.rb +1 -1
  95. data/lib/rubyn_code.rb +45 -2
  96. data/skills/rubyn_self_test.md +322 -14
  97. data/skills/self_test/chisel_smoke.rb +84 -0
  98. data/skills/self_test/fixtures/chisel_sample.rb +64 -0
  99. metadata +37 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b43dc2a8fab138bcfe303180881aa9fa23878faaa532367f07a0fee7605feb34
4
- data.tar.gz: 12dffaa487a75dfe276be9d20ff8152814bd60c6b107efd9ed10d2993afd57a5
3
+ metadata.gz: f5cbf6f4737790408893a904d64412c2dd695e81a2cd2e5960b4da3196e48f4f
4
+ data.tar.gz: f0e9b80f10c77d6eb6c357db7518851344670653546d5eca2784d017d490608c
5
5
  SHA512:
6
- metadata.gz: fcbe574a607028345e38587388a6c0bd9cdb8ab134e690d45583a1a27b4c698383bd43088eb8fa537cddba8690e77d15ab155be92fb997117c8fe62a978df211
7
- data.tar.gz: 43e72475ec24edf03f21e4aafa4402de31351728a38de42e55b69db104aea1828b930a1cafb79d3065d07e16f84c8a5a5023311da5d1e40f7a667daa90ddee3c
6
+ metadata.gz: bc4553f04fbac43903e1515cec43cf595245d36223bca70342ddafb2a2920f38f502b356eb353b4b6e5284d128688e07c015e0a3ebb4aa06f7029fac0ba42a37
7
+ data.tar.gz: b9f8ab09a40e073bff1657ef9d829b7551a5ca56ce9f77b65bd59b835d079665d3663faf92ccf44fd84d4ff3674f835c09d102b4f449c175c77bcafb466dd934
data/README.md CHANGED
@@ -138,6 +138,8 @@ rubyn-code -p "Refactor app/controllers/orders_controller.rb into service object
138
138
  rubyn-code --ide
139
139
  ```
140
140
 
141
+ **Tip:** Reference files inline with `@` — `rubyn > explain @lib/foo.rb and @config/routes.rb`. Rubyn inlines their contents so you don't have to wait for it to read them.
142
+
141
143
  ## What Can Rubyn Do?
142
144
 
143
145
  ### Refactor code
@@ -227,7 +229,7 @@ The extension communicates over 19 RPC methods: `initialize`, `prompt`, `cancel`
227
229
 
228
230
  ## MCP — External Tool Servers
229
231
 
230
- Connect external tool servers via the [Model Context Protocol](https://modelcontextprotocol.io). MCP tools are dynamically discovered and registered as native Rubyn tools, available in the REPL, IDE, and daemon.
232
+ Connect external tool servers via the [Model Context Protocol](https://modelcontextprotocol.io). MCP tools are dynamically discovered and registered as native Rubyn tools, available in the REPL, IDE, and daemon. Servers that expose **resources** and **prompts** are also bridged — Rubyn registers per-server `read_resource` and `get_prompt` tools, and `/mcp` reports the counts.
231
233
 
232
234
  ### Configuration
233
235
 
@@ -349,7 +351,7 @@ Rubyn automatically loads relevant context based on what you're working on:
349
351
  - **Controllers** → includes models, routes, request specs, services
350
352
  - **Models** → includes schema, associations, specs, factories
351
353
  - **Service objects** → includes referenced models and their specs
352
- - **Any file** → checks for `RUBYN.md`, `CLAUDE.md`, or `AGENT.md` instructions
354
+ - **Any file** → checks for `RUBYN.md`, `CLAUDE.md`, `AGENTS.md`, or `AGENT.md` instructions
353
355
 
354
356
  The [codebase index](#codebase-indexing) enhances this with structural awareness — Rubyn knows which files depend on each other before it reads them.
355
357
 
@@ -367,7 +369,7 @@ Drop a `RUBYN.md` in your project root and Rubyn follows your conventions:
367
369
  - Run rubocop before committing
368
370
  ```
369
371
 
370
- Also reads `CLAUDE.md` and `AGENT.md` — no migration needed from other tools.
372
+ Also reads `CLAUDE.md`, `AGENTS.md`, and `AGENT.md` — no migration needed from other tools.
371
373
 
372
374
  | Location | Scope |
373
375
  |----------|-------|
@@ -390,6 +392,65 @@ Focus areas: `all`, `security`, `performance`, `style`, `testing`
390
392
 
391
393
  Severity ratings: **[critical]** **[warning]** **[suggestion]** **[nitpick]**
392
394
 
395
+ ## Chisel — Write the Minimum That Works
396
+
397
+ Chisel is an opt-in mode that makes Rubyn think like the laziest senior dev in
398
+ the room: the best code is the code you never wrote. It's **off by default** and
399
+ only changes the agent's behavior once you turn it on.
400
+
401
+ ```
402
+ rubyn > /chisel # show current intensity
403
+ rubyn > /chisel full # turn it on
404
+ rubyn > /chisel off # back to normal
405
+ ```
406
+
407
+ Intensities: `off` (default) · `lite` · `full` · `ultra`. When on, Rubyn walks a
408
+ decision ladder before writing code — does this need to exist? does stdlib or an
409
+ installed gem already do it? is it one line? — and only then writes the smallest
410
+ change that solves the task. The safety floor (validation, error/data-loss
411
+ handling, security, accessibility) is never on the chopping block.
412
+
413
+ Set it permanently with `chisel_mode: full` in `~/.rubyn-code/config.yml`, or
414
+ per-shell with `RUBYN_CHISEL_MODE=full`.
415
+
416
+ **On-demand audits** (work whether or not the always-on mode is enabled):
417
+
418
+ ```
419
+ rubyn > /chisel-review # over-engineering in your diff vs main
420
+ rubyn > /chisel-review develop # ...vs a different base
421
+ rubyn > /chisel-audit # sweep the whole repo
422
+ rubyn > /chisel-audit app/services # ...scoped to a path
423
+ ```
424
+
425
+ Both return a ranked deletion/simplification list — each item with a location, the
426
+ ladder rung it skipped, and the concrete simpler form — and stay read-only (they
427
+ report, they don't edit).
428
+
429
+ **Debt ledger & status.** Leave a `# chisel: …` comment when you consciously defer a
430
+ simplification, then collect them later:
431
+
432
+ ```
433
+ rubyn > /chisel-debt # list every `# chisel:` deferral, with file:line and note
434
+ rubyn > /chisel-gain # current mode, outstanding debt count, and reference impact
435
+ ```
436
+
437
+ **Verifying Chisel.** A standalone smoke test runs the whole layer against a
438
+ committed, deliberately over-engineered fixture and gives the same result every
439
+ time — no LLM, no network:
440
+
441
+ ```
442
+ $ bundle exec ruby skills/self_test/chisel_smoke.rb
443
+ CHISEL debt: PASS # scans the fixture → its exact planted `# chisel:` markers
444
+ CHISEL engine: PASS # off injects nothing; lite/full/ultra keep the safety floor
445
+ CHISEL inspection: PASS # review/audit prompts assemble; bad scope raises
446
+ CHISEL commands: PASS # all five slash commands register
447
+ CHISEL: PASS
448
+ ```
449
+
450
+ The fixture lives at `skills/self_test/fixtures/chisel_sample.rb`, the same check is
451
+ Section 18 of the `/skill self-test` scorecard, and `spec/rubyn_code/chisel/self_test_fixture_spec.rb`
452
+ guards the fixture's exact scan output so it can't silently drift.
453
+
393
454
  ## Megaplan — Phased Planning
394
455
 
395
456
  For work too big for a single PR — rewrites, migrations, multi-feature initiatives — Rubyn ships a planning workflow that breaks the feature into vertical-slice phases before any code gets written.
@@ -476,6 +537,19 @@ Rubyn gets smarter with every session:
476
537
  3. **On next startup** — injects top instincts into the system prompt
477
538
  4. **Over time** — reinforced instincts strengthen, unused ones decay and get pruned
478
539
 
540
+ ### Take your learnings with you
541
+
542
+ Instincts live in `~/.rubyn-code`. Move them to another machine with `/learning`:
543
+
544
+ ```
545
+ rubyn > /learning # how many instincts you've accumulated
546
+ rubyn > /learning export learnings.json # write them to a portable file
547
+ # ...on the new machine...
548
+ rubyn > /learning import learnings.json --here # load them (--here = apply to this project)
549
+ ```
550
+
551
+ Import regenerates ids and skips duplicates, so it's safe to run repeatedly.
552
+
479
553
  ## Streaming Output
480
554
 
481
555
  Real-time streaming with live syntax highlighting via Rouge/Monokai. Code blocks are buffered and highlighted when complete. No waiting for full responses.
@@ -537,6 +611,8 @@ rubyn-code daemon [OPTIONS] # Run GOLEM autonomous daemon
537
611
  | `/new` | Save session and start a fresh conversation |
538
612
  | `/review [base]` | PR review against best practices |
539
613
  | `/spawn name role` | Spawn a persistent teammate |
614
+ | `/goal <condition>` | Set a goal Rubyn works toward until met (`/goal clear` to cancel) |
615
+ | `/loop [xN] [interval] <prompt-or-/cmd>` | Repeat a prompt or command on an interval (Ctrl-C to stop) |
540
616
  | `/compact` | Compress conversation context |
541
617
  | `/cost` | Show token usage and costs |
542
618
  | `/tasks` | List all tasks |
@@ -547,6 +623,47 @@ rubyn-code daemon [OPTIONS] # Run GOLEM autonomous daemon
547
623
  | `/model` | Show/switch model and provider |
548
624
  | `/doctor` | Run environment health checks |
549
625
  | `/mcp` | MCP server documentation and status |
626
+ | `/agents` | List sub-agent types (built-in + custom) |
627
+ | `/rewind [id] [code\|chat]` | Rewind code and/or conversation to a checkpoint |
628
+
629
+ ### Custom Sub-Agents
630
+
631
+ Beyond the built-in `explore` (read-only) and `worker` (read/write) sub-agents, define your own in `.rubyn-code/agents/<name>.md` (project) or `~/.rubyn-code/agents/<name>.md` (global). `spawn_agent` can then target them by name, and `/agents` lists them.
632
+
633
+ ```markdown
634
+ ---
635
+ description: Reviews a diff for bugs
636
+ tools: read_file, grep, glob, bash # optional — omit for the access default
637
+ access: read # read | write (default: write)
638
+ ---
639
+ You are a meticulous code reviewer. Find correctness bugs only.
640
+ ```
641
+
642
+ ### Custom Slash Commands
643
+
644
+ Drop a markdown file in `.rubyn-code/commands/` (project) or `~/.rubyn-code/commands/` (global) and it becomes a slash command — `deploy.md` → `/deploy`. Project commands override global ones; built-ins always win.
645
+
646
+ ```markdown
647
+ ---
648
+ description: Open a PR for the current branch
649
+ ---
650
+ Open a pull request for the current branch.
651
+ Title: $ARGUMENTS
652
+ Current diff:
653
+ !`git diff main --stat`
654
+ ```
655
+
656
+ Templating in the body:
657
+
658
+ | Token | Expands to |
659
+ |-------|-----------|
660
+ | `$ARGUMENTS` | everything typed after the command |
661
+ | `$1` … `$9` | individual positional arguments |
662
+ | `` !`shell cmd` `` | the command's output, inlined |
663
+
664
+ ### Checkpoints & Rewind
665
+
666
+ Rubyn snapshots a checkpoint at the start of every turn — capturing the conversation and the original contents of any files it changes that turn. `/rewind` lists them; `/rewind <id>` rolls back both code and conversation (or just one with `code`/`chat`). Note: rewind restores files edited via Rubyn's `write_file`/`edit_file`; it does not touch your git history.
550
667
 
551
668
  ## Authentication
552
669
 
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Multi-agent upgrade: adds parent-child tracking to teammates and
4
+ # structured messaging to mailbox_messages.
5
+ #
6
+ # Teammates: adds parent_agent_id column, fixes status CHECK
7
+ # (DB had 'busy' but Ruby code uses 'active').
8
+ # Mailbox: adds correlation_id and data columns for structured messaging.
9
+ #
10
+ # Uses table-rebuild pattern because SQLite cannot ALTER CHECK constraints
11
+ # or ADD COLUMN with constraints reliably.
12
+ module Migration014MultiAgentUpgrade
13
+ module_function
14
+
15
+ def up(db)
16
+ upgrade_teammates(db)
17
+ upgrade_mailbox(db)
18
+ end
19
+
20
+ def upgrade_teammates(db)
21
+ db.execute(<<~SQL)
22
+ CREATE TABLE teammates_new (
23
+ id TEXT PRIMARY KEY,
24
+ name TEXT NOT NULL UNIQUE,
25
+ role TEXT NOT NULL,
26
+ persona TEXT,
27
+ model TEXT NOT NULL DEFAULT 'claude-sonnet-4-20250514',
28
+ status TEXT NOT NULL DEFAULT 'idle' CHECK(status IN ('idle','active','offline')),
29
+ parent_agent_id TEXT,
30
+ metadata TEXT DEFAULT '{}',
31
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
32
+ )
33
+ SQL
34
+
35
+ db.execute(<<~SQL)
36
+ INSERT INTO teammates_new (id, name, role, persona, model, status, metadata, created_at)
37
+ SELECT id, name, role, persona, model,
38
+ CASE WHEN status = 'busy' THEN 'active' ELSE status END,
39
+ metadata, created_at
40
+ FROM teammates
41
+ SQL
42
+
43
+ db.execute('DROP TABLE teammates')
44
+ db.execute('ALTER TABLE teammates_new RENAME TO teammates')
45
+ db.execute('CREATE UNIQUE INDEX IF NOT EXISTS idx_teammates_name ON teammates(name)')
46
+ db.execute('CREATE INDEX IF NOT EXISTS idx_teammates_status ON teammates(status)')
47
+ db.execute('CREATE INDEX IF NOT EXISTS idx_teammates_parent ON teammates(parent_agent_id)')
48
+ end
49
+
50
+ def upgrade_mailbox(db)
51
+ db.execute(<<~SQL)
52
+ CREATE TABLE mailbox_messages_new (
53
+ id TEXT PRIMARY KEY,
54
+ sender TEXT NOT NULL,
55
+ recipient TEXT NOT NULL,
56
+ message_type TEXT NOT NULL DEFAULT 'message'
57
+ CHECK(message_type IN ('message','task','result','error','broadcast','shutdown_request','shutdown_response','status_change')),
58
+ payload TEXT NOT NULL,
59
+ correlation_id TEXT,
60
+ data TEXT,
61
+ read INTEGER NOT NULL DEFAULT 0,
62
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
63
+ )
64
+ SQL
65
+
66
+ db.execute(<<~SQL)
67
+ INSERT INTO mailbox_messages_new (id, sender, recipient, message_type, payload, read, created_at)
68
+ SELECT id, sender, recipient, message_type, payload, read, created_at
69
+ FROM mailbox_messages
70
+ SQL
71
+
72
+ db.execute('DROP TABLE mailbox_messages')
73
+ db.execute('ALTER TABLE mailbox_messages_new RENAME TO mailbox_messages')
74
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_recipient_read ON mailbox_messages(recipient, read)')
75
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_sender ON mailbox_messages(sender)')
76
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_created ON mailbox_messages(created_at)')
77
+ db.execute('CREATE INDEX IF NOT EXISTS idx_mailbox_correlation ON mailbox_messages(correlation_id)')
78
+ end
79
+ end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module RubynCode
4
6
  module Agent
5
- class Conversation
7
+ class Conversation # rubocop:disable Metrics/ClassLength -- message log + incremental token/tool-ID bookkeeping
6
8
  attr_reader :messages
7
9
 
8
10
  def initialize
9
11
  @messages = []
12
+ reset_derived_state!
10
13
  end
11
14
 
12
15
  # Append a user turn to the conversation.
@@ -16,6 +19,7 @@ module RubynCode
16
19
  def add_user_message(content)
17
20
  message = { role: 'user', content: content }
18
21
  @messages << message
22
+ track_added_message(message)
19
23
  message
20
24
  end
21
25
 
@@ -28,6 +32,7 @@ module RubynCode
28
32
  blocks = normalize_content(content, tool_calls)
29
33
  message = { role: 'assistant', content: blocks }
30
34
  @messages << message
35
+ track_added_message(message)
31
36
  message
32
37
  end
33
38
 
@@ -52,8 +57,11 @@ module RubynCode
52
57
  # tool results for the same assistant turn are batched together.
53
58
  if @messages.last && @messages.last[:role] == 'user' && tool_result_message?(@messages.last)
54
59
  @messages.last[:content] << result_block
60
+ track_appended_block(result_block)
55
61
  else
56
- @messages << { role: 'user', content: [result_block] }
62
+ message = { role: 'user', content: [result_block] }
63
+ @messages << message
64
+ track_added_message(message)
57
65
  end
58
66
 
59
67
  result_block
@@ -79,6 +87,29 @@ module RubynCode
79
87
  # @return [void]
80
88
  def clear!
81
89
  @messages.clear
90
+ reset_derived_state!
91
+ end
92
+
93
+ # Character length of the JSON-serialized messages array, maintained
94
+ # incrementally on append so per-turn token estimation doesn't have to
95
+ # re-serialize the whole history. Matches JSON.generate(messages).length.
96
+ #
97
+ # @return [Integer]
98
+ def estimated_json_chars
99
+ @json_chars ||= @messages.sum { |msg| JSON.generate(msg).length }
100
+ return 2 if @messages.empty?
101
+
102
+ # "[" + per-message JSON joined by "," + "]"
103
+ @json_chars + @messages.length + 1
104
+ end
105
+
106
+ # Drops cached serialization/tool-ID bookkeeping. Must be called after
107
+ # messages are mutated in place from outside this class (e.g. by
108
+ # Context::MicroCompact); the caches rebuild lazily on next access.
109
+ #
110
+ # @return [void]
111
+ def refresh_derived_state!
112
+ reset_derived_state!
82
113
  end
83
114
 
84
115
  # Return the messages array formatted for the Claude API.
@@ -115,21 +146,76 @@ module RubynCode
115
146
  @messages.pop
116
147
  removed += 1
117
148
  end
149
+ reset_derived_state!
118
150
  end
119
151
 
120
152
  # Replace messages with a new array (used after compaction).
121
153
  def replace!(new_messages)
122
154
  @messages.replace(new_messages)
155
+ reset_derived_state!
123
156
  end
124
157
 
125
158
  private
126
159
 
160
+ # Derived bookkeeping kept in sync with @messages so hot paths stay
161
+ # cheap: per-message JSON char total (token estimation) and
162
+ # tool_use/tool_result ID sets (orphan repair). nil means "rebuild
163
+ # lazily from @messages on next access".
164
+ def reset_derived_state!
165
+ @json_chars = nil
166
+ @tool_use_ids = nil
167
+ @tool_result_ids = nil
168
+ end
169
+
170
+ def track_added_message(message)
171
+ register_tool_ids(message)
172
+ return if @json_chars.nil?
173
+
174
+ @json_chars += JSON.generate(message).length
175
+ rescue StandardError
176
+ @json_chars = nil
177
+ end
178
+
179
+ # A tool_result block appended to an existing user message grows that
180
+ # message's JSON by the block plus one separator comma.
181
+ def track_appended_block(result_block)
182
+ @tool_result_ids << result_block[:tool_use_id] if @tool_result_ids
183
+ return if @json_chars.nil?
184
+
185
+ @json_chars += JSON.generate(result_block).length + 1
186
+ rescue StandardError
187
+ @json_chars = nil
188
+ end
189
+
190
+ def register_tool_ids(message)
191
+ return unless @tool_use_ids && message[:content].is_a?(Array)
192
+
193
+ message[:content].each do |block|
194
+ next unless block.is_a?(Hash)
195
+
196
+ if message[:role] == 'assistant' && block_matches_type?(block, 'tool_use')
197
+ @tool_use_ids << (block[:id] || block['id'])
198
+ elsif message[:role] == 'user' && block_matches_type?(block, 'tool_result')
199
+ @tool_result_ids << (block[:tool_use_id] || block['tool_use_id'])
200
+ end
201
+ end
202
+ end
203
+
204
+ def rebuild_tool_id_sets!
205
+ @tool_use_ids = collect_tool_use_ids(@messages)
206
+ @tool_result_ids = collect_tool_result_ids(@messages)
207
+ end
208
+
127
209
  # Ensure every tool_use block has a matching tool_result.
128
210
  # If a tool_use is orphaned (e.g. from Ctrl-C interruption),
129
211
  # inject a synthetic tool_result immediately after the assistant
130
212
  # message that contains the orphaned tool_use.
213
+ #
214
+ # The ID sets are tracked incrementally as messages are appended, so
215
+ # the common no-orphan case skips the full-history scans.
131
216
  def repair_orphaned_tool_uses(formatted)
132
- orphaned = collect_tool_use_ids(formatted) - collect_tool_result_ids(formatted)
217
+ rebuild_tool_id_sets! if @tool_use_ids.nil?
218
+ orphaned = @tool_use_ids - @tool_result_ids
133
219
  return formatted if orphaned.empty?
134
220
 
135
221
  insert_orphan_results(formatted, orphaned)
@@ -114,7 +114,7 @@ module RubynCode
114
114
 
115
115
  def run_compaction
116
116
  before = @conversation.length
117
- est = @context_manager.estimated_tokens(@conversation.messages)
117
+ est = @context_manager.estimated_tokens(@conversation)
118
118
  RubynCode::Debug.token(
119
119
  "context=#{est} tokens (~#{before} messages, " \
120
120
  "threshold=#{Config::Defaults::CONTEXT_THRESHOLD_TOKENS})"
@@ -130,7 +130,7 @@ module RubynCode
130
130
  after = @conversation.length
131
131
  return unless after < before
132
132
 
133
- new_est = @context_manager.estimated_tokens(@conversation.messages)
133
+ new_est = @context_manager.estimated_tokens(@conversation)
134
134
  RubynCode::Debug.loop_tick(
135
135
  "Compacted: #{before} -> #{after} messages " \
136
136
  "(#{est} -> #{new_est} tokens)"
@@ -9,7 +9,7 @@ require_relative 'llm_caller'
9
9
 
10
10
  module RubynCode
11
11
  module Agent
12
- class Loop
12
+ class Loop # rubocop:disable Metrics/ClassLength -- core agent loop: LLM calls, tool dispatch, recovery, hooks
13
13
  include SystemPromptBuilder
14
14
  include ResponseParser
15
15
  include ToolProcessor
@@ -18,6 +18,7 @@ module RubynCode
18
18
  include LlmCaller
19
19
 
20
20
  MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
21
+ GOAL_MAX_ITERATIONS = Config::Defaults::GOAL_MAX_ITERATIONS
21
22
 
22
23
  # @param opts [Hash] keyword arguments for loop configuration
23
24
  # @option opts [LLM::Client] :llm_client
@@ -39,6 +40,7 @@ module RubynCode
39
40
  assign_dependencies(opts)
40
41
  assign_callbacks(opts)
41
42
  @plan_mode = false
43
+ @static_prompt_sections = nil
42
44
  end
43
45
 
44
46
  # @return [Boolean]
@@ -61,19 +63,36 @@ module RubynCode
61
63
  @skill_ttl&.tick!
62
64
  autoload_triggered_skills(user_input)
63
65
  @conversation.add_user_message(user_input)
66
+ reset_system_prompt_cache!
64
67
  reset_iteration_state
65
68
 
66
- MAX_ITERATIONS.times do |iteration|
69
+ iteration = 0
70
+ loop do
67
71
  result = run_iteration(iteration)
68
72
  return result if result
73
+
74
+ iteration += 1
75
+ break unless keep_iterating?(iteration)
69
76
  end
70
77
 
71
- RubynCode::Debug.warn("Hit MAX_ITERATIONS (#{MAX_ITERATIONS})")
72
- max_iterations_warning
78
+ RubynCode::Debug.warn("Hit iteration limit (#{iteration})")
79
+ max_iterations_warning(iteration)
73
80
  end
74
81
 
75
82
  private
76
83
 
84
+ # Decide whether the loop should run another iteration after `iteration`
85
+ # turns. Normally capped at MAX_ITERATIONS, but while a Stop hook (e.g. an
86
+ # active /goal) is keeping the agent alive we extend up to a hard ceiling
87
+ # — a goal can need more tool turns than a single request. The GoalHook's
88
+ # own max-attempts valve terminates an unsatisfiable goal; the ceiling is
89
+ # only a runaway guard.
90
+ def keep_iterating?(iteration)
91
+ return true if iteration < MAX_ITERATIONS
92
+
93
+ @stop_block_active && iteration < GOAL_MAX_ITERATIONS
94
+ end
95
+
77
96
  def assign_dependencies(opts)
78
97
  assign_required_deps(opts)
79
98
  assign_optional_deps(opts)
@@ -148,6 +167,7 @@ module RubynCode
148
167
  @max_tokens_override = nil
149
168
  @output_recovery_count = 0
150
169
  @task_budget_remaining = nil
170
+ @stop_block_active = false # true while a Stop hook keeps us going
151
171
  end
152
172
 
153
173
  def run_iteration(iteration)
@@ -199,6 +219,13 @@ module RubynCode
199
219
 
200
220
  @conversation.add_assistant_message(response_content(response))
201
221
 
222
+ # Stop hook: a hook may block stopping (e.g. an active /goal). When
223
+ # blocked, the reason is injected as user feedback and the loop keeps
224
+ # iterating instead of returning the final text. While blocked, the
225
+ # loop is allowed to run past MAX_ITERATIONS (see #keep_iterating?).
226
+ @stop_block_active = stop_blocked?(text)
227
+ return nil if @stop_block_active
228
+
202
229
  # Decision-based compaction (topic switch, milestone)
203
230
  @decision_compactor&.check!(@conversation)
204
231
 
@@ -208,6 +235,19 @@ module RubynCode
208
235
  text
209
236
  end
210
237
 
238
+ # Fires the :stop hook. If a hook blocks (returns { block: true }), the
239
+ # reason is appended as a user message so the next iteration acts on it.
240
+ #
241
+ # @return [Boolean] true if stopping was blocked (keep iterating)
242
+ def stop_blocked?(text)
243
+ decision = @hook_runner.fire(:stop, conversation: @conversation, response_text: text)
244
+ return false unless decision.is_a?(Hash) && decision[:block]
245
+
246
+ RubynCode::Debug.agent('Stop blocked by hook — continuing')
247
+ @conversation.add_user_message(decision[:reason])
248
+ true
249
+ end
250
+
211
251
  # Empty LLM response (0 content blocks). Common after dispatching
212
252
  # background_run — the LLM has nothing to say until results arrive.
213
253
  # Wait briefly for jobs, then either continue or accept the empty response.
@@ -243,15 +283,15 @@ module RubynCode
243
283
  # after text responses — mirrors Claude Code's "pause for compaction"
244
284
  # behavior that keeps context manageable in long sessions.
245
285
  def compact_if_needed
246
- return unless @context_manager.needs_compaction?(@conversation.messages)
286
+ return unless @context_manager.needs_compaction?(@conversation)
247
287
 
248
- est = @context_manager.estimated_tokens(@conversation.messages)
288
+ est = @context_manager.estimated_tokens(@conversation)
249
289
  RubynCode::Debug.token(
250
290
  "Context over threshold (#{est}) — running compaction"
251
291
  )
252
292
  @context_manager.check_compaction!(@conversation)
253
293
 
254
- after = @context_manager.estimated_tokens(@conversation.messages)
294
+ after = @context_manager.estimated_tokens(@conversation)
255
295
  RubynCode::Debug.token("Compacted: #{est} → #{after} tokens")
256
296
  rescue StandardError => e
257
297
  RubynCode::Debug.warn("Compaction failed: #{e.message}")
@@ -266,8 +306,8 @@ module RubynCode
266
306
  @max_tokens_override = Config::Defaults::ESCALATED_MAX_OUTPUT_TOKENS
267
307
  end
268
308
 
269
- def max_iterations_warning
270
- warning = "Reached maximum iteration limit (#{MAX_ITERATIONS}). " \
309
+ def max_iterations_warning(limit = MAX_ITERATIONS)
310
+ warning = "Reached maximum iteration limit (#{limit}). " \
271
311
  'The conversation may be incomplete. Please review the ' \
272
312
  'current state and continue if needed.'
273
313
  @conversation.add_assistant_message([{ type: 'text', text: warning }])
@@ -10,7 +10,7 @@ module RubynCode
10
10
  module SystemPromptBuilder # rubocop:disable Metrics/ModuleLength -- heavily extracted, residual 3 lines over
11
11
  include Prompts
12
12
 
13
- INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
13
+ INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENTS.md AGENT.md].freeze
14
14
 
15
15
  private
16
16
 
@@ -19,16 +19,49 @@ module RubynCode
19
19
  parts << PLAN_MODE_PROMPT if @plan_mode
20
20
  parts << "Working directory: #{@project_root}" if @project_root
21
21
  append_response_mode(parts)
22
+ static = static_prompt_sections
23
+ parts << static unless static.empty?
24
+ parts.join("\n")
25
+ end
26
+
27
+ # The static sections hit SQLite (memories, instincts) and walk the
28
+ # filesystem (instruction files, profile), so they're assembled once
29
+ # per user turn instead of on every iteration of the tool loop. Only
30
+ # the plan-mode flag and response-mode line vary mid-turn, and those
31
+ # stay in build_system_prompt.
32
+ def static_prompt_sections
33
+ @static_prompt_sections ||= build_static_prompt_sections
34
+ end
35
+
36
+ def build_static_prompt_sections
37
+ parts = []
22
38
  append_project_profile(parts)
23
39
  append_codebase_index(parts)
24
40
  append_memories(parts)
25
41
  append_project_instructions(parts)
26
42
  append_instincts(parts)
27
43
  append_skills(parts)
44
+ append_chisel_ruleset(parts)
28
45
  append_deferred_tools(parts)
29
46
  parts.join("\n")
30
47
  end
31
48
 
49
+ # Chisel's "write the minimum that works" ruleset, injected only when the
50
+ # user has turned it on (chisel_mode != off). Guarded so a config or
51
+ # resolution error never breaks prompt assembly.
52
+ def append_chisel_ruleset(parts)
53
+ section = Chisel.prompt_section
54
+ parts << "\n#{section}" unless section.empty?
55
+ rescue StandardError
56
+ nil
57
+ end
58
+
59
+ # Called at the start of each user turn so memory, instruction, and
60
+ # tool changes made between turns show up in the next prompt.
61
+ def reset_system_prompt_cache!
62
+ @static_prompt_sections = nil
63
+ end
64
+
32
65
  def append_response_mode(parts)
33
66
  text = last_user_text
34
67
  return if text.empty?
@@ -182,7 +215,9 @@ module RubynCode
182
215
 
183
216
  db = DB::Connection.instance
184
217
  search = Memory::Search.new(db, project_path: @project_root)
185
- recent = search.recent(limit: 20)
218
+ # touch: false — assembling the prompt is not a memory "access";
219
+ # touching here would issue a SQLite write and inflate access counts.
220
+ recent = search.recent(limit: 20, touch: false)
186
221
  return '' if recent.empty?
187
222
 
188
223
  recent.map { |m| format_memory(m) }.join("\n")
@@ -116,7 +116,9 @@ module RubynCode
116
116
 
117
117
  def execute_tool(tool_name, tool_input)
118
118
  discover_tool(tool_name)
119
- @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
119
+ pre_decision = @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
120
+ raise RubynCode::UserDeniedError, pre_decision[:reason] if pre_decision.is_a?(Hash) && pre_decision[:deny]
121
+
120
122
  result = dispatch_tool(tool_name, tool_input)
121
123
  @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
122
124
  signal_decision_compactor(tool_name, tool_input, result)
@@ -3,7 +3,6 @@
3
3
  require 'securerandom'
4
4
  require 'digest'
5
5
  require 'base64'
6
- require 'faraday'
7
6
  require 'json'
8
7
 
9
8
  module RubynCode
@@ -163,6 +162,7 @@ module RubynCode
163
162
  end
164
163
 
165
164
  def http_client
165
+ require 'faraday'
166
166
  @http_client ||= Faraday.new do |f|
167
167
  f.options.timeout = 30
168
168
  f.options.open_timeout = 10