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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/agents/plastic-enforcer.md +6 -3
  2. package/agents/plastic-executor.md +4 -0
  3. package/agents/plastic-node-research.md +28 -0
  4. package/agents/plastic-node-verify.md +27 -0
  5. package/agents/plastic-node-work.md +32 -0
  6. package/bin/lib/context_budget.rb +1 -1
  7. package/hooks/hooks.json +12 -0
  8. package/hooks/statusline +28 -0
  9. package/hooks/stop +5 -0
  10. package/package.json +1 -1
  11. package/scripts/doctor.rb +80 -6
  12. package/scripts/end-intent +3 -3
  13. package/scripts/graph-measure +249 -0
  14. package/scripts/hook-capture +1 -0
  15. package/scripts/hook-savepoint +24 -2
  16. package/scripts/hook-session-start +40 -0
  17. package/scripts/hook-stop +57 -0
  18. package/scripts/lib/active_delivery.rb +61 -0
  19. package/scripts/lib/agent_models.rb +10 -1
  20. package/scripts/lib/codex_adapter.rb +197 -0
  21. package/scripts/lib/doctor_core.rb +8 -3
  22. package/scripts/lib/engine_permissions.rb +88 -0
  23. package/scripts/lib/graph_edges.rb +4 -4
  24. package/scripts/lib/graph_file.rb +4 -4
  25. package/scripts/lib/graph_measure.rb +645 -0
  26. package/scripts/lib/graph_measure_budget.rb +408 -0
  27. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  28. package/scripts/lib/graph_measure_models.rb +411 -0
  29. package/scripts/lib/graph_measure_report.rb +532 -0
  30. package/scripts/lib/graph_tree.rb +2 -2
  31. package/scripts/lib/handoff.rb +36 -5
  32. package/scripts/lib/harness_adapter.rb +184 -0
  33. package/scripts/lib/hook_registry.rb +13 -1
  34. package/scripts/lib/hook_replay.rb +23 -5
  35. package/scripts/lib/index_projection.rb +1 -1
  36. package/scripts/lib/installer_core.rb +109 -3
  37. package/scripts/lib/intent_screen.rb +1 -1
  38. package/scripts/lib/intent_validator.rb +2 -2
  39. package/scripts/lib/meter_watch.rb +15 -9
  40. package/scripts/lib/node_file.rb +3 -3
  41. package/scripts/lib/node_ledger.rb +8 -1
  42. package/scripts/lib/node_progress.rb +153 -0
  43. package/scripts/lib/outcome_report.rb +1 -1
  44. package/scripts/lib/report_screen.rb +10 -6
  45. package/scripts/lib/roadmap_graph.rb +1 -1
  46. package/scripts/lib/roadmap_queue.rb +1 -1
  47. package/scripts/lib/roadmap_render.rb +1 -1
  48. package/scripts/lib/runner_absorb.rb +31 -5
  49. package/scripts/lib/runner_dispatch.rb +26 -11
  50. package/scripts/lib/runner_until_empty.rb +252 -0
  51. package/scripts/lib/runner_watch.rb +389 -0
  52. package/scripts/lib/savepoint.rb +3 -3
  53. package/scripts/lib/session_git.rb +2 -2
  54. package/scripts/lib/stop_gate.rb +95 -0
  55. package/scripts/lib/verify_intent.rb +2 -2
  56. package/scripts/lib/work_graph_validator.rb +6 -6
  57. package/scripts/new-intent +1 -1
  58. package/scripts/node-run +224 -0
  59. package/scripts/read-config +6 -0
  60. package/scripts/runner +203 -19
  61. package/scripts/verify-intent +1 -1
  62. package/skills/auto/SKILL.md +1 -1
  63. package/skills/conventions/references/knowledge-graph.md +9 -0
  64. package/skills/doctor/SKILL.md +3 -3
  65. package/skills/intent-creating/evals/evals.json +1 -1
  66. package/skills/intent-executing/SKILL.md +6 -0
  67. package/skills/tutorial/references/track-2-auto.md +1 -1
