@zalom/plastic 2.0.0-alpha.23 → 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 (63) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-node-research.md +4 -4
  3. package/agents/plastic-node-verify.md +4 -4
  4. package/agents/plastic-node-work.md +4 -4
  5. package/package.json +1 -1
  6. package/scripts/dashboard.rb +1 -1
  7. package/scripts/end-intent +19 -16
  8. package/scripts/exec-worktree +5 -5
  9. package/scripts/hook-call-budget +6 -6
  10. package/scripts/hook-capture +15 -15
  11. package/scripts/hook-record +21 -14
  12. package/scripts/hook-savepoint +2 -1
  13. package/scripts/hook-session-start +9 -10
  14. package/scripts/lib/active_delivery.rb +44 -0
  15. package/scripts/lib/arm.rb +41 -102
  16. package/scripts/lib/codex_adapter.rb +2 -2
  17. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  18. package/scripts/lib/day_summary.rb +19 -11
  19. package/scripts/lib/doctor_session_ledger.rb +3 -52
  20. package/scripts/lib/exec_worktree.rb +24 -21
  21. package/scripts/lib/graph_measure_budget.rb +29 -28
  22. package/scripts/lib/handoff.rb +4 -8
  23. package/scripts/lib/harness_adapter.rb +6 -6
  24. package/scripts/lib/index_entry.rb +53 -0
  25. package/scripts/lib/insights.rb +1 -1
  26. package/scripts/lib/installer_core.rb +120 -7
  27. package/scripts/lib/lock.rb +8 -9
  28. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  29. package/scripts/lib/node_input_compatibility.rb +62 -0
  30. package/scripts/lib/node_ledger.rb +4 -2
  31. package/scripts/lib/outcome_report.rb +1 -1
  32. package/scripts/lib/project_config.rb +45 -0
  33. package/scripts/lib/ready_set.rb +1 -1
  34. package/scripts/lib/roadmap_savepoint.rb +1 -1
  35. package/scripts/lib/runner_absorb.rb +5 -5
  36. package/scripts/lib/runner_dispatch.rb +44 -44
  37. package/scripts/lib/runner_sweep.rb +4 -4
  38. package/scripts/lib/runner_until_empty.rb +1 -1
  39. package/scripts/lib/savepoint.rb +6 -7
  40. package/scripts/lib/scaffold_intent.rb +6 -3
  41. package/scripts/lib/session_close.rb +30 -28
  42. package/scripts/lib/session_git.rb +20 -14
  43. package/scripts/lib/session_ledger.rb +44 -4
  44. package/scripts/lib/worktree.rb +24 -24
  45. package/scripts/lib/worktree_sweep.rb +3 -3
  46. package/scripts/{node-packet → node-input} +15 -15
  47. package/scripts/node-run +19 -19
  48. package/scripts/plastic-lock +33 -32
  49. package/scripts/runner +1 -1
  50. package/skills/auto/SKILL.md +7 -7
  51. package/skills/auto/references/end-tail.md +9 -11
  52. package/skills/conventions/references/completion-and-done.md +9 -10
  53. package/skills/conventions/references/knowledge-graph.md +1 -1
  54. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  55. package/skills/direct/SKILL.md +2 -2
  56. package/skills/doctor/SKILL.md +1 -1
  57. package/skills/intent-executing/SKILL.md +1 -1
  58. package/skills/releasing/SKILL.md +2 -2
  59. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  60. package/skills/releasing/references/release-lines.md +1 -1
  61. package/templates/index.md +1 -1
  62. package/templates/project.yml +1 -1
  63. package/scripts/lib/bridge.rb +0 -116
@@ -10,13 +10,14 @@ require_relative "savepoint"
10
10
 
11
11
  # Arm - how an auto team takes an intent and gives it back (intent 307).
12
12
  #
13
- # Three durable facts make a delivery: the delivery lock in the intent
14
- # directory (who owns it), the code worktree under the project repo (where
15
- # the code lands), and the per-session pointer in the global store's `.tmp/`
16
- # (which intent this session records into; a day id means the day ledger).
17
- # Before 2.0 a fourth thing, a `/tmp` bridge JSON, cached all three plus a
18
- # stage snapshot; ruling 6 of intent 296 retired it, and this module is what
19
- # replaced the bridge's arm, disarm, and repair methods (removed in 2.0, intent 307).
13
+ # Two durable facts make a delivery: the delivery lock in the intent
14
+ # directory says who delivers, and the code worktree under the project repo
15
+ # says where the code lands. A conversation session's tmp directory under
16
+ # the global store's `.tmp/` says only that a session started; it names no
17
+ # intent. This module carries the
18
+ # arm, disarm, and repair operations (intent 307); the pure INDEX/project-config
19
+ # helpers that once sat alongside them on a shared-helpers module now live on
20
+ # IndexEntry and ProjectConfig (intent 344).
20
21
  #
