@zalom/plastic 2.0.0-alpha.20 → 2.0.0-alpha.22

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 (45) hide show
  1. package/PLASTIC.md +20 -18
  2. package/bin/test +24 -4
  3. package/hooks/call-budget +4 -0
  4. package/hooks/hooks.json +12 -0
  5. package/package.json +1 -1
  6. package/scripts/doctor.rb +79 -4
  7. package/scripts/hook-call-budget +222 -0
  8. package/scripts/hook-session-start +307 -319
  9. package/scripts/insight-append +18 -4
  10. package/scripts/lib/compact_instructions.rb +5 -5
  11. package/scripts/lib/doctor_core.rb +2 -1
  12. package/scripts/lib/graph_edges.rb +16 -0
  13. package/scripts/lib/hook_registry.rb +14 -2
  14. package/scripts/lib/installer_core.rb +13 -5
  15. package/scripts/lib/meter_watch.rb +179 -0
  16. package/scripts/lib/node_packet.rb +27 -5
  17. package/scripts/lib/runner_dispatch.rb +29 -5
  18. package/scripts/lib/runner_policy.rb +31 -0
  19. package/scripts/lib/runner_proposals.rb +21 -0
  20. package/scripts/lib/session_usage.rb +190 -0
  21. package/scripts/meter-watch +57 -0
  22. package/scripts/read-config +3 -3
  23. package/scripts/runner +5 -0
  24. package/scripts/session-usage +56 -0
  25. package/scripts/skill-lint +115 -6
  26. package/skills/auto/SKILL.md +61 -63
  27. package/skills/auto/references/agent-architecture.md +10 -8
  28. package/skills/auto/references/human-report-contract.md +1 -1
  29. package/skills/conventions/references/completion-and-done.md +7 -7
  30. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  31. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  32. package/skills/doctor/report.md +1 -1
  33. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  34. package/skills/intent-creating/SKILL.md +58 -133
  35. package/skills/intent-ending/SKILL.md +48 -56
  36. package/skills/intent-ending/evals/evals.json +1 -1
  37. package/skills/intent-executing/SKILL.md +39 -134
  38. package/skills/intent-speccing/SKILL.md +3 -0
  39. package/skills/releasing/SKILL.md +1 -1
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/skills/tutorial/SKILL.md +2 -1
  42. package/skills/tutorial/references/track-1-guided.md +21 -40
  43. package/skills/tutorial/references/track-2-auto.md +2 -2
  44. package/templates/agents.md +2 -2
  45. package/templates/config.yml +3 -3
@@ -11,8 +11,16 @@
11
11
  # `now:` seam is the test seam; the CLI uses the default Time.now, which is fine
12
12
  # because determinism is covered at the library level (test/insights_test.rb).
13
13
  #
14
+ # --rule (intent 341, G8, C37): tags the entry as a rule, not just an
15
+ # observation, by prepending the literal "rule: " onto the text before it
16
+ # ever reaches Insights.append_insight; the library itself stays untouched,
17
+ # since a tag is a text-level convention, not a new field on the ledger.
18
+ # Doctor's unpromoted_rules check (scripts/doctor.rb) later lists any tagged
19
+ # entry whose exact text no skills/conventions/references/*.md chapter
20
+ # carries yet.
21
+ #
14
22
  # Usage:
15
- # insight-append <intent_dir> <text> --stage S --author A
23
+ # insight-append <intent_dir> <text> --stage S --author A [--rule]
16
24
  #
17
25
  # Exit codes: 0 (entry appended), 2 (usage).
18
26
 
@@ -21,6 +29,7 @@ require_relative "lib/insights"
21
29
  def parse_args(argv)
22
30
  stage = nil
23
31
  author = nil
32
+ rule = false
24
33
  positional = []
25
34
  i = 0
26
35
  while i < argv.length
@@ -31,21 +40,26 @@ def parse_args(argv)
31
40
  when "--author"
32
41
  author = argv[i + 1]
33
42
  i += 2
43
+ when "--rule"
44
+ rule = true
45
+ i += 1
34
46
  else
35
47
  positional << argv[i]