@@ -0,0 +1,252 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "time"
6
+ require "rbconfig"
7
+ require_relative "runner_core"
8
+ require_relative "runner_sweep"
9
+ require_relative "runner_absorb"
10
+ require_relative "runner_dispatch"
11
+ require_relative "node_worktree"
12
+ require_relative "lock"
13
+
14
+ # RunnerUntilEmpty (intent 340b, G7c, n7): the Codex loop. `runner
15
+ # until-empty` composes `step` and `node-run` itself, since Codex has no
16
+ # session on the other end to make the subagent calls `step` only ever
17
+ # prints a plan for - dispatch, run at most two `node-run` subprocesses at
18
+ # once, absorb each return, and around again, stopping on complete,
19
+ # stalled, needs_decision, a refused step, or an iteration cap.
20
+ #
21
+ # #step_once is one turn - abort-if-merging, the heartbeat, absorb every
22
+ # return this call carries (serially, a plain Ruby loop, never a thread),
23
+ # reclaim, reap, then dispatch - the same fixed order scripts/runner's own
24
+ # `run_step_body` uses, returning data rather than printing it, so #run can
25
+ # make its own stop/continue decision instead of scraping stdout. #run is
26
+ # the loop: it grows `active` from whatever #step_once actually dispatched
27
+ # (already capped at the concurrency ceiling by RunnerDispatch's own
28
+ # running-count check, never a second cap layered on top here), spawns one
29
+ # real `node-run` subprocess per newly dispatched node, and blocks for AT
30
+ # LEAST one of them to finish before it ever calls #step_once again - so an
31
+ # absorb is always a single, uncontended call, and a second `node-run`
32
+ # finishing while the first is mid-absorb simply waits its own turn as the
33
+ # next iteration's `returns`, never as a second in-flight absorb.
34
+ #
35
+ # A shared `<repo>/.git` between two worktrees is a real `index.lock`
36
+ # collision risk at commit time; this module does not try to prevent it by
37
+ # serializing the two `node-run` processes (that would throw away the
38
+ # concurrency this node exists to deliver) - it records the collision, on
39
+ # whichever side actually hit it, and lets the kind's retry cap (already
40
+ # built) recover the node on a later attempt.
41
+ #
42
+ # Pure and dependency-injected: `step:`, `node_run_spawner:` and
43
+ # `node_run_waiter:` are keyword seams with real defaults, so a test can
44
+ # drive #run entirely off doubles (no real subprocess, no real git repo)
45
+ # for every row except the ones that are precisely about real OS
46
+ # concurrency or the real CLI arm.
47
+ module RunnerUntilEmpty
48
+ module_function
49
+
50
+ MAX_CONCURRENCY = 2
51
+ DEFAULT_MAX_ITERATIONS = 200
52
+
53
+ NODE_RUN_SCRIPT = File.expand_path(File.join(__dir__, "..", "node-run")).freeze
54
+
55
+ # run(context, harness:) -> {status:, iterations:, ...}. `status` is one
56
+ # of complete, stalled, needs_decision, iteration_cap, refused - never
57
+ # anything else, and the loop returns the instant it reaches one of them.
58
+ def run(context, harness: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
59
+ step: method(:step_once), node_run_spawner: method(:spawn_node_run),
60
+ node_run_waiter: method(:wait_for_node_run), out: $stdout)
61
+ active = {}
62
+ pending_returns = {}
63
+ iterations = 0
64
+
65
+ loop do
66
+ iterations += 1
67
+ if iterations > max_iterations
68
+ out.puts "until-empty: stopped at the iteration cap (#{max_iterations})"
69
+ return { status: "iteration_cap", iterations: iterations - 1 }
70
+ end
71
+
72
+ result = step.call(context, harness: harness, returns: pending_returns)
73
+ pending_returns = {}
74
+
75
+ unless result[:ok]
76
+ out.puts "until-empty: stopped, step refused (#{result[:reason]})"
77
+ return { status: "refused", reason: result[:reason], iterations: iterations }
78
+ end
79
+
80
+ Array(result[:absorbed]).each { |a| out.puts "absorbed #{a[:node]}: #{a[:state]}" }
81
+
82
+ case result[:status]
83
+ when "complete"
84
+ out.puts "complete"
85
+ return { status: "complete", iterations: iterations }
86
+ when "stalled"
87
+ out.puts "stalled"
88
+ Array(result[:blockers]).each { |b| out.puts "blocked: #{b}" }
89
+ return { status: "stalled", iterations: iterations, blockers: result[:blockers] }
90
+ when "needs_decision"
91
+ stop = result[:stop]
92
+ out.puts "needs_decision: #{stop[:node]} - #{stop[:question]}"
93
+ out.puts stop[:answer_command]
94
+ return { status: "needs_decision", iterations: iterations, stop: stop }
95
+ end
96
+
97
+ Array(result[:dispatched]).each do |node|
98
+ next if active.key?(node)
99
+
100
+ active[node] = node_run_spawner.call(context, node: node, harness: harness)
101
+ end
102
+
103
+ if active.empty?
104
+ out.puts "stalled"
105
+ out.puts "blocked: queued with no active node-run to wait on"
106
+ return { status: "stalled", iterations: iterations,
107
+ blockers: ["queued with no active node-run to wait on"] }
108
+ end
109
+
110
+ finished = Array(node_run_waiter.call(active))
111
+ finished.each do |f|
112
+ active.delete(f[:node])
113
+ out.puts "until-empty: index.lock collision recorded for #{f[:node]}" if index_lock_collision?(f)
114
+ pending_returns[f[:node]] = f[:return_path]
115
+ end
116
+ end
117
+ end
118
+
119
+ # --- one turn ----------------------------------------------------------------
120
+
121
+ # step_once(context, harness:, returns:) -> {ok:, reason:, status:,
122
+ # dispatched: [node ids], stop:, blockers:, absorbed: [{node:, state:}]}.
123
+ # Every absorb in `returns` runs in this one Ruby method, in the order
124
+ # `returns` iterates, before `dispatch` ever runs (row 7.3) - never a
125
+ # second call to this method from another thread while one is already in
126
+ # flight (row 7.2), because #run above never starts a second one.
127
+ def step_once(context, harness:, returns: {}, allow_core_drift: false, now: Time.now,
128
+ sweep: RunnerSweep, absorb: RunnerAbsorb, dispatch: RunnerDispatch,
129
+ worktree: NodeWorktree, lock: Lock, config_loader: method(:load_agent_config))
130
+ abort_result = sweep.abort_if_merging(context)
131
+ unless abort_result[:ok]
132
+ return refusal("merge_in_progress", abort_result[:error])
133
+ end
134
+
135
+ lock.heartbeat(context.intent_dir, session: context.session) unless context.session.to_s.strip.empty?
136
+
137
+ return refusal("lock_not_held", nil) unless context.session
138
+
139
+ absorbed = returns.map do |node, path|
140
+ result = absorb.absorb(context, node: node, return_path: path, allow_core_drift: allow_core_drift, now: now)
141
+ { node: node, state: result[:state] }
142
+ end
143
+
144
+ swept = sweep.reclaim(context, skip: returns.keys, now: now)
145
+ reaped = worktree.reap(context)
146
+
147
+ agent_config = config_loader.call(context.plastic_home)
148
+ dispatch_result = dispatch.dispatch(context, config: agent_config, harness: harness, now: now)
149
+ unless dispatch_result[:ok]
150
+ return refusal(dispatch_result[:reason], Array(dispatch_result[:errors]).join("; "))
151
+ end
152
+
153
+ {
154
+ ok: true, reason: nil, status: dispatch_result[:status],
155
+ dispatched: dispatch_result[:dispatched].map { |d| d[:node] },
156
+ stop: dispatch_result[:stop], blockers: dispatch_result[:blockers], absorbed: absorbed,
157
+ reclaimed: swept[:reclaimed], extended: swept[:extended], reaped: reaped[:removed],
158
+ }
159
+ end
160
+
161
+ def refusal(reason, detail)
162
+ { ok: false, reason: reason, detail: detail, status: nil, dispatched: [], stop: nil, blockers: [],
163
+ absorbed: [] }
164
+ end
165
+ private_class_method :refusal
166
+
167
+ # Mirrors scripts/runner's own Runner.load_agent_config exactly (row
168
+ # 1.1 of nodes/n1.md): a missing or unparseable config.yml reads as {},
169
+ # never raises, and it is read fresh every call rather than cached, since
170
+ # a long-running until-empty process must see a config edit made mid-run
171
+ # the same way a fresh `step` call always would.
172
+ def load_agent_config(plastic_home)
173
+ path = File.join(plastic_home.to_s, "config.yml")
174
+ return {} unless File.exist?(path)
175
+
176
+ YAML.safe_load(File.read(path)) || {}
177
+ rescue StandardError
178
+ {}
179
+ end
180
+
181
+ # --- node-run, the real subprocess --------------------------------------------
182
+
183
+ # spawn_node_run(context, node:, harness:) -> a handle {node:, pid:,
184
+ # stdout_io:, stderr_io:}. `harness:` is accepted for symmetry with
185
+ # `step_once` even though node-run itself never takes a --harness flag -
186
+ # the harness was already recorded on the node's own `running` line by the
187
+ # dispatch this call followed, and node-run reads its packet off that
188
+ # line, never off this argv. RUBYOPT is cleared explicitly, the same
189
+ # contract every other ruby-spawning site in this tree carries.
190
+ def spawn_node_run(context, node:, harness: nil)
191
+ argv = [RbConfig.ruby, NODE_RUN_SCRIPT, context.intent_dir, "--node", node]
192
+ argv += ["--session", context.session] if context.session
193
+
194
+ out_r, out_w = IO.pipe
195
+ err_r, err_w = IO.pipe
196
+ pid = Process.spawn({ "RUBYOPT" => nil }, *argv, out: out_w, err: err_w)
197
+ out_w.close
198
+ err_w.close
199
+ { node: node, pid: pid, stdout_io: out_r, stderr_io: err_r }
200
+ end
201
+
202
+ # wait_for_node_run(active) -> [{node:, return_path:, stderr:}] for
203
+ # whichever ONE handle in `active` finishes first (real concurrency
204
+ # between two live node-run processes; this call itself blocks on
205
+ # whichever exits first, never both at once). `active` is keyed by node,
206
+ # so a caller never needs to search its own values by pid outside this
207
+ # method.
208
+ def wait_for_node_run(active)
209
+ pid, = Process.waitpid2(-1)
210
+ handle = active.values.find { |h| h[:pid] == pid }
211
+ return [] unless handle
212
+
213
+ [{ node: handle[:node], return_path: read_and_close(handle[:stdout_io]).to_s.strip,
214
+ stderr: read_and_close(handle[:stderr_io]) }]
215
+ rescue Errno::ECHILD
216
+ []
217
+ end
218
+
219
+ def read_and_close(io)
220
+ io.read
221
+ rescue StandardError
222
+ ""
223
+ ensure
224
+ begin
225
+ io.close
226
+ rescue StandardError
227
+ nil
228
+ end
229
+ end
230
+ private_class_method :read_and_close
231
+
232
+ # index_lock_collision?(finished) -> true when either the node-run
233
+ # subprocess's own stderr or the return body it wrote mentions
234
+ # `index.lock` - the shared signature of a git ref/index lock collision
235
+ # between two worktrees writing into one repository's `.git` at the same
236
+ # time (row 7.4). Never used to change what the loop does next: the
237
+ # finished node's return is absorbed exactly like any other, and the
238
+ # kind's own retry cap is what recovers it.
239
+ def index_lock_collision?(finished)
240
+ parts = [finished[:stderr].to_s]
241
+ path = finished[:return_path]
242
+ parts << safe_read_file(path) if path && !path.empty?
243
+ parts.join("\n").match?(/index\.lock/i)
244
+ end
245
+
246
+ def safe_read_file(path)
247
+ File.exist?(path) ? File.read(path) : ""
248
+ rescue StandardError
249
+ ""
250
+ end
251
+ private_class_method :safe_read_file
252
+ end
@@ -0,0 +1,389 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "digest"
6
+ require "time"
7
+ require "rbconfig"
8
+ require_relative "worktree"
9
+ require_relative "runner_core"
10
+ require_relative "runner_sweep"
11
+ require_relative "ready_set"
12
+ require_relative "node_ledger"
13
+ require_relative "savepoint"
14
+ require_relative "atomic_write"
15
+ require_relative "runner_until_empty"
16
+ require_relative "harness_adapter"
17
+ require_relative "meter_watch"
18
+
19
+ # RunnerWatch (intent 340a, G7b, n1): one tick over disk truth. The whole
20
+ # watch minus the CLI and the dispatch branch (340a n2). Composes the
21
+ # existing modules the way RunnerSweep and MeterWatch already do - the
22
+ # git runner, the clock and the sweep module are all injected, nothing
23
+ # reads ENV, nothing evals.
24
+ #
25
+ # Order, fixed by graph.md D2: take a non-blocking lock on
26
+ # `<intent_dir>/watch.lock` for the whole tick (a losing tick is `busy`,
27
+ # writes nothing); `RunnerSweep.abort_if_merging`; refuse the tick outright
28
+ # on a half-finished merge (row 1.3), writing nothing; `RunnerSweep.reclaim`
29
+ # (never `RunnerSweep.run`, D3: that method heartbeats the delivery lease,
30
+ # which would keep a dead lead's lock fresh forever); classify (D4);
31
+ # persist the snapshot and the record line only when `record:` holds.
32
+ module RunnerWatch
33
+ module_function
34
+
35
+ LOCK_FILENAME = "watch.lock"
36
+ STATE_FILENAME = "watch.state"
37
+ RECORD_FILENAME = "watch.record"
38
+
39
+ # Classes that never dispatch (D8): a finished delivery re-running its own
40
+ # graph on every tick is exactly the bug a timer must not have.
41
+ FINISHED_CLASSES = %w[closed done_unreported].freeze
42
+
43
+ # tick(context, record:, dispatch:, harness:, now:, runner:, sweep:,
44
+ # until_empty:) -> {class:, blockers:, ready:, reclaimed:, dispatched:,
45
+ # tick:, busy:}. `dispatch:` (327 Q6, D7, D8) only ever runs under an
46
+ # explicit ask - never inferred from the class - and only when the lock is
47
+ # held, the class is not finished, and the meter does not read `stop`.
48
+ # `until_empty:` is `RunnerUntilEmpty` by default; a test can inject a
49
+ # double so the whole dispatch path never spawns a real `node-run`
50
+ # subprocess. `harness=` and `meter=` on the record line read `-` only
51
+ # when `dispatch:` itself was never asked for (D5) - every other refusal
52
+ # (an unheld lock, a finished class, a stopped meter) still consults and
53
+ # records the meter, since the tick DID look, it just chose not to act.
54
+ def tick(context, record: true, dispatch: false, harness: nil, now: Time.now,
55
+ runner: Worktree::ShellRunner.new, sweep: RunnerSweep, until_empty: RunnerUntilEmpty)
56
+ intent_dir = context.intent_dir.to_s
57
+ lock_handle = acquire_lock(File.join(intent_dir, LOCK_FILENAME))
58
+ return busy_result unless lock_handle
59
+
60
+ begin
61
+ abort_result = sweep.abort_if_merging(context, runner: runner)
62
+ unless abort_result[:ok]
63
+ return {
64
+ class: "merge_in_progress", blockers: [abort_result[:error]], ready: [],
65
+ reclaimed: [], dispatched: [], tick: nil, busy: false,
66
+ }
67
+ end
68
+
69
+ reclaim_result = sweep.reclaim(context, runner: runner, skip: [], now: now)
70
+ reclaimed_ids = Array(reclaim_result[:reclaimed]).map { |r| r[:node] }
71
+ extended = !Array(reclaim_result[:extended]).empty?
72
+
73
+ view = classify(context, runner: runner, now: now, intent_dir: intent_dir, extended: extended)
74
+
75
+ lock_state = context.session ? "held" : "not_held"
76
+ harness_field = "-"
77
+ meter_state = "-"
78
+ dispatched_ids = []
79
+
80
+ # B1: the dispatch loop and the persist step share one inner
81
+ # begin/ensure so a raise mid `until_empty.run` (a launchd SIGTERM, a
82
+ # Process.spawn Errno) still lands the snapshot and the record line
83
+ # with whatever `dispatched_ids` was collected before the raise, and
84
+ # then the exception keeps propagating past this method (the outer
85
+ # `ensure` below still releases the tick lock).
86
+ begin
87
+ if dispatch
88
+ harness_field = blank?(harness) ? "-" : harness.to_s
89
+ meter_state = read_meter_state(context)
90
+
91
+ if context.session && !FINISHED_CLASSES.include?(view[:class]) && meter_state != "stop"
92
+ run_until_empty_dispatch(context, harness: harness, until_empty: until_empty,
93
+ dispatched_ids: dispatched_ids)
94
+ end
95
+ end
96
+ ensure
97
+ if record
98
+ Worktree.ensure_gitignored(context.plastic_home, STATE_FILENAME, runner: runner)
99
+ write_snapshot(intent_dir, view[:snapshot])
100
+ append_record(
101
+ intent_dir, now: now, tick: view[:tick], klass: view[:class],
102
+ reclaimed: reclaimed_ids, ready: view[:ready], dispatched: dispatched_ids,
103
+ harness: harness_field, meter: meter_state, lock: lock_state
104
+ )
105
+ end
106
+ end
107
+
108
+ {
109
+ class: view[:class], blockers: view[:blockers], ready: view[:ready],
110
+ reclaimed: reclaimed_ids, dispatched: dispatched_ids, tick: view[:tick], busy: false,
111
+ }
112
+ ensure
113
+ release_lock(lock_handle)
114
+ end
115
+ end
116
+
117
+ # install_timer(context, home:, harness_key:, installer:) -> the written
118
+ # plist path. Graph.md D9: the Codex carrier is `runner watch
119
+ # --install-timer`, and it reuses MeterWatch's own writer through
120
+ # `label:`/`arguments:` rather than rendering plist XML here (row 3.5) - a
121
+ # second writer is exactly the drift D9 rules out. The label carries the
122
+ # intent id (row 3.4), so a second intent's timer never overwrites the
123
+ # first's job; `--dispatch --harness codex` rides the arguments only when
124
+ # `HarnessAdapter.unattended_start?` holds for the resolved harness (row
125
+ # 3.3), the same predicate `run_watch` itself already gates `--dispatch`
126
+ # on. `installer:` is `MeterWatch` by default so a test can inject a
127
+ # double that never touches a real home.
128
+ def install_timer(context, home:, harness_key:, installer: MeterWatch)
129
+ runner_path = File.expand_path(File.join(__dir__, "..", "runner"))
130
+ arguments = [RbConfig.ruby, runner_path, "watch", context.intent_dir.to_s]
131
+ arguments += ["--dispatch", "--harness", harness_key.to_s] if HarnessAdapter.unattended_start?(harness_key)
132
+
133
+ installer.install_timer(home: home, script_path: runner_path,
134
+ label: "com.plastic.delivery-watch.#{context.intent_id}", arguments: arguments)
135
+ end
136
+
137
+ # run_until_empty_dispatch(context, harness:, until_empty:) -> every node
138
+ # id the loop dispatched (C30). `until_empty.run` gets `step:` wrapped
139
+ # around `until_empty.step_once` so this method sees every turn's own
140
+ # `:dispatched` ids, the same shape RunnerDispatch.dispatch returns
141
+ # (graph.md D8); `until_empty.run` itself still owns spawning and waiting
142
+ # on the real `node-run` subprocesses (never duplicated here).
143
+ def run_until_empty_dispatch(context, harness:, until_empty:, dispatched_ids:)
144
+ wrapped_step = lambda do |ctx, harness:, returns:|
145
+ result = until_empty.step_once(ctx, harness: harness, returns: returns)
146
+ dispatched_ids.concat(Array(result[:dispatched]))
147
+ result
148
+ end
149
+
150
+ until_empty.run(context, harness: harness, step: wrapped_step)
151
+ end
152
+ private_class_method :run_until_empty_dispatch
153
+
154
+ # read_meter_state(context) -> "ok", "reduce", "stop", "resume", "stale"
155
+ # (whatever MeterWatch's own tick last wrote), or "unavailable" for a
156
+ # missing or unparseable file (D8) - read-only, at MeterWatch's own state
157
+ # path under `context.plastic_home`, never through a MeterWatch instance
158
+ # (that class computes a FRESH state from the rate-limit cache; this tick
159
+ # only ever reads what it already wrote).
160
+ def read_meter_state(context)
161
+ path = File.join(context.plastic_home.to_s, ".cache", "meter-state.json")
162
+ return "unavailable" unless File.file?(path)
163
+
164
+ state = JSON.parse(File.read(path))["state"]
165
+ blank?(state) ? "unavailable" : state.to_s
166
+ rescue StandardError
167
+ "unavailable"
168
+ end
169
+ private_class_method :read_meter_state
170
+
171
+ def blank?(value)
172
+ value.nil? || value.to_s.strip.empty?
173
+ end
174
+ private_class_method :blank?
175
+
176
+ # fingerprint(context, runner:) -> the SHA256 D4 defines: savepoint.md's
177
+ # current content plus the intent branch head, so a commit that lands no
178
+ # ledger line still counts as movement (row 1.13). Public so a caller (and
179
+ # this file's own tests) can compute the exact value a tick would compute
180
+ # without duplicating the hashing here.
181
+ def fingerprint(context, runner: Worktree::ShellRunner.new)
182
+ content = savepoint_content(context.intent_dir)
183
+ Digest::SHA256.hexdigest("#{content}\x1f#{branch_head_sha(context, runner)}")
184
+ end
185
+
186
+ # --- the lock ----------------------------------------------------------------
187
+
188
+ def acquire_lock(path)
189
+ handle = File.open(path, File::CREAT | File::RDWR, 0o644)
190
+ return handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
191
+
192
+ handle.close
193
+ nil
194
+ rescue SystemCallError
195
+ nil
196
+ end
197
+ private_class_method :acquire_lock
198
+
199
+ def release_lock(handle)
200
+ return unless handle
201
+
202
+ handle.flock(File::LOCK_UN)
203
+ rescue SystemCallError
204
+ nil
205
+ ensure
206
+ handle&.close
207
+ end
208
+ private_class_method :release_lock
209
+
210
+ def busy_result
211
+ { class: nil, blockers: [], ready: [], reclaimed: [], dispatched: [], tick: nil, busy: true }
212
+ end
213
+ private_class_method :busy_result
214
+
215
+ # --- classification (D4), first match wins ------------------------------------
216
+
217
+ def classify(context, runner:, now:, intent_dir:, extended: false)
218
+ content = savepoint_content(intent_dir)
219
+ recorded_pairs = Savepoint.savepoint_recorded_pairs(intent_dir)
220
+ previous = read_snapshot(intent_dir)
221
+ tick_number = (previous ? previous[:tick] : 0) + 1
222
+
223
+ if closed?(recorded_pairs)
224
+ return settle("closed", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
225
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
226
+ end
227
+
228
+ unless context.graph[:ok]
229
+ errors = Array(context.graph[:errors])
230
+ errors = ["graph.md could not be parsed"] if errors.empty?
231
+ return settle("stalled", blockers: errors, ready_ids: [], previous: previous, tick_number: tick_number,
232
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
233
+ end
234
+
235
+ if RunnerCore.complete?(context)
236
+ return settle("done_unreported", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
237
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
238
+ end
239
+
240
+ ready_ids = ReadySet.analyze(intent_dir, now: now)[:ranked_ready].map { |r| r[:id] }
241
+ running = any_running?(content)
242
+
243
+ if !running && ready_ids.empty?
244
+ return settle("stalled", blockers: blocked_reasons(context), ready_ids: ready_ids, previous: previous,
245
+ tick_number: tick_number, quiet_ticks: 0, content: content, context: context, runner: runner,
246
+ now: now)
247
+ end
248
+
249
+ current_fingerprint = fingerprint(context, runner: runner)
250
+
251
+ # B2: a reclaim that extended a lease is evidence of live work (the
252
+ # sweep extends only when the node branch has commits newer than the
253
+ # expiry), so it counts as movement here, at the fingerprint
254
+ # comparison, after closed/malformed-graph/done_unreported/the
255
+ # nothing-running-nothing-ready stall have already returned above.
256
+ if extended || previous.nil? || previous[:fingerprint] != current_fingerprint
257
+ return settle("moving", blockers: [], ready_ids: ready_ids, previous: previous, tick_number: tick_number,
258
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now,
259
+ fingerprint: current_fingerprint)
260
+ end
261
+
262
+ next_quiet_ticks = previous[:quiet_ticks] + 1
263
+ if previous[:quiet_ticks] >= 1 && !unexpired_running_lease?(content, now)
264
+ klass = "stalled"
265
+ blockers = ["no observed movement for #{next_quiet_ticks} consecutive ticks and no unexpired running lease"]
266
+ else
267
+ klass = "quiet"
268
+ blockers = []
269
+ end
270
+
271
+ settle(klass, blockers: blockers, ready_ids: ready_ids, previous: previous, tick_number: tick_number,
272
+ quiet_ticks: next_quiet_ticks, content: content, context: context, runner: runner, now: now,
273
+ fingerprint: current_fingerprint)
274
+ end
275
+ private_class_method :classify
276
+
277
+ # Assembles the return view plus the snapshot that will be persisted, when
278
+ # `record:` is honored by the caller. `fingerprint:` defaults to a fresh
279
+ # computation so every class - not only moving/quiet/stalled-by-quiet -
280
+ # persists a value later ticks can compare against.
281
+ def settle(klass, blockers:, ready_ids:, previous:, tick_number:, quiet_ticks:, content:, context:, runner:,
282
+ now:, fingerprint: nil)
283
+ fp = fingerprint || RunnerWatch.fingerprint(context, runner: runner)
284
+ {
285
+ class: klass, blockers: blockers, ready: ready_ids, tick: tick_number,
286
+ snapshot: { fingerprint: fp, quiet_ticks: quiet_ticks, tick: tick_number, at: now.utc.iso8601 },
287
+ }
288
+ end
289
+ private_class_method :settle
290
+
291
+ def closed?(recorded_pairs)
292
+ recorded_pairs.include?(["Done", "delivered"]) || recorded_pairs.include?(["Done", "abandoned"])
293
+ end
294
+ private_class_method :closed?
295
+
296
+ def blocked_reasons(context)
297
+ rows = RunnerCore.status(context)
298
+ rows.values.reject { |v| ReadySet::TERMINAL_STATES.include?(v[:state]) || v[:ready] }
299
+ .flat_map { |v| v[:blockers] }
300
+ .uniq
301
+ end
302
+ private_class_method :blocked_reasons
303
+
304
+ def any_running?(content)
305
+ NodeLedger.status_from_content(content).value?("running")
306
+ end
307
+ private_class_method :any_running?
308
+
309
+ def unexpired_running_lease?(content, now)
310
+ status_map = NodeLedger.status_from_content(content)
311
+ entries = NodeLedger.entries_from_content(content)
312
+ status_map.any? do |subject, state|
313
+ next false unless state == "running"
314
+
315
+ last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
316
+ next false unless last
317
+
318
+ at = parse_time((last[:fields] || {})["expires"])
319
+ at && now < at
320
+ end
321
+ end
322
+ private_class_method :unexpired_running_lease?
323
+
324
+ def branch_head_sha(context, runner)
325
+ worktree = context.worktree
326
+ return "" if worktree.nil? || worktree.to_s.strip.empty?
327
+
328
+ res = runner.run("-C", worktree, "rev-parse", "HEAD")
329
+ res.success? ? res.stdout.to_s.strip : ""
330
+ rescue StandardError
331
+ ""
332
+ end
333
+ private_class_method :branch_head_sha
334
+
335
+ def parse_time(raw)
336
+ return nil if raw.nil? || raw.to_s.strip.empty?
337
+
338
+ Time.iso8601(raw.to_s)
339
+ rescue ArgumentError
340
+ nil
341
+ end
342
+ private_class_method :parse_time
343
+
344
+ def savepoint_content(intent_dir)
345
+ path = File.join(intent_dir.to_s, "savepoint.md")
346
+ File.exist?(path) ? File.read(path) : ""
347
+ end
348
+ private_class_method :savepoint_content
349
+
350
+ # --- the snapshot (D5) ---------------------------------------------------------
351
+
352
+ def read_snapshot(intent_dir)
353
+ path = File.join(intent_dir, STATE_FILENAME)
354
+ return nil unless File.file?(path)
355
+
356
+ data = JSON.parse(File.read(path))
357
+ { fingerprint: data["fingerprint"], quiet_ticks: data["quiet_ticks"].to_i, tick: data["tick"].to_i,
358
+ at: data["at"] }
359
+ rescue StandardError
360
+ nil
361
+ end
362
+ private_class_method :read_snapshot
363
+
364
+ def write_snapshot(intent_dir, snapshot)
365
+ path = File.join(intent_dir, STATE_FILENAME)
366
+ data = {
367
+ "fingerprint" => snapshot[:fingerprint], "quiet_ticks" => snapshot[:quiet_ticks],
368
+ "tick" => snapshot[:tick], "at" => snapshot[:at],
369
+ }
370
+ AtomicWrite.write(path, JSON.generate(data))
371
+ end
372
+ private_class_method :write_snapshot
373
+
374
+ # --- the record (D5) -----------------------------------------------------------
375
+
376
+ def append_record(intent_dir, now:, tick:, klass:, reclaimed:, ready:, dispatched:, harness: "-", meter: "-",
377
+ lock:)
378
+ line = "#{now.utc.iso8601} tick=#{tick} class=#{klass} reclaimed=#{list_or_dash(reclaimed)} " \
379
+ "ready=#{list_or_dash(ready)} dispatched=#{list_or_dash(dispatched)} harness=#{harness} " \
380
+ "meter=#{meter} lock=#{lock}\n"
381
+ File.open(File.join(intent_dir, RECORD_FILENAME), "a") { |f| f.write(line) }
382
+ end
383
+ private_class_method :append_record
384
+
385
+ def list_or_dash(list)
386
+ list.nil? || list.empty? ? "-" : list.join(",")
387
+ end
388
+ private_class_method :list_or_dash
389
+ end
@@ -145,7 +145,7 @@ module Savepoint
145
145
  ["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
146
146
  files << f if stage_file_present?("#{intent_dir}/#{f}")
147
147
  end
148
- # Name the directory that actually exists (fold B3): a nodes-only intent
148
+ # Name the directory that actually exists (review B3): a nodes-only intent
149
149
  # must never claim the literal "actions/" artifact it does not have.
150
150
  # Checks actions/ first, matching D15r's read order.
151
151
  if has_real_files_in?("actions", intent_dir)
@@ -160,7 +160,7 @@ module Savepoint
160
160
  ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
161
161
  # A How-stage intent that already started a nodes/ directory is named
162
162
  # accordingly, so the next-step hint never tells a node-graph intent to
163
- # go make an actions/ directory it will never use (fold B3). Mirrors
163
+ # go make an actions/ directory it will never use (review B3). Mirrors
164
164
  # has_real_files_in?'s actions-first order and its real-file requirement
165
165
  # (post-execution review, non-blocking 4): an intent carrying real files
166
166
  # in both directories, or a real actions/ file beside an empty or
@@ -177,7 +177,7 @@ module Savepoint
177
177
  when "why" then ["spec.md"]
178
178
  when "how"
179
179
  # A node-shaped How intent (a real graph.md already) is named after its
180
- # own two artifacts, never the three D41 removed from its path (fold
180
+ # own two artifacts, never the three D41 removed from its path (review
181
181
  # B3, extended by 336 D13): checked via stage_file_present? alone, the
182
182
  # same primitive derive_stage itself uses.
183
183
  if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
@@ -245,7 +245,7 @@ module SessionGit
245
245
  # The branch HEAD points to, even on an unborn branch (review R3: a fresh
246
246
  # `git init`, zero commits). `git rev-parse --abbrev-ref HEAD` FAILS on an
247
247
  # unborn branch (there is no commit for it to resolve yet), which the old
248
- # implementation misread as "cannot determine a branch" and folded into
248
+ # implementation misread as "cannot determine a branch" and merged into
249
249
  # the detached-HEAD case. `git symbolic-ref --quiet --short HEAD` succeeds
250
250
  # on both a normal AND an unborn branch (HEAD is a symbolic ref to
251
251
  # `refs/heads/<name>` in both cases) and only fails when HEAD is
@@ -354,7 +354,7 @@ module SessionGit
354
354
 
355
355
  # An unknown mode/workspace value, or workspace: worktree's degradation
356
356
  # to checkout, is itself a degradation (spec D2, BLOCKER 3 ruling), so
357
- # it always turns the outcome into a Note, folding both facts into the
357
+ # it always turns the outcome into a Note, merging both facts into the
358
358
  # single savepoint line spec D7 allows.
359
359
  Result.new(message: "#{flow_notes.join('; ')}; #{result.message}", event: "Note")
360
360
  end