@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.
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +12 -0
- package/package.json +1 -1
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-session-start +22 -6
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/doctor_core.rb +2 -1
- package/scripts/lib/graph_edges.rb +16 -0
- package/scripts/lib/hook_registry.rb +14 -2
- package/scripts/lib/installer_core.rb +10 -2
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_packet.rb +27 -5
- package/scripts/lib/runner_dispatch.rb +29 -5
- package/scripts/lib/runner_policy.rb +31 -0
- package/scripts/lib/runner_proposals.rb +21 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/meter-watch +57 -0
- package/scripts/read-config +3 -3
- package/scripts/runner +5 -0
- package/scripts/session-usage +56 -0
- package/skills/auto/SKILL.md +17 -17
- package/skills/intent-executing/SKILL.md +4 -0
- package/templates/config.yml +3 -3
|
@@ -425,7 +425,15 @@ module NodePacket
|
|
|
425
425
|
verify.split("\n").map(&:strip).reject(&:empty?).join("; ")
|
|
426
426
|
end
|
|
427
427
|
|
|
428
|
-
|
|
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)
|
package/scripts/read-config
CHANGED
|
@@ -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,
|
|
18
|
-
"context_offer_tokens" =>
|
|
19
|
-
"context_insist_tokens" =>
|
|
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
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
46
|
-
`ruby ~/.plastic/scripts/qmd-sync search "<terms>"`
|
|
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
|
-
|
|
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
|
|
package/templates/config.yml
CHANGED
|
@@ -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 (
|
|
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:
|
|
9
|
-
context_insist_tokens:
|
|
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
|