36
48
  i += 1
37
49
  end
38
50
  end
39
- [positional[0], positional[1], stage, author]
51
+ [positional[0], positional[1], stage, author, rule]
40
52
  end
41
53
 
42
- intent_dir, text, stage, author = parse_args(ARGV)
54
+ intent_dir, text, stage, author, rule = parse_args(ARGV)
43
55
 
44
56
  if [intent_dir, text, stage, author].any? { |v| v.nil? || v.to_s.empty? }
45
- warn "usage: insight-append <intent_dir> <text> --stage S --author A"
57
+ warn "usage: insight-append <intent_dir> <text> --stage S --author A [--rule]"
46
58
  exit 2
47
59
  end
48
60
 
61
+ text = "rule: #{text}" if rule
62
+
49
63
  entry = Insights.append_insight(File.expand_path(intent_dir), text,
50
64
  stage: stage, author: author)
51
65
  puts "appended: #{entry}"
@@ -19,9 +19,9 @@ require "digest"
19
19
  #
20
20
  # Library only: no CLI, no ENV, no I/O.
21
21
  module CompactInstructions
22
- # 35 and 50 percent of a 1M window.
23
- OFFER_TOKENS = 350_000
24
- INSIST_TOKENS = 500_000
22
+ # 15 and 25 percent of a 1M window (intent 355, n5, D7).
23
+ OFFER_TOKENS = 150_000
24
+ INSIST_TOKENS = 250_000
25
25
 
26
26
  # Static on purpose. A body rendered from the user's config would change its hash
27
27
  # every time they edited config.yml, and doctor would then report a correct install
@@ -32,10 +32,10 @@ module CompactInstructions
32
32
  Plastic watches this session's context. When the harness reports how much of the
33
33
  window is used:
34
34
 
35
- - At 350,000 tokens, offer to compact. Say that the hand-off in today's day ledger
35
+ - At 150,000 tokens, offer to compact. Say that the hand-off in today's day ledger
36
36
  is written and current, and take no for an answer: a task that is nearly done
37
37
  does not need the interruption.
38
- - At 500,000 tokens, insist. Take no new work, write the hand-off in today's day
38
+ - At 250,000 tokens, insist. Take no new work, write the hand-off in today's day
39
39
  ledger, and compact before continuing.
40
40
  - After a compaction, say continue. The day summary at boot and the hand-off carry
41
41
  the state; do not rebuild it by re-reading files.
@@ -30,7 +30,8 @@ class Doctor
30
30
  # The Claude events hooks_registered expects in settings.json: the six-event map of
31
31
  # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
32
32
  # intent 316a added MessageDisplay, registered for message-display, Claude only).
33
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd MessageDisplay].freeze
33
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreToolUse PreCompact PostToolUse UserPromptSubmit SessionEnd
34
+ MessageDisplay].freeze
34
35
 
35
36
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
36
37
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -118,4 +118,20 @@ module GraphEdges
118
118
  visited[node] = true
119
119
  nil
120
120
  end
121
+
122
+ # The declared work ids whose `needs` reach a verify node without passing
123
+ # through another verify node (343 D7), in declaration order. `kinds` maps
124
+ # an id to its node kind, so this module stays blind to where a kind lives.
125
+ def review_fixes(edges, kinds)
126
+ edges.keys.select { |id| kinds[id] == "work" && reaches_verify?(id, edges, kinds, {}) }
127
+ end
128
+
129
+ def reaches_verify?(node, edges, kinds, seen)
130
+ (edges[node] || []).any? do |target|
131
+ next false if seen[target]
132
+
133
+ seen[target] = true
134
+ kinds[target] == "verify" || reaches_verify?(target, edges, kinds, seen)
135
+ end
136
+ end
121
137
  end
@@ -34,6 +34,17 @@ module HookRegistry
34
34
  { "name" => "check-update", "status" => "" },
35
35
  ] },
36
36
  ],
