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