@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,191 @@
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-primary-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-primary-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: {}, harness: "claude-code")
73
+ alias_or_id = policy_for(kind)[:model_role] == :advisor ? advisor_model(config: config, harness: harness) :
74
+ executor_model(config: config, harness: harness)
75
+ codex_harness?(harness) ? (AgentModels.codex_model_for(alias_or_id) || alias_or_id) : alias_or_id
76
+ end
77
+
78
+ def executor_model(config: {}, harness: "claude-code")
79
+ resolve_model(config, EXECUTOR_CONFIG_KEY, DEFAULT_EXECUTOR_MODEL, harness)
80
+ end
81
+
82
+ def advisor_model(config: {}, harness: "claude-code")
83
+ resolve_model(config, ADVISOR_CONFIG_KEY, DEFAULT_ADVISOR_MODEL, harness)
84
+ end
85
+
86
+ def effort_for(kind, config: {}, harness: "claude-code")
87
+ key = policy_for(kind)[:model_role] == :advisor ? ADVISOR_CONFIG_KEY : EXECUTOR_CONFIG_KEY
88
+ value = AgentModels.efforts_section(config, harness_name(harness))[key]
89
+ present?(value) ? value : AgentModels::DEFAULT_EFFORT
90
+ end
91
+
92
+ def resolve_model(config, key, shipped_default, harness)
93
+ value = AgentModels.models_section(config, harness: harness_name(harness))[key]
94
+ present?(value) ? value : shipped_default
95
+ end
96
+ private_class_method :resolve_model
97
+
98
+ def harness_name(harness)
99
+ codex_harness?(harness) ? "codex" : "claude"
100
+ end
101
+ private_class_method :harness_name
102
+
103
+ def codex_harness?(harness)
104
+ harness.to_s == "codex"
105
+ end
106
+ private_class_method :codex_harness?
107
+
108
+ def present?(value)
109
+ !(value.nil? || value.to_s.strip.empty?)
110
+ end
111
+ private_class_method :present?
112
+
113
+ # matrix 5.16/5.17: only `work` gets a worktree; an unknown kind falls back
114
+ # to `work`'s own row like every other field here. `KIND_TABLE["work"]` is
115
+ # kept equal to `NodeWorktree::WORKTREE_KINDS` by the assertion below, so
116
+ # the two lists cannot silently drift apart.
117
+ raise "RunnerPolicy/NodeWorktree kind lists have drifted" unless KIND_TABLE.select { |_, v| v[:worktree] }.keys ==
118
+ NodeWorktree::WORKTREE_KINDS
119
+
120
+ def worktree?(kind)
121
+ !!policy_for(kind)[:worktree]
122
+ end
123
+
124
+ def diff_rule(kind)
125
+ policy_for(kind)[:diff_rule]
126
+ end
127
+
128
+ # matrix 5.12: the SOFT cap, or nil for a kind that carries none (decision).
129
+ def retry_cap(kind)
130
+ policy_for(kind)[:retry_cap]
131
+ end
132
+
133
+ # The soft-cap count itself: every non-torn `failed_verification` line for
134
+ # `node`, delegated to ReadySet (matrix 5.12's "counts from
135
+ # failed_verification lines", never from attempts).
136
+ def retry_count(entries, node)
137
+ ReadySet.failed_verification_count(entries, node.to_s)
138
+ end
139
+
140
+ # true only when a real cap exists AND the count has reached it (a nil cap,
141
+ # e.g. `decision`, never trips).
142
+ def at_retry_cap?(entries, node, kind)
143
+ cap = retry_cap(kind)
144
+ return false if cap.nil?
145
+
146
+ retry_count(entries, node) >= cap
147
+ end
148
+
149
+ # matrix 5.21: `expires=` on `running` comes from the kind's own lease
150
+ # length, never one shared constant - a long `work` build must not be
151
+ # reclaimed under a still-working executor the way a quick `verify` pass
152
+ # would be.
153
+ def lease_minutes(kind)
154
+ policy_for(kind)[:lease_minutes] || policy_for("work")[:lease_minutes]
155
+ end
156
+
157
+ def lease_expires(kind, now: Time.now)
158
+ (now + (lease_minutes(kind) * 60)).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
159
+ end
160
+
161
+ # --- call budget (intent 355, n2) -------------------------------------------
162
+ #
163
+ # D2: a cap on tool calls per attempt, enforced by a PreToolUse hook that
164
+ # counts tool calls in the session transcript. Shipped per kind (matrix
165
+ # 2.1); `decision` carries one too even though it is never dispatched
166
+ # (327 D12 leaves it out of every lease table for the same reason), so
167
+ # `call_cap` never has to special-case an unknown kind here any more than
168
+ # `retry_cap` does.
169
+ CALL_CAP_TABLE = { "work" => 60, "verify" => 40, "research" => 40, "decision" => 10 }.freeze
170
+
171
+ # matrix 5.17's own fallback rule, one call: an unknown or nil kind reads
172
+ # `work`'s cap, never a fourth, undeclared number.
173
+ def call_cap(kind, config: {})
174
+ override = call_caps_section(config)[kind.to_s]
175
+ present?(override) ? override.to_i : CALL_CAP_TABLE.fetch(kind.to_s, CALL_CAP_TABLE["work"])
176
+ end
177
+
178
+ # matrix 2.2: `runner.call_caps.<kind>` in the project config overrides the
179
+ # shipped cap, the same nested-Hash shape AgentModels.models_section reads
180
+ # `agents.models` from - one more caller of the pattern, not a new one.
181
+ def call_caps_section(config)
182
+ return {} unless config.is_a?(Hash)
183
+
184
+ runner = config["runner"]
185
+ return {} unless runner.is_a?(Hash)
186
+
187
+ section = runner["call_caps"]
188
+ section.is_a?(Hash) ? section : {}
189
+ end
190
+ private_class_method :call_caps_section
191
+ end
@@ -0,0 +1,275 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "node_file"
6
+ require_relative "node_ids"
7
+ require_relative "graph_edges"
8
+ require_relative "graph_file"
9
+ require_relative "atomic_write"
10
+ require_relative "ready_set"
11
+ require_relative "node_ledger"
12
+ require_relative "savepoint"
13
+ require_relative "work_graph_validator"
14
+
15
+ # RunnerProposals (intent 340, G7, n6): accepts or refuses what an executor
16
+ # proposed in its return (327 D15, D28, D30). The runner mints every node id;
17
+ # an executor never sees or picks one. A proposed node is scaffolded from its
18
+ # kind's template, its id substituted everywhere the template hard-codes its
19
+ # own placeholder (`n1`, `v1`, `d1`, `r1` - the frontmatter key, the H1, and
20
+ # the failure-mode matrix heading), and appended to graph.md's ## Graph
21
+ # section as a fresh, undispatched line - no ledger line at all, which reads
22
+ # as "planned" by construction. A proposed edge is accepted only when both
23
+ # endpoints exist (among the already-declared nodes or a node this very call
24
+ # is minting), its head is not `running`, and the resulting graph stays
25
+ # acyclic (D30); a refusal writes one ledger comment naming which of the
26
+ # three failed.
27
+ #
28
+ # One call, one all-or-nothing write (D15's "nothing is partially written"):
29
+ # every proposed node and edge is validated BEFORE anything touches disk. A
30
+ # single rejected edge refuses the WHOLE call, leaving graph.md and nodes/
31
+ # byte-identical to how #accept found them, and burning no minted id (an id
32
+ # is only ever consumed once a node FILE actually exists; NodeIds.taken never
33
+ # sees one this call abandoned).
34
+ #
35
+ # Pure and dependency-injected: the full validator re-run (327 D17's "a mid-
36
+ # run scaffold is checked, not merely accepted"), the templates directory,
37
+ # the clock and the rename call are all injectable keyword arguments with
38
+ # real defaults, so a test never touches a real ~/.plastic install.
39
+ module RunnerProposals
40
+ module_function
41
+
42
+ KIND_TEMPLATES = {
43
+ "work" => "node-work.md",
44
+ "verify" => "node-verify.md",
45
+ "decision" => "node-decision.md",
46
+ "research" => "node-research.md",
47
+ }.freeze
48
+
49
+ REVIEW_FIX_CAP = 2
50
+
51
+ # accept(context, proposer:, proposed_nodes:, proposed_edges:, now:,
52
+ # validator:, templates_dir:, renamer:) -> {ok:, minted:, validator:,
53
+ # errors:}. `proposer` names the node whose return carried these proposals
54
+ # - it is used only to attribute the refusal comment; the runner itself
55
+ # decides everything about the proposal's fate.
56
+ def accept(context, proposer:, proposed_nodes: [], proposed_edges: [], now: Time.now,
57
+ validator: WorkGraphValidator.method(:validate), templates_dir: nil,
58
+ renamer: File.method(:rename))
59
+ intent_dir = context.intent_dir
60
+ graph_path = File.join(intent_dir, "graph.md")
61
+ templates_root = templates_dir || File.join(context.plastic_home.to_s, "templates")
62
+
63
+ # v2 NEW-5/row 11.8: `ReadySet.load_graph` documents itself as never
64
+ # raising, but that guarantee is only as good as the parser underneath
65
+ # it - a `graph.md` carrying invalid UTF-8 bytes raises `ArgumentError`
66
+ # out of `GraphFile`'s own fence-line scan before `load_graph` ever gets
67
+ # a chance to catch it. M4 made this call reachable straight from
68
+ # `RunnerAbsorb#absorb`, so a `--return` carrying a proposal against a
69
+ # malformed graph used to die with a raw stack trace instead of a plain
70
+ # refusal.
71
+ loaded = safe_load_graph(intent_dir)
72
+ return refuse(intent_dir, proposer, "graph.md is unreadable: #{loaded[:errors].join('; ')}", now) if loaded[:unreadable]
73
+
74
+ declared = loaded[:edges].keys
75
+ status_map = NodeLedger.status_from_content(read_savepoint(intent_dir))
76
+
77
+ mint_pool = NodeIds.taken(intent_dir).dup
78
+ node_specs = []
79
+
80
+ Array(proposed_nodes).each do |raw|
81
+ spec = stringify(raw)
82
+ kind = spec["kind"].to_s
83
+ template_name = KIND_TEMPLATES[kind]
84
+ unless template_name
85
+ return refuse(intent_dir, proposer, "proposed node kind #{kind.inspect} has no template", now)
86
+ end
87
+
88
+ template_path = File.join(templates_root, template_name)
89
+ unless File.exist?(template_path)
90
+ return refuse(intent_dir, proposer, "missing template for kind #{kind.inspect} at #{template_path}", now)
91
+ end
92
+
93
+ id = NodeFile.mint_id(kind, mint_pool)
94
+ mint_pool << id
95
+ node_specs << {
96
+ id: id, kind: kind, template_path: template_path,
97
+ needs: Array(spec["needs"]).map(&:to_s),
98
+ files: spec["files"].nil? ? nil : Array(spec["files"]),
99
+ budget: spec["budget"],
100
+ }
101
+ end
102
+
103
+ trial_edges = loaded[:edges].dup
104
+ node_specs.each { |s| trial_edges[s[:id]] = s[:needs] }
105
+ known_ids = declared + node_specs.map { |s| s[:id] }
106
+
107
+ edge_specs = []
108
+ Array(proposed_edges).each do |raw|
109
+ e = stringify(raw)
110
+ from = e["from"].to_s
111
+ to = e["to"].to_s
112
+
113
+ unless known_ids.include?(from) && known_ids.include?(to)
114
+ return refuse(intent_dir, proposer,
115
+ "edge #{from}->#{to} refused (endpoint_unknown): both ends must be a declared or proposed node", now)
116
+ end
117
+
118
+ if status_map.fetch(from, "planned") == "running"
119
+ return refuse(intent_dir, proposer,
120
+ "edge #{from}->#{to} refused (head_running): #{from} is currently running", now)
121
+ end
122
+
123
+ trial = trial_edges.dup
124
+ trial[from] = (trial[from] || []) + [to]
125
+ if GraphEdges.cycle(trial)
126
+ return refuse(intent_dir, proposer,
127
+ "edge #{from}->#{to} refused (would_cycle): would make the graph cyclic", now)
128
+ end
129
+
130
+ trial_edges = trial
131
+ edge_specs << { from: from, to: to }
132
+ end
133
+
134
+ trial_nodes = loaded[:nodes].merge(node_specs.to_h { |s| [s[:id], { kind: s[:kind] }] })
135
+ existing_fixes = review_fix_count(loaded[:edges], loaded[:nodes])
136
+ trial_fixes = review_fix_count(trial_edges, trial_nodes)
137
+ if trial_fixes > [existing_fixes, REVIEW_FIX_CAP].max
138
+ return refuse(intent_dir, proposer,
139
+ "proposal refused (review_fix_cap): would make #{trial_fixes} review fixes, the cap is #{REVIEW_FIX_CAP}", now)
140
+ end
141
+
142
+ node_specs.each { |s| scaffold_node_file(intent_dir, s) }
143
+ if node_specs.any? || edge_specs.any?
144
+ append_to_graph(graph_path, node_specs: node_specs, edge_specs: edge_specs, renamer: renamer)
145
+ end
146
+
147
+ # v2 NEW-5/row 11.8: the accept itself already landed on disk by this
148
+ # point (327 D17's "a mid-run scaffold is checked" runs strictly AFTER
149
+ # the write) - a raising validator must report a broken verdict, never
150
+ # turn an already-successful accept into an uncaught exception.
151
+ { ok: true, minted: node_specs.map { |s| s[:id] }, validator: safe_validate(validator, intent_dir), errors: [] }
152
+ end
153
+
154
+ # --- review fixes (355 D4) -------------------------------------------------
155
+
156
+ # review_fix_count(edges, nodes) -> how many work nodes GraphEdges.review_fixes
157
+ # finds, a node's kind read from its declaration and, when that is missing,
158
+ # from its id's NodeFile::KIND_PREFIX (343 D6).
159
+ def review_fix_count(edges, nodes)
160
+ ids = (edges.keys + edges.values.flatten).uniq
161
+ kinds = ids.to_h { |id| [id, (nodes[id] || {})[:kind] || NodeFile::KIND_PREFIX.key(id.to_s[/\A[a-z]+/])] }
162
+ GraphEdges.review_fixes(edges, kinds).length
163
+ end
164
+
165
+ # --- node scaffolding --------------------------------------------------
166
+
167
+ # A template's own placeholder id ("n1", "v1", ...) is replaced only as a
168
+ # whole token, so minting past 9 (a proposed "n10") never mangles a
169
+ # coincidental substring match.
170
+ def substitute_id(text, old_id, new_id)
171
+ text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
172
+ end
173
+
174
+ def scaffold_node_file(intent_dir, spec)
175
+ parsed_template = NodeFile.parse(spec[:template_path])
176
+ old_id = parsed_template[:node]
177
+ raw = File.read(spec[:template_path])
178
+ parts = raw.split("---", 3)
179
+ body = substitute_id(parts[2].to_s, old_id, spec[:id])
180
+
181
+ files = spec[:files].nil? ? parsed_template[:files] : spec[:files]
182
+ budget = spec[:budget].nil? ? parsed_template[:budget] : spec[:budget]
183
+
184
+ frontmatter = "---\nnode: #{spec[:id]}\nkind: #{spec[:kind]}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
185
+ path = File.join(intent_dir, "nodes", "#{spec[:id]}.md")
186
+ FileUtils.mkdir_p(File.dirname(path))
187
+ File.write(path, "#{frontmatter}#{body}")
188
+ end
189
+ private_class_method :scaffold_node_file
190
+
191
+ # --- graph.md editing ----------------------------------------------------
192
+
193
+ # Appends one "- <id> needs <targets>" line per minted node, then applies
194
+ # each accepted edge onto the line (existing or just-appended) whose id
195
+ # matches its `from`, through GraphFile's own public section helpers
196
+ # (section_bounds, section_body, replace_or_append_section - none of them
197
+ # private_class_method'd) so this stays a plain text edit, never a second
198
+ # bespoke parser.
199
+ def append_to_graph(graph_path, node_specs:, edge_specs:, renamer:)
200
+ content = File.read(graph_path)
201
+ bounds = GraphFile.section_bounds(content, "## Graph")
202
+ return unless bounds
203
+
204
+ body = GraphFile.section_body(content, "## Graph").to_s
205
+ lines = body.split("\n")
206
+
207
+ node_specs.each do |s|
208
+ rendered = s[:needs].empty? ? "nothing" : s[:needs].join(" ")
209
+ lines << "- #{s[:id]} needs #{rendered}"
210
+ end
211
+
212
+ edge_specs.each do |e|
213
+ idx = lines.index { |l| l.strip.match?(/\A-\s*#{Regexp.escape(e[:from])}\s+needs\b/) }
214
+ next unless idx
215
+
216
+ stripped = lines[idx].strip
217
+ m = stripped.match(/\A-\s*(\S+)\s+needs\s+(.*)\z/)
218
+ targets = m[2].to_s.strip.split(/\s+/)
219
+ targets = [] if targets == ["nothing"]
220
+ targets << e[:to] unless targets.include?(e[:to])
221
+ lines[idx] = "- #{e[:from]} needs #{targets.empty? ? 'nothing' : targets.join(' ')}"
222
+ end
223
+
224
+ new_body = lines.join("\n")
225
+ new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
226
+ AtomicWrite.write(graph_path, new_content, renamer: renamer)
227
+ end
228
+ private_class_method :append_to_graph
229
+
230
+ # --- refusal -------------------------------------------------------------
231
+
232
+ # One ledger comment (a plain milestone-style line through Savepoint's own
233
+ # public append primitive, never a state transition - a refused proposal
234
+ # changes nothing about the proposer's own status) naming which of the
235
+ # three checks failed. Dedup'd by (stage, text) like every other such line,
236
+ # so an identical refusal repeated verbatim across retries writes once.
237
+ def refuse(intent_dir, proposer, reason, now)
238
+ Savepoint.append_savepoint_line(intent_dir, "Proposal", "#{proposer}: #{reason}", now)
239
+ { ok: false, minted: [], validator: nil, errors: [reason] }
240
+ end
241
+ private_class_method :refuse
242
+
243
+ # --- guarded re-entries into graph.md (v2 NEW-5/row 11.8) -------------------
244
+
245
+ # `unreadable: true` marks the ONE case a raise actually happened -
246
+ # distinct from `ReadySet.load_graph`'s own ordinary `ok: false` (a
247
+ # cyclic graph, a missing node file, ...), which callers here already
248
+ # tolerate and must keep tolerating unchanged.
249
+ def safe_load_graph(intent_dir)
250
+ ReadySet.load_graph(intent_dir)
251
+ rescue StandardError => e
252
+ { ok: false, edges: {}, nodes: {}, errors: ["graph.md could not be read: #{e.message}"], unreadable: true }
253
+ end
254
+ private_class_method :safe_load_graph
255
+
256
+ def safe_validate(validator, intent_dir)
257
+ validator.call(intent_dir)
258
+ rescue StandardError => e
259
+ { ok: false, missing: [], errors: ["graph.md could not be validated: #{e.message}"] }
260
+ end
261
+ private_class_method :safe_validate
262
+
263
+ # --- internals -------------------------------------------------------------
264
+
265
+ def stringify(hash)
266
+ (hash || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
267
+ end
268
+ private_class_method :stringify
269
+
270
+ def read_savepoint(intent_dir)
271
+ path = File.join(intent_dir.to_s, "savepoint.md")
272
+ File.exist?(path) ? File.read(path) : ""
273
+ end
274
+ private_class_method :read_savepoint
275
+ end
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "node_ledger"
6
+ require_relative "ready_set"
7
+ require_relative "node_file"
8
+ require_relative "node_ids"
9
+ require_relative "graph_file"
10
+ require_relative "atomic_write"
11
+ require_relative "runner_core"
12
+ require_relative "worktree"
13
+ require_relative "node_worktree"
14
+
15
+ # RunnerRewind (intent 340, G7, n6): resets the intent branch to a node's own
16
+ # recorded commit, marks every downstream node superseded (its evidence no
17
+ # longer stands once the code it was built on is gone), and respins the
18
+ # rewound node itself the same way RunnerAnswer's hard-cap path does - never
19
+ # a plain `planned` line, because `failed_verification_count` is counted per
20
+ # subject id over the WHOLE ledger and never resets; only a brand new id
21
+ # starts clean.
22
+ #
23
+ # Internal, confirm-gated (327 D15's rewind clause): `confirm:` must be
24
+ # explicitly true, and the whole intent must be quiescent (no `running` node
25
+ # anywhere) before a branch reset is safe to make - resetting the branch
26
+ # under a live executor's own worktree would move the base it is building on
27
+ # out from under it.
28
+ #
29
+ # Pure and dependency-injected: every git call goes through an injected
30
+ # `runner:` (default Worktree::ShellRunner), always `-C <path>`, never cwd;
31
+ # the clock and the rename call are injectable the same way every other
32
+ # runner_* module here already is.
33
+ module RunnerRewind
34
+ module_function
35
+
36
+ def rewind(context, node:, confirm:, now: Time.now, ledger: NodeLedger,
37
+ runner: Worktree::ShellRunner.new, renamer: File.method(:rename),
38
+ worktree: NodeWorktree)
39
+ node = node.to_s
40
+ return refusal("confirm_required") unless confirm
41
+
42
+ intent_dir = context.intent_dir
43
+ # M9: a second, unguarded re-parse of graph.md - RunnerCore.context's
44
+ # own first read is already safe, but this fresh read is not.
45
+ loaded = RunnerCore.safe_load_graph(intent_dir)
46
+ return refusal("invalid_graph", detail: Array(loaded[:errors]).join("; ")) unless loaded[:ok]
47
+
48
+ edges = loaded[:edges]
49
+ nodes_decl = loaded[:nodes]
50
+ before_content = read_savepoint(intent_dir)
51
+ entries = NodeLedger.entries_from_content(before_content)
52
+ status_map = NodeLedger.status_from_content(before_content)
53
+
54
+ return refusal("a_node_is_running") if status_map.value?("running")
55
+
56
+ commit = last_commit(entries, node)
57
+ return refusal("no_recorded_commit") if commit.nil? || commit.to_s.strip.empty?
58
+
59
+ return refusal("no_intent_worktree") if blank?(context.worktree)
60
+
61
+ reset = runner.run("-C", context.worktree, "reset", "--hard", commit)
62
+ return refusal("git_reset_failed", detail: reset.stderr.to_s.strip) unless reset.success?
63
+
64
+ downstream = downstream_of(node, edges)
65
+ downstream.each do |d|
66
+ ledger.append_transition(savepoint_path(intent_dir), subject: d, state: "superseded",
67
+ fields: { by: node }, now: now)
68
+ # M5/D7: a rewind-superseded node's evidence no longer stands once the
69
+ # code it was built on is gone - its worktree releases here too.
70
+ worktree.release(context, node: d, state: "superseded", runner: runner)
71
+ end
72
+
73
+ succ_id = respin(intent_dir, node, nodes_decl, edges, ledger: ledger, now: now, renamer: renamer,
74
+ context: context, worktree: worktree, runner: runner)
75
+
76
+ RunnerCore.render_status(context)
77
+
78
+ {
79
+ ok: true, reset_to: commit, superseded: downstream, respun_to: succ_id, errors: [],
80
+ newly_ready: newly_ready(intent_dir, before_content: before_content, before_nodes: nodes_decl,
81
+ before_edges: edges),
82
+ }
83
+ end
84
+
85
+ # --- downstream: everything that, directly or transitively, needs the
86
+ # rewound node - the mirror of ReadySet.critical_paths' own successors map.
87
+
88
+ def downstream_of(node, edges)
89
+ successors = Hash.new { |h, k| h[k] = [] }
90
+ edges.each { |id, targets| (targets || []).each { |t| successors[t] << id } }
91
+
92
+ seen = []
93
+ stack = successors[node].dup
94
+ until stack.empty?
95
+ n = stack.pop
96
+ next if seen.include?(n)
97
+
98
+ seen << n
99
+ stack.concat(successors[n] || [])
100
+ end
101
+ seen.sort
102
+ end
103
+ private_class_method :downstream_of
104
+
105
+ def last_commit(entries, node)
106
+ last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "done" }.last
107
+ last && (last[:fields] || {})["commit"]
108
+ end
109
+ private_class_method :last_commit
110
+
111
+ # --- the respin (327 D22, same shape as RunnerAnswer's hard-cap path) ------
112
+
113
+ def respin(intent_dir, node, nodes_decl, edges, ledger:, now:, renamer:,
114
+ context:, worktree: NodeWorktree, runner: Worktree::ShellRunner.new)
115
+ decl = nodes_decl[node] || {}
116
+ kind = decl[:kind]
117
+ node_path = ReadySet.find_node_path(intent_dir, node)
118
+ parsed = NodeFile.parse(node_path)
119
+
120
+ mint_pool = NodeIds.taken(intent_dir).dup
121
+ succ_id = NodeFile.mint_id(kind, mint_pool)
122
+
123
+ raw = File.read(node_path)
124
+ parts = raw.split("---", 3)
125
+ body = substitute_id(parts[2].to_s, node, succ_id)
126
+ files = parsed[:files] || decl[:files] || []
127
+ budget = parsed[:budget]
128
+
129
+ frontmatter = "---\nnode: #{succ_id}\nkind: #{kind}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
130
+ succ_path = File.join(intent_dir, "nodes", "#{succ_id}.md")
131
+ FileUtils.mkdir_p(File.dirname(succ_path))
132
+ File.write(succ_path, "#{frontmatter}#{body}")
133
+
134
+ append_graph_line(File.join(intent_dir, "graph.md"), succ_id, edges[node] || [], renamer: renamer)
135
+
136
+ ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "superseded",
137
+ fields: { by: succ_id }, now: now)
138
+
139
+ # M5/D7: the rewound node's own worktree releases too, same as the
140
+ # downstream nodes above.
141
+ worktree.release(context, node: node, state: "superseded", runner: runner)
142
+
143
+ succ_id
144
+ end
145
+ private_class_method :respin
146
+
147
+ def append_graph_line(graph_path, id, needs, renamer:)
148
+ content = File.read(graph_path)
149
+ body = GraphFile.section_body(content, "## Graph").to_s
150
+ lines = body.split("\n")
151
+ rendered = needs.empty? ? "nothing" : needs.join(" ")
152
+ lines << "- #{id} needs #{rendered}"
153
+ new_body = lines.join("\n")
154
+ new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
155
+ AtomicWrite.write(graph_path, new_content, renamer: renamer)
156
+ end
157
+ private_class_method :append_graph_line
158
+
159
+ def substitute_id(text, old_id, new_id)
160
+ text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
161
+ end
162
+ private_class_method :substitute_id
163
+
164
+ def newly_ready(intent_dir, before_content:, before_nodes:, before_edges:)
165
+ after = RunnerCore.safe_load_graph(intent_dir)
166
+ return [] unless after[:ok]
167
+
168
+ after_content = read_savepoint(intent_dir)
169
+
170
+ after[:nodes].keys.select do |id|
171
+ was = before_nodes.key?(id) &&
172
+ ReadySet.ready?(content: before_content, subject: id, graph: { edges: before_edges },
173
+ nodes: before_nodes)[:ready]
174
+ now_ready = ReadySet.ready?(content: after_content, subject: id, graph: { edges: after[:edges] },
175
+ nodes: after[:nodes])[:ready]
176
+ now_ready && !was
177
+ end.sort
178
+ end
179
+ private_class_method :newly_ready
180
+
181
+ def refusal(reason, detail: nil)
182
+ { ok: false, reason: reason, detail: detail, reset_to: nil, superseded: [], respun_to: nil, newly_ready: [] }
183
+ end
184
+ private_class_method :refusal
185
+
186
+ def blank?(value)
187
+ value.nil? || value.to_s.strip.empty?
188
+ end
189
+ private_class_method :blank?
190
+
191
+ def read_savepoint(intent_dir)
192
+ path = savepoint_path(intent_dir)
193
+ File.exist?(path) ? File.read(path) : ""
194
+ end
195
+ private_class_method :read_savepoint
196
+
197
+ def savepoint_path(intent_dir)
198
+ File.join(intent_dir.to_s, "savepoint.md")
199
+ end
200
+ private_class_method :savepoint_path
201
+ end