21
22
  # Pure and dependency-injected: every path and clock is an argument, every git
22
23
  # call goes through an injected runner, and the only environment read is the
@@ -32,7 +33,7 @@ module Arm
32
33
  end
33
34
 
34
35
  # Deterministic, session-less key derived from the store and the intent id,
35
- # for headless runs that carry no session id at all (moved from Bridge).
36
+ # for headless runs that carry no session id at all.
36
37
  def derive_key(store, intent_id)
37
38
  "auto-" + Digest::SHA256.hexdigest("#{store}/#{intent_id}")[0, 10]
38
39
  end
@@ -67,7 +68,7 @@ module Arm
67
68
 
68
69
  # The minimal hash Worktree.provision, release, and finish consume: the
69
70
  # intent block plus, when asked, the derived worktree block.
70
- def bridge_hash(intent_dir:, home: Dir.home, with_worktree: true)
71
+ def delivery(intent_dir:, home: Dir.home, with_worktree: true)
71
72
  dir = File.expand_path(intent_dir)
72
73
  data = {
73
74
  "intent" => { "id" => intent_id_for(dir), "dir" => File.basename(dir), "store" => store_for(dir) },
@@ -94,48 +95,26 @@ module Arm
94
95
  }
95
96
  end
96
97
 
97
- # Owner rule 2026-08-31: does this session already have a live top-level
98
- # pointer pointing SOMEWHERE ELSE (the day ledger or another intent)? A
99
- # pointer already on this intent is the owner re-arming mid-delivery and
100
- # stays idempotent. True means a
101
- # conversation session. Reads only; rescues to false (fail open).
102
- def preexisting_pointer?(session, home:)
103
- path = pointer_path(session, home: home)
104
- File.exist?(path) && !File.read(path).to_s.strip.empty?
98
+ # Owner rule 2026-08-31: has this session already started a conversation?
99
+ # A conversation session's tmp directory is made at boot under the global
100
+ # store's `.tmp/`; a dispatched or headless session (a derived key) never
101
+ # gets one. Reads only; rescues to false (fail open: a broken store must
102
+ # never block a legitimate delivery).
103
+ def started_session?(session, home:)
104
+ store = global_store(home)
105
+ Dir.exist?(SessionLedger.session_tmp_dir(store, SessionLedger.short_session_id(nil, session)))
105
106
  rescue StandardError
106
107
  false
107
108
  end
108
109
 
109
- # --- the pointer -------------------------------------------------------------
110
-
111
-
112
- def pointer_path(session, home:)
113
- store = global_store(home)
114
- SessionLedger.pointer_path(store, SessionLedger.short_session_id(nil, session))
115
- end
116
-
117
- def read_pointer(session, home:)
118
- path = pointer_path(session, home: home)
119
- File.exist?(path) ? File.read(path).strip : nil
120
- end
121
-
122
- def write_pointer(session, value, home:)
123
- store = global_store(home)
124
- SessionLedger.ensure_tmp_root(store)
125
- path = pointer_path(session, home: home)
126
- FileUtils.mkdir_p(File.dirname(path))
127
- File.write(path, "#{value}\n")
128
- path
129
- end
130
-
131
110
  # --- arm ---------------------------------------------------------------------
132
111
 
133
112
  # Take an intent for `session`: acquire delivery.lock (stamped with the run
134
- # mode and the provenance the caller knows), provision the code worktree
135
- # (fail-open for a global-only or non-git intent), and point the session at
136
- # the intent. Returns `{status:, lock:, worktree:, session:, pointer:}`.
137
- # A held, stale, excluded, or corrupt lock returns that status with the
138
- # lock data read and touches nothing.
113
+ # mode and the provenance the caller knows) and provision the code worktree
114
+ # (fail-open for a global-only or non-git intent). Returns
115
+ # `{status:, lock:, worktree:, session:}`. A held, stale, excluded, or
116
+ # corrupt lock returns that status with the lock data read and touches
117
+ # nothing.
139
118
  def arm(intent_dir:, session:, mode: "auto", home: Dir.home, harness: nil,
140
119
  agent: nil, model: nil, thread: nil, now: Time.now, runner: Worktree::ShellRunner.new,
141
120
  host: Socket.gethostname, allow_inline: false)