37
+ # Intent 355, n2: the call budget guard. Not one of the edit-path
38
+ # gates intent 302 removed (those denied a write on content; this
39
+ # denies a call on a per-attempt COUNT, read from the session's own
40
+ # transcript, never from the tool's arguments) - see scripts/hook-
41
+ # call-budget. Claude only: CODEX_LIVE_STATE_EVENTS below does not
42
+ # carry PreToolUse, so codex_hooks_json never projects it.
43
+ "PreToolUse" => [
44
+ { "matcher" => "", "hooks" => [
45
+ { "name" => "call-budget", "status" => "" },
46
+ ] },
47
+ ],
37
48
  "PreCompact" => [
38
49
  { "matcher" => "", "hooks" => [
39
50
  { "name" => "savepoint", "status" => "Saving Plastic intent state..." },
@@ -75,8 +86,9 @@ module HookRegistry
75
86
  # the PostToolUse record hook collapses from Claude's multi-tool matcher onto
76
87
  # Codex's single apply_patch tool (181 F4: apply_patch is Codex's sole
77
88
  # file-mutation tool; tool_name always reports apply_patch), and the live-state
78
- # events project through whole. Since intent 302 there is no PreToolUse group at
79
- # all: the edit-path gates are gone on both harnesses. Command invokes the
89
+ # events project through whole. PreToolUse (intent 355, n2's call-budget guard,
90
+ # Claude only) is not one of CODEX_LIVE_STATE_EVENTS below, so Codex still
91
+ # carries no PreToolUse group of its own. Command invokes the
80
92
  # codex-hook dispatcher with the hook name. Guide-settled shape [guide Part 3]:
81
93
  # top-level {"hooks":{<Event>: [{"matcher","hooks":[{"type":"command","command",
82
94
  # "statusMessage"}]}]}}, identical to Claude's shape, string command. Single
@@ -49,9 +49,9 @@ class InstallerCore
49
49
  # hand-curated pointer rather than embedding the core wholesale, and it never drifts
50
50
  # because it only ever points, never duplicates.
51
51
  CODEX_AGENTS_MD_BODY = <<~MD.freeze
52
- Plastic is installed for this agent. Plastic is intent-driven state management: all
53
- work flows through an intent, moved through What, Why, How, then Exec. Do not jump
54
- straight to code.
52
+ Plastic is installed for this agent. Plastic is intent-driven state management: work runs
53
+ in one of three modes, direct, thinking, or auto (a team drives the runner loop:
54
+ `runner step`, `status`, `answer`). Do not jump straight to code.
55
55
 
56
56
  Standing rules:
57
57
  - Core conventions live in ~/.plastic/PLASTIC.md. Read it and follow it exactly. For
@@ -438,6 +438,8 @@ class InstallerCore
438
438
  "scripts/verify-intent" => "scripts/verify-intent",
439
439
  "scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
440
440
  "scripts/exec-worktree" => "scripts/exec-worktree",
441
+ "scripts/lib/session_usage.rb" => "scripts/lib/session_usage.rb",
442
+ "scripts/session-usage" => "scripts/session-usage",
441
443
  "scripts/doctor.rb" => "scripts/doctor.rb",
442
444
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
443
445
  "scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
@@ -543,6 +545,12 @@ class InstallerCore
543
545
  # by scripts/runner's `step`.
544
546
  "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
545
547
  "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
548
+ # Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
549
+ # is its cap table, above); its launcher (hooks/call-budget) ships via
550
+ # hook_files' own glob, so only the hook script itself needs an entry.
551
+ "scripts/hook-call-budget" => "scripts/hook-call-budget",
552
+ "scripts/meter-watch" => "scripts/meter-watch",
553
+ "scripts/lib/meter_watch.rb" => "scripts/lib/meter_watch.rb",
546
554
  # Intent 340 (G7, n6): answer (closes a decision node or unparks a
547
555
  # work node parked at needs_decision), proposals (mints ids for what
548
556
  # an executor proposed), and rewind (resets the intent branch to a
@@ -564,8 +572,8 @@ class InstallerCore
564
572
  version: 3
565
573
  execution_mode: subagent-driven
566
574
  stale_threshold_days: 3
567
- context_offer_tokens: 350000
568
- context_insist_tokens: 500000
575
+ context_offer_tokens: 150000
576
+ context_insist_tokens: 250000
569
577
  hash_length: 6
570
578
  hash_algorithm: sha256-base36
571
579
  max_slug_words: 5
@@ -0,0 +1,179 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "yaml"
6
+ require "time"
7
+ require "fileutils"
8
+ require "rbconfig"
9
+ require_relative "atomic_write"
10
+
11
+ # MeterWatch (intent 355, n5, D6): reads the owner's rate-limit cache on a
12
+ # timer and writes one state file a session watches, instead of every session
13
+ # parsing the cache and re-deriving the thresholds for itself. No model call
14
+ # is spent on a tick that leaves the state unchanged.
15
+ #
16
+ # Everything is injected: home (holds config.yml and .cache/), the clock, the
17
+ # cache path, and the renamer AtomicWrite uses. Nothing reads ENV, nothing
18
+ # calls launchctl, and --install-timer (scripts/meter-watch) only ever writes
19
+ # under the injected home.
20
+ class MeterWatch
21
+ DEFAULT_REDUCE_AT = 55
22
+ DEFAULT_STOP_AT = 85
23
+ DEFAULT_WEEKLY_STOP_AT = 97
24
+ TICK_SECONDS = 20 * 60
25
+ STALE_AFTER_SECONDS = TICK_SECONDS * 2
26
+
27
+ def initialize(home:, cache_path: nil, config_path: nil, now: Time.now, renamer: File.method(:rename))
28
+ @home = home
29
+ @cache_path = cache_path || File.join(home, ".cache", "rate-limits.json")
30
+ @config_path = config_path || File.join(home, "config.yml")
31
+ @state_path = File.join(home, ".cache", "meter-state.json")
32
+ @now = now
33
+ @renamer = renamer
34
+ @reduce_at, @stop_at, @weekly_stop_at = load_thresholds
35
+ end
36
+
37
+ attr_reader :state_path
38
+
39
+ def tick
40
+ previous = read_state
41
+ state = compute_state(previous)
42
+ write_if_changed(previous, state)
43
+ end
44
+
45
+ private
46
+
47
+ def compute_state(previous)
48
+ return base_state("unavailable") unless File.file?(@cache_path)
49
+ return stopped?(previous) ? previous : base_state("stale") if stale?
50
+
51
+ cache = JSON.parse(File.read(@cache_path))
52
+ five_hour = cache["five_hour"]
53
+ seven_day = cache["seven_day"]
54
+ resets_at = cache["resets_at"]
55
+
56
+ # Resume compares `now` against the STOP's OWN resets_at (carried
57
+ # forward on `previous`, from the tick that first wrote "stop"), never
58
+ # the cache's current resets_at: the cache moves resets_at on to the
59
+ # NEXT window before five_hour/seven_day themselves drop, so comparing
60
+ # against the live value would never report resume (B4).
61
+ label = if stopped?(previous) && reset_passed?(previous["resets_at"])
62
+ "resume"
63
+ else
64
+ classify(five_hour, seven_day)
65
+ end
66
+
67
+ base_state(label, five_hour: five_hour, seven_day: seven_day, resets_at: resets_at)
68
+ rescue JSON::ParserError
69
+ base_state("unavailable")
70
+ end
71
+
72
+ def stopped?(previous)
73
+ previous && previous["state"] == "stop"
74
+ end
75
+
76
+ def classify(five_hour, seven_day)
77
+ return "stop" if five_hour.to_f >= @stop_at
78
+ return "stop" if seven_day.to_f >= @weekly_stop_at
79
+ return "reduce" if five_hour.to_f >= @reduce_at
80
+
81
+ "ok"
82
+ end
83
+
84
+ def stale?
85
+ File.mtime(@cache_path) < (@now - STALE_AFTER_SECONDS)
86
+ rescue Errno::ENOENT
87
+ true
88
+ end
89
+
90
+ def reset_passed?(resets_at)
91
+ at = parse_time(resets_at)
92
+ at && @now >= at
93
+ end
94
+
95
+ def parse_time(value)
96
+ return nil if value.nil? || value.to_s.empty?
97
+
98
+ text = value.to_s
99
+ text.match?(/\A\d+\z/) ? Time.at(text.to_i).utc : Time.iso8601(text)
100
+ rescue ArgumentError, TypeError
101
+ nil
102
+ end
103
+
104
+ def base_state(label, five_hour: nil, seven_day: nil, resets_at: nil)
105
+ {
106
+ "state" => label,
107
+ "five_hour" => five_hour,
108
+ "seven_day" => seven_day,
109
+ "resets_at" => resets_at,
110
+ "checked_at" => @now.getutc.iso8601,
111
+ }
112
+ end
113
+
114
+ def write_if_changed(previous, state)
115
+ return state if previous && previous["state"] == state["state"]
116
+
117
+ FileUtils.mkdir_p(File.dirname(@state_path))
118
+ AtomicWrite.write(@state_path, JSON.generate(state), renamer: @renamer)
119
+ state
120
+ end
121
+
122
+ def read_state
123
+ return nil unless File.file?(@state_path)
124
+
125
+ JSON.parse(File.read(@state_path))
126
+ rescue JSON::ParserError
127
+ nil
128
+ end
129
+
130
+ def load_thresholds
131
+ config = File.file?(@config_path) ? (YAML.safe_load(File.read(@config_path)) || {}) : {}
132
+ meter = config["meter"].is_a?(Hash) ? config["meter"] : {}
133
+ [
134
+ meter.fetch("reduce_at", DEFAULT_REDUCE_AT),
135
+ meter.fetch("stop_at", DEFAULT_STOP_AT),
136
+ meter.fetch("weekly_stop_at", DEFAULT_WEEKLY_STOP_AT),
137
+ ]
138
+ end
139
+
140
+ class << self
141
+ # Writes the LaunchAgent plist under an injectable home; never loads it
142
+ # with launchctl (scripts/meter-watch --install-timer calls this and
143
+ # nothing else). RunAtLoad primes the first tick; StartInterval repeats
144
+ # it every 20 minutes.
145
+ def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS)
146
+ agents_dir = File.join(home, "Library", "LaunchAgents")
147
+ FileUtils.mkdir_p(agents_dir)
148
+ plist_path = File.join(agents_dir, "com.plastic.meter-watch.plist")
149
+ File.write(plist_path, plist(script_path, home, ruby, interval))
150
+ plist_path
151
+ end
152
+
153
+ private
154
+
155
+ def plist(script_path, home, ruby, interval)
156
+ <<~XML
157
+ <?xml version="1.0" encoding="UTF-8"?>
158
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
159
+ <plist version="1.0">
160
+ <dict>
161
+ <key>Label</key>
162
+ <string>com.plastic.meter-watch</string>
163
+ <key>ProgramArguments</key>
164
+ <array>
165
+ <string>#{ruby}</string>
166
+ <string>#{script_path}</string>
167
+ <string>--home</string>
168
+ <string>#{home}</string>
169
+ </array>
170
+ <key>StartInterval</key>
171
+ <integer>#{interval}</integer>
172
+ <key>RunAtLoad</key>
173
+ <true/>
174
+ </dict>
175
+ </plist>
176
+ XML
177
+ end
178
+ end
179
+ end
@@ -425,7 +425,15 @@ module NodePacket
425
425
  verify.split("\n").map(&:strip).reject(&:empty?).join("; ")
426
426
  end
427
427
 
428
- def test_command_block(intent_dir:, project_reader: method(:default_project_reader))
428
+ # `files` (intent 355, n4, D5): a node's own declared `*_test.rb` files
429
+ # name the only test command the executor needs - `bin/test --only <those
430
+ # files>` - so it never has to invent one or fall back to the project's
431
+ # generic `release.verify`. A node that declares no test files (a docs-only
432
+ # node, say) still falls back to `project_reader` exactly as before.
433
+ def test_command_block(intent_dir:, files: [], project_reader: method(:default_project_reader))
434
+ named = Array(files).select { |f| f.to_s.end_with?("_test.rb") }
435
+ return "test command: ruby bin/test --only #{named.join(' ')}" if named.any?
436
+
429
437
  cmd = project_reader.call(intent_dir)
430
438
  cmd ? "test command: #{cmd}" : "test command: none recorded in the project record"
431
439
  end
@@ -435,14 +443,26 @@ module NodePacket
435
443
  # carries no lease. `worktree_block` already renders its own copy when the
436
444
  # worktree is unprovisioned; the two conditions often fire together, so a
437
445
  # directive already present is never repeated.
446
+ #
447
+ # `call_cap` (intent 355, n2, D2): one sentence naming this attempt's tool
448
+ # call cap and the return it hits at, so the executor learns the number
449
+ # from the packet it starts with, never from a denied call mid-edit
450
+ # (matrix 2.4). nil (a caller that names no cap) renders nothing here.
438
451
  def where_to_work_block(intent_dir:, worktree_reader: Arm.method(:worktree_block),
439
- project_reader: method(:default_project_reader), lease_missing: false)
452
+ project_reader: method(:default_project_reader), lease_missing: false, call_cap: nil,
453
+ files: [])
440
454
  wt = worktree_block(intent_dir: intent_dir, worktree_reader: worktree_reader)
441
- parts = [wt, test_command_block(intent_dir: intent_dir, project_reader: project_reader)]
455
+ parts = [wt, test_command_block(intent_dir: intent_dir, files: files, project_reader: project_reader)]
442
456
  parts << STOP_DIRECTIVE if lease_missing && !wt.include?(STOP_DIRECTIVE)
457
+ parts << call_cap_sentence(call_cap) if call_cap
443
458
  parts.join("\n")
444
459
  end
445
460
 
461
+ def call_cap_sentence(call_cap)
462
+ "call budget: this attempt may make at most #{call_cap} tool calls; past that a hook denies the " \
463
+ "next one, so commit what is green and return failed_verification reason=call_budget."
464
+ end
465
+
446
466
  # --- section and list parsing (shared) -------------------------------------
447
467
 
448
468
  # The body of the FIRST heading, at exactly `level` `#` characters, whose
@@ -786,7 +806,8 @@ module NodePacket
786
806
  def build(intent_dir:, node:, budget_tokens: nil, hop_tokens: DEFAULT_HOP_TOKENS,
787
807
  holder: nil, expires: nil, model: nil, attempt: nil, out: nil, force: false,
788
808
  renamer: File.method(:rename), git_runner: DEFAULT_GIT_RUNNER,
789
- worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader))
809
+ worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader),
810
+ call_cap: nil)
790
811
  intent_dir = File.expand_path(intent_dir)
