@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,105 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require_relative "lock"
6
+
7
+ # ActiveDelivery (intent 340b, G7c, n4, D10): which intent a session is
8
+ # delivering. A lock is a file inside an intent directory, and intents live
9
+ # under the global store or under any configured project's store, so
10
+ # answering "which intent" needs a walk, not an index the way node_ids.rb
11
+ # indexes ids: one Dir.glob per configured root (plus one for the global
12
+ # store) and one JSON parse per candidate lock file. No validator, no graph
13
+ # rebuild, nothing else touches disk. Shared by StopGate (the fourth arming
14
+ # condition needs to know the lock's run_mode) and Handoff (the Runner
15
+ # section needs to know which intent's runner-step.last to render).
16
+ #
17
+ # Pure and dependency-injected: every call takes explicit paths; nothing
18
+ # here reads ENV, shells out, or raises across its own boundary.
19
+ module ActiveDelivery
20
+ module_function
21
+
22
+ # resolve(global_store:, project_roots:, session:, ttl:, now:) -> intent_dir or nil.
23
+ # Nil for zero matches or more than one: a session holding two live locks
24
+ # at once is a state nothing downstream may guess through (row 4.25).
25
+ def resolve(global_store:, project_roots:, session:, ttl: Lock::TTL_SECONDS, now: Time.now)
26
+ held = candidate_intent_dirs(global_store: global_store, project_roots: project_roots)
27
+ .uniq
28
+ .select { |dir| held_live?(dir, session: session, ttl: ttl, now: now) }
29
+ held.size == 1 ? held.first : nil
30
+ end
31
+
32
+ # Every intent directory under the global store or a configured project
33
+ # root that carries a delivery.lock file, unparsed. One Dir.glob per root
34
+ # (row 4.27): the global store's intents sit directly under store/, a
35
+ # project root's intents sit two levels down, under <root>/<slug>/store/.
36
+ def candidate_intent_dirs(global_store:, project_roots:)
37
+ dirs = []
38
+ dirs.concat(Dir.glob(File.join(global_store.to_s, "*", "delivery.lock"))) unless blank?(global_store)
39
+ Array(project_roots).each do |root|
40
+ next if blank?(root)
41
+
42
+ dirs.concat(Dir.glob(File.join(root.to_s, "*", "store", "*", "delivery.lock")))
43
+ end
44
+ dirs.map { |lock_path| File.dirname(lock_path) }
45
+ end
46
+
47
+ # Live and held by this session: a fresh (non-expired) lock whose owner or
48
+ # delegate list names this session. A torn or unparseable lock is skipped,
49
+ # never raised (row 4.26): a module a hook depends on must fail as quietly
50
+ # as the hook itself does.
51
+ def held_live?(intent_dir, session:, ttl:, now:)
52
+ return false unless Lock.fresh?(intent_dir, ttl: ttl, now: now)
53
+
54
+ Lock.holds?(intent_dir, session: session)
55
+ rescue StandardError
56
+ false
57
+ end
58
+
59
+ def blank?(value)
60
+ value.nil? || value.to_s.strip.empty?
61
+ end
62
+
63
+ DEFAULT_PROJECT_ROOTS = ["~/.plastic/projects"].freeze
64
+
65
+ # The configured project roots (344 n2, D6): the `project_roots` list in
66
+ # `<plastic_home>/config.yml`, each expanded, or the default
67
+ # `~/.plastic/projects` when the key is absent or the file cannot be read
68
+ # or parsed. Never raises: a broken config.yml must never crash a hook on
69
+ # every prompt.
70
+ def project_roots(plastic_home)
71
+ path = File.join(plastic_home.to_s, "config.yml")
72
+ data = File.exist?(path) ? YAML.safe_load(File.read(path)) : nil
73
+ roots = data.is_a?(Hash) ? data["project_roots"] : nil
74
+ roots = DEFAULT_PROJECT_ROOTS unless roots.is_a?(Array) && !roots.empty?
75
+ roots.map { |root| File.expand_path(root.to_s) }
76
+ rescue StandardError
77
+ DEFAULT_PROJECT_ROOTS.map { |root| File.expand_path(root) }
78
+ end
79
+
80
+ # resolve_by_short_session(...) -> intent_dir or nil (344 n2). The tmp dir
81
+ # name is the short session id, while lock owners and delegates carry full
82
+ # ids, so this matches a lock whose owner or a delegate session STARTS WITH
83
+ # the short id. Nil for zero or more than one match, mirroring resolve's
84
+ # own uniqueness rule: an ambiguous short id names nothing downstream may
85
+ # guess through.
86
+ def resolve_by_short_session(global_store:, project_roots:, short_session:,
87
+ ttl: Lock::TTL_SECONDS, now: Time.now)
88
+ held = candidate_intent_dirs(global_store: global_store, project_roots: project_roots)
89
+ .uniq
90
+ .select { |dir| held_live_by_short?(dir, short_session: short_session, ttl: ttl, now: now) }
91
+ held.size == 1 ? held.first : nil
92
+ end
93
+
94
+ def held_live_by_short?(intent_dir, short_session:, ttl:, now:)
95
+ return false unless Lock.fresh?(intent_dir, ttl: ttl, now: now)
96
+
97
+ data = Lock.read(intent_dir)
98
+ return false unless data.is_a?(Hash)
99
+
100
+ sessions = [data["owner_session"]] + Array(data["delegates"])
101
+ sessions.compact.map(&:to_s).any? { |session| session.start_with?(short_session.to_s) }
102
+ rescue StandardError
103
+ false
104
+ end
105
+ end
@@ -10,9 +10,18 @@
10
10
  module AgentModels
