@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 CHANGED
@@ -2,7 +2,10 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- # Runs the FULL Minitest suite in a single process.
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 }
@@ -0,0 +1,4 @@
1
+ #!/bin/bash
2
+ INPUT=$(cat)
3
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
4
+ echo "$INPUT" | env -u RUBYOPT ruby --disable-gems "$SCRIPT_DIR/../scripts/hook-call-budget"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.20",
3
+ "version": "2.0.0-alpha.21",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- # 35 and 50 percent of a 1M window.
23
- OFFER_TOKENS = 350_000
24
- INSIST_TOKENS = 500_000
22
+ # 15 and 25 percent of a 1M window (intent 355, n5, D7).
23
+ OFFER_TOKENS = 150_000
24
+ INSIST_TOKENS = 250_000
25
25
 
26
26
  # Static on purpose. A body rendered from the user's config would change its hash
27
27
  # every time they edited config.yml, and doctor would then report a correct install
@@ -32,10 +32,10 @@ module CompactInstructions
32
32
  Plastic watches this session's context. When the harness reports how much of the
33
33
  window is used:
34
34
 
35
- - At 350,000 tokens, offer to compact. Say that the hand-off in today's day ledger
35
+ - At 150,000 tokens, offer to compact. Say that the hand-off in today's day ledger
36
36
  is written and current, and take no for an answer: a task that is nearly done
37
37
  does not need the interruption.
38
- - At 500,000 tokens, insist. Take no new work, write the hand-off in today's day
38
+ - At 250,000 tokens, insist. Take no new work, write the hand-off in today's day
39
39
  ledger, and compact before continuing.
40
40
  - After a compaction, say continue. The day summary at boot and the hand-off carry
41
41
  the state; do not rebuild it by re-reading files.
@@ -30,7 +30,8 @@ class Doctor
30
30
  # The Claude events hooks_registered expects in settings.json: the six-event map of
31
31
  # cut-inventory 3b (intent 309 added SessionEnd, registered for close since intent 301;
32
32
  # intent 316a added MessageDisplay, registered for message-display, Claude only).
33
- CLAUDE_HOOK_EVENTS = %w[SessionStart PreCompact PostToolUse UserPromptSubmit SessionEnd MessageDisplay].freeze
33
+ CLAUDE_HOOK_EVENTS = %w[SessionStart PreToolUse PreCompact PostToolUse UserPromptSubmit SessionEnd
34
+ MessageDisplay].freeze
34
35
 
35
36
  # Launchers the installer places in the agent's hooks dir that are NOT hooks
36
37
  # (intent 204): plastic-statusline is the settings["statusLine"] command, wired
@@ -118,4 +118,20 @@ module GraphEdges
118
118
  visited[node] = true
119
119
  nil
120
120
  end
121
+
122
+ # The declared work ids whose `needs` reach a verify node without passing
123
+ # through another verify node (343 D7), in declaration order. `kinds` maps
124
+ # an id to its node kind, so this module stays blind to where a kind lives.
125
+ def review_fixes(edges, kinds)
126
+ edges.keys.select { |id| kinds[id] == "work" && reaches_verify?(id, edges, kinds, {}) }
127
+ end
128
+
129
+ def reaches_verify?(node, edges, kinds, seen)
130
+ (edges[node] || []).any? do |target|
131
+ next false if seen[target]
132
+
133
+ seen[target] = true
134
+ kinds[target] == "verify" || reaches_verify?(target, edges, kinds, seen)
135
+ end
136
+ end
121
137
  end
@@ -34,6 +34,17 @@ module HookRegistry
34
34
  { "name" => "check-update", "status" => "" },
35
35
  ] },
36
36
  ],
