@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,703 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "store_layout"
5
+ require "yaml"
6
+ require "date"
7
+ require "time"
8
+ require "open3"
9
+ require "fileutils"
10
+ require_relative "node_return"
11
+ require_relative "node_ledger"
12
+ require_relative "node_worktree"
13
+ require_relative "node_file"
14
+ require_relative "node_input"
15
+ require_relative "ready_set"
16
+ require_relative "core_integrity"
17
+ require_relative "runner_core"
18
+ require_relative "worktree"
19
+ require_relative "insights"
20
+ require_relative "savepoint"
21
+ require_relative "guarded_append"
22
+ require_relative "atomic_write"
23
+ require_relative "runner_proposals"
24
+
25
+ # RunnerAbsorb (intent 340, G7, n4): the gate that turns one executor return
26
+ # into exactly one node ledger transition. It runs six checks in a fixed
27
+ # order - integrity, schema, scope, named_tests, merge, suite - and stops at
28
+ # the first refusal. Only a clean pass through all six writes `done`; every
29
+ # other path writes `failed_verification`, `needs_decision` or `blocked`,
30
+ # carrying the reason the gate stopped at and `gates=`, the checks that
31
+ # actually ran.
32
+ #
33
+ # `done` is written only on code-verified evidence: the runner re-derives
34
+ # the merge commit and the suite result itself, never trusting the
35
+ # executor's own report of either. The return's `commit:` field is a
36
+ # required attestation at the schema layer (row 4.6), not the value that
37
+ # lands on the ledger line - the merge's own commit wins whenever a merge
38
+ # actually ran (a work node); the return's commit is the fallback only when
39
+ # no merge exists to measure (a verify or research node's kind, out of this
40
+ # node's tested scope).
41
+ #
42
+ # Pure and dependency-injected down to the clock: every side effect -
43
+ # core integrity, the worktree module, git itself, the suite command, the
44
+ # project's verify reader - is an injectable keyword argument with a real
45
+ # default, so a test never touches a real repository, a real suite, or a
46
+ # real filesystem outside its own tmpdir.
47
+ module RunnerAbsorb
48
+ module_function
49
+
50
+ CHECKS = %w[integrity schema scope named_tests merge suite].freeze
51
+
52
+ # M1: `default_project_reader`'s own sentinel for "the project record
53
+ # exists but could not be parsed" - distinct from nil ("no verify command
54
+ # declared at all, or no project record"), so absorb can block rather than
55
+ # silently proceeding as if nothing were declared.
56
+ UNREADABLE_VERIFY_COMMAND = :verify_command_unreadable
57
+
58
+ # The one joined-line cap for a node's findings on one return (D16): a
59
+ # nested structure never reaches this far (NodeReturn already coerced it
60
+ # to plain strings), this cap is purely about line length.
61
+ FINDING_CAP = 200
62
+
63
+ PROJECT_LAYOUT_RE = %r{\A(.*)/(?:projects|stores)/([^/]+)/store/[^/]+\z}.freeze
64
+
65
+ # absorb(context, node:, return_path:) -> a result hash describing the one
66
+ # transition it wrote (or attempted to write). Never raises across its own
67
+ # boundary except GuardedAppend::Unavailable's own explicit recovery path
68
+ # (row 4.40), which is caught, not propagated.
69
+ def absorb(context, node:, return_path:, now: Time.now, allow_core_drift: false,
70
+ integrity_checker: CoreIntegrity.method(:check),
71
+ worktree: NodeWorktree,
72
+ ledger: NodeLedger,
73
+ runner: Worktree::ShellRunner.new,
74
+ suite_runner: method(:default_suite_runner),
75
+ project_reader: method(:default_project_reader),
76
+ proposals: RunnerProposals)
77
+ node = node.to_s
78
+ intent_dir = context.intent_dir
79
+ savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
80
+ content = File.exist?(savepoint_path) ? File.read(savepoint_path) : ""
81
+ entries = NodeLedger.entries_from_content(content)
82
+
83
+ # Row 4.30: a return for a node that is not currently `running` (already
84
+ # terminal, reclaimed, or never dispatched) is refused outright, nothing
85
+ # is written.
86
+ current_state = NodeLedger.status_for_content(content, node)
87
+ return refused("node_not_running") unless current_state == "running"
88
+
89
+ holder = holder_for(entries, node)
90
+ # 340b n8: the harness that ran this attempt, read off the node's own
91
+ # `running` line - never the harness of the session doing the absorbing
92
+ # (row 8.12). `extra_fields` carries it into every terminal fields hash
93
+ # below through the same `.merge(extra_fields)` every path already uses,
94
+ # so it reaches `done`, every `failed_verification`/`needs_decision`, and
95
+ # `blocked` alike; a running line with no `harness=` leaves it out of
96
+ # `extra_fields` entirely, so nothing invents a value (row 8.13).
97
+ harness = harness_for(entries, node)
98
+ extra_fields = {}
99
+ extra_fields[:harness] = harness if harness
100
+
101
+ node_decl = ((context.graph || {})[:nodes] || {})[node] || {}
102
+ kind = node_decl[:kind]
103
+ declared_files = normalize_files(node_decl[:files])
104
+ report_path = declared_report_path(intent_dir, node)
105
+
106
+ # v2 NEW-2: a `graph.md` that failed to parse, or a node whose kind
107
+ # cannot be resolved from it, refuses right here - before ANY check
108
+ # runs. Falling through with `kind` nil used to skip the whole work-node
109
+ # branch (no merge ever ran), pass scope vacuously (`declared_files` was
110
+ # empty), and land `done` carrying the executor's own self-reported
111
+ # commit on work nothing had verified ever reached the intent branch.
112
+ unless (context.graph || {})[:ok] && !kind.nil?
113
+ fields = { reason: "invalid_graph", holder: holder }.merge(extra_fields)
114
+ return write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger)
115
+ end
116
+
117
+ checks_ran = []
118
+
119
+ # 1. integrity ------------------------------------------------------------
120
+ checks_ran << "integrity"
121
+ integrity = integrity_checker.call(plastic_home: context.plastic_home)
122
+ unless integrity[:ok]
123
+ if allow_core_drift
124
+ extra_fields["allow_core_drift"] = "true"
125
+ else
126
+ fields = { reason: "core_integrity", core_drift: drift_summary(integrity), holder: holder,
127
+ gates: checks_ran.join("+") }.merge(extra_fields)
128
+ return write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger)
129
+ end
130
+ end
131
+
132
+ # 2. schema -----------------------------------------------------------------
133
+ checks_ran << "schema"
134
+ text = read_return_text(return_path)
135
+ parsed = NodeReturn.parse(text)
136
+
137
+ attempt = NodeInput.compute_attempt_number(intent_dir: intent_dir, node: node, lease_flag_given: false,
138
+ entries: entries)
139
+ preserve_return_file(intent_dir, node, attempt, return_path, text)
140
+
141
+ unless parsed.ok
142
+ return fail_check(savepoint_path, context, node, "return_unparsable", checks_ran, holder, extra_fields, now, ledger)
143
+ end
144
+ if parsed.node != node
145
+ return fail_check(savepoint_path, context, node, "node_mismatch", checks_ran, holder, extra_fields, now, ledger)
146
+ end
147
+
148
+ report_reason = report_contract_violation(kind, report_path, parsed)
149
+ if report_reason
150
+ return fail_check(savepoint_path, context, node, report_reason, checks_ran, holder, extra_fields, now, ledger)
151
+ end
152
+
153
+ append_findings(intent_dir, node, parsed.findings, now: now)
154
+
155
+ proposal_result = nil
156
+ finish = ->(result) { result.merge(proposal: proposal_result) }
157
+
158
+ # B1: the return's own status governs from here on. The six mechanical
159
+ # checks (3-6) exist to VERIFY a `done` claim, never to overrule an
160
+ # executor's own report of its failure or its own open question - a
161
+ # `needs_decision`, `blocked` or `failed_verification` return writes
162
+ # exactly that state, carrying the field its own schema required
163
+ # (`question` or `reason`), and stops here. Only `done` falls through.
164
+ #
165
+ # v2 NEW-1: the prose riding in `question`/`reason` is the executor's
166
+ # own free text - a YAML block scalar (a two-line question) is the
167
+ # natural way to write it, and `NodeLedger.normalize_value` refuses a
168
+ # tab or a newline outright. Squashed to one line before it ever reaches
169
+ # the ledger, same as every other whitespace run the ledger's own format
170
+ # already collapses.
171
+ unless parsed.status == "done"
172
+ fields = { holder: holder, gates: checks_ran.join("+") }.merge(extra_fields)
173
+ fields[:question] = squash_prose(parsed.question) if parsed.status == "needs_decision"
174
+ fields[:reason] = squash_prose(parsed.reason) if %w[blocked failed_verification].include?(parsed.status)
175
+ return finish.call(write_transition(savepoint_path, context, node, parsed.status, fields, now: now, ledger: ledger))
176
+ end
177
+
178
+ # 340 n10/n11, M4/row 11.14: a proposal is accepted or refused only on a
179
+ # `done` return, right after the schema gate - the only production
180
+ # caller RunnerProposals ever gets. An executor looping on retries must
181
+ # never grow the graph on a failing attempt (v2 minor 11). Every `done`
182
+ # return from here on carries `proposal:` (nil when it proposed
183
+ # nothing), so a refusal is surfaced in the step report rather than left
184
+ # to a savepoint comment nobody reads (row 10.3).
185
+ if Array(parsed.proposed_nodes).any? || Array(parsed.proposed_edges).any?
186
+ proposal_result = proposals.accept(context, proposer: node, proposed_nodes: parsed.proposed_nodes,
187
+ proposed_edges: parsed.proposed_edges, now: now)
188
+ # v2 minor 8/row 11.13: an ACCEPTED proposal's own validator verdict
189
+ # used to be attached only to `result[:proposal]`, which nothing reads
190
+ # - an accepted proposal that invalidates the whole graph bricked
191
+ # every later dispatch with `invalid_graph` and left no record of why.
192
+ # Reported through the same Findings channel the executor's own
193
+ # findings already use, so it survives in the intent record.
194
+ if proposal_result[:ok] && proposal_result[:validator] && !proposal_result[:validator][:ok]
195
+ verdict_errors = Array(proposal_result[:validator][:errors]).join("; ")
196
+ append_findings(intent_dir, node, ["accepted proposal invalidates the graph: #{verdict_errors}"], now: now)
197
+ end
198
+ end
199
+
200
+ # 3. scope --------------------------------------------------------------
201
+ checks_ran << "scope"
202
+ changed = worktree.changed_paths(context, node: node, kind: kind, runner: runner)
203
+ if changed.nil?
204
+ return finish.call(fail_check(savepoint_path, context, node, "scope_unmeasurable", checks_ran, holder, extra_fields, now, ledger))
205
+ end
206
+ scope_reason = scope_violation(kind, changed, declared_files)
207
+ if scope_reason
208
+ return finish.call(fail_check(savepoint_path, context, node, scope_reason, checks_ran, holder, extra_fields, now, ledger))
209
+ end
210
+
211
+ # 4. named_tests ----------------------------------------------------------
212
+ checks_ran << "named_tests"
213
+ if missing_named_tests?(context, node, kind, worktree)
214
+ return finish.call(fail_check(savepoint_path, context, node, "named_test_missing", checks_ran, holder, extra_fields, now, ledger))
215
+ end
216
+
217
+ # 5. merge ------------------------------------------------------------------
218
+ # M2: `merge` lands in gates= only when a merge actually ran - a verify
219
+ # or research return never reaches a merge (only a work node's diff
220
+ # lives on a mergeable branch), so its evidence line must say `merge:none`
221
+ # rather than claiming a check the code skipped for its kind.
222
+ merge_commit = nil
223
+ if kind.to_s == "work"
224
+ checks_ran << "merge"
225
+ merge_result = worktree.merge(context, node: node, runner: runner)
226
+ unless merge_result[:ok]
227
+ conflicted = Array(merge_result[:conflicted])
228
+ if conflicted.empty?
229
+ return finish.call(fail_check(savepoint_path, context, node, "merge_failed", checks_ran, holder, extra_fields, now, ledger))
230
+ end
231
+
232
+ outside = conflicted.reject { |p| path_covered?(p, declared_files) }
233
+ if outside.empty?
234
+ return finish.call(fail_check(savepoint_path, context, node, "merge_conflict", checks_ran, holder, extra_fields, now, ledger))
235
+ end
236
+
237
+ question = "merge conflict touches path(s) outside files: #{outside.sort.join(', ')}"
238
+ fields = { question: question, holder: holder, gates: checks_ran.join("+") }.merge(extra_fields)
239
+ return finish.call(write_transition(savepoint_path, context, node, "needs_decision", fields, now: now, ledger: ledger))
240
+ end
241
+ merge_commit = merge_result[:commit]
242
+ else
243
+ checks_ran << "merge:none"
244
+ end
245
+
246
+ # 6. suite --------------------------------------------------------------
247
+ checks_ran << "suite"
248
+ command = project_reader.call(intent_dir)
249
+ if command == UNREADABLE_VERIFY_COMMAND
250
+ # M1: a project.yml that fails to parse is not the same fact as a
251
+ # project with no verify command at all - the record is broken, so the
252
+ # node blocks rather than proceeding to `done` with `suite:absent`.
253
+ #
254
+ # v2 NEW-3: `gates=` must say `suite:unreadable`, never the bare
255
+ # `suite` `checks_ran` already carries - the suite check itself never
256
+ # ran, so claiming it did is M2's exact defect, reintroduced on the
257
+ # state M1 added.
258
+ gates = (checks_ran[0..-2] + ["suite:unreadable"]).join("+")
259
+ fields = { reason: "verify_command_unreadable", gates: gates, holder: holder }.merge(extra_fields)
260
+ return finish.call(write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger))
261
+ end
262
+ if command.nil?
263
+ gates = (checks_ran[0..-2] + ["suite:absent"]).join("+")
264
+ suite_value = "none"
265
+ else
266
+ suite_result = suite_runner.call(dir: context.worktree, command: command)
267
+ unless suite_result[:ok]
268
+ fields = { reason: "suite_red", gates: checks_ran.join("+"), holder: holder,
269
+ suite: format_suite(suite_result) }.merge(extra_fields)
270
+ return finish.call(write_transition(savepoint_path, context, node, "failed_verification", fields, now: now, ledger: ledger))
271
+ end
272
+ gates = checks_ran.join("+")
273
+ suite_value = format_suite(suite_result)
274
+ end
275
+
276
+ commit_value = merge_commit || parsed.commit
277
+ if report_path
278
+ report_result = persist_report(intent_dir, report_path, parsed.report)
279
+ unless report_result[:ok]
280
+ return finish.call(fail_check(savepoint_path, context, node, report_result[:reason], gates.split("+") + ["report"],
281
+ holder, extra_fields, now, ledger))
282
+ end
283
+ gates = "#{gates}+report"
284
+ end
285
+ fields = { gates: gates, commit: commit_value, holder: holder, suite: suite_value }.merge(extra_fields)
286
+ result = write_transition(savepoint_path, context, node, "done", fields, now: now, ledger: ledger)
287
+
288
+ worktree.release(context, node: node, state: "done", runner: runner) if result[:written]
289
+
290
+ finish.call(result)
291
+ end
292
+
293
+ # --- transitions -------------------------------------------------------------
294
+
295
+ def fail_check(savepoint_path, context, node, reason, checks_ran, holder, extra_fields, now, ledger)
296
+ fields = { reason: reason, gates: checks_ran.join("+"), holder: holder }.merge(extra_fields)
297
+ write_transition(savepoint_path, context, node, "failed_verification", fields, now: now, ledger: ledger)
298
+ end
299
+ private_class_method :fail_check
300
+
301
+ def refused(reason)
302
+ { state: "refused", written: false, reason: reason, gates: "", fields: {} }
303
+ end
304
+ private_class_method :refused
305
+
306
+ # Appends the one transition line, catching GuardedAppend::Unavailable
307
+ # rather than letting it escape (row 4.40): an append that fails AFTER a
308
+ # merge has already landed on the intent branch must still report that
309
+ # merge commit, so the node stays recoverable rather than looking like
310
+ # nothing happened at all. Re-renders graph.md's ## Status after a
311
+ # successfully written line (row 4.44, D28) - RunnerCore already owns that
312
+ # render, this is its only production caller today.
313
+ #
314
+ # v2 NEW-1/row 11.2: `squash_prose` (below) removes every raw tab and
315
+ # newline before a value ever reaches here, but nothing guarantees that is
316
+ # the ONLY way a return's own prose can make `NodeLedger.transition_line`
317
+ # refuse it (`ArgumentError`, raised before any file is ever touched) - a
318
+ # pathological return must still land a transition, not crash the whole
319
+ # step and leave the node stuck `running` until its lease expires. Falls
320
+ # back to `blocked reason=return_unwritable`, whose own fields are all
321
+ # runner-owned strings, never executor prose, so the fallback write itself
322
+ # can never hit the same wall.
323
+ def write_transition(savepoint_path, context, node, state, fields, now:, ledger:)
324
+ result = ledger.append_transition(savepoint_path, subject: node, state: state, fields: fields, now: now)
325
+ rescue GuardedAppend::Unavailable => e
326
+ { state: "append_failed", written: false, fields: fields, gates: fields[:gates],
327
+ commit: fields[:commit], error: e.message }
328
+ rescue ArgumentError => e
329
+ # 340b n8, row 8.11: this fallback still merges no caller `extra_fields`
330
+ # (unchanged from before this node), but `fields[:harness]` is read
331
+ # straight off the fields hash the failed write attempted, which already
332
+ # carries `harness` whenever the caller's own `extra_fields` had it -
333
+ # `.compact` drops the key rather than writing it empty when it did not.
334
+ fallback_fields = { reason: "return_unwritable", holder: fields[:holder], gates: fields[:gates],
335
+ harness: fields[:harness] }.compact
336
+ fallback = begin
337
+ ledger.append_transition(savepoint_path, subject: node, state: "blocked", fields: fallback_fields, now: now)
338
+ rescue GuardedAppend::Unavailable
339
+ :unavailable
340
+ end
341
+ written = fallback == :written
342
+ safe_render_status(context) if written
343
+ { state: "blocked", written: written, fields: fallback_fields, gates: fallback_fields[:gates], commit: nil,
344
+ error: e.message }
345
+ else
346
+ written = result == :written
347
+ safe_render_status(context) if written
348
+ { state: state, written: written, fields: fields, gates: fields[:gates], commit: fields[:commit] }
349
+ end
350
+ private_class_method :write_transition
351
+
352
+ # v2 NEW-5/row 11.9: `RunnerCore.render_status` re-reads and re-parses
353
+ # graph.md from scratch (never trusting `context.graph`, resolved once
354
+ # before this step started) purely to rewrite its own ## Status table - a
355
+ # cosmetic, best-effort render, not evidence. A `graph.md` that turned
356
+ # unreadable mid-step (or was already unreadable, on a node whose kind
357
+ # this call's own NEW-2 guard let through as `blocked`) must never turn a
358
+ # transition ALREADY WRITTEN into a raw stack trace and a half-finished
359
+ # step - the transition already landed in savepoint.md by the time this
360
+ # runs, so a failed render is a missed cosmetic refresh, not a lost write.
361
+ def safe_render_status(context)
362
+ RunnerCore.render_status(context)
363
+ rescue StandardError
364
+ nil
365
+ end
366
+ private_class_method :safe_render_status
367
+
368
+ # Collapse every run of whitespace (space, tab, newline, ...) in an
369
+ # executor's own free text to a single space (v2 NEW-1): the ledger's own
370
+ # field format is a single line, and `NodeLedger.normalize_value` raises
371
+ # ArgumentError outright on a bare tab or newline rather than collapsing
372
+ # it. A YAML block scalar is the natural way to write a two-sentence
373
+ # question, so this runs on every `question:`/`reason:` before either
374
+ # ever reaches a ledger field.
375
+ def squash_prose(text)
376
+ text.to_s.gsub(/\s+/, " ").strip
377
+ end
378
+ private_class_method :squash_prose
379
+
380
+ def holder_for(entries, node)
381
+ last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "running" }.last
382
+ last && (last[:fields] || {})["holder"]
383
+ end
384
+ private_class_method :holder_for
385
+
386
+ # 340b n8/row 8.12: mirrors holder_for exactly - the value on the node's
387
+ # OWN last `running` line, never a fresh resolve of the current session's
388
+ # own harness. nil when that line carried none (row 8.13), which is what
389
+ # keeps `extra_fields` from ever inventing the key.
390
+ def harness_for(entries, node)
391
+ last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "running" }.last
392
+ last && (last[:fields] || {})["harness"]
393
+ end
394
+ private_class_method :harness_for
395
+
396
+ def drift_summary(integrity)
397
+ parts = []
398
+ parts << "drifted:#{integrity[:drifted].join(',')}" if Array(integrity[:drifted]).any?
399
+ parts << "missing:#{integrity[:missing].join(',')}" if Array(integrity[:missing]).any?
400
+ parts << (integrity[:reason] || "unknown") if parts.empty?
401
+ parts.join(";")
402
+ end
403
+ private_class_method :drift_summary
404
+
405
+ # --- scope ---------------------------------------------------------------
406
+
407
+ def scope_violation(kind, changed, declared_files)
408
+ case kind.to_s
409
+ when "work"
410
+ outside = Array(changed).reject { |p| path_covered?(p, declared_files) }
411
+ outside.any? ? "diff_outside_files" : nil
412
+ when "verify"
413
+ Array(changed).any? ? "diff_on_verify_node" : nil
414
+ when "research"
415
+ Array(changed).any? ? "diff_on_research_node" : nil
416
+ end
417
+ end
418
+ private_class_method :scope_violation
419
+
420
+ def path_covered?(path, declared_files)
421
+ norm = normalize_scope_path(path)
422
+ declared_files.any? do |f|
423
+ norm == f || norm.start_with?("#{f}/")
424
+ end
425
+ end
426
+ private_class_method :path_covered?
427
+
428
+ def normalize_files(files)
429
+ Array(files).map { |f| normalize_scope_path(f) }
430
+ end
431
+ private_class_method :normalize_files
432
+
433
+ def normalize_scope_path(path)
434
+ path.to_s.sub(%r{\A\./}, "").sub(%r{/\z}, "")
435
+ end
436
+ private_class_method :normalize_scope_path
437
+
438
+ # --- research report -------------------------------------------------------
439
+
440
+ def declared_report_path(intent_dir, node)
441
+ path = ReadySet.find_node_path(intent_dir, node)
442
+ return nil unless path
443
+
444
+ parsed = NodeFile.parse(path)
445
+ parsed[:ok] ? parsed[:report] : nil
446
+ end
447
+ private_class_method :declared_report_path
448
+
449
+ def report_contract_violation(kind, declared, parsed)
450
+ return "report_not_declared" if parsed.report && declared.nil?
451
+ return nil unless parsed.status == "done"
452
+ return "report_missing" if declared && parsed.report.to_s.strip.empty?
453
+ return "report_not_research" if parsed.report && kind.to_s != "research"
454
+
455
+ nil
456
+ end
457
+ private_class_method :report_contract_violation
458
+
459
+ def persist_report(intent_dir, relative_path, content)
460
+ resources_root = File.expand_path(File.join(intent_dir, "resources"))
461
+ target = File.expand_path(File.join(intent_dir, relative_path.to_s))
462
+ unless target.start_with?("#{resources_root}/") && target.end_with?(".md")
463
+ return { ok: false, reason: "report_path_invalid" }
464
+ end
465
+
466
+ if File.exist?(target)
467
+ return { ok: true, path: target } if File.binread(target) == content.to_s
468
+
469
+ return { ok: false, reason: "report_conflict" }
470
+ end
471
+
472
+ FileUtils.mkdir_p(File.dirname(target))
473
+ AtomicWrite.write(target, content.to_s)
474
+ { ok: true, path: target }
475
+ rescue StandardError
476
+ { ok: false, reason: "report_write_failed" }
477
+ end
478
+ private_class_method :persist_report
479
+
480
+ # --- named tests -----------------------------------------------------------
481
+
482
+ # Row 4.19/minor 1: read the node's own failure-mode matrix table, take
483
+ # every `Test` cell's file basename AND method name (before/after the
484
+ # `#`), and check both that `test/<basename>.rb` exists on the node's own
485
+ # worktree - the tree the executor actually wrote to, not the not-yet-
486
+ # merged intent tree - and that it actually DEFINES the named method
487
+ # (post-execution review minor 1: proving only the file exists lets an
488
+ # executor delete the test method while keeping its file and still pass
489
+ # this gate). A node file that cannot be read, or that carries no such
490
+ # section, names no tests and passes this check vacuously (nothing to
491
+ # prove missing).
492
+ def missing_named_tests?(context, node, kind, worktree)
493
+ path = ReadySet.find_node_path(context.intent_dir, node)
494
+ return false unless path
495
+
496
+ nf = NodeFile.parse(path)
497
+ return false unless nf[:ok]
498
+
499
+ section = NodeFile.split_by_headings(nf[:body]).find { |(heading, _)| heading.to_s =~ /failure-mode matrix/i }
500
+ return false unless section
501
+
502
+ tests = NodeFile.table_rows(section[1]).filter_map do |row|
503
+ cell = row[3]
504
+ next nil if cell.to_s.strip.empty?
505
+
506
+ basename, method = cell.gsub("`", "").split("#", 2)
507
+ next nil if basename.to_s.strip.empty?
508
+
509
+ [basename.strip, method.to_s.strip]
510
+ end.uniq
511
+ return false if tests.empty?
512
+
513
+ check_dir = kind.to_s == "work" ? (worktree.paths(context, node: node) || {})["path"] : context.worktree
514
+ return false if check_dir.nil?
515
+
516
+ tests.any? do |basename, method|
517
+ test_path = File.join(check_dir, "test", "#{basename}.rb")
518
+ !File.exist?(test_path) || !method_defined_in_file?(test_path, method)
519
+ end
520
+ end
521
+ private_class_method :missing_named_tests?
522
+
523
+ # true when `method` is blank (a matrix row that names only a file, no
524
+ # `#method`, still passes on file presence alone) or when `path`'s own
525
+ # source text defines it. A source-text check, not a `require` and
526
+ # introspect: loading an untrusted executor-written test file as living
527
+ # Ruby is not a check RunnerAbsorb should ever perform.
528
+ def method_defined_in_file?(path, method)
529
+ return true if method.to_s.empty?
530
+ return false unless File.exist?(path)
531
+
532
+ File.read(path).match?(/^\s*def\s+#{Regexp.escape(method)}\b/)
533
+ rescue StandardError
534
+ false
535
+ end
536
+ private_class_method :method_defined_in_file?
537
+
538
+ # --- findings --------------------------------------------------------------
539
+
540
+ # One capped bullet under `### Findings` (D16), created under `## Insights`
541
+ # when either heading is absent (row 4.35): Insights.append_insight never
542
+ # creates a nested subsection, so this is its own small insertion, not a
543
+ # call into that module.
544
+ #
545
+ # v2 NEW-4: this is a mid-file rewrite (a `## Links` section commonly
546
+ # follows `### Findings`), so it goes through AtomicWrite directly -
547
+ # sibling-temp-plus-rename, the same shape every other writer in this tree
548
+ # uses (D19r) - which is genuine crash safety: a process that dies mid-
549
+ # write leaves the ORIGINAL file untouched, never a half-written one.
550
+ #
551
+ # It is NOT mutual exclusion, and no longer pretends to be one. The
552
+ # previous shape opened this same path under GuardedAppend's flock first
553
+ # and called AtomicWrite.write from inside that hold - but flock guards
554
+ # one open file HANDLE, and AtomicWrite's own rename puts a brand new
555
+ # inode at the path underneath it. A second writer that opened its handle
556
+ # before the rename keeps the OLD, now-unlinked inode; once the first
557
+ # writer unlocks, the second takes a lock that excludes nobody, reads
558
+ # stale content off the orphaned inode, and its own rename overwrites the
559
+ # first writer's already-landed bullet - reproduced with two real
560
+ # processes. `Insights.append_insight` (this module's sibling for the same
561
+ # file) takes no lock at all either, so a bigger lock here would still
562
+ # lose the race against that path. Findings are best-effort (D16): a lost
563
+ # bullet under real concurrency is an accepted gap, not a promise this
564
+ # method makes and breaks.
565
+ def append_findings(intent_dir, node, findings, now:, renamer: File.method(:rename))
566
+ return if Array(findings).empty?
567
+
568
+ joined = Array(findings).join("; ")
569
+ text = joined.length > FINDING_CAP ? "#{joined[0...(FINDING_CAP - 3)].rstrip}..." : joined
570
+ bullet = "- [#{node}] #{text}"
571
+
572
+ path = Savepoint.intent_file(intent_dir)
573
+ content = File.exist?(path) ? File.read(path) : ""
574
+ AtomicWrite.write(path, insert_finding_bullet(content, bullet), renamer: renamer)
575
+ nil
576
+ rescue StandardError
577
+ nil
578
+ end
579
+ private_class_method :append_findings
580
+
581
+ def insert_finding_bullet(content, bullet)
582
+ lines = content.empty? ? [] : content.split("\n", -1)
583
+ insights_idx = lines.index { |l| l.strip == "## Insights" }
584
+
585
+ if insights_idx.nil?
586
+ body = lines.join("\n")
587
+ body = body.sub(/\n+\z/, "") unless body.empty?
588
+ pieces = body.empty? ? [] : [body, ""]
589
+ pieces.concat(["## Insights", "", "### Findings", bullet])
590
+ return "#{pieces.join("\n")}\n"
591
+ end
592
+
593
+ idx = insights_idx + 1
594
+ findings_idx = nil
595
+ while idx < lines.length && !lines[idx].start_with?("## ")
596
+ findings_idx = idx if lines[idx].strip == "### Findings"
597
+ idx += 1
598
+ end
599
+ insights_end = idx
600
+
601
+ # M12: `split("\n", -1)` keeps a trailing "" element exactly when
602
+ # `content` itself ends in a newline (the file's own trailing newline
603
+ # made visible as an array slot). When Insights is the LAST section (no
604
+ # further "## " heading), `insights_end` walks all the way to
605
+ # `lines.length`, merging that sentinel INTO the section - the very next
606
+ # insert then landed AFTER it, turning the file's trailing newline into
607
+ # a spurious blank line and leaving the new bullet as the final element
608
+ # with no newline of its own. Excluding the sentinel here restores both:
609
+ # the bullet lands where content actually ends, one newline intact.
610
+ insights_end -= 1 if insights_end == lines.length && lines.last == ""
611
+
612
+ if findings_idx
613
+ fidx = findings_idx + 1
614
+ fidx += 1 while fidx < insights_end && !lines[fidx].start_with?("#")
615
+ lines.insert(fidx, bullet)
616
+ else
617
+ lines.insert(insights_end, "", "### Findings", bullet)
618
+ end
619
+ lines.join("\n")
620
+ end
621
+ private_class_method :insert_finding_bullet
622
+
623
+ # --- the return file itself -------------------------------------------------
624
+
625
+ def read_return_text(return_path)
626
+ File.exist?(return_path.to_s) ? File.read(return_path.to_s) : ""
627
+ end
628
+ private_class_method :read_return_text
629
+
630
+ # Row 4.42: the return is kept beside the node input at
631
+ # attempts/<node>--a<N>.return - copied there when `return_path` names
632
+ # somewhere else, left alone when it already is that file.
633
+ def preserve_return_file(intent_dir, node, attempt, return_path, text)
634
+ target = NodeInput.input_path(intent_dir: intent_dir, node: node, attempt: attempt).sub(/\.input\z/, ".return")
635
+ return target if File.expand_path(return_path.to_s) == File.expand_path(target)
636
+
637
+ FileUtils.mkdir_p(File.dirname(target))
638
+ File.write(target, text) unless File.exist?(target) && File.read(target) == text
639
+ target
640
+ end
641
+ private_class_method :preserve_return_file
642
+
643
+ # --- the suite ---------------------------------------------------------------
644
+
645
+ # v1 minor 7/row 11.17: reads the LAST summary line, not the first match -
646
+ # a suite command that shells out to more than one sub-process (or prints
647
+ # its own retry) can carry an earlier RED summary line before the one that
648
+ # actually decided its exit status, and `.match` (first match) would
649
+ # record that earlier line's counts on the `done` line instead.
650
+ def default_suite_runner(dir:, command:)
651
+ out, err, status = Open3.capture3(command, chdir: dir.to_s)
652
+ combined = "#{out}\n#{err}"
653
+ matches = combined.scan(/(\d+)\s+runs,\s+(\d+)\s+assertions,\s+(\d+)\s+failures,\s+(\d+)\s+errors/)
654
+ return { ok: false, runs: nil, assertions: nil, failures: nil, errors: nil } if matches.empty?
655
+
656
+ runs, assertions, failures, errors = matches.last.map(&:to_i)
657
+ { ok: status.success? && failures.zero? && errors.zero?, runs: runs, assertions: assertions,
658
+ failures: failures, errors: errors }
659
+ end
660
+
661
+ def format_suite(result)
662
+ "#{result[:runs]}/#{result[:assertions]}/#{result[:failures]}/#{result[:errors]}"
663
+ end
664
+ private_class_method :format_suite
665
+
666
+ # Own, lightweight equivalent of InstallerCore's private project reader
667
+ # (that class needs a full instance to call its own copy): reads
668
+ # project.yml's release.verify for the project this intent belongs to,
669
+ # nil for a global-store-only intent or a project record with none
670
+ # recorded (row 4.39's absent-command case). M1/row 9.14: a project.yml
671
+ # that EXISTS but fails to parse returns UNREADABLE_VERIFY_COMMAND, never
672
+ # nil - that record is broken, not merely silent on a verify command, and
673
+ # the caller must not read the two the same way.
674
+ def default_project_reader(intent_dir)
675
+ m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
676
+ return nil unless m
677
+
678
+ home, slug = m[1], m[2]
679
+ path = File.join(Plastic::StoreLayout.project_root(home, slug), "project.yml")
680
+ return nil unless File.exist?(path)
681
+
682
+ data = begin
683
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time])
684
+ rescue StandardError
685
+ return UNREADABLE_VERIFY_COMMAND
686
+ end
687
+ # v2 minor 13/row 11.15: a project.yml that PARSES cleanly but to
688
+ # something other than a mapping (an empty file, a bare string, a list)
689
+ # is the same broken-record fact as one that fails to parse outright -
690
+ # M1's whole point was that a broken record must never read the same as
691
+ # "no verify command declared", and reading a non-Hash as nil silently
692
+ # reopened that exact gap.
693
+ return UNREADABLE_VERIFY_COMMAND unless data.is_a?(Hash)
694
+
695
+ release = data["release"]
696
+ return nil unless release.is_a?(Hash)
697
+
698
+ verify = release["verify"]
699
+ return nil unless verify.is_a?(String) && !verify.strip.empty?
700
+
701
+ verify.split("\n").map(&:strip).reject(&:empty?).join("; ")
702
+ end
703
+ end