@@ -145,52 +124,39 @@ module Arm
145
124
  h = home_for(dir, home: home)
146
125
 
147
126
  # Owner rule 2026-08-31: the main session never delivers an intent inline.
148
- # A session that already carries a top-level session pointer is a
149
- # conversation session (SessionStart wrote it at boot); arming there is
150
- # inline delivery and is refused BEFORE any lock is taken. A dispatched or
151
- # headless session has no pre-existing pointer and arms freely.
152
- # --allow-inline is the explicit owner override. Fail open on read errors:
153
- # a broken pointer file must never block a legitimate delivery.
154
- if !allow_inline && preexisting_pointer?(key, home: h) &&
155
- read_pointer(key, home: h).to_s.strip != intent_id_for(dir)
156
- return { status: :inline_refused, lock: nil, worktree: nil, session: key, pointer: nil }
127
+ # A conversation session (its tmp directory already exists under the
128
+ # global store) arming an intent it does not already hold the lock for is
129
+ # inline delivery and is refused BEFORE any lock is taken. Re-arming the
130
+ # intent it already holds stays idempotent. A dispatched or headless
131
+ # session has no tmp directory and arms freely. --allow-inline is the
132
+ # explicit owner override.
133
+ if !allow_inline && started_session?(key, home: h) && !Lock.holds?(dir, session: key)
134
+ return { status: :inline_refused, lock: nil, worktree: nil, session: key }
157
135
  end
158
136
 
159
137
  status, lock = Lock.acquire(dir, session: key, host: host, now: now,
160
138
  harness: harness, agent: agent, model: model,
161
139
  thread: thread, run_mode: mode.to_s)
162
140
  unless %i[acquired owned].include?(status)
163
- return { status: status, lock: lock, worktree: nil, session: key, pointer: nil }
141
+ return { status: status, lock: lock, worktree: nil, session: key }
164
142
  end
165
143
 
166
- data = bridge_hash(intent_dir: dir, home: h, with_worktree: false)
144
+ data = delivery(intent_dir: dir, home: h, with_worktree: false)
167
145
  begin
168
146
  Worktree.provision(data, home: h, runner: runner)
169
147
  rescue StandardError => e
170
148
  warn "plastic: worktree provision raised, continuing unprovisioned: #{e.message}"
171
149
  end
172
150
 
173
- # The pointer is what the capture and record hooks read; they key on the
174
- # harness's real session id, so a derived key gets no pointer (nothing would
175
- # ever read it). A pointer failure warns and never undoes an acquired lock.
176
- pointer = nil
177
- unless key == derive_key(store_for(dir), intent_id_for(dir))
178
- begin
179
- pointer = write_pointer(key, intent_id_for(dir), home: h)
180
- rescue StandardError => e
181
- warn "plastic: session pointer not written (#{e.message}); the lock is held regardless"
182
- end
183
- end
184
151
  { status: status, lock: lock, worktree: worktree_block(intent_dir: dir, home: h),
185
- session: key, pointer: pointer }
152
+ session: key }
186
153
  end
187
154
 
188
155
  # --- disarm ------------------------------------------------------------------
189
156
 
190
- # Give the intent back: remove the worktree (when `remove`), release the
191
- # lock as its recorded owner (falling back to `session`), and reset the
192
- # session pointer to today's day id when it named this intent. Returns
193
- # the lock release status (:released, :none, :not_owner, or :raised).
157
+ # Give the intent back: remove the worktree (when `remove`) and release the
158
+ # lock as its recorded owner (falling back to `session`). Returns the lock
159
+ # release status (:released, :none, :not_owner, or :raised).
194
160
  def disarm(intent_dir:, session:, home: Dir.home, runner: Worktree::ShellRunner.new,
195
161
  remove: true, now: Time.now)
196
162
  dir = File.expand_path(intent_dir)
@@ -198,39 +164,24 @@ module Arm
198
164
  key = resolve_session(session, store: store_for(dir), intent_id: intent_id_for(dir))
199
165
 
200
166
  begin
201
- Worktree.release(bridge_hash(intent_dir: dir, home: h), home: h, runner: runner, remove: remove)
167
+ Worktree.release(delivery(intent_dir: dir, home: h), home: h, runner: runner, remove: remove)
202
168
  rescue StandardError => e
203
169
  warn "plastic: worktree release raised, continuing: #{e.message}"
204
170
  end