11
11
  # Claude Code aliases only (never pinned ids, never Fable). Keys are the agent
12
12
  # file basenames without the `.md` extension.
13
+ # The three per-kind node agents (intent 340b, G7c, n2), dispatched by
14
+ # HarnessAdapter::AGENT_TYPE_BY_KIND. work and research resolve the
15
+ # executor tier, verify the advisor tier, mirroring RunnerPolicy's
16
+ # model_role split (327 D12): work and research run on plastic-executor's
17
+ # own tier, verify on the same tier plastic-advisor's imitation
18
+ # (plastic-faux-advisor) ships, never the cheap tier.
13
19
  TIER_DEFAULTS = {
14
20
  "plastic-enforcer" => "opus",
15
- "plastic-executor" => "sonnet"
21
+ "plastic-executor" => "sonnet",
22
+ "plastic-node-work" => "sonnet",
23
+ "plastic-node-verify" => "opus",
24
+ "plastic-node-research" => "sonnet"
16
25
  }.freeze
17
26
 
18
27
  # The two advisor agents (intent 185 final design): plastic-advisor (the real
@@ -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
@@ -0,0 +1,197 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "timeout"
6
+ require "tmpdir"
7
+ require_relative "runner_policy"
8
+
9
+ # CodexAdapter (intent 340b, G7c, n6): the argv `codex exec` needs for one
10
+ # node's kind, and the mechanics of running it once - a bounded subprocess
11
+ # whose stdin carries the node input, whose stdout and `--output-last-message`
12
+ # file are the only two places a return can come from, and whose timeout
13
+ # kills the whole process group rather than one pid.
14
+ #
15
+ # Pure where it can be: sandbox_mode, git_dir_for_worktree, add_dir_args and
16
+ # build_argv touch nothing but the filesystem read they are explicitly given
17
+ # (the worktree's own `.git` file), never a git call and never a guess at a
18
+ # repository root. #execute is the one place this module actually spawns a
19
+ # child process; scripts/node-run is the only caller.
20
+ module CodexAdapter
21
+ module_function
22
+
23
+ # Verify and research get NO write access at all (spec D9): read-only, no
24
+ # `--add-dir`. Everything else - work, and any kind this table has never
25
+ # heard of - gets the widest treatment, matching RunnerPolicy's own
26
+ # unknown-kind-falls-back-to-work rule (matrix row 6.10).
27
+ READ_ONLY_KINDS = %w[verify research].freeze
28
+
29
+ def sandbox_mode(kind)
30
+ READ_ONLY_KINDS.include?(kind.to_s) ? "read-only" : "workspace-write"
31
+ end
32
+
33
+ # The repository's OWN `.git` directory, derived from `worktree`'s `.git`
34
+ # FILE (a git worktree never carries a real `.git` directory of its own -
35
+ # spec) rather than any guess at how many path segments separate a
36
+ # worktree from its repo (matrix row 6.6). `.git` reads
37
+ # `gitdir: <repo>/.git/worktrees/<name>`; two directories up from that
38
+ # target is the repository's real `.git`. Returns nil when `worktree` is
39
+ # not a git worktree at all (no `.git` file, or `.git` is a directory,
40
+ # meaning `worktree` is a repository's own primary checkout) - callers
41
+ # treat that as "no `--add-dir` to add", never as a reason to guess.
42
+ def git_dir_for_worktree(worktree)
43
+ gitfile = File.join(worktree.to_s, ".git")
44
+ return nil unless File.file?(gitfile)
45
+
46
+ content = File.read(gitfile)
47
+ m = content.match(/\Agitdir:\s*(.+?)\s*\z/m)
48
+ return nil unless m
49
+
50
+ worktrees_entry = File.expand_path(m[1], worktree.to_s)
51
+ File.dirname(File.dirname(worktrees_entry))
52
+ end
53
+
54
+ # [] for a read-only kind (matrix row 6.4): `-C` already makes the
55
+ # worktree writable, and `--add-dir` on top of `--sandbox read-only` would
56
+ # contradict the sandbox mode itself. Exactly one `--add-dir`, the
57
+ # repository's own `.git`, for every other kind (matrix row 6.5) - nil
58
+ # when the worktree's own gitdir pointer cannot be resolved, so a caller
59
+ # handed a plain (non-worktree) directory never gets a bogus argument.
60
+ def add_dir_args(kind:, worktree:)
61
+ return [] if READ_ONLY_KINDS.include?(kind.to_s)
62
+
63
+ git_dir = git_dir_for_worktree(worktree)
64
+ git_dir ? ["--add-dir", git_dir] : []
65
+ end
66
+
67
+ # The whole `codex exec` argv for one node (matrix rows 6.1-6.9): `-C` at
68
+ # the worktree given (row 6.8), the sandbox mode and `--add-dir` this
69
+ # kind's row calls for and nothing else (row 6.7 - never the intent
70
+ # worktree, never `~/.plastic`), `--output-last-message` at the path the
71
+ # caller names, and a bare `-` so the prompt is read from stdin (row 6.9) -
72
+ # the node input itself never rides in this array.
73
+ def build_argv(kind:, worktree:, output_last_message:)
74
+ [
75
+ "codex", "exec",
76
+ "-C", worktree.to_s,
77
+ "--sandbox", sandbox_mode(kind),
78
+ *add_dir_args(kind: kind, worktree: worktree),
79
+ "--output-last-message", output_last_message.to_s,
80
+ "-",
81
+ ]
82
+ end
83
+
84
+ # matrix row 6.23: the wall-clock bound comes from the KIND's own lease
85
+ # length (RunnerPolicy.lease_minutes), never one shared constant - a long
86
+ # `work` build must not be capped as tightly as a quick `verify` pass.
87
+ def timeout_seconds(kind)
88
+ RunnerPolicy.lease_minutes(kind) * 60
89
+ end
90
+
91
+ # The return text (matrix rows 6.18/6.19): `output_last_message_path`'s
92
+ # own content when it exists and is non-blank (row 6.18 - stdout is never
93
+ # even inspected on this path), else the last YAML document found in
94
+ # `stdout` (row 6.19), else nil (no return anywhere - row 6.20 is the
95
+ # caller's problem, not this method's).
96
+ def read_return(output_last_message_path:, stdout:)
97
+ from_file = file_present_content(output_last_message_path)
98
+ return from_file unless from_file.nil?
99
+
100
+ stdout_fallback(stdout)
101
+ end
102
+
103
+ # The LAST YAML document in `text` that parses to a mapping, or nil when
104
+ # none does (row 6.20's other half). Documents are separated the ordinary
105
+ # YAML way, a line holding only `---`; a stream with no such separator at
106
+ # all is treated as one candidate document. Every candidate is tried from
107
+ # the end, so three documents (one row names explicitly) still resolve to
108
+ # the last one, and prose or a fragment that never parses to a mapping is
109
+ # skipped rather than returned as the "document".
110
+ def stdout_fallback(text)
111
+ return nil if text.to_s.strip.empty?
112
+
113
+ segments = text.to_s.split(/^---[ \t]*$/m).map(&:strip).reject(&:empty?)
114
+ segments = [text.to_s.strip] if segments.empty?
115
+
116
+ segments.reverse_each do |segment|
117
+ return segment if safe_yaml(segment).is_a?(Hash)
118
+ end
119
+ nil
120
+ end
121
+
122
+ # Runs `argv` once, feeding `stdin_data` on stdin through a scratch file
123
+ # (never a pipe - the same "never deadlock the read side" reasoning
124
+ # HookReplay.run_bounded already carries), bounded to `timeout_seconds`.
125
+ # `argv`'s own child becomes its own process group (`pgroup: true`) so a
126
+ # timeout can kill every child `codex exec` spawned along the way, not
127
+ # just the pid this call started (matrix row 6.24) - `codex exec` itself
128
+ # spawns shells for the commands it runs, and those would otherwise
129
+ # outlive a plain `Process.kill` on the parent alone, holding the
130
+ # worktree.
131
+ #
132
+ # Returns {exit_code:, timed_out:, stdout:, message:}. `message` is
133
+ # #read_return's result, computed only when the call did not time out - a
134
+ # timed-out child's partial output is never trusted (matrix row 6.22).
135
+ def execute(argv, stdin_data:, timeout_seconds:, output_last_message_path:)
136
+ Dir.mktmpdir("plastic-node-run-") do |scratch|
137
+ in_path = File.join(scratch, "stdin")
138
+ out_path = File.join(scratch, "stdout")
139
+ err_path = File.join(scratch, "stderr")
140
+ File.binwrite(in_path, stdin_data.to_s)
141
+
142
+ pid = Process.spawn(*argv.map(&:to_s), in: in_path, out: out_path, err: err_path, pgroup: true)
143
+ timed_out = false
144
+ exit_code =
145
+ begin
146
+ Timeout.timeout(timeout_seconds) do
147
+ Process.wait(pid)
148
+ $?.exitstatus
149
+ end
150
+ rescue Timeout::Error
151
+ timed_out = true
152
+ kill_group(pid)
153
+ nil
154
+ end
155
+
156
+ stdout = File.exist?(out_path) ? File.read(out_path) : ""
157
+ message = timed_out ? nil : read_return(output_last_message_path: output_last_message_path, stdout: stdout)
158
+
159
+ { exit_code: exit_code, timed_out: timed_out, stdout: stdout, message: message }
160
+ end
161
+ end
162
+
163
+ # --- internals ---------------------------------------------------------------
164
+
165
+ def file_present_content(path)
166
+ return nil if path.nil? || !File.exist?(path)
167
+
168
+ content = File.read(path)
169
+ content.strip.empty? ? nil : content
170
+ end
171
+ private_class_method :file_present_content
172
+
173
+ def safe_yaml(text)
174
+ YAML.safe_load(text, aliases: false, permitted_classes: [])
175
+ rescue Psych::Exception, ArgumentError
176
+ nil
177
+ end
178
+ private_class_method :safe_yaml
179
+
180
+ # Kills the whole process GROUP `pid` leads, not just `pid` itself (matrix
181
+ # row 6.24): a negative pid signals the group. Rescued and reaped exactly
182
+ # like HookReplay.kill_and_reap - a process that exited in the race
183
+ # between the timeout firing and this call raises Errno::ESRCH on the kill
184
+ # or Errno::ECHILD on the wait, neither of which should ever propagate.
185
+ def kill_group(pid)
186
+ Process.kill("KILL", -pid)
187
+ rescue StandardError
188
+ nil
189
+ ensure
190
+ begin
191
+ Process.wait(pid)
192
+ rescue StandardError
193
+ nil
194
+ end
195
+ end
196
+ private_class_method :kill_group
197
+ end
@@ -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)