791
812
 
792
813
  nb = node_block(intent_dir: intent_dir, node: node)
@@ -826,7 +847,8 @@ module NodePacket
826
847
  # ledger data (spec D3's self-cancellation risk).
827
848
  missing_lease = lease_missing?(node: node, holder: holder, expires: expires, model: model, entries: entries)
828
849
  where_text = where_to_work_block(intent_dir: intent_dir, worktree_reader: worktree_reader,
829
- project_reader: project_reader, lease_missing: missing_lease)
850
+ project_reader: project_reader, lease_missing: missing_lease,
851
+ call_cap: call_cap, files: nb[:files])
830
852
 
831
853
  state = {
832
854
  node_text: nb[:text], ledger_text: ledger_text, intent_text: record[:intent],
@@ -45,6 +45,22 @@ module RunnerDispatch
45
45
 
46
46
  HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
47
47
 
48
+ # D8 (355, n6): the agent every dispatched, non-decision node names - a
49
+ # role, never a harness (matrix 6.5), and never `plastic-advisor`, which
50
+ # stays a deliberate, never-auto-dispatched consultation agent.
51
+ SPAWN_AGENT = "plastic-executor"
52
+
53
+ # matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
54
+ # RunnerPolicy.model_for resolved, the packet path, the one test command
55
+ # (NodePacket.test_command_block, n4), and the call cap (n2) - fenced so a
56
+ # session pastes it straight into the Agent tool (327 D42: the runner
57
+ # itself never spawns).
58
+ def spawn_block(model:, packet:, test_command:, call_cap:, agent: SPAWN_AGENT)
59
+ lines = ["agent: #{agent}", "model: #{model}", "packet: #{packet}", test_command,
60
+ NodePacket.call_cap_sentence(call_cap)]
61
+ (["```"] + lines + ["```"]).join("\n")
62
+ end
63
+
48
64
  # dispatch(context, limit:) -> a result hash. Always carries :ok, :reason,
49
65
  # :errors, :rearm_command, :dispatched, :stop, :parked, :status, :blockers,
50
66
  # :plan - fields that do not apply to a given outcome stay nil/empty rather
@@ -195,6 +211,7 @@ module RunnerDispatch
195
211
  holder = context.session
196
212
  model = RunnerPolicy.model_for(kind, config: config)
197
213
  expires = RunnerPolicy.lease_expires(kind, now: now)
214
+ calls_cap = RunnerPolicy.call_cap(kind, config: config)
198
215
 
199
216
  # Row 10.16/M13: recorded BEFORE provisioning - a worktree this dispatch
200
217
  # finds already on disk (kept there by a prior failed_verification
@@ -223,7 +240,7 @@ module RunnerDispatch
223
240
  # default (row 10.9).
224
241
  build_result = packet_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
225
242
  model: model, force: true, worktree_reader: node_reader,
226
- budget_tokens: node_declared_budget(intent_dir, node))
243
+ budget_tokens: node_declared_budget(intent_dir, node), call_cap: calls_cap)
227
244
  unless build_result[:ok]