205
171
 
206
172
  lock = Lock.read(dir)
207
173
  owner = lock && !blank?(lock["owner_session"]) ? lock["owner_session"] : key
208
- release_status = begin
174
+ begin
209
175
  Lock.release(dir, session: owner)
210
176
  rescue StandardError => e
211
177
  warn "plastic: delivery lock release raised for #{dir}, continuing: #{e.message}"
212
178
  :raised
213
179
  end
214
-
215
- begin
216
- reset_pointer(key, intent_id_for(dir), home: h, now: now)
217
- rescue StandardError => e
218
- warn "plastic: session pointer not reset (#{e.message})"
219
- end
220
- release_status
221
- end
222
-
223
- # Point the session back at the day ledger, but only when it names `intent_id`.
224
- def reset_pointer(session, intent_id, home:, now: Time.now)
225
- current = read_pointer(session, home: home)
226
- return false unless current == intent_id.to_s
227
- write_pointer(session, SessionLedger.day_id(now), home: home)
228
- true
229
180
  end
230
181
 
231
182
  # --- repair ------------------------------------------------------------------
232
183
 
233
- # One idempotent repair (the lock half of the bridge's repair, removed in 2.0):
184
+ # One idempotent repair (the lock half of the repair path):
234
185
  # remove a corrupt lock, back off from a fresh foreign lock (`held`), report
235
186
  # a stale foreign lock (`stale`) for the explicit reclaim verb, keep and
236
187
  # enrich an own lock, heartbeat a delegated one, acquire when none, and
@@ -284,7 +235,7 @@ module Arm
284
235
  end
285
236
 
286
237
  begin
287
- Worktree.provision(bridge_hash(intent_dir: dir, home: h, with_worktree: false), home: h, runner: runner)
238
+ Worktree.provision(delivery(intent_dir: dir, home: h, with_worktree: false), home: h, runner: runner)
288
239
  rescue StandardError => e
289
240
  warn "plastic: worktree provision raised during repair, continuing unprovisioned: #{e.message}"
290
241
  end
@@ -293,16 +244,4 @@ module Arm
293
244
 
294
245
  { "status" => "repaired", "actions" => actions, "lock" => lock_data, "session" => key }
295
246
  end
296
-
297
- # The intent directory this session's pointer names, searched in the given
298
- # stores in order; nil when the pointer is absent, empty, or a day id.
299
- def intent_dir_from_pointer(session, home:, stores:)
300
- current = read_pointer(session, home: home)
301
- return nil if blank?(current) || SessionLedger.valid_day_id?(current)
302
- stores.each do |store|
303
- match = Dir.glob(File.join(store, "#{current}--*")).find { |p| File.directory?(p) }
304
- return match if match
305
- end
306
- nil
307
- end
308
247
  end
@@ -8,7 +8,7 @@ require_relative "runner_policy"
8
8
 
9
9
  # CodexAdapter (intent 340b, G7c, n6): the argv `codex exec` needs for one
10
10
  # node's kind, and the mechanics of running it once - a bounded subprocess
11
- # whose stdin carries the packet, whose stdout and `--output-last-message`
11
+ # whose stdin carries the node input, whose stdout and `--output-last-message`
12
12
  # file are the only two places a return can come from, and whose timeout
13
13
  # kills the whole process group rather than one pid.
14
14
  #
@@ -69,7 +69,7 @@ module CodexAdapter
69
69
  # kind's row calls for and nothing else (row 6.7 - never the intent
70
70
  # worktree, never `~/.plastic`), `--output-last-message` at the path the
71
71
  # caller names, and a bare `-` so the prompt is read from stdin (row 6.9) -
72
- # the packet itself never rides in this array.
72
+ # the node input itself never rides in this array.
73
73
  def build_argv(kind:, worktree:, output_last_message:)
