@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
@@ -154,14 +154,54 @@ module SessionLedger
154
154
  File.join(tmp_root(store), session_id)
155
155
  end
156
156
 
157
- def pointer_path(store, session_id)
158
- File.join(session_tmp_dir(store, session_id), "current")
159
- end
160
-
161
157
  def heartbeat_path(store, session_id)
162
158
  File.join(session_tmp_dir(store, session_id), "heartbeat")
163
159
  end
164
160
 
161
+ # --- Session day (spec D7 replacement for the retired pointer) ------------
162
+
163
+ # Every day, ascending, no later than `today` and no earlier than `today`
164
+ # minus `window_days`, whose checklist carries at least one line tagged to
165
+ # `session` (any state). Each day's checklist is read inside its own
166
+ # rescue, so one unreadable or missing checklist skips that day only and
167
+ # never gives up on a later one. Returns an empty array when no day in the
168
+ # window carries the session's line, and on any error reading the store
169
+ # (never raises).
170
+ def session_days(store, session, today:, window_days: 7)
171
+ root = sessions_root(store)
172
+ return [] unless File.directory?(root)
173
+
174
+ today_date = Date.strptime(today, "%Y%m%d")
175
+ floor_date = today_date - window_days
176
+
177
+ days = Dir.children(root).select { |name| valid_day_id?(name) }.select do |name|
178
+ date = Date.strptime(name, "%Y%m%d")
179
+ date >= floor_date && date <= today_date
180
+ end.sort
181
+
182
+ days.select do |day|
183
+ begin
184
+ read_locked(checklist_path(store, day)).each_line.any? do |line|
185
+ parsed = parse_checklist_line(line)
186
+ parsed && parsed[:session] == session
187
+ end
188
+ rescue StandardError
189
+ false
190
+ end
191
+ end
192
+ rescue StandardError
193
+ []
194
+ end
195
+
196
+ # The newest day in `session_days`, the day this session's close and
197
+ # hand-off write belong to (intent 344, G11, D13: a session that crosses
198
+ # midnight carries lines on more than one day, and the newest is the one
199
+ # still open). Falls back to `today` when no day in the window carries the
200
+ # session's line.
201
+ def session_day(store, session, today:, window_days: 7)
202
+ session_days(store, session, today: today, window_days: window_days).last || today
203
+ end
204
+
165
205
  # Create `.tmp/` plus a `.gitignore` holding exactly `*`, if that file does
166
206
  # not already exist. The store is a local git repo that auto-commits
167
207
  # `add -A`, so without this ignore file every heartbeat write would enter
