@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,231 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "fileutils"
6
+ require_relative "worktree"
7
+ require_relative "lock"
8
+ require_relative "node_ledger"
9
+ require_relative "ready_set"
10
+ require_relative "savepoint"
11
+
12
+ # RunnerSweep (intent 340, G7, n2): the first thing every `step` does. Two
13
+ # separately callable entry points - #abort_if_merging and #reclaim - plus
14
+ # #run, which composes them for a caller that has no absorb step to interleave.
15
+ # `step` (a later node) calls #abort_if_merging, runs absorb, then calls
16
+ # #reclaim itself, so a reclaimed node's just-landed work from absorb is never
17
+ # thrown away by a stale read (matrix row 2.20).
18
+ #
19
+ # #abort_if_merging refuses the whole step when the intent worktree already
20
+ # has a merge in progress (`MERGE_HEAD` resolves): dispatching on top of a
21
+ # half-finished merge would hand the next node a diff full of someone else's
22
+ # conflict markers. Nothing is written when this fires - not the ledger, not
23
+ # graph.md, not even the delivery lease heartbeat (row 2.2, row 2.16: the
24
+ # heartbeat runs strictly after the abort check).
25
+ #
26
+ # #reclaim walks every node whose CURRENT status (the ledger's own resolution,
27
+ # never a raw scan) is `running`, skipping anything named in `skip:` (the
28
+ # nodes this step already absorbed, row 2.19) or already terminal (a `done`
29
+ # node is never touched, row 2.12 - it simply never shows up as `running`).
30
+ # An expired lease with no commits on the node's own branch newer than its
31
+ # expiry is reclaimed outright. An expired lease whose branch DOES carry
32
+ # newer commits is extended instead, up to twice per attempt (row 2.7); the
33
+ # extension is never a ledger transition (`running` cannot re-enter `running`
34
+ # under the transition layer), so it is one line appended to
35
+ # attempts/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
36
+ # count (row 2.18), never trusted from the caller. A third expiry reclaims
37
+ # regardless of new commits.
38
+ #
39
+ # Pure and dependency-injected: every git call goes through an injected
40
+ # `runner:` (default Worktree::ShellRunner), never cwd; the delivery-lease
41
+ # heartbeat goes through an injected `heartbeat:` (default Lock.heartbeat) so
42
+ # ordering (row 2.16) is provable without a real lock file. No eval, no
43
+ # ENV/global-constant seam.
44
+ module RunnerSweep
45
+ module_function
46
+
47
+ # D-ish: at most two extensions per attempt (row 2.7); the third expiry
48
+ # reclaims regardless of new commits.
49
+ MAX_EXTENSIONS_PER_ATTEMPT = 2
50
+
51
+ # abort_if_merging(context, runner:) -> {ok:, error:, recovery_command:}.
52
+ # `context.worktree` is nil for a global-store-only intent (no git repo to
53
+ # merge into); that case is always ok - there is nothing to abort.
54
+ def abort_if_merging(context, runner: Worktree::ShellRunner.new)
55
+ worktree = context&.worktree
56
+ return { ok: true, error: nil, recovery_command: nil } if blank?(worktree)
57
+
58
+ res = runner.run("-C", worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD")
59
+ return { ok: true, error: nil, recovery_command: nil } unless res.success?
60
+
61
+ recovery_command = "git -C #{worktree} merge --abort"
62
+ warn "runner: a merge is already in progress in #{worktree}; run `#{recovery_command}` " \
63
+ "before the next step can dispatch"
64
+ { ok: false, error: "a merge is in progress in #{worktree}", recovery_command: recovery_command }
65
+ end
66
+
67
+ # reclaim(context, runner:, skip:, now:) -> {reclaimed: [{node:, holder:,
68
+ # expired:}], extended: [{node:, head:, time:}]}. Reads the ledger fresh on
69
+ # every call (no cache), which is what makes calling it AFTER absorb (row
70
+ # 2.20) actually see absorb's own just-landed work rather than a stale
71
+ # snapshot taken before it.
72
+ def reclaim(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now)
73
+ intent_dir = context.intent_dir
74
+ content = savepoint_content(intent_dir)
75
+ entries = NodeLedger.entries_from_content(content)
76
+ status_map = NodeLedger.status_from_content(content)
77
+ skip_set = Array(skip).map(&:to_s)
78
+
79
+ reclaimed = []
80
+ extended = []
81
+
82
+ status_map.each do |subject, state|
83
+ next unless state == "running"
84
+ next unless subject.match?(Savepoint::NODE_SUBJECT_RE)
85
+ next if skip_set.include?(subject)
86
+
87
+ last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
88
+ next unless last
89
+
90
+ fields = last[:fields] || {}
91
+ expires_raw = fields["expires"]
92
+ expires_at = parse_time(expires_raw)
93
+ next unless expires_at
94
+ next if now < expires_at # row 2.5: an unexpired lease is left alone
95
+
96
+ holder = fields["holder"]
97
+ branch = node_branch(context, subject)
98
+ head_sha, head_time = branch_head(runner, context.worktree, branch)
99
+ has_new_commits = head_time && head_time > expires_at
100
+
101
+ if has_new_commits
102
+ attempt = current_attempt(entries, subject)
103
+ count = extension_count(intent_dir, subject, attempt)
104
+ if count < MAX_EXTENSIONS_PER_ATTEMPT
105
+ record_extension(intent_dir, subject, attempt, head_sha, now)
106
+ extended << { node: subject, head: head_sha, time: now.utc.iso8601 }
107
+ next
108
+ end
109
+ # row 2.7: the cap is spent - fall through and reclaim anyway.
110
+ end
111
+
112
+ NodeLedger.append_transition(
113
+ savepoint_path(intent_dir),
114
+ subject: subject,
115
+ state: "reclaimed",
116
+ fields: { holder: holder, expired: expires_raw },
117
+ now: now
118
+ )
119
+ reclaimed << { node: subject, holder: holder, expired: expires_raw }
120
+ end
121
+
122
+ { reclaimed: reclaimed, extended: extended }
123
+ end
124
+
125
+ # run(context, runner:, skip:, now:, heartbeat:) -> the composed report a
126
+ # caller with no absorb step to interleave uses directly. Order is fixed on
127
+ # purpose (row 2.2, row 2.16): abort check, THEN the lease heartbeat, THEN
128
+ # reclaim - never the reverse, and never a write of any kind before the
129
+ # abort check has cleared.
130
+ def run(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now, heartbeat: Lock.method(:heartbeat))
131
+ abort_result = abort_if_merging(context, runner: runner)
132
+ unless abort_result[:ok]
133
+ return {
134
+ ok: false, aborted: true, error: abort_result[:error],
135
+ recovery_command: abort_result[:recovery_command], reclaimed: [], extended: [],
136
+ }
137
+ end
138
+
139
+ session = context&.session
140
+ heartbeat.call(context.intent_dir, session: session, now: now) unless blank?(session)
141
+
142
+ result = reclaim(context, runner: runner, skip: skip, now: now)
143
+ {
144
+ ok: true, aborted: false, error: nil, recovery_command: nil,
145
+ reclaimed: result[:reclaimed], extended: result[:extended],
146
+ }
147
+ end
148
+
149
+ # --- internals ---------------------------------------------------------------
150
+
151
+ def blank?(value)
152
+ value.nil? || value.to_s.strip.empty?
153
+ end
154
+ private_class_method :blank?
155
+
156
+ def savepoint_path(intent_dir)
157
+ File.join(intent_dir.to_s, "savepoint.md")
158
+ end
159
+ private_class_method :savepoint_path
160
+
161
+ def savepoint_content(intent_dir)
162
+ path = savepoint_path(intent_dir)
163
+ File.exist?(path) ? File.read(path) : ""
164
+ end
165
+ private_class_method :savepoint_content
166
+
167
+ # The node's own worktree branch (n3's naming: `plastic/<id>--<slug>--<node>`),
168
+ # derived from the intent id/slug alone - never through NodeWorktree, which
169
+ # this node does not depend on.
170
+ def node_branch(context, node)
171
+ "plastic/#{context.intent_id}--#{context.intent_slug}--#{node}"
172
+ end
173
+ private_class_method :node_branch
174
+
175
+ # [head_sha, committer_time] for `branch` in `worktree`'s repo, or [nil, nil]
176
+ # when the worktree is gone, the branch does not exist, or anything else
177
+ # about the git call fails (row 2.13: never raise).
178
+ def branch_head(runner, worktree, branch)
179
+ return [nil, nil] if blank?(worktree) || blank?(branch)
180
+
181
+ res = runner.run("-C", worktree, "log", "-1", "--format=%H%x1f%cI", branch)
182
+ return [nil, nil] unless res.success?
183
+
184
+ sha, iso = res.stdout.to_s.strip.split("\x1f")
185
+ [sha, parse_time(iso)]
186
+ rescue StandardError
187
+ [nil, nil]
188
+ end
189
+ private_class_method :branch_head
190
+
191
+ def parse_time(raw)
192
+ return nil if blank?(raw)
193
+
194
+ Time.iso8601(raw.to_s)
195
+ rescue ArgumentError
196
+ nil
197
+ end
198
+ private_class_method :parse_time
199
+
200
+ # Row 2.18: the attempt number comes from the ledger's own count of
201
+ # `running` lines since the subject's last terminal line - the exact same
202
+ # arithmetic NodeInput uses to name that attempt's input file, so the
203
+ # extensions file for a `running` line always matches the node input it extends.
204
+ def current_attempt(entries, subject)
205
+ ReadySet.attempts_count(entries, subject)
206
+ end
207
+ private_class_method :current_attempt
208
+
209
+ def extensions_path(intent_dir, node, attempt)
210
+ File.join(intent_dir.to_s, "attempts", "#{node}--a#{attempt}.extensions")
211
+ end
212
+ private_class_method :extensions_path
213
+
214
+ # Row 2.8: counted from the attempt-scoped file alone, so a prior attempt's
215
+ # extensions never count against a fresh dispatch.
216
+ def extension_count(intent_dir, node, attempt)
217
+ path = extensions_path(intent_dir, node, attempt)
218
+ return 0 unless File.exist?(path)
219
+
220
+ File.read(path).each_line.count { |l| !l.strip.empty? }
221
+ end
222
+ private_class_method :extension_count
223
+
224
+ # Row 2.9: the observed head sha and the time, one line, append-only.
225
+ def record_extension(intent_dir, node, attempt, head_sha, now)
226
+ path = extensions_path(intent_dir, node, attempt)
227
+ FileUtils.mkdir_p(File.dirname(path))
228
+ File.open(path, "a") { |f| f.write("#{now.utc.iso8601} head=#{head_sha}\n") }
229
+ end
230
+ private_class_method :record_extension
231
+ end
@@ -0,0 +1,252 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "time"
6
+ require "rbconfig"
7
+ require_relative "runner_core"
8
+ require_relative "runner_sweep"
9
+ require_relative "runner_absorb"
10
+ require_relative "runner_dispatch"
11
+ require_relative "node_worktree"
12
+ require_relative "lock"
13
+
14
+ # RunnerUntilEmpty (intent 340b, G7c, n7): the Codex loop. `runner
15
+ # until-empty` composes `step` and `node-run` itself, since Codex has no
16
+ # session on the other end to make the subagent calls `step` only ever
17
+ # prints a plan for - dispatch, run at most two `node-run` subprocesses at
18
+ # once, absorb each return, and around again, stopping on complete,
19
+ # stalled, needs_decision, a refused step, or an iteration cap.
20
+ #
21
+ # #step_once is one turn - abort-if-merging, the heartbeat, absorb every
22
+ # return this call carries (serially, a plain Ruby loop, never a thread),
23
+ # reclaim, reap, then dispatch - the same fixed order scripts/runner's own
24
+ # `run_step_body` uses, returning data rather than printing it, so #run can
25
+ # make its own stop/continue decision instead of scraping stdout. #run is
26
+ # the loop: it grows `active` from whatever #step_once actually dispatched
27
+ # (already capped at the concurrency ceiling by RunnerDispatch's own
28
+ # running-count check, never a second cap layered on top here), spawns one
29
+ # real `node-run` subprocess per newly dispatched node, and blocks for AT
30
+ # LEAST one of them to finish before it ever calls #step_once again - so an
31
+ # absorb is always a single, uncontended call, and a second `node-run`
32
+ # finishing while the first is mid-absorb simply waits its own turn as the
33
+ # next iteration's `returns`, never as a second in-flight absorb.
34
+ #
35
+ # A shared `<repo>/.git` between two worktrees is a real `index.lock`
36
+ # collision risk at commit time; this module does not try to prevent it by
37
+ # serializing the two `node-run` processes (that would throw away the
38
+ # concurrency this node exists to deliver) - it records the collision, on
39
+ # whichever side actually hit it, and lets the kind's retry cap (already
40
+ # built) recover the node on a later attempt.
41
+ #
42
+ # Pure and dependency-injected: `step:`, `node_run_spawner:` and
43
+ # `node_run_waiter:` are keyword seams with real defaults, so a test can
44
+ # drive #run entirely off doubles (no real subprocess, no real git repo)
45
+ # for every row except the ones that are precisely about real OS
46
+ # concurrency or the real CLI arm.
47
+ module RunnerUntilEmpty
48
+ module_function
49
+
50
+ MAX_CONCURRENCY = 2
51
+ DEFAULT_MAX_ITERATIONS = 200
52
+
53
+ NODE_RUN_SCRIPT = File.expand_path(File.join(__dir__, "..", "node-run")).freeze
54
+
55
+ # run(context, harness:) -> {status:, iterations:, ...}. `status` is one
56
+ # of complete, stalled, needs_decision, iteration_cap, refused - never
57
+ # anything else, and the loop returns the instant it reaches one of them.
58
+ def run(context, harness: nil, max_iterations: DEFAULT_MAX_ITERATIONS,
59
+ step: method(:step_once), node_run_spawner: method(:spawn_node_run),
60
+ node_run_waiter: method(:wait_for_node_run), out: $stdout)
61
+ active = {}
62
+ pending_returns = {}
63
+ iterations = 0
64
+
65
+ loop do
66
+ iterations += 1
67
+ if iterations > max_iterations
68
+ out.puts "until-empty: stopped at the iteration cap (#{max_iterations})"
69
+ return { status: "iteration_cap", iterations: iterations - 1 }
70
+ end
71
+
72
+ result = step.call(context, harness: harness, returns: pending_returns)
73
+ pending_returns = {}
74
+
75
+ unless result[:ok]
76
+ out.puts "until-empty: stopped, step refused (#{result[:reason]})"
77
+ return { status: "refused", reason: result[:reason], iterations: iterations }
78
+ end
79
+
80
+ Array(result[:absorbed]).each { |a| out.puts "absorbed #{a[:node]}: #{a[:state]}" }
81
+
82
+ case result[:status]
83
+ when "complete"
84
+ out.puts "complete"
85
+ return { status: "complete", iterations: iterations }
86
+ when "stalled"
87
+ out.puts "stalled"
88
+ Array(result[:blockers]).each { |b| out.puts "blocked: #{b}" }
89
+ return { status: "stalled", iterations: iterations, blockers: result[:blockers] }
90
+ when "needs_decision"
91
+ stop = result[:stop]
92
+ out.puts "needs_decision: #{stop[:node]} - #{stop[:question]}"
93
+ out.puts stop[:answer_command]
94
+ return { status: "needs_decision", iterations: iterations, stop: stop }
95
+ end
96
+
97
+ Array(result[:dispatched]).each do |node|
98
+ next if active.key?(node)
99
+
100
+ active[node] = node_run_spawner.call(context, node: node, harness: harness)
101
+ end
102
+
103
+ if active.empty?
104
+ out.puts "stalled"
105
+ out.puts "blocked: queued with no active node-run to wait on"
106
+ return { status: "stalled", iterations: iterations,
107
+ blockers: ["queued with no active node-run to wait on"] }
108
+ end
109
+
110
+ finished = Array(node_run_waiter.call(active))
111
+ finished.each do |f|
112
+ active.delete(f[:node])
113
+ out.puts "until-empty: index.lock collision recorded for #{f[:node]}" if index_lock_collision?(f)
114
+ pending_returns[f[:node]] = f[:return_path]
115
+ end
116
+ end
117
+ end
118
+
119
+ # --- one turn ----------------------------------------------------------------
120
+
121
+ # step_once(context, harness:, returns:) -> {ok:, reason:, status:,
122
+ # dispatched: [node ids], stop:, blockers:, absorbed: [{node:, state:}]}.
123
+ # Every absorb in `returns` runs in this one Ruby method, in the order
124
+ # `returns` iterates, before `dispatch` ever runs (row 7.3) - never a
125
+ # second call to this method from another thread while one is already in
126
+ # flight (row 7.2), because #run above never starts a second one.
127
+ def step_once(context, harness:, returns: {}, allow_core_drift: false, now: Time.now,
128
+ sweep: RunnerSweep, absorb: RunnerAbsorb, dispatch: RunnerDispatch,
129
+ worktree: NodeWorktree, lock: Lock, config_loader: method(:load_agent_config))
130
+ abort_result = sweep.abort_if_merging(context)
131
+ unless abort_result[:ok]
132
+ return refusal("merge_in_progress", abort_result[:error])
133
+ end
134
+
135
+ lock.heartbeat(context.intent_dir, session: context.session) unless context.session.to_s.strip.empty?
136
+
137
+ return refusal("lock_not_held", nil) unless context.session
138
+
139
+ absorbed = returns.map do |node, path|
140
+ result = absorb.absorb(context, node: node, return_path: path, allow_core_drift: allow_core_drift, now: now)
141
+ { node: node, state: result[:state] }
142
+ end
143
+
144
+ swept = sweep.reclaim(context, skip: returns.keys, now: now)
145
+ reaped = worktree.reap(context)
146
+
147
+ agent_config = config_loader.call(context.plastic_home)
148
+ dispatch_result = dispatch.dispatch(context, config: agent_config, harness: harness, now: now)
149
+ unless dispatch_result[:ok]
150
+ return refusal(dispatch_result[:reason], Array(dispatch_result[:errors]).join("; "))
151
+ end
152
+
153
+ {
154
+ ok: true, reason: nil, status: dispatch_result[:status],
155
+ dispatched: dispatch_result[:dispatched].map { |d| d[:node] },
156
+ stop: dispatch_result[:stop], blockers: dispatch_result[:blockers], absorbed: absorbed,
157
+ reclaimed: swept[:reclaimed], extended: swept[:extended], reaped: reaped[:removed],
158
+ }
159
+ end
160
+
161
+ def refusal(reason, detail)
162
+ { ok: false, reason: reason, detail: detail, status: nil, dispatched: [], stop: nil, blockers: [],
163
+ absorbed: [] }
164
+ end
165
+ private_class_method :refusal
166
+
167
+ # Mirrors scripts/runner's own Runner.load_agent_config exactly (row
168
+ # 1.1 of nodes/n1.md): a missing or unparseable config.yml reads as {},
169
+ # never raises, and it is read fresh every call rather than cached, since
170
+ # a long-running until-empty process must see a config edit made mid-run
171
+ # the same way a fresh `step` call always would.
172
+ def load_agent_config(plastic_home)
173
+ path = File.join(plastic_home.to_s, "config.yml")
174
+ return {} unless File.exist?(path)
175
+
176
+ YAML.safe_load(File.read(path)) || {}
177
+ rescue StandardError
178
+ {}
179
+ end
180
+
181
+ # --- node-run, the real subprocess --------------------------------------------
182
+
183
+ # spawn_node_run(context, node:, harness:) -> a handle {node:, pid:,
184
+ # stdout_io:, stderr_io:}. `harness:` is accepted for symmetry with
185
+ # `step_once` even though node-run itself never takes a --harness flag -
186
+ # the harness was already recorded on the node's own `running` line by the
187
+ # dispatch this call followed, and node-run reads its node input off that
188
+ # line, never off this argv. RUBYOPT is cleared explicitly, the same
189
+ # contract every other ruby-spawning site in this tree carries.
190
+ def spawn_node_run(context, node:, harness: nil)
191
+ argv = [RbConfig.ruby, NODE_RUN_SCRIPT, context.intent_dir, "--node", node]
192
+ argv += ["--session", context.session] if context.session
193
+
194
+ out_r, out_w = IO.pipe
195
+ err_r, err_w = IO.pipe
196
+ pid = Process.spawn({ "RUBYOPT" => nil }, *argv, out: out_w, err: err_w)
197
+ out_w.close
198
+ err_w.close
199
+ { node: node, pid: pid, stdout_io: out_r, stderr_io: err_r }
200
+ end
201
+
202
+ # wait_for_node_run(active) -> [{node:, return_path:, stderr:}] for
203
+ # whichever ONE handle in `active` finishes first (real concurrency
204
+ # between two live node-run processes; this call itself blocks on
205
+ # whichever exits first, never both at once). `active` is keyed by node,
206
+ # so a caller never needs to search its own values by pid outside this
207
+ # method.
208
+ def wait_for_node_run(active)
209
+ pid, = Process.waitpid2(-1)
210
+ handle = active.values.find { |h| h[:pid] == pid }
211
+ return [] unless handle
212
+
213
+ [{ node: handle[:node], return_path: read_and_close(handle[:stdout_io]).to_s.strip,
214
+ stderr: read_and_close(handle[:stderr_io]) }]
215
+ rescue Errno::ECHILD
216
+ []
217
+ end
218
+
219
+ def read_and_close(io)
220
+ io.read
221
+ rescue StandardError
222
+ ""
223
+ ensure
224
+ begin
225
+ io.close
226
+ rescue StandardError
227
+ nil
228
+ end
229
+ end
230
+ private_class_method :read_and_close
231
+
232
+ # index_lock_collision?(finished) -> true when either the node-run
233
+ # subprocess's own stderr or the return body it wrote mentions
234
+ # `index.lock` - the shared signature of a git ref/index lock collision
235
+ # between two worktrees writing into one repository's `.git` at the same
236
+ # time (row 7.4). Never used to change what the loop does next: the
237
+ # finished node's return is absorbed exactly like any other, and the
238
+ # kind's own retry cap is what recovers it.
239
+ def index_lock_collision?(finished)
240
+ parts = [finished[:stderr].to_s]
241
+ path = finished[:return_path]
242
+ parts << safe_read_file(path) if path && !path.empty?
243
+ parts.join("\n").match?(/index\.lock/i)
244
+ end
245
+
246
+ def safe_read_file(path)
247
+ File.exist?(path) ? File.read(path) : ""
248
+ rescue StandardError
249
+ ""
250
+ end
251
+ private_class_method :safe_read_file
252
+ end