@zalom/plastic 2.0.0-alpha.9 → 2.0.2

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 (326) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +21 -16
  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 +8 -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/docs/help/completion-and-done.md +53 -0
  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 +5 -4
  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 +378 -56
  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 +74 -91
  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 +111 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +68 -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 +58 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +75 -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 +79 -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 +46 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +95 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +35 -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 +77 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +65 -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 +55 -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 +235 -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 +543 -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 +921 -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 +170 -12
  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 +526 -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 +67 -19
  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/untouched_scaffold.rb +51 -0
  212. package/scripts/lib/verify_intent.rb +36 -8
  213. package/scripts/lib/version_number.rb +48 -0
  214. package/scripts/lib/work_graph.rb +59 -0
  215. package/scripts/lib/work_graph_validator.rb +201 -0
  216. package/scripts/lib/worktree.rb +27 -32
  217. package/scripts/lib/worktree_sweep.rb +6 -5
  218. package/scripts/link-suggest +2 -1
  219. package/scripts/meter-watch +57 -0
  220. package/scripts/migrate-to-global +1 -1
  221. package/scripts/new-intent +4 -13
  222. package/scripts/node-input +92 -0
  223. package/scripts/node-run +225 -0
  224. package/scripts/node-transition +291 -0
  225. package/scripts/outcome-report +74 -0
  226. package/scripts/plastic-lock +65 -60
  227. package/scripts/project-links +6 -21
  228. package/scripts/promote-session-item +3 -2
  229. package/scripts/read-config +53 -9
  230. package/scripts/ready-set +126 -0
  231. package/scripts/release-check +123 -0
  232. package/scripts/report-screen +177 -16
  233. package/scripts/roadmap-graph +125 -0
  234. package/scripts/roadmap-savepoint +7 -0
  235. package/scripts/rollback.rb +5 -1
  236. package/scripts/runner +581 -0
  237. package/scripts/savepoint-note +11 -9
  238. package/scripts/session-commit +2 -1
  239. package/scripts/session-usage +56 -0
  240. package/scripts/skill-lint +115 -6
  241. package/scripts/spawn-preamble +2 -2
  242. package/scripts/update.rb +31 -4
  243. package/scripts/validate-work-graph +39 -0
  244. package/scripts/verify-intent +3 -2
  245. package/scripts/write-handoff +2 -1
  246. package/templates/agents.md +7 -7
  247. package/templates/config.yml +16 -9
  248. package/templates/dashboard-screen.md +22 -0
  249. package/templates/display-fixture.md +21 -0
  250. package/templates/graph.md +16 -0
  251. package/templates/index.md +1 -1
  252. package/templates/intent-screen.md +1 -1
  253. package/templates/node-decision.md +11 -0
  254. package/templates/node-research.md +13 -0
  255. package/templates/node-verify.md +13 -0
  256. package/templates/node-work.md +22 -0
  257. package/templates/outcome.md +8 -3
  258. package/templates/project.yml +1 -1
  259. package/templates/render.css +10 -0
  260. package/templates/report-plan.md +15 -0
  261. package/templates/report-roadmap-delivered.md +10 -0
  262. package/templates/report-roadmap-plan.md +9 -0
  263. package/templates/report-roadmap-state.md +9 -0
  264. package/templates/report-state.md +1 -1
  265. package/templates/roadmap.md +13 -0
  266. package/bin/plastic.js +0 -70
  267. package/scripts/lib/bridge.rb +0 -116
  268. package/skills/agent-advisor/SKILL.md +0 -92
  269. package/skills/auto/SKILL.md +0 -296
  270. package/skills/auto/evals/evals.json +0 -255
  271. package/skills/auto/references/end-tail.md +0 -66
  272. package/skills/auto/references/human-report-contract.md +0 -78
  273. package/skills/conventions/SKILL.md +0 -29
  274. package/skills/conventions/references/completion-and-done.md +0 -43
  275. package/skills/dashboard/SKILL.md +0 -169
  276. package/skills/dashboard/evals/evals.json +0 -38
  277. package/skills/dashboard/references/classification.md +0 -22
  278. package/skills/dashboard/templates/dashboard-global.md +0 -20
  279. package/skills/dashboard/templates/dashboard-project.md +0 -19
  280. package/skills/direct/SKILL.md +0 -66
  281. package/skills/direct/references/request-signals.md +0 -59
  282. package/skills/doctor/SKILL.md +0 -299
  283. package/skills/doctor/report.md +0 -102
  284. package/skills/feedback/SKILL.md +0 -98
  285. package/skills/feedback/references/transport-and-privacy.md +0 -65
  286. package/skills/feedback/report.md +0 -36
  287. package/skills/install/SKILL.md +0 -217
  288. package/skills/intent-continuing/SKILL.md +0 -154
  289. package/skills/intent-continuing/references/board-fill.md +0 -43
  290. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  291. package/skills/intent-continuing/references/context-management.md +0 -28
  292. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  293. package/skills/intent-creating/SKILL.md +0 -164
  294. package/skills/intent-creating/evals/evals.json +0 -72
  295. package/skills/intent-creating/references/lifecycle.md +0 -81
  296. package/skills/intent-creating/references/wikilinks.md +0 -8
  297. package/skills/intent-ending/SKILL.md +0 -176
  298. package/skills/intent-ending/evals/evals.json +0 -74
  299. package/skills/intent-executing/SKILL.md +0 -170
  300. package/skills/intent-executing/evals/evals.json +0 -66
  301. package/skills/intent-executing/implementer-prompt.md +0 -42
  302. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  303. package/skills/intent-speccing/SKILL.md +0 -130
  304. package/skills/intent-speccing/evals/evals.json +0 -126
  305. package/skills/intent-speccing/references/design-principles.md +0 -44
  306. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  307. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  308. package/skills/project-creating/SKILL.md +0 -162
  309. package/skills/project-creating/references/hubs-projects.md +0 -55
  310. package/skills/project-creating/references/project-scaffolding.md +0 -97
  311. package/skills/releasing/SKILL.md +0 -337
  312. package/skills/releasing/references/deprecations.md +0 -60
  313. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  314. package/skills/releasing/references/release-lines.md +0 -105
  315. package/skills/roadmap/SKILL.md +0 -64
  316. package/skills/roadmap/references/file-format.md +0 -124
  317. package/skills/roadmap/references/operations.md +0 -112
  318. package/skills/rollback/SKILL.md +0 -91
  319. package/skills/tutorial/SKILL.md +0 -65
  320. package/skills/tutorial/evals/evals.json +0 -186
  321. package/skills/uninstall/SKILL.md +0 -75
  322. package/skills/update/SKILL.md +0 -126
  323. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  324. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  325. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  326. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,526 @@
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
+ # The spawn block's default agent. A dispatched node names its own kind's
50
+ # node agent instead (HarnessAdapter.agent_type_for_kind), the same agent
51
+ # the dispatch line under it names (acceptance N12).
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
+ "read_only" => !RunnerPolicy.worktree?(kind) }
242
+ end
243
+
244
+ # Row 5.18: build the node input BEFORE writing `running` - a `running` line
245
+ # naming bytes that do not exist yet is worse than a node input nobody reads.
246
+ # Row 5.30: `force: true` always - a fresh attempt number this dispatch
247
+ # computes is, by construction, never one `running` has already claimed,
248
+ # so an existing file at that path is always an orphan from a step that
249
+ # crashed between building the node input and writing `running`, safe to
250
+ # overwrite outright.
251
+ # Row 5.20/10.8: the node's own declared budget: (M7) - nil when the node
252
+ # names none, in which case NodeInput.build falls back to its own
253
+ # default (row 10.9).
254
+ build_result = input_builder.call(intent_dir: intent_dir, node: node, holder: holder, expires: expires,
255
+ model: model, force: true, worktree_reader: node_reader,
256
+ budget_tokens: node_declared_budget(intent_dir, node), call_cap: calls_cap)
257
+ unless build_result[:ok]
258
+ # M6: a failed input build never leaves an orphan worktree behind, and
259
+ # its errors travel back up so the step's report can name the node and
260
+ # the reason instead of a bare "stalled" (row 10.6/10.7).
261
+ rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
262
+ worktree: worktree, created_this_dispatch: !pre_existing_worktree)
263
+ return { ok: false, input_build_failed: true, node: node, errors: build_result[:errors] }
264
+ end
265
+
266
+ precondition = lambda do |c|
267
+ ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
268
+ end
269
+ # Row 1.19/1.20/D21: harness= rides alongside model= on every `running`
270
+ # line, resolved once by the caller through HarnessAdapter and threaded
271
+ # straight through here - never re-resolved, never a literal.
272
+ fields = { holder: holder, expires: expires, input: build_result[:sha], model: model, effort: effort, harness: harness,
273
+ calls: calls_cap }
274
+
275
+ result = begin
276
+ ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
277
+ precondition: precondition)
278
+ rescue GuardedAppend::Unavailable
279
+ :unavailable
280
+ end
281
+
282
+ # Row 5.20: a refused (or unavailable) `running` write rolls back both
283
+ # side effects this method already produced - the node never ran, so
284
+ # nothing may act like it did.
285
+ unless result == :written
286
+ rollback_dispatch(context, node: node, kind: kind, input_path: build_result[:path], runner: runner,
287
+ worktree: worktree, created_this_dispatch: !pre_existing_worktree)
288
+ return { ok: false }
289
+ end
290
+
291
+ test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
292
+ spawn = spawn_block(model: model, effort: effort, input: build_result[:path], test_command: test_command,
293
+ call_cap: calls_cap, agent: HarnessAdapter.agent_type_for_kind(kind))
294
+
295
+ {
296
+ ok: true,
297
+ entry: { node: node, kind: kind.to_s, role: role_for(kind), model: model, effort: effort,
298
+ worktree: provisioned[:path],
299
+ input: build_result[:path], spawn: spawn },
300
+ }
301
+ end
302
+
303
+ # The node's own declared budget: (frontmatter), or nil when it names
304
+ # none - M7. Parsed directly off the node file, never through
305
+ # `nodes_decl` (ReadySet.load_graph's own decl hash carries only kind and
306
+ # files, never budget), so this stays independent of that module.
307
+ def node_declared_budget(intent_dir, node)
308
+ path = ReadySet.find_node_path(intent_dir, node)
309
+ return nil unless path
310
+
311
+ nf = NodeFile.parse(path)
312
+ nf[:ok] ? nf[:budget] : nil
313
+ end
314
+ private_class_method :node_declared_budget
315
+
316
+ # true iff a `work` node's own worktree already exists BEFORE this call
317
+ # provisions anything - the pre-check `rollback_dispatch` needs to tell a
318
+ # worktree this dispatch created from one it merely found (row 10.16).
319
+ def worktree_pre_existing?(worktree, context, node, kind)
320
+ return false unless RunnerPolicy.worktree?(kind)
321
+
322
+ p = worktree.paths(context, node: node)
323
+ !!(p && p["path"] && Dir.exist?(p["path"]))
324
+ end
325
+ private_class_method :worktree_pre_existing?
326
+
327
+ def role_for(kind)
328
+ kind.to_s == "verify" ? "advisor" : "executor"
329
+ end
330
+
331
+ def unprovisioned
332
+ { ok: true, path: nil, branch: nil, provisioned: false }
333
+ end
334
+ private_class_method :unprovisioned
335
+
336
+ # Row 10.16/M13: `created_this_dispatch:` gates the worktree half of the
337
+ # rollback - a worktree this call did not create (kept on disk by a prior
338
+ # attempt's failed_verification, D7) is never touched, only a node input this
339
+ # call's own `input_builder` may have written is ever deleted.
340
+ def rollback_dispatch(context, node:, kind:, input_path:, runner:, worktree:, created_this_dispatch:)
341
+ File.delete(input_path) if input_path && File.exist?(input_path)
342
+ return unless RunnerPolicy.worktree?(kind)
343
+ return unless created_this_dispatch
344
+
345
+ p = worktree.paths(context, node: node)
346
+ return if p["path"].nil? || !Dir.exist?(p["path"])
347
+
348
+ Worktree.remove_worktree(runner, repo: p["repo"], worktree: p["path"])
349
+ Worktree.prune(runner, repo: p["repo"])
350
+ end
351
+ private_class_method :rollback_dispatch
352
+
353
+ # --- decision stop and the retry-cap park -----------------------------------
354
+
355
+ # Row 5.9/5.10: a ready decision node is never dispatched - it stops the
356
+ # WHOLE step (any node ranked after it this step is simply not reached)
357
+ # and writes its own `needs_decision` line so it reads that way from
358
+ # `status` too, carrying the exact `runner answer` command that clears it.
359
+ def write_decision_stop(savepoint_path, intent_dir, node, ledger:, now:)
360
+ question = decision_question(intent_dir, node)
361
+ safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
362
+ { reason: "decision", node: node, question: question, answer_command: answer_command(intent_dir, node) }
363
+ end
364
+ private_class_method :write_decision_stop
365
+
366
+ # Row 5.11/5.27/5.28: a node at RunnerPolicy's SOFT cap is parked at
367
+ # `needs_decision` with a synthesized `question=` rather than dispatched a
368
+ # further time, carrying the same `runner answer` shape.
369
+ def write_retry_cap_park(savepoint_path, intent_dir, node, kind, entries, ledger:, now:)
370
+ count = RunnerPolicy.retry_count(entries, node)
371
+ cap = RunnerPolicy.retry_cap(kind)
372
+ question = "#{node} has failed verification #{count} time(s), its #{kind} retry cap is #{cap}; " \
373
+ "retry, rewind, or abandon it?"
374
+ safe_append(ledger, savepoint_path, node, "needs_decision", { question: question }, now: now)
375
+ { reason: "retry_cap", node: node, question: question, answer_command: answer_command(intent_dir, node) }
376
+ end
377
+ private_class_method :write_retry_cap_park
378
+
379
+ def safe_append(ledger, savepoint_path, node, state, fields, now:)
380
+ ledger.append_transition(savepoint_path, subject: node, state: state, fields: fields, now: now)
381
+ rescue GuardedAppend::Unavailable
382
+ nil
383
+ end
384
+ private_class_method :safe_append
385
+
386
+ def decision_question(intent_dir, node)
387
+ path = ReadySet.find_node_path(intent_dir, node)
388
+ fallback = "#{node} needs an owner decision; see its ## Question section"
389
+ return fallback unless path
390
+
391
+ nf = NodeFile.parse(path)
392
+ return fallback unless nf[:ok]
393
+
394
+ section = NodeFile.split_by_headings(nf[:body]).find { |(heading, _)| heading.to_s.strip == "## Question" }
395
+ text = section && section[1].to_s.strip
396
+ text && !text.empty? ? squash(text) : fallback
397
+ end
398
+ private_class_method :decision_question
399
+
400
+ def squash(text)
401
+ text.to_s.gsub(/\s+/, " ").strip
402
+ end
403
+ private_class_method :squash
404
+
405
+ # Row 5.10/5.28: the one command shape every stop and every park prints,
406
+ # matching scripts/runner's own published usage
407
+ # (`runner <step|status|answer> <intent_dir> [--node ID] [--answer TEXT]`).
408
+ def answer_command(intent_dir, node)
409
+ "runner answer #{intent_dir} --node #{node} --answer \"<your answer>\""
410
+ end
411
+
412
+ # Row 5.32: re-arms delivery.lock for a resumed session with a new id -
413
+ # `plastic auto take` is the public command that takes ownership again.
414
+ def rearm_command(intent_dir)
415
+ "plastic auto take #{File.basename(intent_dir.to_s).split("--").first}"
416
+ end
417
+
418
+ # --- refusals and the report -------------------------------------------------
419
+
420
+ def empty_result
421
+ { ok: true, reason: nil, errors: [], rearm_command: nil, dispatched: [], stop: nil, parked: [],
422
+ status: nil, blockers: [], plan: nil, harness: nil }
423
+ end
424
+ private_class_method :empty_result
425
+
426
+ def lock_refusal(intent_dir)
427
+ empty_result.merge(ok: false, reason: "lock_not_held", rearm_command: rearm_command(intent_dir))
428
+ end
429
+ private_class_method :lock_refusal
430
+
431
+ def invalid_graph_result(errors)
432
+ empty_result.merge(ok: false, reason: "invalid_graph", errors: Array(errors))
433
+ end
434
+ private_class_method :invalid_graph_result
435
+
436
+ def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false, input_failures: [],
437
+ running_count: 0, harness: nil)
438
+ base = empty_result.merge(dispatched: dispatched, stop: stop, parked: parked,
439
+ plan: render_plan(dispatched), harness: harness)
440
+
441
+ if dispatched.any?
442
+ base.merge(status: "dispatched")
443
+ elsif stop
444
+ base.merge(status: "needs_decision")
445
+ elsif ceiling_blocked || running_count.to_i.positive?
446
+ # M10/v2 NEW-7: a ready node waiting on the concurrency ceiling is one
447
+ # shape of "still in flight" - a graph where every ready node is
448
+ # ALREADY running (no candidate ever reaches the ceiling check at all,
449
+ # so `ceiling_blocked` never sets) is the ordinary busy case, and it
450
+ # used to fall all the way through to `stalled`. Any node genuinely
451
+ # `running` means the graph can still continue on its own.
452
+ base.merge(status: "queued")
453
+ else
454
+ # Row 5.25: complete iff EVERY declared node is terminal - an empty
455
+ # ready set from parked/blocked nodes must never read as finished.
456
+ complete = RunnerCore.complete?(context)
457
+ if complete
458
+ base.merge(status: "complete")
459
+ else
460
+ # M6/row 10.7: a failed input build writes no ledger line at all,
461
+ # so `named_blockers` (ledger-derived) never sees it on its own -
462
+ # its own node and reason are named here so `stalled` never prints
463
+ # bare.
464
+ blockers = named_blockers(context) + input_failures.map { |f| input_failure_blocker(f) }
465
+ base.merge(status: "stalled", blockers: blockers)
466
+ end
467
+ end
468
+ end
469
+ private_class_method :build_report
470
+
471
+ def input_failure_blocker(failure)
472
+ "#{failure[:node]}: node input build failed (#{Array(failure[:errors]).join('; ')})"
473
+ end
474
+ private_class_method :input_failure_blocker
475
+
476
+ # Row 5.25a/5.26: every unfinished node's own blockers, with ReadySet's
477
+ # hard-attempt-cap wording renamed so it reads as the named backstop it is
478
+ # (D22: the exit here is `runner answer`, never another dispatch), rather
479
+ # than one more indistinguishable blocker line.
480
+ def named_blockers(context)
481
+ intent_dir = context.intent_dir
482
+ rows = RunnerCore.status(context)
483
+ rows.each_with_object([]) do |(id, view), out|
484
+ next if ReadySet::TERMINAL_STATES.include?(view[:state])
485
+
486
+ view[:blockers].each { |b| out << name_blocker(intent_dir, id, b) }
487
+ end
488
+ end
489
+ private_class_method :named_blockers
490
+
491
+ def name_blocker(intent_dir, id, blocker)
492
+ rest = blocker.to_s.sub(/\A#{Regexp.escape(id)} /, "")
493
+ m = rest.match(HARD_CAP_RE)
494
+ return "#{id}: #{blocker}" unless m
495
+
496
+ "#{id} has reached its hard attempt backstop (#{m[1]}/#{m[2]}) - #{answer_command(intent_dir, id)}"
497
+ end
498
+ private_class_method :name_blocker
499
+
500
+ # Row 5.22/5.23/5.24: one machine-readable (YAML) document naming, per
501
+ # dispatched node, the node input path, the model, the worktree, the kind and
502
+ # the role, plus the return contract ONCE at the top level - never inside
503
+ # any one node's input. Row 6.4: "spawn" carries the same, already fully
504
+ # rendered spawn block for each dispatched node in order, so any reader of
505
+ # this data (YAML today, JSON if it is ever re-serialized) finds it under
506
+ # `spawn` rather than re-deriving it from the other fields.
507
+ def render_plan(dispatched)
508
+ return nil if dispatched.empty?
509
+
510
+ YAML.dump(
511
+ "return_contract" => RETURN_CONTRACT,
512
+ "dispatch" => dispatched.map do |d|
513
+ { "node" => d[:node], "kind" => d[:kind], "role" => d[:role], "model" => d[:model],
514
+ "effort" => d[:effort],
515
+ "worktree" => d[:worktree], "input" => d[:input] }
516
+ end,
517
+ "spawn" => dispatched.map { |d| d[:spawn] }
518
+ )
519
+ end
520
+ private_class_method :render_plan
521
+
522
+ def read_savepoint(path)
523
+ File.exist?(path) ? File.read(path) : ""
524
+ end
525
+ private_class_method :read_savepoint
526
+ end