228
245
  # M6: a failed packet build never leaves an orphan worktree behind, and
229
246
  # its errors travel back up so the step's report can name the node and
@@ -236,7 +253,7 @@ module RunnerDispatch
236
253
  precondition = lambda do |c|
237
254
  ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
238
255
  end
239
- fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model }
256
+ fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model, calls: calls_cap }
240
257
 
241
258
  result = begin
242
259
  ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
@@ -254,10 +271,13 @@ module RunnerDispatch
254
271
  return { ok: false }
255
272
  end
256
273
 
274
+ test_command = NodePacket.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
275
+ spawn = spawn_block(model: model, packet: build_result[:path], test_command: test_command, call_cap: calls_cap)
276
+
257
277
  {
258
278
  ok: true,
259
279
  entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, worktree: provisioned[:path],
260
- packet: build_result[:path] },
280
+ packet: build_result[:path], spawn: spawn },
261
281
  }
262
282
  end
263
283
 
@@ -461,7 +481,10 @@ module RunnerDispatch
461
481
  # Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
462
482
  # dispatched node, the packet path, the model, the worktree, the kind and
463
483
  # the role, plus the return contract ONCE at the top level - never inside
464
- # any one node's packet.
484
+ # any one node's packet. Row 6.4: "spawn" carries the same, already fully
485
+ # rendered spawn block for each dispatched node in order, so any reader of
486
+ # this data (YAML today, JSON if it is ever re-serialized) finds it under
487
+ # `spawn` rather than re-deriving it from the other fields.
465
488
  def render_plan(dispatched)
