@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
package/bin/test
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
# encoding: UTF-8
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# Runs the
|
|
5
|
+
# Runs the Minitest suite in a single process, either the whole suite or a
|
|
6
|
+
# named subset via --only (intent 355, n4, D5). Every run, named or full,
|
|
7
|
+
# goes through FailuresReporter, so a green run costs one line and a red run
|
|
8
|
+
# prints only the failures plus that line, never a dot per test.
|
|
6
9
|
#
|
|
7
10
|
# `release.verify` used to be `ruby -Itest test/*_test.rb`, but Ruby runs only
|
|
8
11
|
# the first glob-expanded file as the program — the rest land in ARGV and are
|
|
@@ -12,17 +15,34 @@
|
|
|
12
15
|
# loaded.
|
|
13
16
|
|
|
14
17
|
root = File.expand_path("..", __dir__)
|
|
15
|
-
|
|
16
|
-
files = Dir.glob(File.join(root, "test", "**", "*_test.rb")).sort
|
|
17
|
-
abort "No test files found under #{File.join(root, "test")}" if files.empty?
|
|
18
|
+
discover = -> { Dir.glob(File.join(root, "test", "**", "*_test.rb")).sort }
|
|
18
19
|
|
|
19
20
|
# `bin/test --list` prints the discovered files without running them, so the
|
|
20
21
|
# discovery logic can be tested cheaply (no full-suite execution).
|
|
21
22
|
if ARGV.delete("--list")
|
|
23
|
+
files = discover.call
|
|
24
|
+
abort "No test files found under #{File.join(root, "test")}" if files.empty?
|
|
22
25
|
puts files.map { |f| f.sub("#{root}/", "") }
|
|
23
26
|
exit 0
|
|
24
27
|
end
|
|
25
28
|
|
|
29
|
+
only_index = ARGV.index("--only")
|
|
30
|
+
if only_index
|
|
31
|
+
named = ARGV.slice!(only_index..-1)
|
|
32
|
+
named.shift
|
|
33
|
+
abort "--only requires at least one test file" if named.empty?
|
|
34
|
+
files = named.map { |f| File.expand_path(f, root) }
|
|
35
|
+
missing = files.reject { |f| File.exist?(f) }
|
|
36
|
+
unless missing.empty?
|
|
37
|
+
warn "no such test file: #{missing.map { |f| f.sub("#{root}/", "") }.join(', ')}"
|
|
38
|
+
exit 2
|
|
39
|
+
end
|
|
40
|
+
else
|
|
41
|
+
files = discover.call
|
|
42
|
+
abort "No test files found under #{File.join(root, "test")}" if files.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
26
45
|
$LOAD_PATH.unshift File.join(root, "test")
|
|
46
|
+
require_relative "../test/lib/failures_reporter"
|
|
27
47
|
require "minitest/autorun"
|
|
28
48
|
files.each { |f| require f }
|
package/hooks/hooks.json
CHANGED
|
@@ -17,6 +17,18 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
+
"PreToolUse": [
|
|
21
|
+
{
|
|
22
|
+
"matcher": "",
|
|
23
|
+
"hooks": [
|
|
24
|
+
{
|
|
25
|
+
"type": "command",
|
|
26
|
+
"command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/run-hook\" call-budget",
|
|
27
|
+
"statusMessage": ""
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
],
|
|
20
32
|
"PreCompact": [
|
|
21
33
|
{
|
|
22
34
|
"matcher": "",
|
package/package.json
CHANGED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# Usage: hook-call-budget (reads the PreToolUse stdin JSON payload)
|
|
6
|
+
#
|
|
7
|
+
# Intent 355, n2 (review fix n8). A node gets a call budget by kind
|
|
8
|
+
# (RunnerPolicy.call_cap): a cap on tool calls per attempt. RunnerDispatch
|
|
9
|
+
# writes the cap onto the running line as `calls=` and states it as one
|
|
10
|
+
# sentence in the node's own input, so the executor learns the number before
|
|
11
|
+
# it ever gets denied.
|
|
12
|
+
#
|
|
13
|
+
# Inside a subagent the hook input's session_id and transcript_path are the
|
|
14
|
+
# MAIN session's, never the subagent's own (v1 finding). So this hook caps
|
|
15
|
+
# ONLY a subagent call: agent_id present in the payload. A call with no
|
|
16
|
+
# agent_id (the main thread, a plain conversation, a live --agent session's
|
|
17
|
+
# own turns) is never capped and the decision returns before any file is
|
|
18
|
+
# touched.
|
|
19
|
+
#
|
|
20
|
+
# Which node a subagent is working is read from the subagent's OWN
|
|
21
|
+
# transcript, at <dirname(transcript_path)>/<session_id>/subagents/
|
|
22
|
+
# agent-<agent_id>.jsonl (the main transcript_path plus the main session_id
|
|
23
|
+
# plus the subagent's own agent_id): its first user record carries the
|
|
24
|
+
# dispatch prompt, and the lead/spawn block always pastes the node's packet
|
|
25
|
+
# path into that prompt (PACKET_PATH_RE). The packet path names the intent
|
|
26
|
+
# directory directly (its grandparent), so this hook never scans the store to
|
|
27
|
+
# find it. No packet path in that first prompt means allow.
|
|
28
|
+
#
|
|
29
|
+
# The cap applies only when that node's LATEST transition line in
|
|
30
|
+
# <intent>/savepoint.md is `running` and carries `calls=`; a later `done` or
|
|
31
|
+
# `failed_verification` line (or a sibling node's own running line) means no
|
|
32
|
+
# cap. Past the cap, a Bash call that runs only a safe git verb (add, commit,
|
|
33
|
+
# status, rev-parse, log) is still allowed, so the denial the hook itself
|
|
34
|
+
# orders (commit what is green) is never the thing the hook blocks.
|
|
35
|
+
#
|
|
36
|
+
# Pure and dependency-injected down to the paths it reads; the trailing block
|
|
37
|
+
# guarded by `$PROGRAM_NAME == __FILE__` is this file's only I/O (stdin,
|
|
38
|
+
# stdout), so a test can `load` this file and call HookCallBudget's module
|
|
39
|
+
# functions directly without spawning a process or touching stdin.
|
|
40
|
+
|
|
41
|
+
require "json"
|
|
42
|
+
require_relative "lib/node_ledger"
|
|
43
|
+
require_relative "lib/savepoint"
|
|
44
|
+
|
|
45
|
+
module HookCallBudget
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
RETURN_INSTRUCTION = "commit what is green and return failed_verification reason=call_budget"
|
|
49
|
+
|
|
50
|
+
# A node id, then "--a", then an attempt number, inside a /packets/
|
|
51
|
+
# directory: the exact shape RunnerDispatch's spawn block pastes into a
|
|
52
|
+
# subagent's own first prompt. Captures [full_path, node].
|
|
53
|
+
PACKET_PATH_RE = %r{(\S+/packets/([a-z]{1,2}\d+)--a\d+\.packet)}.freeze
|
|
54
|
+
|
|
55
|
+
FIRST_RECORD_SCAN_LIMIT = 20
|
|
56
|
+
|
|
57
|
+
GIT_ALLOWED_VERBS = %w[add commit status rev-parse log].freeze
|
|
58
|
+
GIT_UNSAFE_RE = /[;&|\n]|\$\(|`/.freeze
|
|
59
|
+
|
|
60
|
+
# The one decision: nil (allow, no output) or a deny payload Hash. Never
|
|
61
|
+
# raises across its own boundary.
|
|
62
|
+
def decide(payload)
|
|
63
|
+
return nil unless payload.is_a?(Hash)
|
|
64
|
+
|
|
65
|
+
agent_id = payload["agent_id"]
|
|
66
|
+
return nil if agent_id.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
session_id = payload["session_id"].to_s
|
|
69
|
+
transcript_path = payload["transcript_path"].to_s
|
|
70
|
+
return nil if session_id.empty? || transcript_path.empty?
|
|
71
|
+
|
|
72
|
+
sub_path = subagent_transcript_path(transcript_path, session_id, agent_id)
|
|
73
|
+
|
|
74
|
+
prompt = begin
|
|
75
|
+
first_user_prompt_text(sub_path)
|
|
76
|
+
rescue StandardError
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
return nil unless prompt
|
|
80
|
+
|
|
81
|
+
match = PACKET_PATH_RE.match(prompt)
|
|
82
|
+
return nil unless match
|
|
83
|
+
|
|
84
|
+
node = match[2]
|
|
85
|
+
intent_dir = File.dirname(File.dirname(match[1]))
|
|
86
|
+
|
|
87
|
+
cap = node_cap(intent_dir, node)
|
|
88
|
+
return nil unless cap
|
|
89
|
+
|
|
90
|
+
count = begin
|
|
91
|
+
count_tool_use_blocks(sub_path)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
note_unreadable(intent_dir, session_id, e.message)
|
|
94
|
+
return nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
return nil if count <= cap
|
|
98
|
+
return nil if git_only_bash_call?(payload)
|
|
99
|
+
|
|
100
|
+
deny_payload("#{node} is past its call budget (#{count}/#{cap} tool calls); #{RETURN_INSTRUCTION}")
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def subagent_transcript_path(main_transcript_path, session_id, agent_id)
|
|
104
|
+
File.join(File.dirname(main_transcript_path), session_id, "subagents", "agent-#{agent_id}.jsonl")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The first `"type":"user"` record in `path`'s own transcript, rendered to
|
|
108
|
+
# plain text (message.content as a string, or the joined text blocks of a
|
|
109
|
+
# content array). Scans at most FIRST_RECORD_SCAN_LIMIT lines: the dispatch
|
|
110
|
+
# prompt is always the transcript's opening record, so this never touches
|
|
111
|
+
# the bulk of a long-running subagent's history. Raises on a genuinely
|
|
112
|
+
# unreadable path (a directory, a permission error); the caller decides
|
|
113
|
+
# what that means.
|
|
114
|
+
def first_user_prompt_text(path)
|
|
115
|
+
File.open(path) do |f|
|
|
116
|
+
f.each_line.first(FIRST_RECORD_SCAN_LIMIT).each do |raw|
|
|
117
|
+
record = begin
|
|
118
|
+
JSON.parse(raw)
|
|
119
|
+
rescue JSON::ParserError
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
next unless record.is_a?(Hash) && record["type"] == "user"
|
|
123
|
+
|
|
124
|
+
return message_text(record)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def message_text(record)
|
|
131
|
+
content = record.dig("message", "content")
|
|
132
|
+
case content
|
|
133
|
+
when String
|
|
134
|
+
content
|
|
135
|
+
when Array
|
|
136
|
+
content.select { |b| b.is_a?(Hash) && b["type"] == "text" }.map { |b| b["text"].to_s }.join("\n")
|
|
137
|
+
else
|
|
138
|
+
""
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The cap named on `node`'s LATEST (file-order) non-torn transition line in
|
|
143
|
+
# `intent_dir`/savepoint.md, or nil when that line is not `running`, carries
|
|
144
|
+
# no `calls=`, or does not exist at all (matrix 2.6/2.6b: a later done/
|
|
145
|
+
# failed_verification line, or a sibling node's own running line, never
|
|
146
|
+
# caps this node).
|
|
147
|
+
def node_cap(intent_dir, node)
|
|
148
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
149
|
+
return nil unless File.exist?(path)
|
|
150
|
+
|
|
151
|
+
latest = NodeLedger.entries(path).select { |e| !e[:torn] && e[:subject] == node }.last
|
|
152
|
+
return nil unless latest && latest[:state] == "running"
|
|
153
|
+
|
|
154
|
+
cap = (latest[:fields] || {})["calls"]
|
|
155
|
+
cap ? cap.to_i : nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Streams `path` a line at a time, counting `"type":"tool_use"` content
|
|
159
|
+
# blocks with a plain regex rather than a full JSON parse per line (matrix
|
|
160
|
+
# 2.5/2.8): a 3 MB transcript counts in well under 50 ms this way.
|
|
161
|
+
def count_tool_use_blocks(path)
|
|
162
|
+
count = 0
|
|
163
|
+
File.foreach(path) { |ln| count += ln.scan(/"type"\s*:\s*"tool_use"/).length }
|
|
164
|
+
count
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Past the cap, a Bash call is still allowed when its command runs only one
|
|
168
|
+
# safe git verb (matrix 2.7/D3): the git add/commit the denial itself
|
|
169
|
+
# orders must never be the call the hook blocks. Any chaining token
|
|
170
|
+
# (`;`, `&`, `|`, a newline, a subshell or a backtick) refuses the whole
|
|
171
|
+
# command, so a compound command can never smuggle an unrelated call past
|
|
172
|
+
# the cap riding on a leading `git`.
|
|
173
|
+
def git_only_bash_call?(payload)
|
|
174
|
+
return false unless payload["tool_name"].to_s == "Bash"
|
|
175
|
+
|
|
176
|
+
command = payload.dig("tool_input", "command").to_s.strip
|
|
177
|
+
return false if command.empty? || GIT_UNSAFE_RE.match?(command)
|
|
178
|
+
|
|
179
|
+
tokens = command.split(/\s+/)
|
|
180
|
+
return false unless tokens.shift == "git"
|
|
181
|
+
|
|
182
|
+
tokens.shift(2) if tokens.first == "-C"
|
|
183
|
+
GIT_ALLOWED_VERBS.include?(tokens.first)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def deny_payload(reason)
|
|
187
|
+
{ "hookSpecificOutput" => { "hookEventName" => "PreToolUse", "permissionDecision" => "deny",
|
|
188
|
+
"permissionDecisionReason" => reason } }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Best-effort note that the transcript could not be counted (matrix 2.9): a
|
|
192
|
+
# plain milestone line, the same shared primitive Commit/Review/Report
|
|
193
|
+
# lines already use, dedup'd on (stage, milestone) so a repeat failure in
|
|
194
|
+
# the same session with the same message is not written twice.
|
|
195
|
+
def note_unreadable(intent_dir, session_id, message, now: Time.now)
|
|
196
|
+
Savepoint.append_savepoint_line(intent_dir, "CallBudget", "transcript unreadable for #{session_id}: #{message}",
|
|
197
|
+
now)
|
|
198
|
+
rescue StandardError
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
if $PROGRAM_NAME == __FILE__
|
|
204
|
+
raw = begin
|
|
205
|
+
$stdin.read
|
|
206
|
+
rescue StandardError
|
|
207
|
+
nil
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
if raw && !raw.strip.empty?
|
|
211
|
+
payload = begin
|
|
212
|
+
JSON.parse(raw)
|
|
213
|
+
rescue StandardError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
result = HookCallBudget.decide(payload)
|
|
218
|
+
puts JSON.generate(result) if result
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
exit 0
|
|
222
|
+
end
|
|
@@ -36,6 +36,22 @@ rescue StandardError
|
|
|
36
36
|
end
|
|
37
37
|
payload_session_id = stdin_payload.is_a?(Hash) ? stdin_payload["session_id"].to_s : ""
|
|
38
38
|
|
|
39
|
+
# --- subagent marker (intent 355 spec D9, node n7; review fix n8, B6): the
|
|
40
|
+
# stdin payload carries agent_id only when this SessionStart call runs
|
|
41
|
+
# inside a spawned agent. agent_type alone is not enough: a live
|
|
42
|
+
# `claude --agent` session carries agent_type on every turn but never
|
|
43
|
+
# agent_id, so keying on agent_type would boot a live agent session with the
|
|
44
|
+
# core banner only. Read from that payload only, never from an environment
|
|
45
|
+
# variable, so a helper needs the core banner alone. An absent agent_id (the
|
|
46
|
+
# common case) is a live session. Any exception here still boots the banner,
|
|
47
|
+
# never nothing: the rescue falls back to a live session, whose own content
|
|
48
|
+
# already comes from paths this file already guards independently.
|
|
49
|
+
subagent_session = begin
|
|
50
|
+
stdin_payload.is_a?(Hash) && !!stdin_payload["agent_id"]
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
39
55
|
# Plastic home and the store are two different paths (intent 231). The shim passes
|
|
40
56
|
# home (~/.plastic) as argument 2; the store lives one level below it. Compose the
|
|
41
57
|
# store exactly once here, so no later line re-derives it and no path can gain a
|
|
@@ -254,9 +270,9 @@ begin
|
|
|
254
270
|
end
|
|
255
271
|
rescue Exception
|
|
256
272
|
# Any failure (timeout, missing binary, parse error) — stay silent, never crash.
|
|
257
|
-
end
|
|
273
|
+
end unless subagent_session
|
|
258
274
|
|
|
259
|
-
if plastic_md
|
|
275
|
+
if plastic_md && !subagent_session
|
|
260
276
|
# Conventions always loaded first
|
|
261
277
|
parts << plastic_md
|
|
262
278
|
parts << "\n---\n"
|
|
@@ -292,7 +308,7 @@ if plastic_md
|
|
|
292
308
|
end
|
|
293
309
|
end
|
|
294
310
|
|
|
295
|
-
if active_deprecations.any?
|
|
311
|
+
if active_deprecations.any? && !subagent_session
|
|
296
312
|
parts << ""
|
|
297
313
|
active_deprecations.each do |dep|
|
|
298
314
|
severity = dep["severity"] || "info"
|
|
@@ -319,7 +335,7 @@ if active_deprecations.any?
|
|
|
319
335
|
end
|
|
320
336
|
end
|
|
321
337
|
|
|
322
|
-
if update_notice
|
|
338
|
+
if update_notice && !subagent_session
|
|
323
339
|
parts.unshift("! #{update_notice}\n")
|
|
324
340
|
end
|
|
325
341
|
|
|
@@ -367,7 +383,7 @@ begin
|
|
|
367
383
|
end
|
|
368
384
|
rescue StandardError
|
|
369
385
|
nil
|
|
370
|
-
end
|
|
386
|
+
end unless subagent_session
|
|
371
387
|
|
|
372
388
|
# --- Session day ledger: open or join today, write the per-session pointer
|
|
373
389
|
# and heartbeat (intent 298, spec D4). Best-effort: any failure here degrades
|
|
@@ -416,7 +432,7 @@ begin
|
|
|
416
432
|
end
|
|
417
433
|
rescue StandardError
|
|
418
434
|
nil
|
|
419
|
-
end
|
|
435
|
+
end unless subagent_session
|
|
420
436
|
|
|
421
437
|
# Emit nothing when there is genuinely nothing to surface (no conventions,
|
|
422
438
|
# no deprecations, no update notice).
|
|
@@ -19,9 +19,9 @@ require "digest"
|
|
|
19
19
|
#
|
|
20
20
|
# Library only: no CLI, no ENV, no I/O.
|
|
21
21
|
module CompactInstructions
|
|
22
|
-
#
|
|
23
|
-
OFFER_TOKENS =
|
|
24
|
-
INSIST_TOKENS =
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
79
|
-
#
|
|
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
|
|
@@ -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:
|
|
568
|
-
context_insist_tokens:
|
|
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
|