@@ -0,0 +1,95 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require_relative "active_delivery"
6
+ require_relative "lock"
7
+ require_relative "ready_set"
8
+
9
+ # StopGate (intent 340b, G7c, n4, D5/D7/D8/D9): may this session stop? Blocks
10
+ # only when four conditions hold together: the payload's stop_hook_active is
11
+ # true, runner.stop_hook parses as a real boolean true, this session holds
12
+ # the delivering intent's delivery.lock live and in auto mode, and the last
13
+ # runner step left work a new turn can actually move (some node is ready to
14
+ # dispatch right now - a graph whose only non-terminal node is already
15
+ # running, or that has no ready node at all, permits, because blocking that
16
+ # would trip Claude Code's own eight-block ceiling for nothing).
17
+ #
18
+ # Fail-open is the design, not a fallback: a missing store, a torn lock, an
19
+ # unreadable graph, a raised exception, a payload that is not JSON - every
20
+ # one of those permits. #decide never raises.
21
+ module StopGate
22
+ module_function
23
+
24
+ # decide(payload:, config_stop_hook:, session:, global_store:, project_roots:, ttl:, now:) ->
25
+ # {block: false} or {block: true, reason: "..."}.
26
+ #
27
+ # `payload` is the hook input: already a Hash, or the raw JSON text (row
28
+ # 4.19 - a payload that is not JSON permits, never raises). `config_stop_hook`
29
+ # is the raw string read-config returns for runner.stop_hook: parsed as a
30
+ # boolean here, never trusted as one.
31
+ def decide(payload:, config_stop_hook:, session:, global_store:, project_roots:,
32
+ ttl: Lock::TTL_SECONDS, now: Time.now, caps: nil)
33
+ payload = parse_payload(payload)
34
+
35
+ return permit unless payload["stop_hook_active"] == true
36
+ return permit unless flag_true?(config_stop_hook)
37
+
38
+ intent_dir = ActiveDelivery.resolve(global_store: global_store, project_roots: project_roots,
39
+ session: session, ttl: ttl, now: now)
40
+ return permit unless intent_dir
41
+
42
+ lock = Lock.read(intent_dir)
43
+ return permit unless lock
44
+ return permit unless lock["run_mode"].to_s == "auto"
45
+
46
+ return permit unless work_movable?(intent_dir, caps: caps)
47
+
48
+ block(intent_dir)
49
+ rescue StandardError
50
+ permit
51
+ end
52
+
53
+ # Accepts a Hash as-is, parses a String as JSON, and falls back to an
54
+ # empty Hash for anything else or anything that fails to parse - a
55
+ # malformed payload must never raise (row 4.19).
56
+ def parse_payload(payload)
57
+ return payload if payload.is_a?(Hash)
58
+ return {} unless payload.is_a?(String)
59
+
60
+ parsed = JSON.parse(payload)
61
+ parsed.is_a?(Hash) ? parsed : {}
62
+ rescue JSON::ParserError
63
+ {}
64
+ end
65
+
66
+ # A config value counts as on only when it parses as the literal boolean
67
+ # true (row 4.4): read-config emits strings, and the string "false" is
68
+ # truthy in Ruby, so testing the raw value would arm the hook on its own
69
+ # shipped default. Anything else, including an absent key (row 4.5), is off.
70
+ def flag_true?(value)
71
+ value.to_s.strip.downcase == "true"
72
+ end
73
+
74
+ # Some node is ready to dispatch right now. Never raises across this
75
+ # boundary (ReadySet.analyze already never does); an unreadable or absent
76
+ # graph, or an intent with no declared nodes, reads as ok: false or an
77
+ # empty ready set either way, and both permit.
78
+ def work_movable?(intent_dir, caps:)
79
+ analysis = caps ? ReadySet.analyze(intent_dir, caps: caps) : ReadySet.analyze(intent_dir)
80
+ analysis[:ok] && analysis[:ranked_ready].any?
81
+ end
82
+
83
+ def permit
84
+ { block: false }
85
+ end
86
+
87
+ # Names the next instruction (row 4.17): a session that is blocked and
88
+ # told nothing stops again immediately.
89
+ def block(intent_dir)
90
+ intent_id = File.basename(intent_dir.to_s)
91
+ reason = "Plastic: intent #{intent_id} still has ready work. Run `runner step` for it " \
92
+ "and dispatch what it prints before stopping."
93
+ { block: true, reason: reason }
94
+ end
95
+ end
@@ -9,7 +9,7 @@ require_relative "scaffold_intent"
9
9
  # (`Doctor#run_intent_check`), a net-new added-line em-dash diff guard (the first standing
10
10
  # implementation of this check; the only prior automated em-dash check,
11
11
  # `test/skill_command_lint_test.rb`, asserts against two FIXED file sets and cannot scan a
12
- # diff), a diffstat, and an optional caller-supplied `--suite` command folded into the same
12
+ # diff), a diffstat, and an optional caller-supplied `--suite` command merged into the same
13
13
  # verdict.
14
14
  #
15
15
  # Repo resolution and base-branch detection are NOT re-implemented here: `resolve_repo_dir`,
@@ -245,7 +245,7 @@ module VerifyIntent
245
245
 
246
246
  # Every `Report`-kind savepoint line for this intent, oldest first: [timestamp, text].
247
247
  # Never fails or gates anything (D3: the diffstat check already prints a summary block,
248
- # this folds into the same verdict so verify-intent surfaces them too) - a delivery with
248
+ # this merges into the same verdict so verify-intent surfaces them too) - a delivery with
249
249
  # no Report line is visible to `doctor`'s intent_reports_printed_check instead.
250
250
  def report_lines(intent_dir)