466
489
  return nil if dispatched.empty?
467
490
 
@@ -470,7 +493,8 @@ module RunnerDispatch
470
493
  "dispatch" => dispatched.map do |d|
471
494
  { "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
472
495
  "worktree" => d[:worktree], "packet" => d[:packet] }
473
- end
496
+ end,
497
+ "spawn" => dispatched.map { |d| d[:spawn] }
474
498
  )
475
499
  end
476
500
  private_class_method :render_plan
@@ -139,4 +139,35 @@ module RunnerPolicy
139
139
  def lease_expires(kind, now: Time.now)
140
140
  (now + (lease_minutes(kind) * 60)).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
141
141
  end
142
+
143
+ # --- call budget (intent 355, n2) -------------------------------------------
144
+ #
145
+ # D2: a cap on tool calls per attempt, enforced by a PreToolUse hook that
146
+ # counts tool calls in the session transcript. Shipped per kind (matrix
147
+ # 2.1); `decision` carries one too even though it is never dispatched
148
+ # (327 D12 leaves it out of every lease table for the same reason), so
149
+ # `call_cap` never has to special-case an unknown kind here any more than
150
+ # `retry_cap` does.
151
+ CALL_CAP_TABLE = { "work" => 60, "verify" => 40, "research" => 40, "decision" => 10 }.freeze
152
+
153
+ # matrix 5.17's own fallback rule, one call: an unknown or nil kind reads
154
+ # `work`'s cap, never a fourth, undeclared number.
155
+ def call_cap(kind, config: {})
156
+ override = call_caps_section(config)[kind.to_s]
157
+ present?(override) ? override.to_i : CALL_CAP_TABLE.fetch(kind.to_s, CALL_CAP_TABLE["work"])
158
+ end
159
+
160
+ # matrix 2.2: `runner.call_caps.<kind>` in the project config overrides the
161
+ # shipped cap, the same nested-Hash shape AgentModels.models_section reads
162
+ # `agents.models` from - one more caller of the pattern, not a new one.
163
+ def call_caps_section(config)
164
+ return {} unless config.is_a?(Hash)
165
+
166
+ runner = config["runner"]
167
+ return {} unless runner.is_a?(Hash)
168
+
169
+ section = runner["call_caps"]
170
+ section.is_a?(Hash) ? section : {}
171
+ end
172
+ private_class_method :call_caps_section
142
173
  end
