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

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 (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. package/scripts/lib/bridge.rb +0 -116
@@ -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
@@ -3,14 +3,13 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  # Savepoint: the intent-directory savepoint ledger and the stage derivation it
6
- # rests on (intent 303, split out of bridge.rb). Everything here is derived from
6
+ # rests on (intent 303). Everything here is derived from
7
7
  # the files on disk inside one intent directory: which lifecycle files are real
8
8
  # (not the placeholder sentinel), which stage that makes, and the append-only
9
- # savepoint.md ledger that records each milestone. No session, no bridge, no
9
+ # savepoint.md ledger that records each milestone. No session, no lock, no
10
10
  # lock: a caller that only wants to append a ledger line or ask "which stage is
11
- # this intent at" loads this file and nothing else. bridge.rb (the session
12
- # pointer, arm and disarm, lock repair) requires this file; this file never
13
- # requires the bridge.
11
+ # this intent at" loads this file and nothing else. `scripts/lib/arm.rb` (arm,
12
+ # disarm, lock repair) requires this file; this file never requires arm.rb.
14
13
  require "fileutils"
15
14
  require "time" # Time#iso8601 for the ledger timestamps
16
15
 
@@ -60,7 +59,7 @@ module Savepoint
60
59
 
61
60
  # Walk up from file_path; return the first ancestor that looks like an intent
62
61
  # directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
63
- # target without needing a bridge. The input is always a file inside the intent
62
+ # target without needing the lock or arm machinery. The input is always a file inside the intent
64
63
  # dir (never the dir itself), so the walk-up starts at its parent.
65
64
  def self.intent_dir_for(file_path)
66
65
  dir = File.expand_path(file_path)
@@ -145,7 +144,7 @@ module Savepoint
145
144
  ["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
146
145
  files << f if stage_file_present?("#{intent_dir}/#{f}")
147
146
  end
148
- # Name the directory that actually exists (fold B3): a nodes-only intent
147
+ # Name the directory that actually exists (review B3): a nodes-only intent
149
148
  # must never claim the literal "actions/" artifact it does not have.
150
149
  # Checks actions/ first, matching D15r's read order.
151
150
  if has_real_files_in?("actions", intent_dir)
@@ -160,7 +159,7 @@ module Savepoint
160
159
  ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
161
160
  # A How-stage intent that already started a nodes/ directory is named
162
161
  # 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
162
+ # go make an actions/ directory it will never use (review B3). Mirrors
164
163
  # has_real_files_in?'s actions-first order and its real-file requirement
165
164
  # (post-execution review, non-blocking 4): an intent carrying real files
166
165
  # in both directories, or a real actions/ file beside an empty or
@@ -177,7 +176,7 @@ module Savepoint
177
176
  when "why" then ["spec.md"]
178
177
  when "how"
179
178
  # 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
179
+ # own two artifacts, never the three D41 removed from its path (review
181
180
  # B3, extended by 336 D13): checked via stage_file_present? alone, the
182
181
  # same primitive derive_stage itself uses.
183
182
  if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
@@ -384,7 +383,7 @@ module Savepoint
384
383
 
385
384
  # --- Phantom-line detection (intent 134) ------------------------------------
386
385
  #
387
- # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
386
+ # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no lock or
388
387
  # session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
389
388
  # a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
390
389
  # can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
@@ -3,7 +3,6 @@
3
3
 
4
4
  require "fileutils"
5
5
  require_relative "worktree"
6
- require_relative "bridge"
7
6
  require_relative "savepoint"
8
7
  require_relative "intent_validator"
9
8
 
@@ -21,7 +20,7 @@ require_relative "intent_validator"
21
20
  # copy or a mechanical rendering of a committed artifact.
22
21
  #
23
22
  # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
24
- # `ENV` directly (only via the ambient `Dir.home` default, matching Bridge/Worktree
23
+ # `ENV` directly (only via the ambient `Dir.home` default, matching this module's own
25
24
  # convention). A git seam is injected as `runner:`, defaulting to
26
25
  # `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
27
26
  # never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
@@ -29,6 +28,10 @@ require_relative "intent_validator"
29
28
  module ScaffoldIntent
30
29
  module_function
31
30
 
31
+ def blank?(value)
32
+ value.nil? || value.to_s.strip.empty?
33
+ end
34
+
32
35
  # --- path resolution (pure) --------------------------------------------------
33
36
 
34
37
  def expand(path)
@@ -211,7 +214,7 @@ module ScaffoldIntent
211
214
  end
212
215
  end
213
216
 
214
- unless Bridge.blank?(test_summary)
217
+ unless blank?(test_summary)
215
218
  out << "\n"
216
219
  out << "Test summary from #{test_summary}:\n"
217
220
  out << "```\n"
@@ -17,8 +17,9 @@ module SessionClose
17
17
  NOOP_REASONS = %w[clear resume].freeze
18
18
 
19
19
  # The default hand-off writer (intent 311, spec D6): renders this session's
20
- # share of the pointer day into handoff--<session>.md before the tmp dir
21
- # goes. The hook script builds it with the shipped templates dir.
20
+ # share of its session day (SessionLedger.session_day, graph.md D7) into
21
+ # handoff--<session>.md before the tmp dir goes. The hook script builds it
22
+ # with the shipped templates dir.
22
23
  def default_handoff(templates)
23
24
  lambda do |store, day, session|
24
25
  Handoff.write(store: store, day: day, session: session, trigger: "close", templates: templates)
@@ -45,26 +46,33 @@ module SessionClose
45
46
  session = SessionLedger.short_session_id(payload.is_a?(Hash) ? payload["session_id"] : nil, nil)
46
47
  return report if session.empty?
47
48
 
48
- pointer_day = safely { read_pointer_day(store, session) } || today
49
+ days = safely { SessionLedger.session_days(store, session, today: today) } || []
50
+ days = [today] if days.empty?
51
+ session_day = days.last
49
52
  project = "global"
50
53
 
54
+ # Intent 344 (G11, D13): a session that crossed midnight carries pending
55
+ # lines on more than one day ledger; every one of them drops, not only
56
+ # the newest, so a close never leaves an earlier day's lines pending.
51
57
  report[:dropped] = safely do
52
- checklist = SessionLedger.checklist_path(store, pointer_day)
53
- count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
54
- if count.positive?
55
- SessionLedger.append_line(SessionLedger.savepoint_path(store, pointer_day),
56
- SessionLedger.savepoint_line("Note", session, project,
57
- "dropped #{count} pending lines at close", now: now))
58
+ days.sum do |day|
59
+ checklist = SessionLedger.checklist_path(store, day)
60
+ count = SessionLedger.flip_all(checklist, from: :pending, to: :dropped, session: session)
61
+ if count.positive?
62
+ SessionLedger.append_line(SessionLedger.savepoint_path(store, day),
63
+ SessionLedger.savepoint_line("Note", session, project,
64
+ "dropped #{count} pending lines at close", now: now))
65
+ end
66
+ count
58
67
  end
59
- count
60
68
  end || 0
61
69
 
62
70
  # The hand-off (intent 311, spec D6) is written after the drop, so it
63
- # reflects it, and before the tmp dir goes, since the pointer lives there.
64
- # An intent pointer falls back to today, the same as the drop above.
71
+ # reflects it, and before the tmp dir goes. A session with no line in the
72
+ # day ledger's window falls back to today, the same as the drop above.
65
73
  if handoff
66
74
  report[:handoff] = safely do
67
- handoff.call(store, pointer_day, session)
75
+ handoff.call(store, session_day, session)
68
76
  true
69
77
  end || false
70
78
  end
@@ -75,27 +83,21 @@ module SessionClose
75
83
  true
76
84
  end || false
77
85
 
78
- if pointer_day < today
86
+ # One carry spawns per day earlier than today, oldest first; a single
87
+ # earlier day keeps reporting its own arg array (not a one-element
88
+ # list) so an existing caller reading report[:spawned] as one array is
89
+ # unaffected.
90
+ earlier_days = days.select { |day| day < today }
91
+ unless earlier_days.empty?
79
92
  report[:spawned] = safely do
80
- args = ["--day", pointer_day, "--carry-to", today, "--store", store]
81
- spawner.call(args)
82
- args
93
+ arg_lists = earlier_days.map { |day| ["--day", day, "--carry-to", today, "--store", store] }
94
+ arg_lists.each { |args| spawner.call(args) }
95
+ arg_lists.length == 1 ? arg_lists.first : arg_lists
83
96
  end
84
97
  end
85
98
  report
86
99
  end
87
100
 
88
- # The pointer names the intent this session records into; a day id means
89
- # the day ledger. Anything else (an intent id) means an auto team owns the
90
- # record and the close touches no day ledger.
91
- def read_pointer_day(store, session)
92
- path = SessionLedger.pointer_path(store, session)
93
- return nil unless File.exist?(path)
94
-
95
- value = File.read(path).strip
96
- SessionLedger.valid_day_id?(value) ? value : nil
97
- end
98
-
99
101
  def safely
100
102
  yield
101
103
  rescue StandardError
@@ -5,6 +5,7 @@ require "yaml"
5
5
  require_relative "worktree"
6
6
  require_relative "scaffold_intent"
7
7
  require_relative "session_ledger"
8
+ require_relative "active_delivery"
8
9
 
9
10
  # SessionGit - the branch model and per-repo flow settings behind
10
11
  # `scripts/session-commit` (intent 300), which is how a checklist item
@@ -185,24 +186,29 @@ module SessionGit
185
186
  text.to_s.downcase.scan(/[a-z0-9]+/).first(max_words).join("-")
186
187
  end
187
188
 
188
- # The intent id named by the session's pointer file when `ticket_source`
189
- # is `intent_id`, else the day id (spec D4). Per intent 298 spec D6, the
190
- # pointer at `.tmp/<session>/current` holds exactly one line: either
191
- # today's day id (the session records into the day ledger) or an intent
192
- # id (an auto team owns the record). A day id in the pointer is therefore
193
- # not an intent name, and falls back to the day id here too, which is the
194
- # same value either way.
195
- def resolve_ticket(day:, store:, session:, ticket_source:)
189
+ # The delivering intent's id when `ticket_source` is `intent_id`, else the
190
+ # day id (344 n2, D4-D7, D12). `resolver` is injected so tests need no
191
+ # real ~/.plastic; it defaults to ActiveDelivery.resolve_by_short_session
192
+ # against the store's own project roots.
193
+ def resolve_ticket(day:, store:, session:, ticket_source:, resolver: nil)
196
194
  return day.to_s unless ticket_source.to_s == "intent_id"
197
195
 
198
- pointer = SessionLedger.pointer_path(store, session)
199
- return day.to_s unless File.exist?(pointer)
196
+ resolver ||= lambda { |short_session|
197
+ ActiveDelivery.resolve_by_short_session(
198
+ global_store: store,
199
+ project_roots: ActiveDelivery.project_roots(File.dirname(store)),
200
+ short_session: short_session
201
+ )
202
+ }
200
203
 
201
- content = File.read(pointer).to_s.strip
202
- return day.to_s if content.empty?
203
- return day.to_s if SessionLedger.valid_day_id?(content)
204
+ dir = begin
205
+ resolver.call(session)
206
+ rescue StandardError
207
+ nil
208
+ end
209
+ return day.to_s if dir.to_s.empty?
204
210
 
205
- content
211
+ File.basename(dir).split("--", 2).first
206
212
  end
207
213
 
208
214
  # --- commit message ------------------------------------------------------------
@@ -245,7 +251,7 @@ module SessionGit
245
251
  # The branch HEAD points to, even on an unborn branch (review R3: a fresh
246
252
  # `git init`, zero commits). `git rev-parse --abbrev-ref HEAD` FAILS on an
247
253
  # 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
254
+ # implementation misread as "cannot determine a branch" and merged into
249
255
  # the detached-HEAD case. `git symbolic-ref --quiet --short HEAD` succeeds
250
256
  # on both a normal AND an unborn branch (HEAD is a symbolic ref to
251
257
  # `refs/heads/<name>` in both cases) and only fails when HEAD is
@@ -354,7 +360,7 @@ module SessionGit
354
360
 
355
361
  # An unknown mode/workspace value, or workspace: worktree's degradation
356
362
  # 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
363
+ # it always turns the outcome into a Note, merging both facts into the
358
364
  # single savepoint line spec D7 allows.
359
365
  Result.new(message: "#{flow_notes.join('; ')}; #{result.message}", event: "Note")
360
366
  end