251
251
  path = File.join(intent_dir, "savepoint.md")
@@ -8,14 +8,14 @@ require_relative "action_graph_shim"
8
8
 
9
9
  # WorkGraphValidator (intent 334, n4): the in-batch reader over one intent's
10
10
  # graph.md and nodes/ - 327's rule for budget, files, and the decision and
11
- # research kinds (fold A17). {ok:, missing:, errors:}, the same Result shape
12
- # ProjectValidator returns (fold B7). Named apart from
11
+ # research kinds (review A17). {ok:, missing:, errors:}, the same Result shape
12
+ # ProjectValidator returns (review B7). Named apart from
13
13
  # IntentValidator#validate_graph, which already exists for the KNOWLEDGE
14
- # graph and stays unrelated to this one under D40 (fold B9).
14
+ # graph and stays unrelated to this one under D40 (review B9).
15
15
  #
16
16
  # Built over GraphFile, GraphEdges, and NodeFile only - never IntentValidator,
17
17
  # never ReportScreen. Every check accumulates into `errors` rather than
18
- # short-circuiting on the first one (fold: "the owner fixes one thing per
18
+ # short-circuiting on the first one (see: "the owner fixes one thing per
19
19
  # run").
20
20
  module WorkGraphValidator
21
21
  module_function
@@ -140,7 +140,7 @@ module WorkGraphValidator
140
140
 
141
141
  # A node "touches" the graph when some edge involves it on either side:
142
142
  # it targets something, or something targets it. A verify node declared
143
- # with "needs nothing" and targeted by nothing is fully isolated (fold
143
+ # with "needs nothing" and targeted by nothing is fully isolated (review
144
144
  # A13).
145
145
  def node_touches_graph?(id, edges)
146
146
  (edges[id] || []).any? || edges.values.any? { |targets| targets.include?(id) }
@@ -159,7 +159,7 @@ module WorkGraphValidator
159
159
  end
160
160
 
161
161
  # The matrix table must sit under a heading whose tokens include the
162
- # node's own id, and that heading must own at least one data row (fold
162
+ # node's own id, and that heading must own at least one data row (review
163
163
  # A1 - the same table-owning rule report_screen's resolver uses, so a
164
164
  # node's own Proven-by cell is never "not recorded" the moment it ships).
165
165
  def has_valid_matrix?(id, body)
@@ -108,16 +108,16 @@ module Worktree
108
108
 
109
109
  # --- provisioning ----------------------------------------------------------
110
110
 
111
- # Resolve the slug from the bridge's intent.store, create code + store
111
+ # Resolve the slug from the delivery's intent.store, create code + store
112
112
  # worktrees (idempotent: reuse an existing worktree path, do not error), write
113
- # the `worktree` block plus `provisioned: true` onto bridge_data, return it.
113
+ # the `worktree` block plus `provisioned: true` onto delivery, return it.
114
114
  #
115
115
  # Fails open with a stderr log when the repo is non-git or unresolvable:
116
116
  # sets `provisioned: false` and leaves `code: null`. All git ops use
117
117
  # `git -C <resolved path>` -- never cwd (decision D6).
118
- def provision(bridge_data, home: Dir.home, runner: ShellRunner.new)
119
- return bridge_data unless bridge_data.is_a?(Hash)
120
- intent = bridge_data["intent"] || {}
118
+ def provision(delivery, home: Dir.home, runner: ShellRunner.new)
119
+ return delivery unless delivery.is_a?(Hash)
120
+ intent = delivery["intent"] || {}
121
121
  intent_id = intent["id"].to_s
122
122
  store = intent["store"].to_s
123
123
  intent_slug = slug_from_dir(intent["dir"]) || slug_from_dir(store)
@@ -173,15 +173,15 @@ module Worktree
173
173
  # replaced by the fresh unprovisioned block; the guard must not fail harder
174
174
  # than the bug it guards.
175
175
  if code_ok
176
- bridge_data["worktree"] = block
176
+ delivery["worktree"] = block
177
177
  else
178
- existing = bridge_data["worktree"]
178
+ existing = delivery["worktree"]
179
179
  keep = existing.is_a?(Hash) && !blank?(existing["code"]) &&