74
74
  [
75
75
  "codex", "exec",
@@ -3,13 +3,13 @@
3
3
 
4
4
  require "digest"
5
5
 
6
- # PacketWrapper (intent 338, G5, n1): the trust boundary a node packet is
6
+ # DataBoundary (intent 338, G5, n1): the trust boundary a node input is
7
7
  # built over. A data block carries text other agents and the owner wrote,
8
8
  # and is opened by `<<<PLASTIC-DATA:<token> label="..." source="...">>>` and
9
9
  # closed by `<<<END-PLASTIC-DATA:<token>>>`. The token is content-derived
10
10
  # (spec D4), so the same payloads always produce the same token and the
11
- # packet stays deterministic; the escaping rule (spec D5) runs on every
12
- # payload independently of the token, so a payload carrying this packet's
11
+ # node input stays deterministic; the escaping rule (spec D5) runs on every
12
+ # payload independently of the token, so a payload carrying this node input's
13
13
  # own closing marker still cannot close its block. Attribute values
14
14
  # (`label`, `source`) are sanitized separately, by a whitelist (spec D5a),
15
15
  # because they are not payload text and the payload escaping rule does not
@@ -18,7 +18,7 @@ require "digest"
18
18
  # Pure and side-effect-free: no I/O, no clock, nothing raised across the
19
19
  # boundary. `estimate_tokens` is the one arithmetic every budget in this
20
20
  # delivery is spent in (spec D6): bytes over four, rounded.
21
- module PacketWrapper
21
+ module DataBoundary
22
22
  module_function
23
23
 
24
24
  MARKER_OPEN = "<<<PLASTIC-DATA:"
@@ -34,16 +34,16 @@ module PacketWrapper
34
34
  CLOSE_LINE_RE = /\A#{Regexp.escape(MARKER_CLOSE)}([0-9a-f]+)>>>\z/.freeze
35
35
 
36
36
  # The first twelve hex characters of the SHA-256 over the payloads joined
37
- # by a newline (spec D4): fixed per packet, unguessable from any single
37
+ # by a newline (spec D4): fixed per node input, unguessable from any single
38
38
  # record file, deterministic across two builds of the same payloads.
39
39
  def boundary_token(payloads)
40
40
  Digest::SHA256.hexdigest(Array(payloads).map(&:to_s).join("\n"))[0, 12]
41
41
  end
42
42
 
43
43
  # One-way marker escaping (spec D5), plus a UTF-8 scrub (matrix 1.10) so
44
- # one invalid byte anywhere in a record never raises the whole packet
44
+ # one invalid byte anywhere in a record never raises the whole node input
45
45
  # build. Runs regardless of what token trails the marker text, because a
46
- # payload copied from an earlier packet may carry ANY token, not only this
46
+ # payload copied from an earlier node input may carry ANY token, not only this
47
47
  # one (matrix 1.2's concern applied to escaping rather than to the token
48
48
  # itself).
49
49
  def escape(payload)
@@ -12,6 +12,7 @@ require "time"
12
12
  require_relative "session_ledger"
13
13
  require_relative "handoff"
14
14
  require_relative "lock"
15
+ require_relative "active_delivery"
15
16
  require_relative "report_screen"
16
17
 
17
18
  module DaySummary
@@ -43,7 +44,7 @@ module DaySummary
43
44
  open: open_items(store, day),
44
45
  done: last_done(store, day),
45
46
  live: live_intents(home, now: now),
46
- others: active_sessions(store, session, now: now, ttl: heartbeat_ttl),
47
+ others: active_sessions(store, session, now: now, ttl: heartbeat_ttl, home: home),
47
48
  }
48
49
  hidden = Hash.new(0)
49
50
  cap!(lists, hidden, :open, OPEN_CAP, keep: :newest)
@@ -142,7 +143,7 @@ module DaySummary
142
143
  "(no savepoint yet)"
143
144
  end
144
145
 
145
- def active_sessions(store, session, now:, ttl:)
146
+ def active_sessions(store, session, now:, ttl:, home: nil)
146
147
  tmp_root = SessionLedger.tmp_root(store)
147
148
  return [] unless File.directory?(tmp_root)
148
149
 
@@ -155,7 +156,7 @@ module DaySummary
155
156
  age = heartbeat_age(dir, now)
156
157
  next if age.nil? || age > ttl
157
158
 
158
- "- #{sid} (#{(age / 60).floor}m ago, on #{pointer_of(dir)})"
159
+ "- #{sid} (#{(age / 60).floor}m ago, on #{delivering_label(store, home, sid, now)})"
159
160
  end
160
161
  rescue SystemCallError
161
162
  []
@@ -178,14 +179,21 @@ module DaySummary
178
179
  nil
179
180
  end
180
181
 
