@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,506 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "yaml"
6
+ require_relative "ready_set"
7
+ require_relative "node_ledger"
8
+ require_relative "node_file"
9
+ require_relative "node_packet"
10
+ require_relative "node_worktree"
11
+ require_relative "work_graph_validator"
12
+ require_relative "runner_core"
13
+ require_relative "runner_policy"
14
+ require_relative "worktree"
15
+ require_relative "savepoint"
16
+ require_relative "guarded_append"
17
+
18
+ # RunnerDispatch (intent 340, G7, n5): validates the graph, computes the
19
+ # ready set, applies RunnerPolicy, mints leases, builds packets, writes
20
+ # `running`, and returns the dispatch plan `step` prints. Never spawns an
21
+ # agent itself (327 D42): the session does that from the plan this returns.
22
+ #
23
+ # Pure and dependency-injected down to the clock: every side effect - the
24
+ # full validator, the ready-set analyzer, the packet builder, the worktree
25
+ # module, the ledger write, git itself - is an injectable keyword argument
26
+ # with a real default, so a test never touches a real repository or a real
27
+ # filesystem outside its own tmpdir.
28
+ module RunnerDispatch
29
+ module_function
30
+
31
+ DEFAULT_LIMIT = 2
32
+
33
+ # The return-schema instruction (327 D5): rides in the dispatch PLAN, never
34
+ # inside the packet, so `packet=<sha>` keeps naming a reproducible input
35
+ # (matrix row 5.23). NodeReturn.parse (n4) is this text's implementation.
36
+ RETURN_CONTRACT = <<~TEXT.freeze
37
+ RETURN CONTRACT: reply with exactly one YAML document as your final
38
+ message, nothing else around it. Keys: node, status, commit, summary,
39
+ findings, proposed_nodes, proposed_edges, question, reason. status is one
40
+ of done, failed_verification, needs_decision, blocked. done requires
41
+ commit; needs_decision requires question; failed_verification and
42
+ blocked require reason. Anything that does not parse under this closed
43
+ schema is refused as failed_verification reason=return_unparsable.
44
+ TEXT
45
+
46
+ HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
47
+
48
+ # D8 (355, n6): the agent every dispatched, non-decision node names - a
49
+ # role, never a harness (matrix 6.5), and never `plastic-advisor`, which
50
+ # stays a deliberate, never-auto-dispatched consultation agent.
51
+ SPAWN_AGENT = "plastic-executor"
52
+
53
+ # matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
54
+ # RunnerPolicy.model_for resolved, the packet path, the one test command
55
+ # (NodePacket.test_command_block, n4), and the call cap (n2) - fenced so a
56
+ # session pastes it straight into the Agent tool (327 D42: the runner
57
+ # itself never spawns).
58
+ def spawn_block(model:, packet:, test_command:, call_cap:, agent: SPAWN_AGENT)
59
+ lines = ["agent: #{agent}", "model: #{model}", "packet: #{packet}", test_command,
60
+ NodePacket.call_cap_sentence(call_cap)]
61
+ (["```"] + lines + ["```"]).join("\n")
62
+ end
63
+
64
+ # dispatch(context, limit:) -> a result hash. Always carries :ok, :reason,
65
+ # :errors, :rearm_command, :dispatched, :stop, :parked, :status, :blockers,
66
+ # :plan - fields that do not apply to a given outcome stay nil/empty rather
67
+ # than being omitted, so a caller never has to guard with `dig`.
68
+ def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, caps: ReadySet::DEFAULT_CAPS,
69
+ validator: WorkGraphValidator.method(:validate),
70
+ ready_analyzer: ReadySet.method(:analyze),
71
+ packet_builder: NodePacket.method(:build),
72
+ worktree: NodeWorktree,
73
+ ledger: NodeLedger,
74
+ runner: Worktree::ShellRunner.new)
75
+ intent_dir = context.intent_dir
76
+ savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
77
+
78
+ # Row 5.31/5.32: this is RunnerDispatch's OWN lock check, never a shelled
79
+ # `node-transition` call - append_transition below is used in-process
80
+ # (RunnerAbsorb's own pattern), so nothing here inherits node-transition's
81
+ # CLI-level lock refusal (exit 4) for free.
82
+ return lock_refusal(intent_dir) unless context.session
83
+
84
+ content = read_savepoint(savepoint_path)
85
+ entries = NodeLedger.entries_from_content(content)
86
+
87
+ # Row 5.1/5.2/5.3: the full validator runs only when the ledger holds no
88
+ # `running` line for ANY node yet (327 D17's exact precondition) - every
89
+ # later dispatch skips it and relies on the cheaper re-read below.
90
+ #
91
+ # M9: both the validator and the ready analyzer re-parse graph.md fresh
92
+ # (RunnerCore.context's own first read, already guarded, is not reused
93
+ # here on purpose - row 5.4), and a malformed graph.md - non-UTF-8 bytes,
94
+ # say - raises out of both rather than reporting :ok false. Guarded here
95
+ # so `step` refuses cleanly instead of dying with a raw stack trace.
96
+ if entries.none? { |e| e[:state] == "running" }
97
+ full = safe_validate(validator, intent_dir)
98
+ return invalid_graph_result(full[:errors]) unless full[:ok]
99
+ end
100
+
101
+ # Row 5.4: re-read graph.md and cycle-check on EVERY dispatch, first or
102
+ # not. ReadySet.analyze re-parses graph.md and nodes/ from disk itself,
103
+ # so nothing here trusts `context.graph`, which was resolved once,
104
+ # before this step even started.
105
+ analysis = safe_analyze(ready_analyzer, intent_dir, now: now, caps: caps)
106
+ return invalid_graph_result(analysis[:errors]) unless analysis[:ok]
107
+
108
+ loaded = RunnerCore.safe_load_graph(intent_dir)
109
+ return invalid_graph_result(loaded[:errors]) unless loaded[:ok]
110
+
111
+ edges = loaded[:edges]
112
+ nodes_decl = loaded[:nodes]
113
+
114
+ # Minor 9: count `running` only for NODE subjects - an `Intent` subject
115
+ # in a running-like state must never eat a dispatch slot meant for the
116
+ # concurrency ceiling over nodes.
117
+ running_count = NodeLedger.status_from_content(content).count do |subject, s|
118
+ s == "running" && subject.to_s.match?(Savepoint::NODE_SUBJECT_RE)
119
+ end
120
+ slots = [limit.to_i - running_count, 0].max
121
+
122
+ dispatched = []
123
+ parked = []
124
+ stop = nil
125
+ packet_failures = []
126
+ ceiling_blocked = false
127
+
128
+ analysis[:ranked_ready].each do |row|
129
+ break if stop
130
+
131
+ node = row[:id].to_s
132
+ kind = (nodes_decl[node] || {})[:kind] || row[:kind]
133
+
134
+ # Row 5.33: the ONLY overlap check anywhere in this loop - re-evaluated
135
+ # against the latest on-disk content on every iteration, so a sibling
136
+ # this very step just dispatched (its `running` line already written)
137
+ # is seen by the very next candidate, without RunnerDispatch ever
138
+ # carrying its own copy of the overlap rule.
139
+ live = ReadySet.ready?(content: content, subject: node, graph: { edges: edges }, nodes: nodes_decl,
140
+ caps: caps)
141
+ next unless live[:ready]
142
+
143
+ if kind.to_s == "decision"
144
+ stop = write_decision_stop(savepoint_path, intent_dir, node, ledger: ledger, now: now)
145
+ content = read_savepoint(savepoint_path)
146
+ next
147
+ end
148
+
149
+ if RunnerPolicy.at_retry_cap?(entries, node, kind)
150
+ parked << write_retry_cap_park(savepoint_path, intent_dir, node, kind, entries, ledger: ledger, now: now)
151
+ content = read_savepoint(savepoint_path)
152
+ entries = NodeLedger.entries_from_content(content)
153
+ next
154
+ end
155
+
156
+ # M10: a ready node that cannot dispatch because every slot is taken
157
+ # is QUEUED, not stalled - the graph can still continue, it is merely
158
+ # waiting on the ceiling, matrix row 10.13.
159
+ if slots <= 0
160
+ ceiling_blocked = true
161
+ next
162
+ end
163
+
164
+ result = dispatch_one(context, node: node, kind: kind, now: now, config: config, caps: caps,
165
+ edges: edges, nodes_decl: nodes_decl, packet_builder: packet_builder,
166
+ worktree: worktree, ledger: ledger, runner: runner)
167
+ if result[:packet_build_failed]
168
+ packet_failures << result
169
+ next
170
+ end
171
+ next unless result[:ok]
172
+
173
+ dispatched << result[:entry]
174
+ slots -= 1
175
+ content = read_savepoint(savepoint_path)
176
+ entries = NodeLedger.entries_from_content(content)
177
+ end
178
+
179
+ # Row 5.34: re-render graph.md's ## Status once, after every write this
180
+ # step made, mirroring RunnerAbsorb's own single call after its own
181
+ # transition.
182
+ RunnerCore.render_status(context) if dispatched.any? || stop || parked.any?
183
+
184
+ build_report(context: context, dispatched: dispatched, stop: stop, parked: parked,
185
+ ceiling_blocked: ceiling_blocked, packet_failures: packet_failures, running_count: running_count)
186
+ end
187
+
188
+ # --- guarded re-entries into graph.md (M9) ----------------------------------
189
+
190
+ def safe_validate(validator, intent_dir)
191
+ validator.call(intent_dir)
192
+ rescue StandardError => e
193
+ { ok: false, errors: ["graph.md could not be read: #{e.message}"] }
194
+ end
195
+ private_class_method :safe_validate
196
+
197
+ def safe_analyze(ready_analyzer, intent_dir, now:, caps:)
198
+ ready_analyzer.call(intent_dir, now: now, caps: caps)
199
+ rescue StandardError => e
200
+ { ok: false, errors: ["graph.md could not be read: #{e.message}"] }
201
+ end
202
+ private_class_method :safe_analyze
203
+
204
+ # --- one node's whole dispatch (packet, lease, `running`) -------------------
205
+
206
+ def dispatch_one(context, node:, kind:, now:, config:, caps:, edges:, nodes_decl:, packet_builder:, worktree:,
207
+ ledger:, runner:)
208
+ intent_dir = context.intent_dir
209
+ savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
210
+
211
+ holder = context.session
212
+ model = RunnerPolicy.model_for(kind, config: config)
213
+ expires = RunnerPolicy.lease_expires(kind, now: now)
214
+ calls_cap = RunnerPolicy.call_cap(kind, config: config)
215
+
216
+ # Row 10.16/M13: recorded BEFORE provisioning - a worktree this dispatch
217
+ # finds already on disk (kept there by a prior failed_verification
218
+ # attempt, D7) must never be the one a later rollback in this same call
219
+ # deletes; only a worktree THIS call actually creates may be rolled back.
220
+ pre_existing_worktree = worktree_pre_existing?(worktree, context, node, kind)
221
+
222
+ # Row 5.16/5.29: only a `work` node gets a worktree, and this is the
223
+ # node-scoped `worktree_reader:` D23 injects into NodePacket.build - it
224
+ # names THIS node's own worktree and branch, never the intent's.
225
+ provisioned = RunnerPolicy.worktree?(kind) ? worktree.provision(context, node: node, kind: kind, runner: runner)
226
+ : unprovisioned
227
+ node_reader = lambda do |intent_dir:|
228
+ { "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned] }
229
+ end
230
+
231
+ # Row 5.18: build the packet BEFORE writing `running` - a `running` line
232
+ # naming bytes that do not exist yet is worse than a packet nobody reads.
233
+ # Row 5.30: `force: true` always - a fresh attempt number this dispatch
234
+ # computes is, by construction, never one `running` has already claimed,
235
+ # so an existing file at that path is always an orphan from a step that
236
+ # crashed between building the packet and writing `running`, safe to
237
+ # overwrite outright.
238
+ # Row 5.20/10.8: the node's own declared budget: (M7) - nil when the node
239
+ # names none, in which case NodePacket.build falls back to its own
240
+ # default (row 10.9).
241
+ build_result = packet_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
242
+ model: model, force: true, worktree_reader: node_reader,
243
+ budget_tokens: node_declared_budget(intent_dir, node), call_cap: calls_cap)
244
+ unless build_result[:ok]
245
+ # M6: a failed packet build never leaves an orphan worktree behind, and
246
+ # its errors travel back up so the step's report can name the node and
247
+ # the reason instead of a bare "stalled" (row 10.6/10.7).
248
+ rollback_dispatch(context, node: node, kind: kind, packet_path: build_result[:path], runner: runner,
249
+ worktree: worktree, created_this_dispatch: !pre_existing_worktree)
250
+ return { ok: false, packet_build_failed: true, node: node, errors: build_result[:errors] }
251
+ end
252
+
253
+ precondition = lambda do |c|
254
+ ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
255
+ end
256
+ fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model, calls: calls_cap }
257
+
258
+ result = begin
259
+ ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
260
+ precondition: precondition)
261
+ rescue GuardedAppend::Unavailable
262
+ :unavailable
263
+ end
264
+
265
+ # Row 5.20: a refused (or unavailable) `running` write rolls back both
266
+ # side effects this method already produced - the node never ran, so
267
+ # nothing may act like it did.
268
+ unless result == :written
269
+ rollback_dispatch(context, node: node, kind: kind, packet_path: build_result[:path], runner: runner,
270
+ worktree: worktree, created_this_dispatch: !pre_existing_worktree)
271
+ return { ok: false }
272
+ end
273
+
274
+ test_command = NodePacket.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
275
+ spawn = spawn_block(model: model, packet: build_result[:path], test_command: test_command, call_cap: calls_cap)
276
+
277
+ {
278
+ ok: true,
279
+ entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, worktree: provisioned[:path],
280
+ packet: build_result[:path], spawn: spawn },
281
+ }
282
+ end
283
+
284
+ # The node's own declared budget: (frontmatter), or nil when it names
285
+ # none - M7. Parsed directly off the node file, never through
286
+ # `nodes_decl` (ReadySet.load_graph's own decl hash carries only kind and
287
+ # files, never budget), so this stays independent of that module.
288
+ def node_declared_budget(intent_dir, node)
289
+ path = ReadySet.find_node_path(intent_dir, node)
290
+ return nil unless path
291
+
292
+ nf = NodeFile.parse(path)
293
+ nf[:ok] ? nf[:budget] : nil
294
+ end
295
+ private_class_method :node_declared_budget
296
+
297
+ # true iff a `work` node's own worktree already exists BEFORE this call
298
+ # provisions anything - the pre-check `rollback_dispatch` needs to tell a
299
+ # worktree this dispatch created from one it merely found (row 10.16).
300
+ def worktree_pre_existing?(worktree, context, node, kind)
301
+ return false unless RunnerPolicy.worktree?(kind)
302
+
303
+ p = worktree.paths(context, node: node)
304
+ !!(p && p["path"] && Dir.exist?(p["path"]))
305
+ end
306
+ private_class_method :worktree_pre_existing?
307
+
308
+ def role_for(kind)
309
+ kind.to_s == "verify" ? "advisor" : "executor"
310
+ end
311
+
312
+ def unprovisioned
313
+ { ok: true, path: nil, branch: nil, provisioned: false }
314
+ end
315
+ private_class_method :unprovisioned
316
+
317
+ # Row 10.16/M13: `created_this_dispatch:` gates the worktree half of the
318
+ # rollback - a worktree this call did not create (kept on disk by a prior
319
+ # attempt's failed_verification, D7) is never touched, only a packet this
320
+ # call's own `packet_builder` may have written is ever deleted.
321
+ def rollback_dispatch(context, node:, kind:, packet_path:, runner:, worktree:, created_this_dispatch:)
322
+ File.delete(packet_path) if packet_path && File.exist?(packet_path)
323
+ return unless RunnerPolicy.worktree?(kind)
324
+ return unless created_this_dispatch
325
+
326
+ p = worktree.paths(context, node: node)
327
+ return if p["path"].nil? || !Dir.exist?(p["path"])
328
+
329
+ Worktree.remove_worktree(runner, repo: p["repo"], worktree: p["path"])
330
+ Worktree.prune(runner, repo: p["repo"])
331
+ end
332
+ private_class_method :rollback_dispatch
333
+
334
+ # --- decision stop and the retry-cap park -----------------------------------
335
+
336
+ # Row 5.9/5.10: a ready decision node is never dispatched - it stops the
337
+ # WHOLE step (any node ranked after it this step is simply not reached)
338
+ # and writes its own `needs_decision` line so it reads that way from
339
+ # `status` too, carrying the exact `runner answer` command that clears it.
340
+ def write_decision_stop(savepoint_path, intent_dir, node, ledger:, now:)
341
+ question = decision_question(intent_dir, node)
342
+ safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
343
+ { reason: "decision", node: node, question: question, answer_command: answer_command(intent_dir, node) }
344
+ end
345
+ private_class_method :write_decision_stop
346
+
347
+ # Row 5.11/5.27/5.28: a node at RunnerPolicy's SOFT cap is parked at
348
+ # `needs_decision` with a synthesized `question=` rather than dispatched a
349
+ # further time, carrying the same `runner answer` shape.
350
+ def write_retry_cap_park(savepoint_path, intent_dir, node, kind, entries, ledger:, now:)
351
+ count = RunnerPolicy.retry_count(entries, node)
352
+ cap = RunnerPolicy.retry_cap(kind)
353
+ question = "#{node} has failed verification #{count} time(s), its #{kind} retry cap is #{cap}; " \
354
+ "retry, rewind, or abandon it?"
355
+ safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
356
+ { reason: "retry_cap", node: node, question: question, answer_command: answer_command(intent_dir, node) }
357
+ end
358
+ private_class_method :write_retry_cap_park
359
+
360
+ def safe_append(ledger, savepoint_path, node, state, fields, now:)
361
+ ledger.append_transition(savepoint_path, subject: node, state: state, fields: fields, now: now)
362
+ rescue GuardedAppend::Unavailable
363
+ nil
364
+ end
365
+ private_class_method :safe_append
366
+
367
+ def decision_question(intent_dir, node)
368
+ path = ReadySet.find_node_path(intent_dir, node)
369
+ fallback = "#{node} needs an owner decision; see its ## Question section"
370
+ return fallback unless path
371
+
372
+ nf = NodeFile.parse(path)
373
+ return fallback unless nf[:ok]
374
+
375
+ section = NodeFile.split_by_headings(nf[:body]).find { |(heading, _)| heading.to_s.strip == "## Question" }
376
+ text = section && section[1].to_s.strip
377
+ text && !text.empty? ? squash(text) : fallback
378
+ end
379
+ private_class_method :decision_question
380
+
381
+ def squash(text)
382
+ text.to_s.gsub(/\s+/, " ").strip
383
+ end
384
+ private_class_method :squash
385
+
386
+ # Row 5.10/5.28: the one command shape every stop and every park prints,
387
+ # matching scripts/runner's own published usage
388
+ # (`runner <step|status|answer> <intent_dir> [--node ID] [--answer TEXT]`).
389
+ def answer_command(intent_dir, node)
390
+ "runner answer #{intent_dir} --node #{node} --answer \"<your answer>\""
391
+ end
392
+
393
+ # Row 5.32: re-arms delivery.lock for a resumed session with a new id -
394
+ # `plastic-lock arm` is the shipped command that takes ownership again.
395
+ def rearm_command(intent_dir)
396
+ "plastic-lock arm --intent-dir #{intent_dir}"
397
+ end
398
+
399
+ # --- refusals and the report -------------------------------------------------
400
+
401
+ def empty_result
402
+ { ok: true, reason: nil, errors: [], rearm_command: nil, dispatched: [], stop: nil, parked: [],
403
+ status: nil, blockers: [], plan: nil }
404
+ end
405
+ private_class_method :empty_result
406
+
407
+ def lock_refusal(intent_dir)
408
+ empty_result.merge(ok: false, reason: "lock_not_held", rearm_command: rearm_command(intent_dir))
409
+ end
410
+ private_class_method :lock_refusal
411
+
412
+ def invalid_graph_result(errors)
413
+ empty_result.merge(ok: false, reason: "invalid_graph", errors: Array(errors))
414
+ end
415
+ private_class_method :invalid_graph_result
416
+
417
+ def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false, packet_failures: [],
418
+ running_count: 0)
419
+ base = empty_result.merge(dispatched: dispatched, stop: stop, parked: parked,
420
+ plan: render_plan(dispatched))
421
+
422
+ if dispatched.any?
423
+ base.merge(status: "dispatched")
424
+ elsif stop
425
+ base.merge(status: "needs_decision")
426
+ elsif ceiling_blocked || running_count.to_i.positive?
427
+ # M10/v2 NEW-7: a ready node waiting on the concurrency ceiling is one
428
+ # shape of "still in flight" - a graph where every ready node is
429
+ # ALREADY running (no candidate ever reaches the ceiling check at all,
430
+ # so `ceiling_blocked` never sets) is the ordinary busy case, and it
431
+ # used to fall all the way through to `stalled`. Any node genuinely
432
+ # `running` means the graph can still continue on its own.
433
+ base.merge(status: "queued")
434
+ else
435
+ # Row 5.25: complete iff EVERY declared node is terminal - an empty
436
+ # ready set from parked/blocked nodes must never read as finished.
437
+ complete = RunnerCore.complete?(context)
438
+ if complete
439
+ base.merge(status: "complete")
440
+ else
441
+ # M6/row 10.7: a failed packet build writes no ledger line at all,
442
+ # so `named_blockers` (ledger-derived) never sees it on its own -
443
+ # its own node and reason are named here so `stalled` never prints
444
+ # bare.
445
+ blockers = named_blockers(context) + packet_failures.map { |f| packet_failure_blocker(f) }
446
+ base.merge(status: "stalled", blockers: blockers)
447
+ end
448
+ end
449
+ end
450
+ private_class_method :build_report
451
+
452
+ def packet_failure_blocker(failure)
453
+ "#{failure[:node]}: packet build failed (#{Array(failure[:errors]).join('; ')})"
454
+ end
455
+ private_class_method :packet_failure_blocker
456
+
457
+ # Row 5.25a/5.26: every unfinished node's own blockers, with ReadySet's
458
+ # hard-attempt-cap wording renamed so it reads as the named backstop it is
459
+ # (D22: the exit here is `runner answer`, never another dispatch), rather
460
+ # than one more indistinguishable blocker line.
461
+ def named_blockers(context)
462
+ intent_dir = context.intent_dir
463
+ rows = RunnerCore.status(context)
464
+ rows.each_with_object([]) do |(id, view), out|
465
+ next if ReadySet::TERMINAL_STATES.include?(view[:state])
466
+
467
+ view[:blockers].each { |b| out << name_blocker(intent_dir, id, b) }
468
+ end
469
+ end
470
+ private_class_method :named_blockers
471
+
472
+ def name_blocker(intent_dir, id, blocker)
473
+ rest = blocker.to_s.sub(/\A#{Regexp.escape(id)} /, "")
474
+ m = rest.match(HARD_CAP_RE)
475
+ return "#{id}: #{blocker}" unless m
476
+
477
+ "#{id} has reached its hard attempt backstop (#{m[1]}/#{m[2]}) - #{answer_command(intent_dir, id)}"
478
+ end
479
+ private_class_method :name_blocker
480
+
481
+ # Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
482
+ # dispatched node, the packet path, the model, the worktree, the kind and
483
+ # the role, plus the return contract ONCE at the top level - never inside
484
+ # any one node's packet. Row 6.4: "spawn" carries the same, already fully
485
+ # rendered spawn block for each dispatched node in order, so any reader of
486
+ # this data (YAML today, JSON if it is ever re-serialized) finds it under
487
+ # `spawn` rather than re-deriving it from the other fields.
488
+ def render_plan(dispatched)
489
+ return nil if dispatched.empty?
490
+
491
+ YAML.dump(
492
+ "return_contract" => RETURN_CONTRACT,
493
+ "dispatch" => dispatched.map do |d|
494
+ { "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
495
+ "worktree" => d[:worktree], "packet" => d[:packet] }
496
+ end,
497
+ "spawn" => dispatched.map { |d| d[:spawn] }
498
+ )
499
+ end
500
+ private_class_method :render_plan
501
+
502
+ def read_savepoint(path)
503
+ File.exist?(path) ? File.read(path) : ""
504
+ end
505
+ private_class_method :read_savepoint
506
+ end
@@ -0,0 +1,173 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "ready_set"
5
+ require_relative "node_worktree"
6
+ require_relative "agent_models"
7
+
8
+ # RunnerPolicy (intent 340, G7, n5): the kind table. Four facts per kind -
9
+ # which model it runs on, whether it gets a worktree, its SOFT retry cap, and
10
+ # its diff rule - looked up by one fallback rule: an unknown or nil kind gets
11
+ # `work`'s row, the widest of the four, never no policy at all (matrix 5.17,
12
+ # mirroring ReadySet's own `caps.fetch(kind) { caps["work"] }` fallback).
13
+ #
14
+ # This is deliberately a SECOND, SOFTER cap than ReadySet::DEFAULT_CAPS
15
+ # (327 D22): that cap is the transition layer's hard backstop, counted as
16
+ # `running` lines since the last terminal line, and `node-transition` is
17
+ # never changed to accept an override of it. RunnerPolicy's cap is counted
18
+ # from `failed_verification` lines alone (ReadySet.failed_verification_count,
19
+ # matrix 5.12) - a node reclaimed after a crash, with no failed_verification
20
+ # line at all, never trips this cap, exactly as 327 D12 specifies: "work runs
21
+ # ... retry cap 2 ... verify ... retry cap 1 ... research ... retry cap 1".
22
+ #
23
+ # Pure and dependency-injected: `model_for` takes `config:` (an already
24
+ # loaded config hash, AgentModels' own `agents.models` shape), never reads
25
+ # ENV or a real config.yml itself - the caller (RunnerDispatch) owns loading
26
+ # real config, this module only resolves values out of what it is handed.
27
+ module RunnerPolicy
28
+ module_function
29
+
30
+ # D31 (327): "One advisor: the smartest model available, or the model set
31
+ # in user config." Never the cheap tier - that is the one thing D31 rules
32
+ # out for a verify node (matrix 5.14).
33
+ #
34
+ # D19 (327): "knows nothing about either harness." The executor default
35
+ # resolves through AgentModels::TIER_DEFAULTS, the one place
36
+ # `plastic-executor`'s shipped tier is already declared (post-execution
37
+ # review minor 4), rather than a second, independently-drifting literal
38
+ # here. `plastic-advisor` carries no lifecycle-stage entry in
39
+ # TIER_DEFAULTS at all (it is a consultation agent, never auto-dispatched
40
+ # - see agent_models.rb's own docstring), so its default stays the one
41
+ # literal this table cannot source from anywhere else.
42
+ DEFAULT_EXECUTOR_MODEL = AgentModels::TIER_DEFAULTS.fetch("plastic-executor")
43
+ DEFAULT_ADVISOR_MODEL = "opus"
44
+
45
+ EXECUTOR_CONFIG_KEY = "plastic-executor"
46
+ ADVISOR_CONFIG_KEY = "plastic-advisor"
47
+
48
+ # {model_role:, worktree:, retry_cap:, diff_rule:, lease_minutes:} per kind
49
+ # (327 D12). `decision` carries no retry cap or lease: it is never
50
+ # dispatched (RunnerDispatch stops the loop on one instead), so nothing
51
+ # here ever needs to answer "how long is a decision node's lease".
52
+ KIND_TABLE = {
53
+ "work" => { model_role: :executor, worktree: true, retry_cap: 2, diff_rule: :inside_files,
54
+ lease_minutes: 180 },
55
+ "verify" => { model_role: :advisor, worktree: false, retry_cap: 1, diff_rule: :none,
56
+ lease_minutes: 30 },
57
+ "research" => { model_role: :executor, worktree: false, retry_cap: 1, diff_rule: :none,
58
+ lease_minutes: 60 },
59
+ "decision" => { model_role: nil, worktree: false, retry_cap: nil, diff_rule: :none,
60
+ lease_minutes: nil },
61
+ }.freeze
62
+
63
+ # matrix 5.17: an unknown, nil, or blank kind gets `work`'s whole row.
64
+ def policy_for(kind)
65
+ KIND_TABLE.fetch(kind.to_s, KIND_TABLE["work"])
66
+ end
67
+
68
+ # matrix 5.13/5.14/5.15: work and research resolve the executor model,
69
+ # verify resolves the advisor model, and a config with no override falls
70
+ # back to the shipped default rather than an empty string (`running`
71
+ # requires a non-blank `model=`).
72
+ def model_for(kind, config: {})
73
+ policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config) : executor_model(config: config)
74
+ end
75
+
76
+ def executor_model(config: {})
77
+ resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL)
78
+ end
79
+
80
+ def advisor_model(config: {})
81
+ resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL)
82
+ end
83
+
84
+ def resolve_model(config, key, shipped_default)
85
+ value = AgentModels.models_section(config)[key]
86
+ present?(value) ? value : shipped_default
87
+ end
88
+ private_class_method :resolve_model
89
+
90
+ def present?(value)
91
+ !(value.nil? || value.to_s.strip.empty?)
92
+ end
93
+ private_class_method :present?
94
+
95
+ # matrix 5.16/5.17: only `work` gets a worktree; an unknown kind falls back
96
+ # to `work`'s own row like every other field here. `KIND_TABLE["work"]` is
97
+ # kept equal to `NodeWorktree::WORKTREE_KINDS` by the assertion below, so
98
+ # the two lists cannot silently drift apart.
99
+ raise "RunnerPolicy/NodeWorktree kind lists have drifted" unless KIND_TABLE.select { |_, v| v[:worktree] }.keys ==
100
+ NodeWorktree::WORKTREE_KINDS
101
+
102
+ def worktree?(kind)
103
+ !!policy_for(kind)[:worktree]
104
+ end
105
+
106
+ def diff_rule(kind)
107
+ policy_for(kind)[:diff_rule]
108
+ end
109
+
110
+ # matrix 5.12: the SOFT cap, or nil for a kind that carries none (decision).
111
+ def retry_cap(kind)
112
+ policy_for(kind)[:retry_cap]
113
+ end
114
+
115
+ # The soft-cap count itself: every non-torn `failed_verification` line for
116
+ # `node`, delegated to ReadySet (matrix 5.12's "counts from
117
+ # failed_verification lines", never from attempts).
118
+ def retry_count(entries, node)
119
+ ReadySet.failed_verification_count(entries, node.to_s)
120
+ end
121
+
122
+ # true only when a real cap exists AND the count has reached it (a nil cap,
123
+ # e.g. `decision`, never trips).
124
+ def at_retry_cap?(entries, node, kind)
125
+ cap = retry_cap(kind)
126
+ return false if cap.nil?
127
+
128
+ retry_count(entries, node) >= cap
129
+ end
130
+
131
+ # matrix 5.21: `expires=` on `running` comes from the kind's own lease
132
+ # length, never one shared constant - a long `work` build must not be
133
+ # reclaimed under a still-working executor the way a quick `verify` pass
134
+ # would be.
135
+ def lease_minutes(kind)
136
+ policy_for(kind)[:lease_minutes] || policy_for("work")[:lease_minutes]
137
+ end
138
+
139
+ def lease_expires(kind, now: Time.now)
140
+ (now + (lease_minutes(kind) * 60)).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
141
+ end
142
+
143
+ # --- call budget (intent 355, n2) -------------------------------------------
144
+ #
145
+ # D2: a cap on tool calls per attempt, enforced by a PreToolUse hook that
146
+ # counts tool calls in the session transcript. Shipped per kind (matrix
147
+ # 2.1); `decision` carries one too even though it is never dispatched
148
+ # (327 D12 leaves it out of every lease table for the same reason), so
149
+ # `call_cap` never has to special-case an unknown kind here any more than
150
+ # `retry_cap` does.
151
+ CALL_CAP_TABLE = { "work" => 60, "verify" => 40, "research" => 40, "decision" => 10 }.freeze
152
+
153
+ # matrix 5.17's own fallback rule, one call: an unknown or nil kind reads
154
+ # `work`'s cap, never a fourth, undeclared number.
155
+ def call_cap(kind, config: {})
156
+ override = call_caps_section(config)[kind.to_s]
157
+ present?(override) ? override.to_i : CALL_CAP_TABLE.fetch(kind.to_s, CALL_CAP_TABLE["work"])
158
+ end
159
+
160
+ # matrix 2.2: `runner.call_caps.<kind>` in the project config overrides the
161
+ # shipped cap, the same nested-Hash shape AgentModels.models_section reads
162
+ # `agents.models` from - one more caller of the pattern, not a new one.
163
+ def call_caps_section(config)
164
+ return {} unless config.is_a?(Hash)
165
+
166
+ runner = config["runner"]
167
+ return {} unless runner.is_a?(Hash)
168
+
169
+ section = runner["call_caps"]
170
+ section.is_a?(Hash) ? section : {}
171
+ end
172
+ private_class_method :call_caps_section
173
+ end