180
180
  Dir.exist?(existing["code"].to_s)
181
- bridge_data["worktree"] = block unless keep
181
+ delivery["worktree"] = block unless keep
182
182
  end
183
183
 
184
- bridge_data
184
+ delivery
185
185
  end
186
186
 
187
187
  # Remove the worktree (then `git worktree prune`), clear the worktree block.
@@ -189,21 +189,21 @@ module Worktree
189
189
  # policy on top via `finish`; this is the plain remove. Pass `remove: false` to
190
190
  # clear the block WITHOUT touching git (so `finish` can merge first, then call
191
191
  # release to drop the worktree once the code branch is integrated).
192
- def release(bridge_data, home: Dir.home, runner: ShellRunner.new, remove: true)
193
- return bridge_data unless bridge_data.is_a?(Hash)
194
- block = bridge_data["worktree"]
195
- return bridge_data unless block.is_a?(Hash)
192
+ def release(delivery, home: Dir.home, runner: ShellRunner.new, remove: true)
193
+ return delivery unless delivery.is_a?(Hash)
194
+ block = delivery["worktree"]
195
+ return delivery unless block.is_a?(Hash)
196
196
 
197
197
  if remove
198
- slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
198
+ slug = slug_for_store(delivery.dig("intent", "store").to_s, home: home)
199
199
  repo = repo_for(slug, home: home)
200
200
 
201
201
  remove_worktree(runner, repo: repo, worktree: block["code"]) if repo && block["code"]
202
202
  prune(runner, repo: repo) if repo
203
203
  end
204
204
 
205
- bridge_data.delete("worktree")
206
- bridge_data
205
+ delivery.delete("worktree")
206
+ delivery
207
207
  end
208
208
 
209
209
  # --- cleanup policy (merge-vs-remove) -------------------------------------
@@ -222,19 +222,19 @@ module Worktree
222
222
  # Fail-open and idempotent throughout: a missing block, missing branch, or any
223
223
  # git failure never raises and never blocks teardown. All git ops use
224
224
  # `git -C <path>`, never cwd (decision D6). No-op when nothing was provisioned.
225
- def finish(bridge_data, home: Dir.home, runner: ShellRunner.new, merge: false)
226
- return bridge_data unless bridge_data.is_a?(Hash)
227
- block = bridge_data["worktree"]
228
- return bridge_data unless block.is_a?(Hash)
225
+ def finish(delivery, home: Dir.home, runner: ShellRunner.new, merge: false)
226
+ return delivery unless delivery.is_a?(Hash)
227
+ block = delivery["worktree"]
228
+ return delivery unless block.is_a?(Hash)
229
229
 
230
230
  if merge
231
- slug = slug_for_store(bridge_data.dig("intent", "store").to_s, home: home)
231
+ slug = slug_for_store(delivery.dig("intent", "store").to_s, home: home)
232
232
  repo = repo_for(slug, home: home)
233
233
  branch = block["code_branch"]
234
234
  merge_branch(runner, repo: repo, branch: branch) if repo && !blank?(branch)
235
235
  end
236
236
 
237
- release(bridge_data, home: home, runner: runner, remove: true)
237
+ release(delivery, home: home, runner: runner, remove: true)
238
238
  end
239
239
 
240
240
  # Merge `branch` into the repo's default branch from the main checkout. The
@@ -298,8 +298,8 @@ module Worktree
298
298
  # --- lock ------------------------------------------------------------------
299
299
 
300
300
  # True iff ANOTHER session's delivery.lock is FRESH on this intent's dir
301
- # (intent 108, D2): the durable lock file decides; /tmp bridges are not
302
- # consulted and no pid is probed. current_session being the owner or a
301
+ # (intent 108, D2): the durable lock file decides alone, and no pid is
302
+ # probed. current_session being the owner or a
303
303
  # delegate does not count as "other". A stale lock does not hold (explicit
304
304
  # takeover reclaims it).