181
- def pointer_of(dir)
182
- path = File.join(dir, "current")
183
- return "?" unless File.file?(path)
184
-
185
- value = File.read(path).strip
186
- value.empty? ? "?" : value[0, 80]
187
- rescue SystemCallError
188
- "?"
182
+ # The delivering intent's id, or "day ledger" (344 n2, D4-D7): the
183
+ # basename before "--" of ActiveDelivery.resolve_by_short_session's
184
+ # match, else the session records into today's day ledger.
185
+ def delivering_label(store, home, sid, now)
186
+ return "day ledger" if home.to_s.empty?
187
+
188
+ dir = ActiveDelivery.resolve_by_short_session(
189
+ global_store: store,
190
+ project_roots: ActiveDelivery.project_roots(home),
191
+ short_session: sid,
192
+ now: now
193
+ )
194
+ dir ? File.basename(dir).split("--", 2).first : "day ledger"
195
+ rescue StandardError
196
+ "day ledger"
189
197
  end
190
198
 
191
199
  # --- rendering, pure -------------------------------------------------------------
@@ -15,21 +15,12 @@ require_relative "session_ledger"
15
15
  # heartbeat is only refreshed on prompts and edits, so an old heartbeat is evidence, not
16
16
  # proof, that the session is gone. Fail-open: an unreadable directory is skipped, never
17
17
  # raised. Depends on the including class for `plastic_home` and `check`.
18
+ #
19
+ # Intent 344 (G11, D10): the third check this category used to carry,
20
+ # `no_pointer_session_tmp`, is retired along with the per-session state it read.
18
21
  module DoctorSessionLedger
19
22
  ORPHAN_TTL_SECONDS = 24 * 60 * 60
20
23
 
21
- # Row H (spec D9): a `.tmp/<sid>/` directory with no `current` pointer is a
22
- # DIFFERENT orphan class than ORPHAN_TTL_SECONDS's -- a session where
23
- # session start never ran at all (a `-p` print session, a resumed
24
- # background job, an unregistered SessionStart hook), not a session that
25
- # ran and then never closed. No choice of session id ever creates a
26
- # pointer for that class, so it would otherwise stay invisible for the
27
- # full 24 hours (or forever, once hook-capture/hook-record stop creating
28
- # it at all). Short relative to ORPHAN_TTL_SECONDS on purpose: a session
29
- # between its own start and its first pointer write is normal and must not
30
- # be flagged, but that window is seconds, not hours.
31
- NO_POINTER_TTL_SECONDS = 5 * 60
32
-
33
24
  # Seconds since the session's last heartbeat: the ISO-8601 content of `heartbeat`,
34
25
  # else that file's mtime, else the directory's mtime.
35
26
  def heartbeat_age(dir, now)
@@ -49,7 +40,6 @@ module DoctorSessionLedger
49
40
 
50
41
  store_dir = File.join(plastic_home, "store")
51
42
  orphans = orphaned_session_dirs(store_dir, now)
52
- no_pointer = no_pointer_session_dirs(store_dir, now)
53
43
  shape = day_ledger_shape_problems(store_dir)
54
44
 
55
45
  checks = []
@@ -67,20 +57,6 @@ module DoctorSessionLedger
67
57
  "session is gone: a live session rewrites its heartbeat on every " \
68
58
  "prompt and edit, so only a listed directory may be removed, by hand.")
69
59
  end
70
- checks << if no_pointer.empty?
71
- check(category: "session_ledger", name: "no_pointer_session_tmp", status: "pass",
72
- message: "No .tmp/<session>/ directory has gone without a `current` pointer for " \
73
- "longer than #{NO_POINTER_TTL_SECONDS} seconds")
74
- else
75
- check(category: "session_ledger", name: "no_pointer_session_tmp", status: "warn",
76
- message: "#{no_pointer.size} .tmp/<session>/ director#{no_pointer.size == 1 ? "y" : "ies"} " \
77
- "with no `current` pointer for longer than #{NO_POINTER_TTL_SECONDS} seconds " \
78
- "(session start never ran for this session)",
79
- details: no_pointer, fixable: true,
80
- fix_hint: "Remove each listed .tmp/<session>/ directory after confirming that " \
81
- "session never started: no `current` pointer means session start never " \
82
- "ran for it, so this is not a live session missing a checklist entry.")
83
- end
84
60
  checks << if shape.empty?
85
61
  check(category: "session_ledger", name: "day_ledger_shape", status: "pass",
86
62
  message: "Every .sessions/ entry is a YYYYMMDD day directory with its <day>.md file")
@@ -113,31 +89,6 @@ module DoctorSessionLedger
113
89
  [] # unreadable .tmp/: nothing to report, never a crash
114
90
  end
115
91
 