@@ -46,6 +46,8 @@ module RunnerProposals
46
46
  "research" => "node-research.md",
47
47
  }.freeze
48
48
 
49
+ REVIEW_FIX_CAP = 2
50
+
49
51
  # accept(context, proposer:, proposed_nodes:, proposed_edges:, now:,
50
52
  # validator:, templates_dir:, renamer:) -> {ok:, minted:, validator:,
51
53
  # errors:}. `proposer` names the node whose return carried these proposals
@@ -129,6 +131,14 @@ module RunnerProposals
129
131
  edge_specs << { from: from, to: to }
130
132
  end
131
133
 
134
+ trial_nodes = loaded[:nodes].merge(node_specs.to_h { |s| [s[:id], { kind: s[:kind] }] })
135
+ existing_fixes = review_fix_count(loaded[:edges], loaded[:nodes])
136
+ trial_fixes = review_fix_count(trial_edges, trial_nodes)
137
+ if trial_fixes > [existing_fixes, REVIEW_FIX_CAP].max
138
+ return refuse(intent_dir, proposer,
139
+ "proposal refused (review_fix_cap): would make #{trial_fixes} review fixes, the cap is #{REVIEW_FIX_CAP}", now)
140
+ end
141
+
132
142
  node_specs.each { |s| scaffold_node_file(intent_dir, s) }