305
305
  def lock_held_by_other?(intent_id:, store:, current_session:, home: Dir.home,
@@ -2,7 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "worktree"
5
- require_relative "bridge"
5
+ require_relative "index_entry"
6
6
 
7
7
  # WorktreeSweep -- the one-time orphan sweep for store worktrees retired by
8
8
  # intent 178 (D2). Pure candidate classification plus a thin apply step, both
@@ -62,7 +62,7 @@ module WorktreeSweep
62
62
 
63
63
  # The INDEX.md section heading (e.g. "Active", "Completed") that lists this
64
64
  # intent dir, or nil if no INDEX.md entry links to it. Reuses
65
- # Bridge.index_entry_match so this never drifts from the shared parser.
65
+ # IndexEntry.match so this never drifts from the shared parser.
66
66
  def index_status(intent_dir, name)
67
67
  store = File.dirname(intent_dir)
68
68
  index = File.join(File.dirname(store), "INDEX.md")
@@ -75,7 +75,7 @@ module WorktreeSweep
75
75
  section = stripped.sub(/\A##\s*/, "").strip
76
76
  next
77
77
  end
78
- m = Bridge.index_entry_match(stripped)
78
+ m = IndexEntry.match(stripped)
79
79
  next unless m
80
80
  return section if m[3].to_s.include?(name)
81
81
  end
@@ -362,7 +362,7 @@ def main(argv)
362
362
  # 4a. I1 reciprocity: write the child's id into EACH source intent's frontmatter
363
363
  # `chain` (the formative-reciprocity backlink), for BOTH the `--parent` and the
364
364
  # `--sources` path. `sources` is the redundant-explicit set from step 3 (it already
365
- # folds in `--parent`). Collect the touched source files so their `## Links` can be
365
+ # includes `--parent`). Collect the touched source files so their `## Links` can be
366
366
  # re-projected once the chain edges are on disk.
367
367
  source_files = []
368
368
  sources.each do |src_id|
@@ -2,30 +2,30 @@
2
2
  # encoding: UTF-8
3
3
  # frozen_string_literal: true
4
4
 
5
- require_relative "lib/node_packet"
5
+ require_relative "lib/node_input"
6
6
  require_relative "lib/savepoint"
7
7
 
8
- # node-packet - the CLI over NodePacket (intent 338, G5). Builds one node's
9
- # whole input from disk and writes it to packets/<node>--a<N>.packet (or
8
+ # node-input - the CLI over NodeInput (intent 338, G5). Builds one node's
9
+ # whole input from disk and writes it to attempts/<node>--a<N>.input (or
10
10
  # --out), printing a parsable summary and the exact node-transition running
11
11
  # command intent 340's runner should record.
12
12
  #
13
13
  # Usage:
14
- # node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N]
14
+ # node-input <intent_dir> --node <id> [--budget N] [--hop-tokens N]
15
15
  # [--holder H] [--expires E] [--model M] [--attempt N]
16
16
  # [--out PATH] [--force]
17
17
  #
18
18
  # Exit codes (spec D17, shared with node-transition's family):
19
- # 0 - the packet was written (or the identical bytes already existed)
19
+ # 0 - the node input was written (or the identical bytes already existed)
20
20
  # 2 - usage: not an intent directory, missing --node, or an unknown node
21
21
  # 3 - an unreadable, cyclic or unparsable graph, node file, or record
22
22
  # 4 - overflow past the third cut; nothing was written
23
23
  # 5 - an existing attempt file whose bytes differ; nothing was written
24
- module NodePacketCLI
24
+ module NodeInputCLI
25
25
  module_function
26
26
 
27
27
  def usage
28
- warn "Usage: node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N] " \
28
+ warn "Usage: node-input <intent_dir> --node <id> [--budget N] [--hop-tokens N] " \
29
29
  "[--holder H] [--expires E] [--model M] [--attempt N] [--out PATH] [--force]"
30
30
  end
31
31
 
@@ -42,7 +42,7 @@ module NodePacketCLI
42
42
  intent_dir_arg = args.shift
43
43
 
44
44
  unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
45
- warn "node-packet: #{intent_dir_arg.inspect} is not an intent directory"
45
+ warn "node-input: #{intent_dir_arg.inspect} is not an intent directory"
46
46
  usage
47
47
  exit 2
48
48
  end
@@ -50,7 +50,7 @@ module NodePacketCLI
50
50
 
51
51
  node = opt(args, "--node")
52
52
  if node.to_s.strip.empty?
53
- warn "node-packet: --node is required"
53
+ warn "node-input: --node is required"
54
54
  usage
55
55
  exit 2
56
56
  end
@@ -64,11 +64,11 @@ module NodePacketCLI
64
64
  model = opt(args, "--model")
65
65
  force = args.include?("--force")
66
66
 
67
- result = NodePacket.build(
67
+ result = NodeInput.build(
68
68
  intent_dir: intent_dir,
69
69
  node: node,
70
- budget_tokens: budget ? budget.to_i : NodePacket::DEFAULT_BUDGET_TOKENS,
71
- hop_tokens: hop_tokens ? hop_tokens.to_i : NodePacket::DEFAULT_HOP_TOKENS,
70
+ budget_tokens: budget ? budget.to_i : NodeInput::DEFAULT_BUDGET_TOKENS,
71
+ hop_tokens: hop_tokens ? hop_tokens.to_i : NodeInput::DEFAULT_HOP_TOKENS,
72
72
  holder: holder,
73
73
  expires: expires,
74
74
  model: model,
@@ -78,15 +78,15 @@ module NodePacketCLI
78
78
  )
79
79
 
80
80
  if result[:ok]
81
- puts NodePacket.summary_line(result)
81
+ puts NodeInput.summary_line(result)
82
82
  puts result[:running_command]
83
83
  exit 0
84
84
  end
85
85
 
86
- Array(result[:errors]).each { |e| warn "node-packet: #{e}" }
86
+ Array(result[:errors]).each { |e| warn "node-input: #{e}" }
87
87
  puts result[:needs_decision_command] if result[:needs_decision_command]
88
88
  exit result[:exit_code]
89
89
  end
90
90
  end
91
91
 
92
- NodePacketCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
92
+ NodeInputCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require "digest"
6
+ require "time"
7
+ require "yaml"
8
+ require "fileutils"
9
+ require_relative "lib/savepoint"
10
+ require_relative "lib/node_ledger"
11
+ require_relative "lib/node_input"
12
+ require_relative "lib/node_worktree"
13
+ require_relative "lib/node_file"
14
+ require_relative "lib/ready_set"
15
+ require_relative "lib/runner_core"
16
+ require_relative "lib/codex_adapter"
17
+
18
+ # node-run - the Codex leg of one node's whole attempt (intent 340b, G7c, n6).
19
+ #
20
+ # On Claude Code a subagent dispatch IS the executor; on Codex the executor
21
+ # is a subprocess, so this script owns the timing of a whole node: it reads
22
+ # the node's live `running` line, resolves the node input that line names,
23
+ # builds the `codex exec` argv for the node's kind (CodexAdapter), runs it
24
+ # in the node's worktree with the node input on stdin, reads the executor's
25
+ # final message, and writes it to the attempt's own return path.
26
+ #
27
+ # It writes NO ledger transition, on any path - success, refusal or error
28
+ # alike. That is the whole point: exactly one thing writes `done` (the
29
+ # runner's own six-check absorb gate), and a second writer here would throw
30
+ # that guarantee away. The caller absorbs with `runner step --return
31
+ # ID=PATH` through the same six checks every other return goes through.
32
+ #
33
+ # Usage:
34
+ # node-run <intent_dir> --node <id> [--session SID] [--now ISO8601]
35
+ # [--timeout-seconds N]
36
+ #
37
+ # Exit codes: 0 - a return file was written (whatever its content actually
38
+ # says; that judgment belongs to the absorb gate, not this script). Every
39
+ # other code is a REFUSAL: nothing is written, node-run never even reached
40
+ # `codex exec`.
41
+ # 2 - usage: not an intent directory, or missing --node
42
+ # 3 - the node has no live `running` line
43
+ # 4 - the running line is held by another session
44
+ # 5 - the node input the running line names is missing from disk
45
+ # 6 - the node input on disk does not hash to the running line's input=
46
+ # 7 - no worktree could be resolved to run in
47
+ module NodeRunCLI
48
+ module_function
49
+
50
+ def usage
51
+ warn "Usage: node-run <intent_dir> --node <id> [--session SID] [--now ISO8601] " \
52
+ "[--timeout-seconds N]"
53
+ end
54
+
55
+ def opt(args, name)
56
+ (i = args.index(name)) && args[i + 1]
57
+ end
58
+
59
+ def intent_directory?(dir)
60
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
61
+ end
62
+
63
+ # <intent_dir>/attempts/<node>--a<N>.return.yml (spec): beside the node input,
64
+ # one per attempt, the same treatment input files already get - never
65
+ # reused across attempts, so a second attempt's return can never be
66
+ # mistaken for the first's.
67
+ def return_path(intent_dir, node, attempt)
68
+ File.join(intent_dir, "attempts", "#{node}--a#{attempt}.return.yml")
69
+ end
70
+
71
+ # The node's own declared kind, read straight off its node file - "work"
72
+ # when the file cannot be found or parsed at all (mirroring RunnerPolicy's
73
+ # own unknown-kind fallback), but the LITERAL string it declares
74
+ # otherwise, even an invalid one (matrix row 6.10): CodexAdapter's own
75
+ # kind table only distinguishes "read-only" (verify, research) from
76
+ # everything else, so an unrecognized kind still gets the widest, safest
77
+ # treatment rather than no treatment at all.
78
+ def resolve_kind(intent_dir, node)
79
+ path = ReadySet.find_node_path(intent_dir, node)
80
+ return "work" unless path
81
+
82
+ parsed = NodeFile.parse(path)
83
+ kind = parsed[:kind].to_s.strip
84
+ kind.empty? ? "work" : kind
85
+ end
86
+
87
+ # The directory `codex exec -C` runs in: a `work` node's own worktree when
88
+ # one is actually provisioned on disk, else the shared intent worktree -
89
+ # verify and research nodes never get a worktree of their own (327 D6,
90
+ # D29), so the only place they could ever leave a diff is the intent
91
+ # worktree itself, exactly where NodeWorktree.changed_paths already looks
92
+ # for one.
93
+ def node_target_dir(context, node:, kind:)
94
+ if NodeWorktree::WORKTREE_KINDS.include?(kind.to_s)
95
+ p = NodeWorktree.paths(context, node: node)
96
+ return p["path"] if p["path"] && Dir.exist?(p["path"])
97
+ end
98
+ context.worktree
99
+ end
100
+
101
+ # A synthetic, deliberately non-YAML-mapping line the absorb gate reads as
102
+ # unparsable (spec Approach: "a return file the gate reads as
103
+ # unparsable"), covering every subprocess failure the same way: a nonzero
104
+ # exit, a timeout, an empty message file, and a message with no YAML
105
+ # document either way. Carries no colon-shaped `key: value` text on
106
+ # purpose, so it can never accidentally parse as a YAML mapping that
107
+ # happens to satisfy NodeReturn's own schema.
108
+ def unparsable_body(node, result)
109
+ reason =
110
+ if result[:timed_out]
111
+ "node-run - codex exec for #{node} timed out with no usable return"
112
+ elsif !result[:exit_code].nil? && result[:exit_code] != 0
113
+ "node-run - codex exec for #{node} exited #{result[:exit_code]} with no usable return"
114
+ else
115
+ "node-run - codex exec for #{node} produced no parsable YAML document on either " \
116
+ "--output-last-message or stdout"
117
+ end
118
+ "#{reason}\n"
119
+ end
120
+
121
+ def main(argv)
122
+ args = argv.dup
123
+ intent_dir_arg = args.shift
124
+
125
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
126
+ warn "node-run: #{intent_dir_arg.inspect} is not an intent directory"
127
+ usage
128
+ exit 2
129
+ end
130
+ intent_dir = File.expand_path(intent_dir_arg)
131
+
132
+ node = opt(args, "--node")
133
+ if node.to_s.strip.empty?
134
+ warn "node-run: --node is required"
135
+ usage
136
+ exit 2
137
+ end
138
+
139
+ session_flag = opt(args, "--session")
140
+ now_flag = opt(args, "--now")
141
+ timeout_flag = opt(args, "--timeout-seconds")
142
+
143
+ begin
144
+ now_flag ? Time.iso8601(now_flag) : Time.now
145
+ rescue ArgumentError
146
+ warn "node-run: --now must be ISO 8601"
147
+ exit 2
148
+ end
149
+
150
+ context = RunnerCore.context(intent_dir: intent_dir, session: session_flag,
151
+ env: ENV["CLAUDE_CODE_SESSION_ID"])
152
+
153
+ savepoint_path = File.join(intent_dir, "savepoint.md")
154
+ current_status = NodeLedger.status_for(savepoint_path, node)
155
+ running_entry = NodeLedger.last_running(savepoint_path, node)
156
+ unless current_status == "running" && running_entry
157
+ warn "node-run: #{node} has no live running line (state is #{current_status})"
158
+ exit 3
159
+ end
160
+
161
+ fields = running_entry[:fields] || {}
162
+ holder = fields["holder"]
163
+ if context.session.nil? || context.session.to_s != holder.to_s
164
+ warn "node-run: #{node}'s running line is held by #{holder.inspect}, not this session"
165
+ exit 4
166
+ end
167
+
168
+ kind = resolve_kind(intent_dir, node)
169
+
170
+ # matrix row 6.11: the attempt number comes from NodeInput's own
171
+ # counter, never a hand count. `lease_flag_given: false` because the
172
+ # running line this attempt is acting on ALREADY exists on disk (it is
173
+ # what we just read above) - counting it a second time would double it
174
+ # (the same count, plus one, that produced it in the first place when
175
+ # the node input was originally built).
176
+ attempt = NodeInput.compute_attempt_number(intent_dir: intent_dir, node: node, lease_flag_given: false)
177
+ input_path = NodeInput.input_path(intent_dir: intent_dir, node: node, attempt: attempt)
178
+
179
+ unless File.exist?(input_path)
180
+ warn "node-run: #{node}'s node input is missing at #{input_path}"
181
+ exit 5
182
+ end
183
+
184
+ input_bytes = File.binread(input_path)
185
+ actual_sha = Digest::SHA256.hexdigest(input_bytes)[0, 12]
186
+ expected_sha = fields["input"]
187
+ unless expected_sha && actual_sha == expected_sha
188
+ warn "node-run: #{node}'s node input at #{input_path} does not hash to input=#{expected_sha.inspect} " \
189
+ "(computed #{actual_sha})"
190
+ exit 6
191
+ end
192
+
193
+ worktree = node_target_dir(context, node: node, kind: kind)
194
+ if worktree.nil? || !Dir.exist?(worktree)
195
+ warn "node-run: #{node} has no resolvable worktree to run in"
196
+ exit 7
197
+ end
198
+
199
+ out_path = return_path(intent_dir, node, attempt)
200
+ FileUtils.mkdir_p(File.dirname(out_path))
201
+ message_path = "#{out_path}.msg"
202
+
203
+ timeout_seconds = timeout_flag ? timeout_flag.to_i : CodexAdapter.timeout_seconds(kind)
204
+ codex_argv = CodexAdapter.build_argv(kind: kind, worktree: worktree, output_last_message: message_path)
205
+
206
+ result = CodexAdapter.execute(codex_argv, stdin_data: input_bytes, timeout_seconds: timeout_seconds,
207
+ output_last_message_path: message_path)
208
+
209
+ # matrix rows 6.20-6.22: a nonzero exit or a timeout is authoritative -
210
+ # any message text the child managed to leave behind is never trusted
211
+ # once either has happened. Only a clean exit with no usable message at
212
+ # all falls to "no parsable YAML document".
213
+ failed = result[:timed_out] || (!result[:exit_code].nil? && result[:exit_code] != 0)
214
+ body = failed ? unparsable_body(node, result) : (result[:message] || unparsable_body(node, result))
215
+
216
+ File.write(out_path, body)
217
+ File.delete(message_path) if File.exist?(message_path)
218
+
219
+ puts out_path
220
+ exit 0
221
+ end
222
+ end
223
+
224
+ NodeRunCLI.main(ARGV) if $PROGRAM_NAME == __FILE__