116
- # Row H (spec D9): `.tmp/<sid>/` directories with no `current` pointer, past
117
- # NO_POINTER_TTL_SECONDS. A different signal than #orphaned_session_dirs:
118
- # that one is age-only and blind to whether a pointer exists at all, so a
119
- # young no-pointer dir (a session between its start and its first pointer
120
- # write) must not appear here even though it may well appear there once it
121
- # ages past ORPHAN_TTL_SECONDS -- the two checks answer different questions
122
- # and a dir can legitimately show up in neither, either, or both.
123
- def no_pointer_session_dirs(store_dir, now)
124
- tmp_root = SessionLedger.tmp_root(store_dir)
125
- return [] unless File.directory?(tmp_root)
126
-
127
- Dir.children(tmp_root).sort.filter_map do |name|
128
- dir = File.join(tmp_root, name)
129
- next unless File.directory?(dir)
130
- next if File.exist?(File.join(dir, "current"))
131
-
132
- age = heartbeat_age(dir, now)
133
- next if age <= NO_POINTER_TTL_SECONDS
134
-
135
- "global: #{dir} (session #{name}, no `current` pointer, last heartbeat #{age.round}s ago)"
136
- end
137
- rescue SystemCallError
138
- [] # unreadable .tmp/: nothing to report, never a crash
139
- end
140
-
141
92
  def day_ledger_shape_problems(store_dir)
142
93
  root = SessionLedger.sessions_root(store_dir)
143
94
  return [] unless File.directory?(root)
@@ -2,7 +2,6 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "open3"
5
- require_relative "bridge"
6
5
  require_relative "arm"
7
6
  require_relative "lock"
8
7
  require_relative "worktree"
@@ -29,8 +28,8 @@ require_relative "scaffold_intent"
29
28
  # fail-open, see the merge-verification note below), `finisher:` (defaults to
30
29
  # `Worktree.method(:finish)`), `status_checker:` (defaults to a `git status --porcelain`
31
30
  # lambda). Every seam exists so
32
- # tests run with no real git, no real worktree removal, and no real bridge file outside a
33
- # tmpdir.
31
+ # tests run with no real git, no real worktree removal, and no real delivery-lock file
32
+ # outside a tmpdir.
34
33
  #
35
34
  # MERGE VERIFICATION (delivered disposition only). `Worktree.merge_branch` is deliberately
36
35
  # fail-open: on a conflict it runs `git merge --abort`, warns, and returns false, and
@@ -39,7 +38,7 @@ require_relative "scaffold_intent"
39
38
  # module never trusts the discarded return value; it asks git directly, AFTER finish, whether
40
39
  # the intent's code branch is now an ancestor of the repo's current HEAD
41
40
  # (`merged_into_current_branch?` below). `repo` and `branch` are captured BEFORE the finisher
42
- # runs, because finish deletes the bridge's `worktree` block.
41
+ # runs, because finish deletes the delivery's `worktree` block.
43
42
  #
44
43
  # DOES NOT RUN A TEST SUITE, in any form. `verify-intent` owns test execution; one concern
45
44
  # per script (spec D8).
@@ -60,6 +59,10 @@ module ExecWorktree
60
59
  # [stdout, stderr, status] triple Open3.capture3 returns (status responds to
61
60
  # success?). A raised error (git missing, corrupt repo) is caught and reported as a
62
61
  # nil status, so the caller can fail closed the same way a failed exit status would.
62
+ def blank?(value)
63
+ value.nil? || value.to_s.strip.empty?
64
+ end
65
+
63
66
  def default_status_checker
64
67
  lambda do |worktree_code|
65
68
  Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
@@ -85,7 +88,7 @@ module ExecWorktree
85
88
  # `<repo>/.claude/worktrees/<name>` (scripts/lib/worktree.rb:75), so walking up three
86
89
  # directories recovers `<repo>` deterministically, with no I/O and no git.
87
90
  def repo_from_worktree_code(worktree_code)
88
- return nil if Bridge.blank?(worktree_code)
91
+ return nil if blank?(worktree_code)
89
92
  File.dirname(File.dirname(File.dirname(worktree_code.to_s)))
90
93
  end
91
94
 
@@ -94,7 +97,7 @@ module ExecWorktree
94
97
  # i.e. the merge actually landed. Fails CLOSED (not merged) on a blank repo/branch or a
95
98
  # raising runner, so an inconclusive check is never reported as a success.
96
99
  def merged_into_current_branch?(runner, repo:, branch:)
97
- return false if Bridge.blank?(repo) || Bridge.blank?(branch)
100
+ return false if blank?(repo) || blank?(branch)
98
101
  runner.run("-C", repo, "merge-base", "--is-ancestor", branch, "HEAD").success?
