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

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.
@@ -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
@@ -0,0 +1,190 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "time"
6
+
7
+ # SessionUsage (intent 355, D10): reads the harness transcripts modified since
8
+ # a cutoff, dedupes assistant records by message id (the harness logs one
9
+ # record per content block), and reports per session the model, the calls in
10
+ # the window, the boot and last context, the median step, the steps over 5k,
11
+ # and the cache read. Context is input plus cache read plus cache write.
12
+ # Broken records are counted and named, never averaged over. The transcripts
13
+ # root and the rate-limit cache path are injected; nothing reads ENV.
14
+ class SessionUsage
15
+ WINDOW = 5 * 3600
16
+ BIG_STEP = 5_000
17
+ LABEL_WIDTH = 70
18
+ NO_PROMPT = "(no prompt)"
19
+ SYNTHETIC_MODEL = "<synthetic>"
20
+ CONTEXT_FIELDS = %w[input_tokens cache_read_input_tokens cache_creation_input_tokens].freeze
21
+ TRANSCRIPT_GLOBS = [File.join("*", "*.jsonl"), File.join("*", "*", "subagents", "*.jsonl")].freeze
22
+ HEADERS = ["session", "model", "calls", "boot", "last", "median step", "big steps", "cache read", "broken", "prompt"].freeze
23
+
24
+ def initialize(transcripts_root:, rate_limits_path:, now: Time.now)
25
+ @transcripts_root = transcripts_root
26
+ @rate_limits_path = rate_limits_path
27
+ @now = now
28
+ end
29
+
30
+ def cutoff(since: nil)
31
+ return [since, "since"] if since
32
+
33
+ reset = reset_time
34
+ return [reset - WINDOW, "rate-limit reset"] if reset && reset > @now
35
+
36
+ [@now - WINDOW, "last 5 hours"]
37
+ end
38
+
39
+ def report(since: nil)
40
+ at, source = cutoff(since: since)
41
+ header = { "status" => "ok", "root" => @transcripts_root, "cutoff" => at.getutc.iso8601, "cutoff_source" => source }
42
+ return header.merge("status" => "unavailable") unless File.directory?(@transcripts_root)
43
+
44
+ broken = []
45
+ sessions = transcripts(at).filter_map { |path| summarize(path, at, broken) }
46
+ header.merge("sessions" => sessions.sort_by { |s| -s["cache_read"] }, "broken" => broken)
47
+ end
48
+
49
+ def self.render_text(report)
50
+ return "Session usage: unavailable (no transcripts directory at #{report['root']})\n" if report["status"] == "unavailable"
51
+
52
+ out = +"Session usage since #{report['cutoff']} (#{report['cutoff_source']})\n\n"
53
+ out << (report["sessions"].empty? ? "(no sessions)\n" : table(report["sessions"]))
54
+ return out if report["broken"].empty?
55
+
56
+ out << "\nBroken records (#{report['broken'].size}):\n"
57
+ report["broken"].each { |b| out << " #{b['file']}:#{b['line']} #{b['error']}\n" }
58
+ out
59
+ end
60
+
61
+ def self.table(sessions)
62
+ rows = sessions.map do |s|
63
+ boot = tokens(s["boot"]) + (s["started_before_cutoff"] ? "*" : "")
64
+ [s["id"], s["model"].to_s, s["calls"].to_s, boot, tokens(s["last"]), tokens(s["median_step"]),
65
+ s["big_steps"].to_s, tokens(s["cache_read"]), s["broken"].to_s, s["label"]]
66
+ end
67
+ widths = HEADERS.each_index.map { |i| ([HEADERS] + rows).map { |r| r[i].length }.max }
68
+ lines = ([HEADERS] + rows).map { |r| r.each_with_index.map { |cell, i| cell.ljust(widths[i]) }.join(" ").rstrip }
69
+ note = sessions.any? { |s| s["started_before_cutoff"] } ? "\n* boot predates the cutoff\n" : ""
70
+ lines.join("\n") + "\n" + note
71
+ end
72
+
73
+ def self.tokens(count)
74
+ return "-" if count.nil?
75
+
76
+ count.abs >= 1_000 ? format("%.1fk", count / 1_000.0) : count.to_s
77
+ end
78
+
79
+ private_class_method :table, :tokens
80
+
81
+ private
82
+
83
+ def reset_time
84
+ return unless File.file?(@rate_limits_path)
85
+
86
+ value = JSON.parse(File.read(@rate_limits_path))["resets_at"].to_s
87
+ return if value.empty?
88
+
89
+ value.match?(/\A\d+\z/) ? Time.at(value.to_i).utc : Time.iso8601(value)
90
+ rescue JSON::ParserError, ArgumentError, TypeError
91
+ nil
92
+ end
93
+
94
+ def transcripts(cutoff)
95
+ TRANSCRIPT_GLOBS.flat_map { |glob| Dir.glob(File.join(@transcripts_root, glob)) }
96
+ .select { |path| File.mtime(path) >= cutoff }
97
+ .sort
98
+ end
99
+
100
+ def summarize(path, cutoff, broken)
101
+ calls = {}
102
+ label = nil
103
+ torn = 0
104
+ File.foreach(path).with_index(1) do |line, number|
105
+ next if line.strip.empty?
106
+
107
+ record = JSON.parse(line)
108
+ raise TypeError, "record is not a JSON object" unless record.is_a?(Hash)
109
+
110
+ label ||= prompt_line(record)
111
+ call = call_from(record)
112
+ next unless call
113
+
114
+ calls[call[:id]] = calls.key?(call[:id]) ? call.merge(at: calls[call[:id]][:at]) : call
115
+ rescue JSON::ParserError
116
+ torn += 1
117
+ broken << { "file" => path, "line" => number, "error" => "unparsable JSON" }
118
+ rescue ArgumentError, TypeError => e
119
+ torn += 1
120
+ broken << { "file" => path, "line" => number, "error" => e.message }
121
+ end
122
+ row(path, calls.values, cutoff, label, torn)
123
+ end
124
+
125
+ def row(path, calls, cutoff, label, torn)
126
+ window = calls.each_index.select { |i| calls[i][:at] >= cutoff }
127
+ return if window.empty? && torn.zero?
128
+
129
+ steps = window.filter_map { |i| calls[i][:context] - calls[i - 1][:context] if i.positive? }
130
+ {
131
+ "id" => File.basename(path, ".jsonl"),
132
+ "file" => path,
133
+ "label" => label || NO_PROMPT,
134
+ "model" => calls.last && calls.last[:model],
135
+ "calls" => window.size,
136
+ "boot" => calls.first && calls.first[:context],
137
+ "last" => calls.last && calls.last[:context],
138
+ "median_step" => median(steps),
139
+ "big_steps" => steps.count { |step| step > BIG_STEP },
140
+ "cache_read" => window.sum { |i| calls[i][:cache_read] },
141
+ "broken" => torn,
142
+ "started_before_cutoff" => !calls.empty? && calls.first[:at] < cutoff,
143
+ }
144
+ end
145
+
146
+ def call_from(record)
147
+ return unless record["type"] == "assistant"
148
+
149
+ message = record["message"]
150
+ raise TypeError, "assistant record without a message" unless message.is_a?(Hash)
151
+ return if message["model"] == SYNTHETIC_MODEL
152
+
153
+ usage = message["usage"]
154
+ raise ArgumentError, "assistant record without message id or usage" unless message["id"] && usage.is_a?(Hash)
155
+
156
+ {
157
+ id: message["id"],
158
+ model: message["model"],
159
+ at: Time.iso8601(record["timestamp"].to_s),
160
+ context: CONTEXT_FIELDS.sum { |field| usage[field].to_i },
161
+ cache_read: usage["cache_read_input_tokens"].to_i,
162
+ }
163
+ end
164
+
165
+ def prompt_line(record)
166
+ return unless record["type"] == "user" && !record["isMeta"]
167
+
168
+ text = prompt_text(record["message"].is_a?(Hash) ? record["message"]["content"] : nil)
169
+ return if text.nil?
170
+
171
+ first = text.lines.map(&:strip).find { |line| !line.empty? && !line.start_with?("<") }
172
+ first && first[0, LABEL_WIDTH]
173
+ end
174
+
175
+ def prompt_text(content)
176
+ return content if content.is_a?(String)
177
+ return unless content.is_a?(Array)
178
+
179
+ block = content.find { |b| b.is_a?(Hash) && b["type"] == "text" }
180
+ block && block["text"]
181
+ end
182
+
183
+ def median(values)
184
+ return if values.empty?
185
+
186
+ sorted = values.sort
187
+ middle = sorted.size / 2
188
+ sorted.size.odd? ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2
189
+ end
190
+ end
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # meter-watch (intent 355, n5, D6): reads the rate-limit cache under --home
6
+ # and writes ~/.plastic/.cache/meter-state.json with the state (ok, reduce,
7
+ # stop, resume, stale, unavailable), the two raw percentages, the reset
8
+ # time, and when it was checked. A session watches that one file instead of
9
+ # every session parsing the cache and re-deriving the thresholds.
10
+ #
11
+ # Usage:
12
+ # meter-watch [--home DIR]
13
+ # meter-watch --install-timer [--home DIR]
14
+ #
15
+ # --install-timer writes a LaunchAgent plist under --home (default
16
+ # ~/.plastic when --home is not given) that runs this tick every 20
17
+ # minutes. It never calls launchctl; load it yourself with the command it
18
+ # prints. The Plastic installer never calls --install-timer on its own.
19
+ #
20
+ # Exit codes: 0 reported (unavailable included); 2 usage error.
21
+
22
+ require "json"
23
+ require_relative "lib/meter_watch"
24
+
25
+ def usage_abort(message)
26
+ warn "meter-watch: #{message}"
27
+ warn "Usage: meter-watch [--home DIR] [--install-timer]"
28
+ exit 2
29
+ end
30
+
31
+ home = File.join(Dir.home, ".plastic")
32
+ install_timer = false
33
+
34
+ argv = ARGV.dup
35
+ until argv.empty?
36
+ case (token = argv.shift)
37
+ when "--home"
38
+ usage_abort("--home requires a value") if argv.empty?
39
+ home = argv.shift
40
+ when "--install-timer"
41
+ install_timer = true
42
+ else
43
+ usage_abort("unknown argument #{token.inspect}")
44
+ end
45
+ end
46
+
47
+ home = File.expand_path(home)
48
+
49
+ if install_timer
50
+ plist_path = MeterWatch.install_timer(home: home, script_path: File.expand_path(__FILE__))
51
+ puts "Installed LaunchAgent at #{plist_path}"
52
+ puts "Run `launchctl load #{plist_path}` to activate it."
53
+ exit 0
54
+ end
55
+
56
+ state = MeterWatch.new(home: home).tick
57
+ puts JSON.generate(state)
@@ -14,9 +14,9 @@ require_relative "lib/agent_models"
14
14
  DEFAULTS = {
15
15
  "version" => 3,
16
16
  "stale_threshold_days" => 3,
17
- # Absolute token counts for a 1M window, 35 and 50 percent (intent 312, 296 D38).
18
- "context_offer_tokens" => 350_000,
19
- "context_insist_tokens" => 500_000,
17
+ # Absolute token counts for a 1M window, 15 and 25 percent (intent 355, n5, D7).
18
+ "context_offer_tokens" => 150_000,
19
+ "context_insist_tokens" => 250_000,
20
20
  "execution_mode" => "subagent-driven",
21
21
  "hash_length" => 6,
22
22
  "hash_algorithm" => "sha256-base36",
package/scripts/runner CHANGED
@@ -5,6 +5,7 @@
5
5
  require_relative "lib/savepoint"
6
6
  require_relative "lib/ready_set"
7
7
  require_relative "lib/runner_core"
8
+ require_relative "lib/runner_proposals"
8
9
 
9
10
  # runner - the one executable over the graph-ready loop's declared node graph
10
11
  # (intent 340, G7, n1). A subcommand table: the public verbs (step, status,
@@ -138,6 +139,10 @@ module Runner
138
139
  end
139
140
  end
140
141
 
142
+ graph = context.graph || {}
143
+ fixes = RunnerProposals.review_fix_count(graph[:edges] || {}, graph[:nodes] || {})
144
+ puts "review fixes: #{fixes} of #{RunnerProposals::REVIEW_FIX_CAP}"
145
+
141
146
  complete = RunnerCore.complete?(context)
142
147
  puts
143
148
  puts complete ? "complete" : "stalled"
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # session-usage (intent 355, D10): read the harness transcripts since a cutoff
6
+ # and report per session the model, calls, boot context, last context, median
7
+ # step, steps over 5k, and cache read. Broken records are counted and named.
8
+ #
9
+ # Usage:
10
+ # session-usage [--since <iso8601>] [--format text|json] [--root <dir>] [--rate-limits <file>]
11
+ #
12
+ # Defaults: the root is ~/.claude/projects, the rate-limit cache is
13
+ # ~/.plastic/.cache/rate-limits.json, and the cutoff is that cache's reset
14
+ # time minus five hours, else the last five hours.
15
+ #
16
+ # Exit codes: 0 reported (unavailable included); 2 usage error.
17
+
18
+ require "json"
19
+ require "time"
20
+ require_relative "lib/session_usage"
21
+
22
+ FLAGS = %w[--since --format --root --rate-limits].freeze
23
+ FORMATS = %w[text json].freeze
24
+
25
+ def usage_abort(message)
26
+ warn "session-usage: #{message}"
27
+ warn "Usage: session-usage [--since <iso8601>] [--format text|json] [--root <dir>] [--rate-limits <file>]"
28
+ exit 2
29
+ end
30
+
31
+ def parse_args(argv)
32
+ usage_abort("every flag takes a value") if argv.length.odd?
33
+ opts = argv.each_slice(2).to_h
34
+ unknown = opts.keys - FLAGS
35
+ usage_abort("unknown flag #{unknown.first.inspect}") unless unknown.empty?
36
+ usage_abort("unknown format #{opts['--format'].inspect}") if opts["--format"] && !FORMATS.include?(opts["--format"])
37
+ opts
38
+ end
39
+
40
+ def parse_since(value)
41
+ value && Time.iso8601(value)
42
+ rescue ArgumentError
43
+ usage_abort("--since #{value.inspect} is not an ISO 8601 time")
44
+ end
45
+
46
+ opts = parse_args(ARGV)
47
+ usage = SessionUsage.new(
48
+ transcripts_root: File.expand_path(opts.fetch("--root", File.join(Dir.home, ".claude", "projects"))),
49
+ rate_limits_path: File.expand_path(opts.fetch("--rate-limits", File.join(Dir.home, ".plastic", ".cache", "rate-limits.json")))
50
+ )
51
+ report = usage.report(since: parse_since(opts["--since"]))
52
+ if opts["--format"] == "json"
53
+ puts JSON.pretty_generate(report)
54
+ else
55
+ print SessionUsage.render_text(report)
56
+ end
@@ -13,20 +13,18 @@ user-invocable: true
13
13
  Announce: "Taking over intent [ID] - [name] for autonomous delivery."
14
14
 
15
15
  **Advisory (not a rule).** At auto-mode start, recommend once that the user run this
16
- orchestrating main session on the best available thinking model (Fable, Opus, or whatever
17
- supersedes them). This is advice only: it changes no behavior. Dispatched agents keep their
18
- configured model and never resolve to Fable unless an explicit `agents.models.<name>` config
19
- override names Fable for that role. The two advisors, `plastic-advisor` and
20
- `plastic-faux-advisor`, are consultation roles the user or the main session summons
21
- deliberately; the auto pipeline never dispatches them.
16
+ orchestrating session on the best available thinking model (Fable, Opus, or whatever supersedes
17
+ them); this is advice only, and dispatched agents keep their configured model, never resolving
18
+ to Fable without an explicit `agents.models.<name>` config override. `plastic-advisor` and
19
+ `plastic-faux-advisor` are consultation roles the user or this session summons deliberately;
20
+ the auto pipeline never dispatches them.
22
21
 
23
22
  ## Precondition
24
23
 
25
24
  An active intent MUST exist in INDEX.md. If none exists, refuse: "No active intent found.
26
25
  Create one first with /plastic-intent-creating."
27
26
 
28
- If several active intents exist, ask the user which one to deliver (the one question auto asks
29
- at boarding, before delivery starts).
27
+ If several active intents exist, ask which to deliver (the one question auto asks at boarding).
30
28
 
31
29
  **Picking work when no intent is specified.** If the user says "auto" without naming an intent
32
30
  and none is active, consult the roadmap first (the primary planning surface), then fall back to
@@ -42,9 +40,8 @@ frontier batch is still delivering, report it and wait, never dispatch a later b
42
40
  `exhausted` means fall back to `ruby ~/.plastic/scripts/dashboard.rb all --json` and work its
43
41
  `dispatchable_queue` in `rank` order, leaving `human_only` and `next_big_thing` for the user.
44
42
 
45
- QMD-first (when available): when the user describes the work rather than naming an intent, run
46
- `ruby ~/.plastic/scripts/qmd-sync search "<terms>"` before scanning the store, then open the
47
- authoritative intent file for the hit you take over. The command is a no-op when QMD is absent.
43
+ QMD-first (when available): when the user describes the work instead of naming an intent, run
44
+ `ruby ~/.plastic/scripts/qmd-sync search "<terms>"` first, then open the hit's authoritative intent file; a no-op when QMD is absent.
48
45
 
49
46
  ## Take the intent (do this FIRST)
50
47
 
@@ -90,6 +87,12 @@ tier and no stage agent; depth follows the work.
90
87
  Two boots is the normal delivery; the third is the exception the risk rule names. The lead is
91
88
  this session (the `plastic-enforcer` role), never a dispatched agent.
92
89
 
90
+ A lead is a choice, not a requirement (D8, 355). `runner step` computes the plan and prints a
91
+ spawn block per node - agent, model, packet path, the test command, the call cap - fenced for a
92
+ session to paste into the Agent tool; the runner never spawns (327 D42). A lead earns its keep
93
+ on a graph carrying a decision node, weighing its `needs_decision` stop; a graph with none runs
94
+ end to end from `runner step` alone.
95
+
93
96
  ## Team
94
97
 
95
98
  - **plastic-enforcer**: this session. Writes the Why and How record, dispatches, folds reviews,
@@ -148,13 +151,10 @@ each one as a delegate before (or when) it needs to write into the intent dir:
148
151
 
149
152
  Only the owner can delegate. Delegates cannot re-delegate or release.
150
153
 
151
- Headless note: in a headless or background run the session id may be unset; the arm verb then
152
- keys the lock by a derived key and the record hook still writes the savepoint ledger from the
153
- written path. Verify the lock with `plastic-lock status` rather than assuming.
154
+ Headless note: in a headless or background run the session id may be unset; the arm verb then keys the lock by a derived key and the record hook still writes the ledger.
155
+ Verify with `plastic-lock status` rather than assuming.
154
156
 
155
- Solo fallback: on a harness with no agent dispatch (Codex CLI today), this session walks the
156
- five steps itself: it still writes the matrix, still writes the tests first, and reviews its own
157
- plan against the matrix before code, saying so in `## Insights`.
157
+ Solo fallback: on a harness with no agent dispatch (Codex CLI today), this session walks the five steps itself, still writing the matrix and the tests first and reviewing its own plan against the matrix before code, saying so in `## Insights`.
158
158
 
159
159
  ## Stage-Aware Entry
160
160
 
@@ -44,6 +44,10 @@ Dispatches subagents to do the work. The controller never implements. It dispatc
44
44
 
45
45
  The post-execution review in Step 3 runs by risk (the rule lives in the auto skill). When it runs, the reviewer is a separate agent with fresh context, never the maker. The plan itself is reviewed before code by the adversarial plan reviewer (`plan-reviewer-prompt.md`), dispatched by the lead at How.
46
46
 
47
+ ### Graph dispatch: the paste
48
+
49
+ When the plan is a graph (`nodes/*.md`, not `plan.md` plus action files), the dispatch step is the paste, not a lead's hand-typed brief. `runner step` computes readiness, applies policy, and prints a spawn block per dispatched node - agent, model, packet path, the one test command, the call cap - fenced in its own stdout. Copy each block into the Agent tool as its own dispatch, verbatim; the runner itself never spawns an agent (327 D42).
50
+
47
51
  ### Inline (Fallback)
48
52
  Executes tasks sequentially in the current session. Use when subagents aren't available or user explicitly requests inline mode.
49
53
 
@@ -2,11 +2,11 @@ version: 3
2
2
  project_roots:
3
3
  - ~/.plastic/projects
4
4
  stale_threshold_days: 3
5
- # When to compact. Absolute token counts for a 1M window (35 and 50 percent), not
5
+ # When to compact. Absolute token counts for a 1M window (15 and 25 percent), not
6
6
  # percentages: models are reliable only to roughly 50 to 65 percent of advertised
7
7
  # context, so a percentage that is right at 200k floats to an untested size at 1M.
8
- context_offer_tokens: 350000
9
- context_insist_tokens: 500000
8
+ context_offer_tokens: 150000
9
+ context_insist_tokens: 250000
10
10
  execution_mode: subagent-driven
11
11
  hash_length: 6
12
12
  hash_algorithm: sha256-base36