@zalom/plastic 2.0.0-alpha.9 → 2.0.1

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 (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +55 -2
  34. package/hooks/session-start +5 -1
  35. package/hooks/statusline +32 -27
  36. package/hooks/stop +5 -0
  37. package/package.json +5 -3
  38. package/scripts/append-ledger +2 -1
  39. package/scripts/dashboard.rb +267 -16
  40. package/scripts/day-summary +2 -1
  41. package/scripts/doctor.rb +680 -39
  42. package/scripts/end-intent +153 -30
  43. package/scripts/exec-worktree +5 -5
  44. package/scripts/file-session-intent +2 -1
  45. package/scripts/graph-measure +249 -0
  46. package/scripts/hook-call-budget +222 -0
  47. package/scripts/hook-capture +24 -123
  48. package/scripts/hook-close +2 -1
  49. package/scripts/hook-message-display +7 -0
  50. package/scripts/hook-record +24 -16
  51. package/scripts/hook-savepoint +27 -3
  52. package/scripts/hook-session-start +359 -321
  53. package/scripts/hook-stop +58 -0
  54. package/scripts/index-projection +74 -0
  55. package/scripts/insight-append +17 -4
  56. package/scripts/install.rb +9 -7
  57. package/scripts/lib/action_graph_shim.rb +279 -0
  58. package/scripts/lib/active_delivery.rb +105 -0
  59. package/scripts/lib/agent_models.rb +63 -25
  60. package/scripts/lib/arm.rb +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +23 -14
  130. package/scripts/lib/doctor_core.rb +114 -38
  131. package/scripts/lib/doctor_session_ledger.rb +5 -53
  132. package/scripts/lib/engine_permissions.rb +88 -0
  133. package/scripts/lib/exec_worktree.rb +24 -21
  134. package/scripts/lib/feedback_report.rb +1 -1
  135. package/scripts/lib/graph_edges.rb +137 -0
  136. package/scripts/lib/graph_file.rb +246 -0
  137. package/scripts/lib/graph_measure.rb +645 -0
  138. package/scripts/lib/graph_measure_budget.rb +409 -0
  139. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  140. package/scripts/lib/graph_measure_models.rb +413 -0
  141. package/scripts/lib/graph_measure_report.rb +532 -0
  142. package/scripts/lib/graph_tree.rb +98 -0
  143. package/scripts/lib/guarded_append.rb +155 -0
  144. package/scripts/lib/handoff.rb +40 -13
  145. package/scripts/lib/harness_adapter.rb +184 -0
  146. package/scripts/lib/hook_registry.rb +27 -3
  147. package/scripts/lib/hook_replay.rb +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +531 -71
  152. package/scripts/lib/intent_screen.rb +4 -4
  153. package/scripts/lib/intent_screen_ansi.rb +73 -12
  154. package/scripts/lib/intent_validator.rb +2 -2
  155. package/scripts/lib/lock.rb +10 -11
  156. package/scripts/lib/message_display.rb +350 -54
  157. package/scripts/lib/meter_watch.rb +185 -0
  158. package/scripts/lib/node_file.rb +234 -0
  159. package/scripts/lib/node_ids.rb +99 -0
  160. package/scripts/lib/node_input.rb +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +1371 -47
  175. package/scripts/lib/rlm/corpus.rb +13 -0
  176. package/scripts/lib/rlm/probe.rb +29 -0
  177. package/scripts/lib/rlm/query.rb +22 -0
  178. package/scripts/lib/roadmap_graph.rb +210 -0
  179. package/scripts/lib/roadmap_migration.rb +95 -0
  180. package/scripts/lib/roadmap_queue.rb +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +365 -28
  196. package/scripts/lib/screens/dashboard.rb +20 -0
  197. package/scripts/lib/screens/plan.rb +18 -0
  198. package/scripts/lib/screens/roadmap.rb +15 -0
  199. package/scripts/lib/search_index.rb +55 -0
  200. package/scripts/lib/session_close.rb +30 -28
  201. package/scripts/lib/session_git.rb +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +177 -16
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +8 -3
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -296
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -176
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,525 @@
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_input"
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
+ require_relative "harness_adapter"
18
+
19
+ # RunnerDispatch (intent 340, G7, n5): validates the graph, computes the
20
+ # ready set, applies RunnerPolicy, mints leases, builds node inputs, writes
21
+ # `running`, and returns the dispatch plan `step` prints. Never spawns an
22
+ # agent itself (327 D42): the session does that from the plan this returns.
23
+ #
24
+ # Pure and dependency-injected down to the clock: every side effect - the
25
+ # full validator, the ready-set analyzer, the input builder, the worktree
26
+ # module, the ledger write, git itself - is an injectable keyword argument
27
+ # with a real default, so a test never touches a real repository or a real
28
+ # filesystem outside its own tmpdir.
29
+ module RunnerDispatch
30
+ module_function
31
+
32
+ DEFAULT_LIMIT = 2
33
+
34
+ # The return-schema instruction (327 D5): rides in the dispatch PLAN, never
35
+ # inside the node input, so `input=<sha>` keeps naming a reproducible input
36
+ # (matrix row 5.23). NodeReturn.parse (n4) is this text's implementation.
37
+ RETURN_CONTRACT = <<~TEXT.freeze
38
+ RETURN CONTRACT: reply with exactly one YAML document as your final
39
+ message, nothing else around it. Keys: node, status, commit, summary,
40
+ findings, report, proposed_nodes, proposed_edges, question, reason. status is one
41
+ of done, failed_verification, needs_decision, blocked. done requires
42
+ commit; needs_decision requires question; failed_verification and
43
+ blocked require reason. Anything that does not parse under this closed
44
+ schema is refused as failed_verification reason=return_unparsable.
45
+ TEXT
46
+
47
+ HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
48
+
49
+ # D8 (355, n6): the agent every dispatched, non-decision node names - a
50
+ # role, never a harness (matrix 6.5), and never a consultation advisor, which
51
+ # stays a deliberate, never-auto-dispatched consultation agent.
52
+ SPAWN_AGENT = "plastic-executor"
53
+
54
+ # matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
55
+ # RunnerPolicy.model_for resolved, the node input path, the one test command
56
+ # (NodeInput.test_command_block, n4), and the call cap (n2) - fenced so a
57
+ # session pastes it straight into the Agent tool (327 D42: the runner
58
+ # itself never spawns).
59
+ def spawn_block(model:, input:, test_command:, call_cap:, effort: AgentModels::DEFAULT_EFFORT, agent: SPAWN_AGENT)
60
+ lines = ["agent: #{agent}", "model: #{model}", "effort: #{effort}", "input: #{input}", test_command,
61
+ NodeInput.call_cap_sentence(call_cap)]
62
+ (["```"] + lines + ["```"]).join("\n")
63
+ end
64
+
65
+ # dispatch(context, limit:) -> a result hash. Always carries :ok, :reason,
66
+ # :errors, :rearm_command, :dispatched, :stop, :parked, :status, :blockers,
67
+ # :plan - fields that do not apply to a given outcome stay nil/empty rather
68
+ # than being omitted, so a caller never has to guard with `dig`.
69
+ def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, harness: nil, caps: ReadySet::DEFAULT_CAPS,
70
+ validator: WorkGraphValidator.method(:validate),
71
+ ready_analyzer: ReadySet.method(:analyze),
72
+ input_builder: NodeInput.method(:build),
73
+ worktree: NodeWorktree,
74
+ ledger: NodeLedger,
75
+ runner: Worktree::ShellRunner.new,
76
+ harness_adapter: HarnessAdapter)
77
+ intent_dir = context.intent_dir
78
+ savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
79
+
80
+ # Intent 340b, G7c, n1, rows 1.19/1.20/D21: resolved ONCE for the whole
81
+ # step through HarnessAdapter, never a literal - `harness:` (this call's
82
+ # `--harness` override, or nil) wins over `config`'s own `agent.type`.
83
+ # Every node this call dispatches carries the SAME value, and the
84
+ # caller (scripts/runner) reads it back off the report to pick which
85
+ # harness's block to render.
86
+ harness_key = harness_adapter.resolve_key(config: config, override: harness)
87
+
88
+ # Row 5.31/5.32: this is RunnerDispatch's OWN lock check, never a shelled
89
+ # `node-transition` call - append_transition below is used in-process
90
+ # (RunnerAbsorb's own pattern), so nothing here inherits node-transition's
91
+ # CLI-level lock refusal (exit 4) for free.
92
+ return lock_refusal(intent_dir) unless context.session
93
+
94
+ content = read_savepoint(savepoint_path)
95
+ entries = NodeLedger.entries_from_content(content)
96
+
97
+ # Row 5.1/5.2/5.3: the full validator runs only when the ledger holds no
98
+ # `running` line for ANY node yet (327 D17's exact precondition) - every
99
+ # later dispatch skips it and relies on the cheaper re-read below.
100
+ #
101
+ # M9: both the validator and the ready analyzer re-parse graph.md fresh
102
+ # (RunnerCore.context's own first read, already guarded, is not reused
103
+ # here on purpose - row 5.4), and a malformed graph.md - non-UTF-8 bytes,
104
+ # say - raises out of both rather than reporting :ok false. Guarded here
105
+ # so `step` refuses cleanly instead of dying with a raw stack trace.
106
+ if entries.none? { |e| e[:state] == "running" }
107
+ full = safe_validate(validator, intent_dir)
108
+ return invalid_graph_result(full[:errors]) unless full[:ok]
109
+ end
110
+
111
+ # Row 5.4: re-read graph.md and cycle-check on EVERY dispatch, first or
112
+ # not. ReadySet.analyze re-parses graph.md and nodes/ from disk itself,
113
+ # so nothing here trusts `context.graph`, which was resolved once,
114
+ # before this step even started.
115
+ analysis = safe_analyze(ready_analyzer, intent_dir, now: now, caps: caps)
116
+ return invalid_graph_result(analysis[:errors]) unless analysis[:ok]
117
+
118
+ loaded = RunnerCore.safe_load_graph(intent_dir)
119
+ return invalid_graph_result(loaded[:errors]) unless loaded[:ok]
120
+
121
+ edges = loaded[:edges]
122
+ nodes_decl = loaded[:nodes]
123
+
124
+ # Minor 9: count `running` only for NODE subjects - an `Intent` subject
125
+ # in a running-like state must never eat a dispatch slot meant for the
126
+ # concurrency ceiling over nodes.
127
+ running_count = NodeLedger.status_from_content(content).count do |subject, s|
128
+ s == "running" && subject.to_s.match?(Savepoint::NODE_SUBJECT_RE)
129
+ end
130
+ slots = [limit.to_i - running_count, 0].max
131
+
132
+ dispatched = []
133
+ parked = []
134
+ stop = nil
135
+ input_failures = []
136
+ ceiling_blocked = false
137
+
138
+ analysis[:ranked_ready].each do |row|
139
+ break if stop
140
+
141
+ node = row[:id].to_s
142
+ kind = (nodes_decl[node] || {})[:kind] || row[:kind]
143
+
144
+ # Row 5.33: the ONLY overlap check anywhere in this loop - re-evaluated
145
+ # against the latest on-disk content on every iteration, so a sibling
146
+ # this very step just dispatched (its `running` line already written)
147
+ # is seen by the very next candidate, without RunnerDispatch ever
148
+ # carrying its own copy of the overlap rule.
149
+ live = ReadySet.ready?(content: content, subject: node, graph: { edges: edges }, nodes: nodes_decl,
150
+ caps: caps)
151
+ next unless live[:ready]
152
+
153
+ if kind.to_s == "decision"
154
+ stop = write_decision_stop(savepoint_path, intent_dir, node, ledger: ledger, now: now)
155
+ content = read_savepoint(savepoint_path)
156
+ next
157
+ end
158
+
159
+ if RunnerPolicy.at_retry_cap?(entries, node, kind)
160
+ parked << write_retry_cap_park(savepoint_path, intent_dir, node, kind, entries, ledger: ledger, now: now)
161
+ content = read_savepoint(savepoint_path)
162
+ entries = NodeLedger.entries_from_content(content)
163
+ next
164
+ end
165
+
166
+ # M10: a ready node that cannot dispatch because every slot is taken
167
+ # is QUEUED, not stalled - the graph can still continue, it is merely
168
+ # waiting on the ceiling, matrix row 10.13.
169
+ if slots <= 0
170
+ ceiling_blocked = true
171
+ next
172
+ end
173
+
174
+ result = dispatch_one(context, node: node, kind: kind, now: now, config: config, harness: harness_key,
175
+ caps: caps, edges: edges, nodes_decl: nodes_decl, input_builder: input_builder,
176
+ worktree: worktree, ledger: ledger, runner: runner)
177
+ if result[:input_build_failed]
178
+ input_failures << result
179
+ next
180
+ end
181
+ next unless result[:ok]
182
+
183
+ dispatched << result[:entry]
184
+ slots -= 1
185
+ content = read_savepoint(savepoint_path)
186
+ entries = NodeLedger.entries_from_content(content)
187
+ end
188
+
189
+ # Row 5.34: re-render graph.md's ## Status once, after every write this
190
+ # step made, mirroring RunnerAbsorb's own single call after its own
191
+ # transition.
192
+ RunnerCore.render_status(context) if dispatched.any? || stop || parked.any?
193
+
194
+ build_report(context: context, dispatched: dispatched, stop: stop, parked: parked,
195
+ ceiling_blocked: ceiling_blocked, input_failures: input_failures, running_count: running_count,
196
+ harness: harness_key)
197
+ end
198
+
199
+ # --- guarded re-entries into graph.md (M9) ----------------------------------
200
+
201
+ def safe_validate(validator, intent_dir)
202
+ validator.call(intent_dir)
203
+ rescue StandardError => e
204
+ { ok: false, errors: ["graph.md could not be read: #{e.message}"] }
205
+ end
206
+ private_class_method :safe_validate
207
+
208
+ def safe_analyze(ready_analyzer, intent_dir, now:, caps:)
209
+ ready_analyzer.call(intent_dir, now: now, caps: caps)
210
+ rescue StandardError => e
211
+ { ok: false, errors: ["graph.md could not be read: #{e.message}"] }
212
+ end
213
+ private_class_method :safe_analyze
214
+
215
+ # --- one node's whole dispatch (node input, lease, `running`) -------------------
216
+
217
+ def dispatch_one(context, node:, kind:, now:, config:, harness:, caps:, edges:, nodes_decl:, input_builder:,
218
+ worktree:, ledger:, runner:)
219
+ intent_dir = context.intent_dir
220
+ savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
221
+
222
+ holder = context.session
223
+ model = RunnerPolicy.model_for(kind, config: config, harness: harness)
224
+ effort = RunnerPolicy.effort_for(kind, config: config, harness: harness)
225
+ expires = RunnerPolicy.lease_expires(kind, now: now)
226
+ calls_cap = RunnerPolicy.call_cap(kind, config: config)
227
+
228
+ # Row 10.16/M13: recorded BEFORE provisioning - a worktree this dispatch
229
+ # finds already on disk (kept there by a prior failed_verification
230
+ # attempt, D7) must never be the one a later rollback in this same call
231
+ # deletes; only a worktree THIS call actually creates may be rolled back.
232
+ pre_existing_worktree = worktree_pre_existing?(worktree, context, node, kind)
233
+
234
+ # Row 5.16/5.29: only a `work` node gets a worktree, and this is the
235
+ # node-scoped `worktree_reader:` D23 injects into NodeInput.build - it
236
+ # names THIS node's own worktree and branch, never the intent's.
237
+ provisioned = RunnerPolicy.worktree?(kind) ? worktree.provision(context, node: node, kind: kind, runner: runner)
238
+ : unprovisioned
239
+ node_reader = lambda do |intent_dir:|
240
+ { "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned] }
241
+ end
242
+
243
+ # Row 5.18: build the node input BEFORE writing `running` - a `running` line
244
+ # naming bytes that do not exist yet is worse than a node input nobody reads.
245
+ # Row 5.30: `force: true` always - a fresh attempt number this dispatch
246
+ # computes is, by construction, never one `running` has already claimed,
247
+ # so an existing file at that path is always an orphan from a step that
248
+ # crashed between building the node input and writing `running`, safe to
249
+ # overwrite outright.
250
+ # Row 5.20/10.8: the node's own declared budget: (M7) - nil when the node
251
+ # names none, in which case NodeInput.build falls back to its own
252
+ # default (row 10.9).
253
+ build_result = input_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
254
+ model: model, force: true, worktree_reader: node_reader,
255
+ budget_tokens: node_declared_budget(intent_dir, node), call_cap: calls_cap)
256
+ unless build_result[:ok]
257
+ # M6: a failed input build never leaves an orphan worktree behind, and
258
+ # its errors travel back up so the step's report can name the node and
259
+ # the reason instead of a bare "stalled" (row 10.6/10.7).
260
+ rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
261
+ worktree: worktree, created_this_dispatch: !pre_existing_worktree)
262
+ return { ok: false, input_build_failed: true, node: node, errors: build_result[:errors] }
263
+ end
264
+
265
+ precondition = lambda do |c|
266
+ ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
267
+ end
268
+ # Row 1.19/1.20/D21: harness= rides alongside model= on every `running`
269
+ # line, resolved once by the caller through HarnessAdapter and threaded
270
+ # straight through here - never re-resolved, never a literal.
271
+ fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, effort: effort, harness: harness,
272
+ calls: calls_cap }
273
+
274
+ result = begin
275
+ ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
276
+ precondition: precondition)
277
+ rescue GuardedAppend::Unavailable
278
+ :unavailable
279
+ end
280
+
281
+ # Row 5.20: a refused (or unavailable) `running` write rolls back both
282
+ # side effects this method already produced - the node never ran, so
283
+ # nothing may act like it did.
284
+ unless result == :written
285
+ rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
286
+ worktree: worktree, created_this_dispatch: !pre_existing_worktree)
287
+ return { ok: false }
288
+ end
289
+
290
+ test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
291
+ spawn = spawn_block(model: model, effort: effort, input: build_result[:path], test_command: test_command,
292
+ call_cap: calls_cap)
293
+
294
+ {
295
+ ok: true,
296
+ entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, effort: effort,
297
+ worktree: provisioned[:path],
298
+ input: build_result[:path], spawn: spawn },
299
+ }
300
+ end
301
+
302
+ # The node's own declared budget: (frontmatter), or nil when it names
303
+ # none - M7. Parsed directly off the node file, never through
304
+ # `nodes_decl` (ReadySet.load_graph's own decl hash carries only kind and
305
+ # files, never budget), so this stays independent of that module.
306
+ def node_declared_budget(intent_dir, node)
307
+ path = ReadySet.find_node_path(intent_dir, node)
308
+ return nil unless path
309
+
310
+ nf = NodeFile.parse(path)
311
+ nf[:ok] ? nf[:budget] : nil
312
+ end
313
+ private_class_method :node_declared_budget
314
+
315
+ # true iff a `work` node's own worktree already exists BEFORE this call
316
+ # provisions anything - the pre-check `rollback_dispatch` needs to tell a
317
+ # worktree this dispatch created from one it merely found (row 10.16).
318
+ def worktree_pre_existing?(worktree, context, node, kind)
319
+ return false unless RunnerPolicy.worktree?(kind)
320
+
321
+ p = worktree.paths(context, node: node)
322
+ !!(p && p["path"] && Dir.exist?(p["path"]))
323
+ end
324
+ private_class_method :worktree_pre_existing?
325
+
326
+ def role_for(kind)
327
+ kind.to_s == "verify" ? "advisor" : "executor"
328
+ end
329
+
330
+ def unprovisioned
331
+ { ok: true, path: nil, branch: nil, provisioned: false }
332
+ end
333
+ private_class_method :unprovisioned
334
+
335
+ # Row 10.16/M13: `created_this_dispatch:` gates the worktree half of the
336
+ # rollback - a worktree this call did not create (kept on disk by a prior
337
+ # attempt's failed_verification, D7) is never touched, only a node input this
338
+ # call's own `input_builder` may have written is ever deleted.
339
+ def rollback_dispatch(context, node:, kind:, input_path:, runner:, worktree:, created_this_dispatch:)
340
+ File.delete(input_path) if input_path && File.exist?(input_path)
341
+ return unless RunnerPolicy.worktree?(kind)
342
+ return unless created_this_dispatch
343
+
344
+ p = worktree.paths(context, node: node)
345
+ return if p["path"].nil? || !Dir.exist?(p["path"])
346
+
347
+ Worktree.remove_worktree(runner, repo: p["repo"], worktree: p["path"])
348
+ Worktree.prune(runner, repo: p["repo"])
349
+ end
350
+ private_class_method :rollback_dispatch
351
+
352
+ # --- decision stop and the retry-cap park -----------------------------------
353
+
354
+ # Row 5.9/5.10: a ready decision node is never dispatched - it stops the
355
+ # WHOLE step (any node ranked after it this step is simply not reached)
356
+ # and writes its own `needs_decision` line so it reads that way from
357
+ # `status` too, carrying the exact `runner answer` command that clears it.
358
+ def write_decision_stop(savepoint_path, intent_dir, node, ledger:, now:)
359
+ question = decision_question(intent_dir, node)
360
+ safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
361
+ { reason: "decision", node: node, question: question, answer_command: answer_command(intent_dir, node) }
362
+ end
363
+ private_class_method :write_decision_stop
364
+
365
+ # Row 5.11/5.27/5.28: a node at RunnerPolicy's SOFT cap is parked at
366
+ # `needs_decision` with a synthesized `question=` rather than dispatched a
367
+ # further time, carrying the same `runner answer` shape.
368
+ def write_retry_cap_park(savepoint_path, intent_dir, node, kind, entries, ledger:, now:)
369
+ count = RunnerPolicy.retry_count(entries, node)
370
+ cap = RunnerPolicy.retry_cap(kind)
371
+ question = "#{node} has failed verification #{count} time(s), its #{kind} retry cap is #{cap}; " \
372
+ "retry, rewind, or abandon it?"
373
+ safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
374
+ { reason: "retry_cap", node: node, question: question, answer_command: answer_command(intent_dir, node) }
375
+ end
376
+ private_class_method :write_retry_cap_park
377
+
378
+ def safe_append(ledger, savepoint_path, node, state, fields, now:)
379
+ ledger.append_transition(savepoint_path, subject: node, state: state, fields: fields, now: now)
380
+ rescue GuardedAppend::Unavailable
381
+ nil
382
+ end
383
+ private_class_method :safe_append
384
+
385
+ def decision_question(intent_dir, node)
386
+ path = ReadySet.find_node_path(intent_dir, node)
387
+ fallback = "#{node} needs an owner decision; see its ## Question section"
388
+ return fallback unless path
389
+
390
+ nf = NodeFile.parse(path)
391
+ return fallback unless nf[:ok]
392
+
393
+ section = NodeFile.split_by_headings(nf[:body]).find { |(heading, _)| heading.to_s.strip == "## Question" }
394
+ text = section && section[1].to_s.strip
395
+ text && !text.empty? ? squash(text) : fallback
396
+ end
397
+ private_class_method :decision_question
398
+
399
+ def squash(text)
400
+ text.to_s.gsub(/\s+/, " ").strip
401
+ end
402
+ private_class_method :squash
403
+
404
+ # Row 5.10/5.28: the one command shape every stop and every park prints,
405
+ # matching scripts/runner's own published usage
406
+ # (`runner <step|status|answer> <intent_dir> [--node ID] [--answer TEXT]`).
407
+ def answer_command(intent_dir, node)
408
+ "runner answer #{intent_dir} --node #{node} --answer \"<your answer>\""
409
+ end
410
+
411
+ # Row 5.32: re-arms delivery.lock for a resumed session with a new id -
412
+ # `plastic-lock arm` is the shipped command that takes ownership again.
413
+ def rearm_command(intent_dir)
414
+ "plastic-lock arm --intent-dir #{intent_dir}"
415
+ end
416
+
417
+ # --- refusals and the report -------------------------------------------------
418
+
419
+ def empty_result
420
+ { ok: true, reason: nil, errors: [], rearm_command: nil, dispatched: [], stop: nil, parked: [],
421
+ status: nil, blockers: [], plan: nil, harness: nil }
422
+ end
423
+ private_class_method :empty_result
424
+
425
+ def lock_refusal(intent_dir)
426
+ empty_result.merge(ok: false, reason: "lock_not_held", rearm_command: rearm_command(intent_dir))
427
+ end
428
+ private_class_method :lock_refusal
429
+
430
+ def invalid_graph_result(errors)
431
+ empty_result.merge(ok: false, reason: "invalid_graph", errors: Array(errors))
432
+ end
433
+ private_class_method :invalid_graph_result
434
+
435
+ def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false, input_failures: [],
436
+ running_count: 0, harness: nil)
437
+ base = empty_result.merge(dispatched: dispatched, stop: stop, parked: parked,
438
+ plan: render_plan(dispatched), harness: harness)
439
+
440
+ if dispatched.any?
441
+ base.merge(status: "dispatched")
442
+ elsif stop
443
+ base.merge(status: "needs_decision")
444
+ elsif ceiling_blocked || running_count.to_i.positive?
445
+ # M10/v2 NEW-7: a ready node waiting on the concurrency ceiling is one
446
+ # shape of "still in flight" - a graph where every ready node is
447
+ # ALREADY running (no candidate ever reaches the ceiling check at all,
448
+ # so `ceiling_blocked` never sets) is the ordinary busy case, and it
449
+ # used to fall all the way through to `stalled`. Any node genuinely
450
+ # `running` means the graph can still continue on its own.
451
+ base.merge(status: "queued")
452
+ else
453
+ # Row 5.25: complete iff EVERY declared node is terminal - an empty
454
+ # ready set from parked/blocked nodes must never read as finished.
455
+ complete = RunnerCore.complete?(context)
456
+ if complete
457
+ base.merge(status: "complete")
458
+ else
459
+ # M6/row 10.7: a failed input build writes no ledger line at all,
460
+ # so `named_blockers` (ledger-derived) never sees it on its own -
461
+ # its own node and reason are named here so `stalled` never prints
462
+ # bare.
463
+ blockers = named_blockers(context) + input_failures.map { |f| input_failure_blocker(f) }
464
+ base.merge(status: "stalled", blockers: blockers)
465
+ end
466
+ end
467
+ end
468
+ private_class_method :build_report
469
+
470
+ def input_failure_blocker(failure)
471
+ "#{failure[:node]}: node input build failed (#{Array(failure[:errors]).join('; ')})"
472
+ end
473
+ private_class_method :input_failure_blocker
474
+
475
+ # Row 5.25a/5.26: every unfinished node's own blockers, with ReadySet's
476
+ # hard-attempt-cap wording renamed so it reads as the named backstop it is
477
+ # (D22: the exit here is `runner answer`, never another dispatch), rather
478
+ # than one more indistinguishable blocker line.
479
+ def named_blockers(context)
480
+ intent_dir = context.intent_dir
481
+ rows = RunnerCore.status(context)
482
+ rows.each_with_object([]) do |(id, view), out|
483
+ next if ReadySet::TERMINAL_STATES.include?(view[:state])
484
+
485
+ view[:blockers].each { |b| out << name_blocker(intent_dir, id, b) }
486
+ end
487
+ end
488
+ private_class_method :named_blockers
489
+
490
+ def name_blocker(intent_dir, id, blocker)
491
+ rest = blocker.to_s.sub(/\A#{Regexp.escape(id)} /, "")
492
+ m = rest.match(HARD_CAP_RE)
493
+ return "#{id}: #{blocker}" unless m
494
+
495
+ "#{id} has reached its hard attempt backstop (#{m[1]}/#{m[2]}) - #{answer_command(intent_dir, id)}"
496
+ end
497
+ private_class_method :name_blocker
498
+
499
+ # Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
500
+ # dispatched node, the node input path, the model, the worktree, the kind and
501
+ # the role, plus the return contract ONCE at the top level - never inside
502
+ # any one node's input. Row 6.4: "spawn" carries the same, already fully
503
+ # rendered spawn block for each dispatched node in order, so any reader of
504
+ # this data (YAML today, JSON if it is ever re-serialized) finds it under
505
+ # `spawn` rather than re-deriving it from the other fields.
506
+ def render_plan(dispatched)
507
+ return nil if dispatched.empty?
508
+
509
+ YAML.dump(
510
+ "return_contract" => RETURN_CONTRACT,
511
+ "dispatch" => dispatched.map do |d|
512
+ { "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
513
+ "effort" => d[:effort],
514
+ "worktree" => d[:worktree], "input" => d[:input] }
515
+ end,
516
+ "spawn" => dispatched.map { |d| d[:spawn] }
517
+ )
518
+ end
519
+ private_class_method :render_plan
520
+
521
+ def read_savepoint(path)
522
+ File.exist?(path) ? File.read(path) : ""
523
+ end
524
+ private_class_method :read_savepoint
525
+ end