@zalom/plastic 2.0.0-alpha.8 → 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 +23 -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 +62 -1
  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 +238 -22
  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 +19 -5
  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 +30 -16
  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 +532 -71
  152. package/scripts/lib/intent_screen.rb +9 -6
  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 +377 -173
  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 +1460 -74
  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 +555 -0
  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 +192 -30
  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 +15 -2
  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 -295
  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 -173
  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,913 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "store_layout"
5
+ require "yaml"
6
+ require "date"
7
+ require "digest"
8
+ require "fileutils"
9
+ require_relative "node_file"
10
+ require_relative "graph_file"
11
+ require_relative "node_ledger"
12
+ require_relative "savepoint"
13
+ require_relative "insights"
14
+ require_relative "data_boundary"
15
+ require_relative "atomic_write"
16
+ require_relative "arm"
17
+
18
+ # NodeInput (intent 338, G5): builds a node's whole input from disk, the
19
+ # five blocks 327 section 8 fixed - the node, the ledger, the record, the
20
+ # knowledge hop, and where to work. n2 (this half) is the readers: every one
21
+ # of them a keyword seam with a real default, so the suite never shells out
22
+ # to git, never reads the owner's real store, and never sets an environment
23
+ # variable (spec D16). n3 adds assembly, the budget, and the node input's
24
+ # identity on top of the same file.
25
+ module NodeInput
26
+ module_function
27
+
28
+ DEFAULT_BUDGET_TOKENS = 8000
29
+ DEFAULT_HOP_TOKENS = 2000
30
+ MAX_LANDED_COMMITS = 10
31
+ DECISIONS_KEEP = 5
32
+ INSIGHTS_KEEP = 3
33
+
34
+ # C7's "the executor stops without it" (spec D9), rendered whenever a
35
+ # node input carries no lease and whenever the worktree Arm.worktree_block
36
+ # reports is not provisioned.
37
+ STOP_DIRECTIVE = "STOP: no lease is recorded for this node. Do not edit files or run any command until a runner dispatches this node with a holder, an expiry and a model."
38
+
39
+ # Pinned so `input=<sha>` is a function of the repo's history alone
40
+ # (post-execution review finding B4): unpinned, `git log --stat` varies
41
+ # with the terminal's COLUMNS (abbreviates paths, narrows the graph
42
+ # column), the caller's `color.ui` (ANSI escapes land in the ledger data
43
+ # block), and gitconfig's `format.pretty`/`log.date`/`log.showSignature`.
44
+ GIT_LOG_FIXED_ARGS = %w[-c color.ui=false -c log.showSignature=false --no-pager log --no-color --pretty=fuller
45
+ --stat=200,200].freeze
46
+ LANDED_COMMITS_MAX_BYTES = 8000
47
+
48
+ def git_log_command(repo_dir:, files:)
49
+ ["git", "-C", repo_dir.to_s, *GIT_LOG_FIXED_ARGS, "-n", MAX_LANDED_COMMITS.to_s, "--", *Array(files)]
50
+ end
51
+
52
+ # `COLUMNS` unset (Process.spawn/Open3 delete a var whose value is nil)
53
+ # rather than merely left alone, so an interactive caller's terminal width
54
+ # never reaches `git log --stat`'s column math.
55
+ def git_log_env
56
+ { "COLUMNS" => nil }
57
+ end
58
+
59
+ # `landed commits` is one of the three never-cut blocks (matrix 3.9), so an
60
+ # unbounded `git log --stat` (ten verbose commit messages, say) could route
61
+ # the whole node input straight to exit 4 with no cut able to help.
62
+ def truncate_landed_commits(out)
63
+ return out.to_s if out.to_s.bytesize <= LANDED_COMMITS_MAX_BYTES
64
+
65
+ "#{out.byteslice(0, LANDED_COMMITS_MAX_BYTES)}\n[landed commits truncated at #{LANDED_COMMITS_MAX_BYTES} bytes]"
66
+ end
67
+
68
+ DEFAULT_GIT_RUNNER = lambda do |repo_dir:, files:|
69
+ require "open3"
70
+ out, _err, status = Open3.capture3(git_log_env, *git_log_command(repo_dir: repo_dir, files: files))
71
+ status.success? ? truncate_landed_commits(out) : nil
72
+ end
73
+
74
+ PROJECT_LAYOUT_RE = %r{\A(.*)/(?:projects|stores)/([^/]+)/store/[^/]+\z}.freeze
75
+
76
+ # --- paths ---------------------------------------------------------------
77
+
78
+ def savepoint_path(intent_dir)
79
+ File.join(intent_dir, "savepoint.md")
80
+ end
81
+
82
+ def graph_path(intent_dir)
83
+ File.join(intent_dir, "graph.md")
84
+ end
85
+
86
+ def find_node_path(intent_dir, node)
87
+ dir = File.join(intent_dir, "nodes")
88
+ exact = File.join(dir, "#{node}.md")
89
+ return exact if File.exist?(exact)
90
+
91
+ Dir.glob(File.join(dir, "#{node}--*.md")).sort.first
92
+ end
93
+
94
+ # --- block 1: the node (instruction) --------------------------------------
95
+
96
+ # {ok:, error_kind:, text:, errors:, kind:, files:, budget:}. error_kind is
97
+ # :unknown_node (no node file for this id, or the id is not declared in
98
+ # graph.md - the runner's usage-error bucket, matrix 2.2), :unparsable (the
99
+ # node file exists but NodeFile.parse rejects it, matrix 2.1), or
100
+ # :unreadable_graph (graph.md itself does not parse).
101
+ def node_block(intent_dir:, node:, node_reader: NodeFile.method(:parse), graph_reader: GraphFile.method(:parse))
102
+ node_path = find_node_path(intent_dir, node)
103
+ unless node_path
104
+ return failure_block(:unknown_node, ["no node file for #{node.inspect}"])
105
+ end
106
+
107
+ parsed = node_reader.call(node_path)
108
+ unless parsed[:ok]
109
+ return failure_block(:unparsable, parsed[:errors])
110
+ end
111
+
112
+ graph = graph_reader.call(graph_path(intent_dir))
113
+ unless graph[:ok]
114
+ return failure_block(:unreadable_graph, graph[:errors])
115
+ end
116
+
117
+ unless graph[:graph][:nodes].include?(node.to_s)
118
+ return failure_block(:unknown_node, ["node #{node} is not declared in graph.md"])
119
+ end
120
+
121
+ text = render_node_block(node: node, kind: parsed[:kind], files: parsed[:files], budget: parsed[:budget],
122
+ report: parsed[:report], body: parsed[:body])
123
+ { ok: true, error_kind: nil, text: text, errors: [], kind: parsed[:kind], files: parsed[:files] || [],
124
+ budget: parsed[:budget], report: parsed[:report] }
125
+ end
126
+
127
+ def failure_block(kind, errors)
128
+ { ok: false, error_kind: kind, text: nil, errors: errors, kind: nil, files: nil, budget: nil, report: nil }
129
+ end
130
+ private_class_method :failure_block
131
+
132
+ def render_node_block(node:, kind:, files:, budget:, body:, report: nil)
133
+ lines = []
134
+ lines << "# Node #{node}"
135
+ lines << "kind: #{kind}"
136
+ lines << "files: #{Array(files).join(', ')}"
137
+ lines << "budget: #{budget}"
138
+ if report
139
+ lines << "report: #{report}"
140
+ lines << "report delivery: return the complete Markdown body in the YAML report field; the runner writes it"
141
+ end
142
+ lines << ""
143
+ lines << body.to_s.strip
144
+ "#{lines.join("\n")}\n"
145
+ end
146
+
147
+ # --- block 2: the ledger (retrieved data) ---------------------------------
148
+
149
+ # Every transition line for `node`, in file order, torn lines marked as
150
+ # such rather than silently dropped or read as evidence (matrix 2.4-2.6).
151
+ def ledger_lines_block(intent_dir:, node:, entries: nil)
152
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
153
+ node_entries = entries.select { |e| e[:subject] == node.to_s }
154
+ return "(no transition lines for #{node})" if node_entries.empty?
155
+
156
+ node_entries.map { |e| e[:torn] ? "[torn] #{e[:raw]}" : e[:raw] }.join("\n")
157
+ end
158
+
159
+ # Predecessors come from graph.md's edges (327 D41 removed them from the
160
+ # node envelope, matrix 2.7); only an attributed, well-formed `done` line
161
+ # counts as evidence the predecessor actually finished (matrix 2.8).
162
+ # `entries` is an optional pre-read of the whole ledger (matrix 2.7a, B12
163
+ # of the post-execution review): `node-transition` is a concurrent
164
+ # appending writer, so re-reading `savepoint.md` once per predecessor let a
165
+ # line landing mid-build make the Transitions, Lease and attempt number of
166
+ # one node input disagree with each other. `build` reads once and threads the
167
+ # same entries through every block; a direct caller with no entries to
168
+ # share still gets a real default that reads the file itself.
169
+ def predecessor_block(intent_dir:, node:, graph_reader: GraphFile.method(:parse), entries: nil)
170
+ graph = graph_reader.call(graph_path(intent_dir))
171
+ return "(no predecessors)" unless graph[:ok]
172
+
173
+ targets = graph[:graph][:edges][node.to_s] || []
174
+ return "(no predecessors)" if targets.empty?
175
+
176
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
177
+ targets.map do |t|
178
+ target_entries = entries.select { |e| e[:subject] == t }
179
+ evidence = target_entries.select { |e| !e[:torn] && e[:attributed] && e[:state] == "done" }.last
180
+ evidence ? "#{t}: done — #{evidence[:raw]}" : "#{t}: not yet done"
181
+ end.join("\n")
182
+ end
183
+
184
+ def lease_present?(value)
185
+ !(value.nil? || value.to_s.strip.empty?)
186
+ end
187
+ private_class_method :lease_present?
188
+
189
+ def last_running_entry(node:, entries:)
190
+ entries.select { |e| e[:subject] == node.to_s && !e[:torn] && e[:state] == "running" }.last
191
+ end
192
+ private_class_method :last_running_entry
193
+
194
+ # Whether the node input renders no lease at all (matrix 2.11a, post-execution
195
+ # review finding A1): neither a flag-supplied lease nor a recorded
196
+ # `running` line for this node. `lease_block` itself only ever renders
197
+ # `lease: none` for this case (spec D3: block 2 is retrieved data, and C7's
198
+ # stop directive is instruction, so it can never live inside that data
199
+ # block, on pain of being self-cancelling under the node input's own trust
200
+ # rule). `build` uses this to decide whether the stop directive belongs in
201
+ # block 5 instead, deduplicated against the worktree's own copy.
202
+ def lease_missing?(node:, holder:, expires:, model:, entries:)
203
+ return false if lease_present?(holder) || lease_present?(expires) || lease_present?(model)
204
+
205
+ last_running_entry(node: node, entries: entries).nil?
206
+ end
207
+
208
+ # The lease from --holder/--expires/--model when given (matrix 2.9), else
209
+ # the node's last `running` ledger line (matrix 2.10), else `lease: none`
210
+ # (matrix 2.11) with no directive of any kind: the stop directive is
211
+ # instruction (spec D3) and is rendered in block 5 by `build`, never here.
212
+ def lease_block(intent_dir:, node:, holder: nil, expires: nil, model: nil, entries: nil)
213
+ if lease_present?(holder) || lease_present?(expires) || lease_present?(model)
214
+ return "lease: holder=#{holder} expires=#{expires} model=#{model}"
215
+ end
216
+
217
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
218
+ last = last_running_entry(node: node, entries: entries)
219
+ return "lease: none" unless last
220
+
221
+ f = last[:fields] || {}
222
+ "lease: holder=#{f['holder']} expires=#{f['expires']} model=#{f['model']}"
223
+ end
224
+
225
+ # Landed commits after a reclaim (spec D14, C11). Never shells out to git
226
+ # unless the node actually carries a `reclaimed` line (matrix 2.13); a
227
+ # failing runner degrades to a note, never an exception (matrix 2.14).
228
+ def landed_commits_block(intent_dir:, node:, files:, repo_dir:, git_runner: DEFAULT_GIT_RUNNER, entries: nil)
229
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
230
+ node_entries = entries.select { |e| e[:subject] == node.to_s && !e[:torn] }
231
+ return nil unless node_entries.any? { |e| e[:state] == "reclaimed" }
232
+ return nil if Array(files).empty? || repo_dir.to_s.empty?
233
+
234
+ begin
235
+ out = git_runner.call(repo_dir: repo_dir, files: files)
236
+ out.to_s.strip.empty? ? "landed commits: none found for #{files.join(', ')}" : out
237
+ rescue StandardError => e
238
+ "landed commits: unavailable (#{e.class}: #{e.message})"
239
+ end
240
+ end
241
+
242
+ # --- block 3: the record (retrieved data) ---------------------------------
243
+
244
+ # {ok:, intent:, decisions: [...], insights: [...], errors:}. Only the
245
+ # three named sections are ever carried (matrix 2.15): `## Intent` in
246
+ # full (the floor, spec D8), `### Decisions` (falling back to a top-level
247
+ # `## Decisions` when the nested one is absent) split into list items, and
248
+ # the last three `## Insights` entries (matrix 2.16), with the kind-aware
249
+ # `### Findings` exclusion applied before entries are split (matrix 2.17,
250
+ # 2.18, 2.18a).
251
+ def record_block(intent_dir:, kind:)
252
+ path = Savepoint.intent_file(intent_dir)
253
+ return { ok: false, intent: nil, decisions: [], insights: [], errors: ["record file not found: #{path}"] } unless File.exist?(path)
254
+
255
+ content = File.read(path)
256
+ intent_text = section_at_level(content, 2, /\AIntent\z/i).to_s.strip
257
+ decisions_text = section_at_level(content, 3, /\ADecisions\b/i)
258
+ decisions_text = section_at_level(content, 2, /\ADecisions\b/i) if decisions_text.to_s.strip.empty?
259
+ decisions = split_list_items(decisions_text.to_s)
260
+
261
+ insights_text = section_at_level(content, 2, /\AInsights\z/i).to_s
262
+ insights_text = strip_findings(insights_text) if kind.to_s == "verify"
263
+ insights = split_insight_entries(insights_text).last(INSIGHTS_KEEP)
264
+
265
+ { ok: true, intent: intent_text, decisions: decisions, insights: insights, errors: [] }
266
+ end
267
+
268
+ def record_sources(intent_dir)
269
+ path = Savepoint.intent_file(intent_dir)
270
+ return [] unless File.exist?(path)
271
+
272
+ content = File.read(path)
273
+ return [] unless content.start_with?("---")
274
+
275
+ parts = content.split("---", 3)
276
+ return [] if parts.length < 3
277
+
278
+ fm = begin
279
+ YAML.safe_load(parts[1], permitted_classes: [Date, Time])
280
+ rescue StandardError
281
+ nil
282
+ end
283
+ return [] unless fm.is_a?(Hash)
284
+
285
+ Array(fm["sources"])
286
+ end
287
+
288
+ # --- block 4: the knowledge hop (retrieved data) --------------------------
289
+
290
+ # One level, never transitive (matrix 2.19): each source's own `## Outcome`
291
+ # and `### Decisions` (falling back to that source's spec.md `## Decisions`
292
+ # when the record carries none, matrix 2.20a, spec D13). An unresolvable
293
+ # source is noted, never raised (matrix 2.21). Capped at `hop_tokens`, with
294
+ # a truncation note when it is cut (matrix 2.22); `hop_tokens` 0 disables
295
+ # the hop entirely (matrix 2.23, spec D7/224's kill criterion).
296
+ #
297
+ # `hop=` on the running line is measured against this cap (C33, intent
298
+ # 224's kill criterion), so the reported `tokens` must never overshoot it
299
+ # by the truncation note's own cost (post-execution review finding C13):
300
+ # the note's bytes are subtracted from the byte budget before slicing, and
301
+ # the slice is trimmed to valid UTF-8 (never repaired) before it is
302
+ # counted, because a raw `byteslice` can split a multi-byte character.
303
+ # Trimming, not `String#scrub`, is what keeps the count honest: `scrub`
304
+ # repairs an invalid tail by inserting a three-byte replacement character,
305
+ # which can grow the slice back past the very budget it was cut to.
306
+ def hop_block(store_dir:, sources:, hop_tokens: DEFAULT_HOP_TOKENS)
307
+ return { text: nil, tokens: 0 } if hop_tokens.to_i <= 0 || Array(sources).empty?
308
+
309
+ chunks = Array(sources).map { |src| hop_chunk(store_dir, src) }
310
+ text = chunks.join("\n\n").scrub
311
+ cap = hop_tokens.to_i
312
+ tokens = DataBoundary.estimate_tokens(text)
313
+ if tokens > cap
314
+ note = "\n[hop truncated at #{cap} tokens]"
315
+ max_bytes = [(cap * 4) - note.bytesize, 0].max
316
+ text = "#{safe_byteslice(text, max_bytes)}#{note}"
317
+ tokens = DataBoundary.estimate_tokens(text)
318
+ end
319
+ { text: text, tokens: tokens }
320
+ end
321
+
322
+ # Shrinks a byte slice (never grows it) until it is valid UTF-8, so a cut
323
+ # that lands inside a multi-byte character is trimmed away rather than
324
+ # repaired with a replacement character (post-execution review finding
325
+ # C13). Bounded by `max_bytes` on every path: the result's bytesize never
326
+ # exceeds what was asked for.
327
+ def safe_byteslice(text, max_bytes)
328
+ bytes = [max_bytes.to_i, 0].max
329
+ slice = text.to_s.byteslice(0, bytes)
330
+ while slice && !slice.valid_encoding? && bytes.positive?
331
+ bytes -= 1
332
+ slice = text.to_s.byteslice(0, bytes)
333
+ end
334
+ slice && slice.valid_encoding? ? slice : ""
335
+ end
336
+ private_class_method :safe_byteslice
337
+
338
+ def hop_chunk(store_dir, source_id)
339
+ source_dir = resolve_source_dir(store_dir, source_id)
340
+ return "### #{source_id}\nunresolvable source: no directory for #{source_id.inspect}" unless source_dir
341
+
342
+ record_path = Savepoint.intent_file(source_dir)
343
+ content = File.exist?(record_path) ? File.read(record_path) : nil
344
+ outcome = content ? section_at_level(content, 2, /\AOutcome\z/i).to_s.strip : ""
345
+ decisions_text = content ? section_at_level(content, 3, /\ADecisions\b/i) : nil
346
+ if decisions_text.to_s.strip.empty?
347
+ spec_path = File.join(source_dir, "spec.md")
348
+ spec_content = File.exist?(spec_path) ? File.read(spec_path) : nil
349
+ # D13's fallback (post-execution review finding B10): `\b` here, to
350
+ # match `record_block`'s own `### Decisions` pattern, which D12's
351
+ # Findings rule already establishes tolerates a trailing qualifier on
352
+ # the heading line. `\z` required an exact "Decisions" heading, so a
353
+ # source whose spec.md carries "## Decisions (from intent Context)" or
354
+ # "## Decisions Log" (eight specs in this store do) silently rendered
355
+ # an empty hop instead of using the fallback D13 exists to provide.
356
+ decisions_text = spec_content ? section_at_level(spec_content, 2, /\ADecisions\b/i) : nil
357
+ end
358
+ "### #{source_id}\n#### Outcome\n#{outcome}\n\n#### Decisions\n#{decisions_text.to_s.strip}"
359
+ end
360
+ private_class_method :hop_chunk
361
+
362
+ # The record's and each hop source's real store-relative path (post-
363
+ # execution review finding C15, spec D5a: "source is rendered
364
+ # store-relative"): the literal constants "record" and "sources" were not
365
+ # paths at all, defeating matrix row 1.3's reason for the attribute to
366
+ # exist (telling the executor which file a paragraph came from).
367
+ def record_source_path(intent_dir)
368
+ path = Savepoint.intent_file(intent_dir)
369
+ "#{File.basename(intent_dir)}/#{File.basename(path)}"
370
+ end
371
+
372
+ def hop_source_paths(store_dir, sources)
373
+ Array(sources).map do |src|
374
+ dir = resolve_source_dir(store_dir, src)
375
+ dir ? "#{File.basename(dir)}/#{File.basename(Savepoint.intent_file(dir))}" : "#{src} (unresolved)"
376
+ end.join(", ")
377
+ end
378
+
379
+ def resolve_source_dir(store_dir, source_id)
380
+ exact = File.join(store_dir, source_id.to_s)
381
+ return exact if File.directory?(exact)
382
+
383
+ Dir.glob(File.join(store_dir, "#{source_id}--*")).select { |p| File.directory?(p) }.sort.first
384
+ end
385
+ private_class_method :resolve_source_dir
386
+
387
+ # --- block 5: where to work (instruction) ---------------------------------
388
+
389
+ def worktree_block(intent_dir:, worktree_reader: Arm.method(:worktree_block))
390
+ info = worktree_reader.call(intent_dir: intent_dir)
391
+ if info && info["provisioned"]
392
+ "worktree: #{info['code']} (branch #{info['code_branch']})"
393
+ else
394
+ "worktree: none provisioned\n#{STOP_DIRECTIVE}"
395
+ end
396
+ end
397
+
398
+ def default_project_reader(intent_dir)
399
+ m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
400
+ return nil unless m
401
+
402
+ home, slug = m[1], m[2]
403
+ path = File.join(Plastic::StoreLayout.project_root(home, slug), "project.yml")
404
+ return nil unless File.exist?(path)
405
+
406
+ # `permitted_classes` (post-execution review finding B3): the same bug
407
+ # class `record_sources` already fixed once in 607e31e. Any project.yml
408
+ # that gains a date-typed value (a `created:` field, say) silently
409
+ # stripped the test command from every node input for that project, since
410
+ # the rescue swallowed `Psych::DisallowedClass` and returned nil.
411
+ data = begin
412
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time])
413
+ rescue StandardError
414
+ nil
415
+ end
416
+ return nil unless data.is_a?(Hash)
417
+
418
+ release = data["release"]
419
+ return nil unless release.is_a?(Hash)
420
+
421
+ verify = release["verify"]
422
+ return nil unless verify.is_a?(String) && !verify.strip.empty?
423
+
424
+ # A multi-line `verify` (post-execution review finding A2) is collapsed
425
+ # to one line rather than refused: each of its own lines is joined with
426
+ # "; ", the shell-sequencing separator, so "ruby bin/test\necho done"
427
+ # reads as "ruby bin/test; echo done" instead of landing as extra raw
428
+ # lines in block 5 (instruction, un-wrapped) where one of those lines
429
+ # could happen to be a complete data marker.
430
+ verify.split("\n").map(&:strip).reject(&:empty?).join("; ")
431
+ end
432
+
433
+ # `files` (intent 355, n4, D5): a node's own declared `*_test.rb` files
434
+ # name the only test command the executor needs - `bin/test --only <those
435
+ # files>` - so it never has to invent one or fall back to the project's
436
+ # generic `release.verify`. A node that declares no test files (a docs-only
437
+ # node, say) still falls back to `project_reader` exactly as before.
438
+ def test_command_block(intent_dir:, files: [], project_reader: method(:default_project_reader))
439
+ named = Array(files).select { |f| f.to_s.end_with?("_test.rb") }
440
+ return "test command: ruby bin/test --only #{named.join(' ')}" if named.any?
441
+
442
+ cmd = project_reader.call(intent_dir)
443
+ cmd ? "test command: #{cmd}" : "test command: none recorded in the project record"
444
+ end
445
+
446
+ # `lease_missing` (post-execution review finding A1) hoists C7's stop
447
+ # directive here, block 5 (instruction, spec D3), whenever the node input
448
+ # carries no lease. `worktree_block` already renders its own copy when the
449
+ # worktree is unprovisioned; the two conditions often fire together, so a
450
+ # directive already present is never repeated.
451
+ #
452
+ # `call_cap` (intent 355, n2, D2): one sentence naming this attempt's tool
453
+ # call cap and the return it hits at, so the executor learns the number
454
+ # from the node input it starts with, never from a denied call mid-edit
455
+ # (matrix 2.4). nil (a caller that names no cap) renders nothing here.
456
+ def where_to_work_block(intent_dir:, worktree_reader: Arm.method(:worktree_block),
457
+ project_reader: method(:default_project_reader), lease_missing: false, call_cap: nil,
458
+ files: [])
459
+ wt = worktree_block(intent_dir: intent_dir, worktree_reader: worktree_reader)
460
+ parts = [wt, test_command_block(intent_dir: intent_dir, files: files, project_reader: project_reader)]
461
+ parts << STOP_DIRECTIVE if lease_missing && !wt.include?(STOP_DIRECTIVE)
462
+ parts << call_cap_sentence(call_cap) if call_cap
463
+ parts.join("\n")
464
+ end
465
+
466
+ def call_cap_sentence(call_cap)
467
+ "call budget: this attempt may make at most #{call_cap} tool calls; past that a hook denies the " \
468
+ "next one, so commit what is green and return failed_verification reason=call_budget."
469
+ end
470
+
471
+ # --- section and list parsing (shared) -------------------------------------
472
+
473
+ # The body of the FIRST heading, at exactly `level` `#` characters, whose
474
+ # text (after the marks) matches `title_re`, up to (excluding) the next
475
+ # heading at `level` or shallower, or EOF. Fence-aware via
476
+ # NodeFile.each_fence_line, so a fenced example line starting with `#`
477
+ # never ends a section early. nil when no such heading exists.
478
+ def section_at_level(content, level, title_re)
479
+ in_section = false
480
+ found = false
481
+ body_lines = []
482
+ NodeFile.each_fence_line(content.to_s) do |line, fenced|
483
+ if fenced
484
+ body_lines << line if in_section
485
+ next
486
+ end
487
+
488
+ m = line.match(/\A(#+)[ \t]+(.*?)\s*\z/)
489
+ if in_section
490
+ if m && m[1].length <= level
491
+ in_section = false
492
+ else
493
+ body_lines << line
494
+ end
495
+ elsif m && m[1].length == level && m[2] =~ title_re
496
+ in_section = true
497
+ found = true
498
+ end
499
+ end
500
+ found ? body_lines.join : nil
501
+ end
502
+
503
+ # A Markdown bullet list split into items: a line starting with `- ` opens
504
+ # a new item, and every following line up to the next such line is that
505
+ # item's continuation (spec: Decisions cut to "the last five", matrix
506
+ # 3.8, which only makes sense over list items, not raw lines).
507
+ def split_list_items(text)
508
+ items = []
509
+ current = nil
510
+ text.to_s.each_line do |line|
511
+ if line.match?(/\A-\s/)
512
+ items << current if current
513
+ current = +line
514
+ elsif current
515
+ current << line
516
+ end
517
+ end
518
+ items << current if current
519
+ items
520
+ end
521
+
522
+ # `## Insights` entries split by `Insights::PREFIX_RE` (matrix 2.16a): a
523
+ # continuation line (one that does not itself open a new prefixed entry)
524
+ # is appended to the entry it follows, never counted as its own entry.
525
+ # Text before the first prefixed line (the scaffold placeholder, matrix
526
+ # 2.16b) is dropped rather than treated as an entry.
527
+ def split_insight_entries(text)
528
+ entries = []
529
+ current = nil
530
+ text.to_s.each_line do |line|
531
+ if line.match?(Insights::PREFIX_RE)
532
+ entries << current if current
533
+ current = +line
534
+ elsif current
535
+ current << line
536
+ end
537
+ end
538
+ entries << current if current
539
+ entries
540
+ end
541
+
542
+ # Remove any `### Findings` subsection (tolerating a trailing qualifier on
543
+ # the heading line, matrix 2.18a) from `text`, up to the next heading at
544
+ # level 3 or shallower, or EOF. Anchored to whatever section `text` already
545
+ # is (the caller passes only the `## Insights` body), so a `### Findings`
546
+ # living under a DIFFERENT section (matrix 2.18a's intent-109 case, `##
547
+ # Context`) is never reached because it is never part of `text`.
548
+ def strip_findings(text)
549
+ lines = text.to_s.each_line.to_a
550
+ out = []
551
+ i = 0
552
+ while i < lines.length
553
+ m = lines[i].match(/\A(#+)[ \t]+(.*?)\s*\z/)
554
+ if m && m[1].length == 3 && m[2] =~ /\AFindings\b/i
555
+ i += 1
556
+ while i < lines.length
557
+ m2 = lines[i].match(/\A(#+)[ \t]+/)
558
+ break if m2 && m2[1].length <= 3
559
+
560
+ i += 1
561
+ end
562
+ next
563
+ end
564
+ out << lines[i]
565
+ i += 1
566
+ end
567
+ out.join
568
+ end
569
+
570
+ # ===========================================================================
571
+ # n3: assembly, the budget, and the node input's identity
572
+ # ===========================================================================
573
+
574
+ # Block labels/sources for the wrapped (data) blocks, spec D3.
575
+ LEDGER_LABEL = "ledger"
576
+ RECORD_LABEL = "record"
577
+ HOP_LABEL = "knowledge hop"
578
+
579
+ # The whole "ledger" data block (spec block 2): the node's own transition
580
+ # lines, its predecessors' evidence, its lease, and any landed commits
581
+ # after a reclaim.
582
+ def full_ledger_text(intent_dir:, node:, files:, holder:, expires:, model:, repo_dir:, git_runner:, entries: nil)
583
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
584
+ landed = landed_commits_block(intent_dir: intent_dir, node: node, files: files, repo_dir: repo_dir,
585
+ git_runner: git_runner, entries: entries)
586
+ parts = [
587
+ "### Transitions",
588
+ ledger_lines_block(intent_dir: intent_dir, node: node, entries: entries),
589
+ "",
590
+ "### Predecessors",
591
+ predecessor_block(intent_dir: intent_dir, node: node, entries: entries),
592
+ "",
593
+ "### Lease",
594
+ lease_block(intent_dir: intent_dir, node: node, holder: holder, expires: expires, model: model,
595
+ entries: entries),
596
+ ]
597
+ if landed
598
+ parts << ""
599
+ parts << "### Landed commits"
600
+ parts << landed
601
+ end
602
+ parts.join("\n")
603
+ end
604
+
605
+ # The record's Intent/Decisions/Insights rendered as one payload, over
606
+ # whatever (possibly already-cut) decisions/insights arrays the cut ladder
607
+ # is currently holding.
608
+ def render_record_text(intent:, decisions:, insights:)
609
+ parts = ["## Intent", intent.to_s.strip, ""]
610
+ parts << "## Decisions"
611
+ parts << (decisions.empty? ? "(none)" : decisions.join.rstrip)
612
+ parts << ""
613
+ parts << "## Insights"
614
+ parts << (insights.empty? ? "(none)" : insights.join.rstrip)
615
+ "#{parts.join("\n")}\n"
616
+ end
617
+
618
+ # One node input, node and where-to-work as plain instruction text, ledger,
619
+ # record and (when present) hop wrapped as labeled data sharing ONE
620
+ # boundary token computed over their raw payloads (spec D2-D4, matrix
621
+ # 3.1-3.3).
622
+ #
623
+ # Blocks 1 and 5 are raw-interpolated (spec D3: instruction, not data), but
624
+ # that trust does not reach a `release.verify` a project.yml can carry
625
+ # (post-execution review finding A2): a line that is, on its own, a
626
+ # complete data marker is disarmed by `DataBoundary.neutralize_marker_lines`
627
+ # before it ever reaches the node input, so a forged marker cannot open a
628
+ # block outside the wrapper's own boundary. `record_source`/`hop_source`
629
+ # (finding C15) are the record's and the hop sources' real store-relative
630
+ # paths, never the placeholder label "record"/"sources".
631
+ def render_input(node_text:, ledger_text:, intent_text:, decisions:, insights:, hop:, where_text:,
632
+ record_source: "record", hop_source: "sources")
633
+ record_text = render_record_text(intent: intent_text, decisions: decisions, insights: insights)
634
+ hop_text = hop && hop[:text]
635
+
636
+ payloads = [ledger_text, record_text]
637
+ payloads << hop_text if hop_text
638
+ token = DataBoundary.boundary_token(payloads)
639
+
640
+ node_text_safe = DataBoundary.neutralize_marker_lines(node_text.to_s)
641
+ where_text_safe = DataBoundary.neutralize_marker_lines(where_text.to_s)
642
+
643
+ # Normalized through `attr_safe` exactly as `wrap` normalizes the marker
644
+ # attributes it writes (post-execution review finding A2's integrity
645
+ # check): comparing the raw `record_source`/`hop_source` against what
646
+ # `unwrap` reads back off the rendered marker line raised on every nil
647
+ # source, because `attr_safe(nil)` renders as `""`, not `"nil".to_s`.
648
+ wrapped_specs = [
649
+ { label: DataBoundary.attr_safe(LEDGER_LABEL), source: DataBoundary.attr_safe("savepoint.md") },
650
+ { label: DataBoundary.attr_safe(RECORD_LABEL), source: DataBoundary.attr_safe(record_source) },
651
+ ]
652
+ wrapped_specs << { label: DataBoundary.attr_safe(HOP_LABEL), source: DataBoundary.attr_safe(hop_source) } if hop_text
653
+
654
+ parts = [node_text_safe.rstrip, ""]
655
+ parts << DataBoundary.wrap(ledger_text, label: LEDGER_LABEL, source: "savepoint.md", token: token).rstrip
656
+ parts << ""
657
+ parts << DataBoundary.wrap(record_text, label: RECORD_LABEL, source: record_source, token: token).rstrip
658
+ parts << ""
659
+ if hop_text
660
+ parts << DataBoundary.wrap(hop_text, label: HOP_LABEL, source: hop_source, token: token).rstrip
661
+ parts << ""
662
+ end
663
+ parts << where_text_safe.rstrip
664
+ rendered = "#{parts.join("\n")}\n"
665
+
666
+ assert_input_integrity!(rendered, wrapped_specs)
667
+ rendered
668
+ end
669
+
670
+ # The trust boundary's own invariant (post-execution review finding A2):
671
+ # the finished node input must unwrap to exactly the data blocks that were
672
+ # wrapped, same count, same labels, same sources, in order. This is what
673
+ # the escaping rule and the marker-line neutralization pass are FOR, so the
674
+ # check belongs here, not only in a test that could rot independently of
675
+ # the code it is meant to guard.
676
+ def assert_input_integrity!(rendered, wrapped_specs)
677
+ actual = DataBoundary.unwrap(rendered).map { |b| { label: b[:label], source: b[:source] } }
678
+ return if actual == wrapped_specs
679
+
680
+ raise "node input integrity check failed: expected #{wrapped_specs.inspect}, got #{actual.inspect}"
681
+ end
682
+ private_class_method :assert_input_integrity!
683
+
684
+ def render_from_state(state)
685
+ render_input(node_text: state[:node_text], ledger_text: state[:ledger_text], intent_text: state[:intent_text],
686
+ decisions: state[:decisions], insights: state[:insights], hop: state[:hop],
687
+ where_text: state[:where_text], record_source: state[:record_source] || "record",
688
+ hop_source: state[:hop_source] || "sources")
689
+ end
690
+ private_class_method :render_from_state
691
+
692
+ def estimate_rendered_tokens(rendered)
693
+ DataBoundary.estimate_tokens(rendered)
694
+ end
695
+
696
+ # The C28 cut ladder: drop the hop whole, then cut Insights to the last
697
+ # one, then cut Decisions to the last five - applied only as far as
698
+ # needed, and only when a step actually shrinks the rendered bytes (matrix
699
+ # 3.23: a cut that would not reduce the render is skipped rather than
700
+ # counted as applied). The node, ledger and where-to-work blocks are never
701
+ # touched (matrix 3.9) because nothing here ever rewrites those keys.
702
+ def apply_cut_ladder(state, budget_tokens)
703
+ cuts = []
704
+ current = state
705
+ rendered = render_from_state(current)
706
+ tokens = estimate_rendered_tokens(rendered)
707
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
708
+
709
+ if current[:hop] && current[:hop][:text]
710
+ candidate_state = current.merge(hop: { text: nil, tokens: 0 })
711
+ candidate = render_from_state(candidate_state)
712
+ if candidate.bytesize < rendered.bytesize
713
+ current, rendered = candidate_state, candidate
714
+ tokens = estimate_rendered_tokens(rendered)
715
+ cuts << :hop
716
+ end
717
+ end
718
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
719
+
720
+ if current[:insights].length > 1
721
+ candidate_state = current.merge(insights: current[:insights].last(1))
722
+ candidate = render_from_state(candidate_state)
723
+ if candidate.bytesize < rendered.bytesize
724
+ current, rendered = candidate_state, candidate
725
+ tokens = estimate_rendered_tokens(rendered)
726
+ cuts << :insights
727
+ end
728
+ end
729
+ return [rendered, tokens, cuts] if tokens <= budget_tokens
730
+
731
+ if current[:decisions].length > DECISIONS_KEEP
732
+ candidate_state = current.merge(decisions: current[:decisions].last(DECISIONS_KEEP))
733
+ candidate = render_from_state(candidate_state)
734
+ if candidate.bytesize < rendered.bytesize
735
+ current, rendered = candidate_state, candidate
736
+ tokens = estimate_rendered_tokens(rendered)
737
+ cuts << :decisions
738
+ end
739
+ end
740
+
741
+ [rendered, tokens, cuts]
742
+ end
743
+ private_class_method :apply_cut_ladder
744
+
745
+ # Which never-cut-or-already-at-floor block is largest, so a refusal names
746
+ # what to shorten (matrix 3.24) rather than just saying "too big".
747
+ def name_oversized_block(state)
748
+ record_text = render_record_text(intent: state[:intent_text], decisions: state[:decisions],
749
+ insights: state[:insights])
750
+ candidates = {
751
+ "node" => state[:node_text],
752
+ "ledger" => state[:ledger_text],
753
+ "record" => record_text,
754
+ "where to work" => state[:where_text],
755
+ }
756
+ name, text = candidates.max_by { |_, t| DataBoundary.estimate_tokens(t.to_s) }
757
+ [name, DataBoundary.estimate_tokens(text.to_s)]
758
+ end
759
+ private_class_method :name_oversized_block
760
+
761
+ def lease_flag_given?(holder)
762
+ lease_present?(holder)
763
+ end
764
+ private_class_method :lease_flag_given?
765
+
766
+ # C21: the number of `running` lines already recorded for `node`, plus one
767
+ # when a lease is being supplied by flag (a NEW dispatch), floored at 1.
768
+ # Minor 5: a torn `running` line (missing holder=/expires=/input=/model=)
769
+ # is skipped here exactly as ReadySet.attempts_count already skips it -
770
+ # counting it desynchronizes this attempt number from the extensions file
771
+ # a live node's own `attempts/<node>--a<N>.extensions` names, since that
772
+ # file is keyed by the attempt sweep computed off the SAME filtered count.
773
+ def compute_attempt_number(intent_dir:, node:, lease_flag_given:, entries: nil)
774
+ entries ||= NodeLedger.entries(savepoint_path(intent_dir))
775
+ count = entries.count { |e| !e[:torn] && e[:subject] == node.to_s && e[:state] == "running" }
776
+ [count + (lease_flag_given ? 1 : 0), 1].max
777
+ end
778
+
779
+ def input_path(intent_dir:, node:, attempt:)
780
+ File.join(intent_dir, "attempts", "#{node}--a#{attempt}.input")
781
+ end
782
+
783
+ def summary_line(result)
784
+ "path=#{result[:path]} sha=#{result[:sha]} tokens=#{result[:tokens]} hop_tokens=#{result[:hop_tokens]} " \
785
+ "attempt=#{result[:attempt]}"
786
+ end
787
+
788
+ def running_command(intent_dir:, node:, sha:, hop_tokens:)
789
+ "node-transition #{intent_dir} --node #{node} --state running --field input=#{sha} --field hop=#{hop_tokens}"
790
+ end
791
+
792
+ def needs_decision_command(intent_dir:, node:, question:)
793
+ escaped = question.to_s.gsub("\\", "\\\\\\\\").gsub('"', "\\\"")
794
+ "node-transition #{intent_dir} --node #{node} --state needs_decision --field question=\"#{escaped}\""
795
+ end
796
+
797
+ # Build one node's whole input from disk (spec D1). Returns
798
+ # {ok:, exit_code:, path:, sha:, tokens:, hop_tokens:, attempt:,
799
+ # cuts_applied:, running_command:, errors:} on success, or
800
+ # {ok: false, exit_code:, errors:, needs_decision_command: (on overflow)}
801
+ # on refusal. Exit codes follow the node-transition family (spec D17): 2
802
+ # usage (unknown node), 3 unreadable/unparsable graph, node file or
803
+ # record, 4 overflow past the third cut, 5 an existing attempt whose bytes
804
+ # differ.
805
+ # M7/row 10.9: `budget_tokens: nil` (rather than DEFAULT_BUDGET_TOKENS)
806
+ # is how a caller says "no override" - the fallback below then reaches for
807
+ # the node block's OWN declared `budget:` before ever touching the
808
+ # shipped default, so a caller that never learned about a node's budget
809
+ # (the CLI, another future caller) still gets it, not just the one path
810
+ # RunnerDispatch explicitly threads it through (row 10.8).
811
+ def build(intent_dir:, node:, budget_tokens: nil, hop_tokens: DEFAULT_HOP_TOKENS,
812
+ holder: nil, expires: nil, model: nil, attempt: nil, out: nil, force: false,
813
+ renamer: File.method(:rename), git_runner: DEFAULT_GIT_RUNNER,
814
+ worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader),
815
+ call_cap: nil)
816
+ intent_dir = File.expand_path(intent_dir)
817
+
818
+ nb = node_block(intent_dir: intent_dir, node: node)
819
+ unless nb[:ok]
820
+ return { ok: false, exit_code: nb[:error_kind] == :unknown_node ? 2 : 3, errors: nb[:errors] }
821
+ end
822
+
823
+ budget_tokens = (budget_tokens || nb[:budget] || DEFAULT_BUDGET_TOKENS).to_i
824
+
825
+ record = record_block(intent_dir: intent_dir, kind: nb[:kind])
826
+ return { ok: false, exit_code: 3, errors: record[:errors] } unless record[:ok]
827
+
828
+ repo_dir = begin
829
+ info = worktree_reader.call(intent_dir: intent_dir)
830
+ info && info["code"]
831
+ rescue StandardError
832
+ nil
833
+ end
834
+
835
+ # Read the ledger once and thread it through every block that consults
836
+ # it (post-execution review finding B12): `node-transition` is a
837
+ # concurrent appending writer, so re-reading `savepoint.md` once per
838
+ # block risked a line landing mid-build and making the Transitions,
839
+ # Lease and attempt number of one node input disagree with each other.
840
+ entries = NodeLedger.entries(savepoint_path(intent_dir))
841
+
842
+ ledger_text = full_ledger_text(intent_dir: intent_dir, node: node, files: nb[:files], holder: holder,
843
+ expires: expires, model: model, repo_dir: repo_dir, git_runner: git_runner,
844
+ entries: entries)
845
+
846
+ store_dir = File.dirname(intent_dir)
847
+ sources = record_sources(intent_dir)
848
+ hop_full = hop_block(store_dir: store_dir, sources: sources, hop_tokens: hop_tokens)
849
+
850
+ # Finding A1: the stop directive belongs in block 5 (instruction)
851
+ # whenever the node input carries no lease at all, never inside block 2's
852
+ # ledger data (spec D3's self-cancellation risk).
853
+ missing_lease = lease_missing?(node: node, holder: holder, expires: expires, model: model, entries: entries)
854
+ where_text = where_to_work_block(intent_dir: intent_dir, worktree_reader: worktree_reader,
855
+ project_reader: project_reader, lease_missing: missing_lease,
856
+ call_cap: call_cap, files: nb[:files])
857
+
858
+ state = {
859
+ node_text: nb[:text], ledger_text: ledger_text, intent_text: record[:intent],
860
+ decisions: record[:decisions], insights: record[:insights], hop: hop_full, where_text: where_text,
861
+ # Finding C15: the record's and the hop sources' real store-relative
862
+ # paths, never the placeholder labels "record"/"sources".
863
+ record_source: record_source_path(intent_dir), hop_source: hop_source_paths(store_dir, sources),
864
+ }
865
+
866
+ rendered, tokens, cuts_applied = apply_cut_ladder(state, budget_tokens)
867
+
868
+ if tokens > budget_tokens
869
+ final_state = state.merge(
870
+ hop: cuts_applied.include?(:hop) ? { text: nil, tokens: 0 } : state[:hop],
871
+ insights: cuts_applied.include?(:insights) ? state[:insights].last(1) : state[:insights],
872
+ decisions: cuts_applied.include?(:decisions) ? state[:decisions].last(DECISIONS_KEEP) : state[:decisions],
873
+ )
874
+ oversized_name, oversized_tokens = name_oversized_block(final_state)
875
+ question = "node input for #{node} is #{tokens} tokens after every cut, over the #{budget_tokens}-token " \
876
+ "budget; #{oversized_name} alone is #{oversized_tokens} tokens, shorten it"
877
+ return {
878
+ ok: false, exit_code: 4, tokens: tokens,
879
+ needs_decision_command: needs_decision_command(intent_dir: intent_dir, node: node, question: question),
880
+ errors: ["overflow: #{oversized_name} is #{oversized_tokens} tokens over the #{budget_tokens}-token budget"],
881
+ }
882
+ end
883
+
884
+ attempt_n = attempt || compute_attempt_number(intent_dir: intent_dir, node: node,
885
+ lease_flag_given: lease_flag_given?(holder), entries: entries)
886
+ path = out ? File.expand_path(out) : input_path(intent_dir: intent_dir, node: node, attempt: attempt_n)
887
+ FileUtils.mkdir_p(File.dirname(path))
888
+
889
+ if File.exist?(path)
890
+ existing = File.binread(path)
891
+ if existing == rendered
892
+ # Rebuilding an unchanged attempt is a no-op (spec D11): the file on
893
+ # disk already IS these exact bytes.
894
+ elsif force
895
+ AtomicWrite.write(path, rendered, renamer: renamer)
896
+ else
897
+ return { ok: false, exit_code: 5, errors: ["attempt file exists with different bytes: #{path}"] }
898
+ end
899
+ else
900
+ AtomicWrite.write(path, rendered, renamer: renamer)
901
+ end
902
+
903
+ sha = Digest::SHA256.hexdigest(File.binread(path))[0, 12]
904
+ hop_tokens_measured = cuts_applied.include?(:hop) ? 0 : hop_full[:tokens].to_i
905
+
906
+ {
907
+ ok: true, exit_code: 0, path: path, sha: sha, tokens: tokens, hop_tokens: hop_tokens_measured,
908
+ attempt: attempt_n, cuts_applied: cuts_applied,
909
+ running_command: running_command(intent_dir: intent_dir, node: node, sha: sha, hop_tokens: hop_tokens_measured),
910
+ errors: [],
911
+ }
912
+ end
913
+ end