99
102
  rescue StandardError
100
103
  false
@@ -106,12 +109,12 @@ module ExecWorktree
106
109
  # The lock-owner fallback is correct here: this is a teardown step on an intent this
107
110
  # session has been delivering.
108
111
  def resolve_session(explicit, env_session, intent_dir)
109
- return explicit.to_s.strip unless Bridge.blank?(explicit)
110
- return env_session.to_s.strip unless Bridge.blank?(env_session)
112
+ return explicit.to_s.strip unless blank?(explicit)
113
+ return env_session.to_s.strip unless blank?(env_session)
111
114
  lock = Lock.read(intent_dir)
112
115
  return nil unless lock
113
116
  owner = lock["owner_session"].to_s
114
- Bridge.blank?(owner) ? nil : owner
117
+ blank?(owner) ? nil : owner
115
118
  end
116
119
 
117
120
  # --- result builders (the exit_code/stdout/stderr shape the thin CLIs share) --------
@@ -181,7 +184,7 @@ module ExecWorktree
181
184
  merge_line =
182
185
  if disposition != "delivered"
183
186
  "not attempted (abandoned)"
184
- elsif Bridge.blank?(target_branch)
187
+ elsif blank?(target_branch)
185
188
  "merged (integration target branch could not be resolved)"
186
189
  else
187
190
  "merged into #{target_branch}"
@@ -202,10 +205,10 @@ module ExecWorktree
202
205
  runner: Worktree::ShellRunner.new,
203
206
  finisher: Worktree.method(:finish),
204
207
  status_checker: default_status_checker)
205
- return usage_result("--store is required") if Bridge.blank?(store)
206
- return usage_result("--id is required") if Bridge.blank?(id)
207
- return usage_result("--home is required") if Bridge.blank?(home)
208
- return usage_result("--disposition is required") if Bridge.blank?(disposition)
208
+ return usage_result("--store is required") if blank?(store)
209
+ return usage_result("--id is required") if blank?(id)
210
+ return usage_result("--home is required") if blank?(home)
211
+ return usage_result("--disposition is required") if blank?(disposition)
209
212
  unless DISPOSITIONS.include?(disposition)
210
213
  return usage_result("--disposition must be one of #{DISPOSITIONS.join('|')} (got #{disposition.inspect})")
211
214
  end
@@ -220,16 +223,16 @@ module ExecWorktree
220
223
 
221
224
  # Step 0: session resolution + pre-flight refusal (D3-mirroring; see resolve_session).
222
225
  key_session = resolve_session(session, env_session, intent_dir)
223
- if Bridge.blank?(key_session) && File.exist?(Lock.path(intent_dir))
226
+ if blank?(key_session) && File.exist?(Lock.path(intent_dir))
224
227
  return deny_result(EXIT_UNRESOLVED, no_session_message(intent_dir))
225
228
  end
226
229
 
227
- bridge_data = Arm.bridge_hash(intent_dir: intent_dir, home: normalized_home)
228
- worktree_code = bridge_data.dig("worktree", "code")
229
- return ok_result(nothing_provisioned_message(intent_dir)) if Bridge.blank?(worktree_code)
230
+ delivery = Arm.delivery(intent_dir: intent_dir, home: normalized_home)
231
+ worktree_code = delivery.dig("worktree", "code")
232
+ return ok_result(nothing_provisioned_message(intent_dir)) if blank?(worktree_code)
230
233
 
231
- branch = bridge_data.dig("worktree", "code_branch")
232
- # Captured now, before `finisher.call` below (Worktree.finish) deletes the bridge's
234
+ branch = delivery.dig("worktree", "code_branch")
235
+ # Captured now, before `finisher.call` below (Worktree.finish) deletes the delivery's
233
236
  # `worktree` block, so the post-finish merge verification (BLOCKING 1/2) still has
234
237
  # both `repo` and `branch` to work with.
235
238
  repo = repo_from_worktree_code(worktree_code)
@@ -254,7 +257,7 @@ module ExecWorktree
254
257
  # disposition only, whether the worktree directory still exists on disk (teardown
255
258
  # incomplete); second, for a delivered disposition only, whether the branch actually
256
259
  # merged (BLOCKING 1/2, see merged_into_current_branch? above).
257
- finisher.call(bridge_data, home: normalized_home, runner: runner,
260
+ finisher.call(delivery, home: normalized_home, runner: runner,
258
261
  merge: disposition == "delivered")
259
262
 
260
263
  removed = !Dir.exist?(worktree_code)