133
143
  if node_specs.any? || edge_specs.any?
134
144
  append_to_graph(graph_path, node_specs: node_specs, edge_specs: edge_specs, renamer: renamer)
@@ -141,6 +151,17 @@ module RunnerProposals
141
151
  { ok: true, minted: node_specs.map { |s| s[:id] }, validator: safe_validate(validator, intent_dir), errors: [] }
142
152
  end
143
153
 
154
+ # --- review fixes (355 D4) -------------------------------------------------
155
+
156
+ # review_fix_count(edges, nodes) -> how many work nodes GraphEdges.review_fixes
157
+ # finds, a node's kind read from its declaration and, when that is missing,
158
+ # from its id's NodeFile::KIND_PREFIX (343 D6).
159
+ def review_fix_count(edges, nodes)
160
+ ids = (edges.keys + edges.values.flatten).uniq
161
+ kinds = ids.to_h { |id| [id, (nodes[id] || {})[:kind] || NodeFile::KIND_PREFIX.key(id.to_s[/\A[a-z]+/])] }
162
+ GraphEdges.review_fixes(edges, kinds).length
163
+ end
164
+
144
165
  # --- node scaffolding --------------------------------------------------
145
166
 
146
167
  # A template's own placeholder id ("n1", "v1", ...) is replaced only as a