37
+ # Intent 355, n2: the call budget guard. Not one of the edit-path
38
+ # gates intent 302 removed (those denied a write on content; this
39
+ # denies a call on a per-attempt COUNT, read from the session's own
40
+ # transcript, never from the tool's arguments) - see scripts/hook-
41
+ # call-budget. Claude only: CODEX_LIVE_STATE_EVENTS below does not
42
+ # carry PreToolUse, so codex_hooks_json never projects it.
43
+ "PreToolUse" => [
44
+ { "matcher" => "", "hooks" => [
45
+ { "name" => "call-budget", "status" => "" },
46
+ ] },
47
+ ],
37
48
  "PreCompact" => [
38
49
  { "matcher" => "", "hooks" => [
39
50
  { "name" => "savepoint", "status" => "Saving Plastic intent state..." },
@@ -75,8 +86,9 @@ module HookRegistry
75
86
  # the PostToolUse record hook collapses from Claude's multi-tool matcher onto
76
87
  # Codex's single apply_patch tool (181 F4: apply_patch is Codex's sole
77
88
  # file-mutation tool; tool_name always reports apply_patch), and the live-state
78
- # events project through whole. Since intent 302 there is no PreToolUse group at
79
- # all: the edit-path gates are gone on both harnesses. Command invokes the
89
+ # events project through whole. PreToolUse (intent 355, n2's call-budget guard,
90
+ # Claude only) is not one of CODEX_LIVE_STATE_EVENTS below, so Codex still
91
+ # carries no PreToolUse group of its own. Command invokes the
80
92
  # codex-hook dispatcher with the hook name. Guide-settled shape [guide Part 3]:
81
93
  # top-level {"hooks":{<Event>: [{"matcher","hooks":[{"type":"command","command",
82
94
  # "statusMessage"}]}]}}, identical to Claude's shape, string command. Single
@@ -438,6 +438,8 @@ class InstallerCore
438
438
  "scripts/verify-intent" => "scripts/verify-intent",
439
439
  "scripts/lib/exec_worktree.rb" => "scripts/lib/exec_worktree.rb",
440
440
  "scripts/exec-worktree" => "scripts/exec-worktree",
441
+ "scripts/lib/session_usage.rb" => "scripts/lib/session_usage.rb",
442
+ "scripts/session-usage" => "scripts/session-usage",
441
443
  "scripts/doctor.rb" => "scripts/doctor.rb",
442
444
  "scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
443
445
  "scripts/lib/hook_replay.rb" => "scripts/lib/hook_replay.rb",
@@ -543,6 +545,12 @@ class InstallerCore
543
545
  # by scripts/runner's `step`.
544
546
  "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
545
547
  "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
548
+ # Intent 355 (n2): the call budget PreToolUse hook (RunnerPolicy.call_cap
549
+ # is its cap table, above); its launcher (hooks/call-budget) ships via
550
+ # hook_files' own glob, so only the hook script itself needs an entry.
551
+ "scripts/hook-call-budget" => "scripts/hook-call-budget",
552
+ "scripts/meter-watch" => "scripts/meter-watch",
553
+ "scripts/lib/meter_watch.rb" => "scripts/lib/meter_watch.rb",
546
554
  # Intent 340 (G7, n6): answer (closes a decision node or unparks a
547
555
  # work node parked at needs_decision), proposals (mints ids for what
548
556
  # an executor proposed), and rewind (resets the intent branch to a
@@ -564,8 +572,8 @@ class InstallerCore
564
572
  version: 3
565
573
  execution_mode: subagent-driven
566
574
  stale_threshold_days: 3
567
- context_offer_tokens: 350000
568
- context_insist_tokens: 500000
575
+ context_offer_tokens: 150000
576
+ context_insist_tokens: 250000
569
577
  hash_length: 6
570
578
  hash_algorithm: sha256-base36
571
579
  max_slug_words: 5
@@ -0,0 +1,179 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "yaml"
6
+ require "time"
7
+ require "fileutils"
8
+ require "rbconfig"
9
+ require_relative "atomic_write"
10
+
11
+ # MeterWatch (intent 355, n5, D6): reads the owner's rate-limit cache on a
12
+ # timer and writes one state file a session watches, instead of every session
13
+ # parsing the cache and re-deriving the thresholds for itself. No model call
14
+ # is spent on a tick that leaves the state unchanged.
15
+ #
16
+ # Everything is injected: home (holds config.yml and .cache/), the clock, the
17
+ # cache path, and the renamer AtomicWrite uses. Nothing reads ENV, nothing
18
+ # calls launchctl, and --install-timer (scripts/meter-watch) only ever writes
19
+ # under the injected home.
20
+ class MeterWatch
21
+ DEFAULT_REDUCE_AT = 55
22
+ DEFAULT_STOP_AT = 85
23
+ DEFAULT_WEEKLY_STOP_AT = 97
24
+ TICK_SECONDS = 20 * 60
25
+ STALE_AFTER_SECONDS = TICK_SECONDS * 2
26
+
27
+ def initialize(home:, cache_path: nil, config_path: nil, now: Time.now, renamer: File.method(:rename))
28
+ @home = home
29
+ @cache_path = cache_path || File.join(home, ".cache", "rate-limits.json")
30
+ @config_path = config_path || File.join(home, "config.yml")
31
+ @state_path = File.join(home, ".cache", "meter-state.json")
32
+ @now = now
33
+ @renamer = renamer
34
+ @reduce_at, @stop_at, @weekly_stop_at = load_thresholds
35
+ end
36
+
37
+ attr_reader :state_path
38
+
39
+ def tick
40
+ previous = read_state
41
+ state = compute_state(previous)
42
+ write_if_changed(previous, state)
43
+ end
44
+
45
+ private
46
+
47
+ def compute_state(previous)
48
+ return base_state("unavailable") unless File.file?(@cache_path)
49
+ return stopped?(previous) ? previous : base_state("stale") if stale?
50
+
51
+ cache = JSON.parse(File.read(@cache_path))
52
+ five_hour = cache["five_hour"]
53
+ seven_day = cache["seven_day"]
54
+ resets_at = cache["resets_at"]
55
+
56
+ # Resume compares `now` against the STOP's OWN resets_at (carried
57
+ # forward on `previous`, from the tick that first wrote "stop"), never
58
+ # the cache's current resets_at: the cache moves resets_at on to the
59
+ # NEXT window before five_hour/seven_day themselves drop, so comparing
60
+ # against the live value would never report resume (B4).
61
+ label = if stopped?(previous) && reset_passed?(previous["resets_at"])
62
+ "resume"
63
+ else
64
+ classify(five_hour, seven_day)
65
+ end
66
+
67
+ base_state(label, five_hour: five_hour, seven_day: seven_day, resets_at: resets_at)
68
+ rescue JSON::ParserError
69
+ base_state("unavailable")
70
+ end
71
+
72
+ def stopped?(previous)
73
+ previous && previous["state"] == "stop"
74
+ end
75
+
76
+ def classify(five_hour, seven_day)
77
+ return "stop" if five_hour.to_f >= @stop_at
78
+ return "stop" if seven_day.to_f >= @weekly_stop_at
79
+ return "reduce" if five_hour.to_f >= @reduce_at
80
+
81
+ "ok"
82
+ end
83
+
84
+ def stale?
85
+ File.mtime(@cache_path) < (@now - STALE_AFTER_SECONDS)
86
+ rescue Errno::ENOENT
87
+ true
88
+ end
89
+
90
+ def reset_passed?(resets_at)
91
+ at = parse_time(resets_at)
92
+ at && @now >= at
93
+ end
94
+
95
+ def parse_time(value)
96
+ return nil if value.nil? || value.to_s.empty?
97
+
98
+ text = value.to_s
99
+ text.match?(/\A\d+\z/) ? Time.at(text.to_i).utc : Time.iso8601(text)
100
+ rescue ArgumentError, TypeError
101
+ nil
102
+ end
103
+
104
+ def base_state(label, five_hour: nil, seven_day: nil, resets_at: nil)
105
+ {
106
+ "state" => label,
107
+ "five_hour" => five_hour,
108
+ "seven_day" => seven_day,
109
+ "resets_at" => resets_at,
110
+ "checked_at" => @now.getutc.iso8601,
111
+ }
112
+ end
113
+
114
+ def write_if_changed(previous, state)
115
+ return state if previous && previous["state"] == state["state"]
116
+
117
+ FileUtils.mkdir_p(File.dirname(@state_path))
118
+ AtomicWrite.write(@state_path, JSON.generate(state), renamer: @renamer)
119
+ state
120
+ end
121
+
122
+ def read_state
123
+ return nil unless File.file?(@state_path)
124
+
125
+ JSON.parse(File.read(@state_path))
126
+ rescue JSON::ParserError
127
+ nil
128
+ end
129
+
130
+ def load_thresholds
131
+ config = File.file?(@config_path) ? (YAML.safe_load(File.read(@config_path)) || {}) : {}
132
+ meter = config["meter"].is_a?(Hash) ? config["meter"] : {}
133
+ [
134
+ meter.fetch("reduce_at", DEFAULT_REDUCE_AT),
135
+ meter.fetch("stop_at", DEFAULT_STOP_AT),
136
+ meter.fetch("weekly_stop_at", DEFAULT_WEEKLY_STOP_AT),
137
+ ]
138
+ end
139
+
140
+ class << self
141
+ # Writes the LaunchAgent plist under an injectable home; never loads it
142
+ # with launchctl (scripts/meter-watch --install-timer calls this and
143
+ # nothing else). RunAtLoad primes the first tick; StartInterval repeats
144
+ # it every 20 minutes.
145
+ def install_timer(home:, script_path:, ruby: RbConfig.ruby, interval: TICK_SECONDS)
146
+ agents_dir = File.join(home, "Library", "LaunchAgents")
147
+ FileUtils.mkdir_p(agents_dir)
148
+ plist_path = File.join(agents_dir, "com.plastic.meter-watch.plist")
149
+ File.write(plist_path, plist(script_path, home, ruby, interval))
150
+ plist_path
151
+ end
152
+
153
+ private
154
+
155
+ def plist(script_path, home, ruby, interval)
156
+ <<~XML
157
+ <?xml version="1.0" encoding="UTF-8"?>
158
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
159
+ <plist version="1.0">
160
+ <dict>
161
+ <key>Label</key>
162
+ <string>com.plastic.meter-watch</string>
163
+ <key>ProgramArguments</key>
164
+ <array>
165
+ <string>#{ruby}</string>
166
+ <string>#{script_path}</string>
167
+ <string>--home</string>
168
+ <string>#{home}</string>
169
+ </array>
170
+ <key>StartInterval</key>
171
+ <integer>#{interval}</integer>
172
+ <key>RunAtLoad</key>
173
+ <true/>
174
+ </dict>
175
+ </plist>
176
+ XML
177
+ end
178
+ end
179
+ end