@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
@@ -37,7 +37,7 @@
37
37
  # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
38
38
  # (delivered) or `## Abandoned` (abandoned), dated today, appending
39
39
  # --index-note (if given). Accepts a real em dash OR a plain hyphen as
40
- # the id/title separator on READ (shared matcher, Bridge.index_entry_match);
40
+ # the id/title separator on READ (shared matcher, IndexEntry.match);
41
41
  # always EMITS the real em dash on write. An id that resolves to neither
42
42
  # `## Active` nor the terminal section is a loud failure (exit 1); an id
43
43
  # already correctly in the terminal section is a quiet, idempotent success.
@@ -46,10 +46,13 @@
46
46
  # 5. Disarm (intent 188, D2/D16; intent 307): check the code worktree (derived
47
47
  # from projects.yml and the intent id, Arm.worktree_block) for uncommitted
48
48
  # changes first (exit 5 if dirty, unless --discard-worktree-changes); call the
49
- # disarm seam (default Arm.disarm: worktree released, lock cleared, the session
50
- # pointer back on the day ledger); verify the durable lock file is actually gone
51
- # afterward, and release it directly when a foreign-keyed disarm left it (the
52
- # /tmp bridge this step once read was removed in 2.0, intent 307).
49
+ # disarm seam (default Arm.disarm: worktree released, lock cleared); verify
50
+ # the durable lock file is actually gone afterward, and release it directly
51
+ # when a foreign-keyed disarm left it.
52
+ #
53
+ # A delivered close never merges code. Its code branch must already be merged
54
+ # into the repo checkout's current branch, or the close refuses before any
55
+ # write (exit 9).
53
56
  #
54
57
  # Exit codes:
55
58
  # 0 ok - the intent is closed and no delivery.lock remains.
@@ -57,7 +60,7 @@
57
60
  # nor the terminal section (D11). Deliberately double duty; see plan.md.
58
61
  # 2 retired in 2.0 (intent 308): the outcome.md guard reports and proceeds.
59
62
  # 3 steps 1-4 already committed, but the delivery lock genuinely could not
60
- # be cleared (not a bridge cache miss; see step 5 above).
63
+ # be cleared (see step 5 above).
61
64
  # 4 pre-flight refusal: a FRESH foreign lock is held by a live,
62
65
  # non-delegate session; a FRESH lock that will not parse (corrupt); or a
63
66
  # lock file with no resolvable session identity at all. Authors nothing.
@@ -68,19 +71,37 @@
68
71
  # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
69
72
  # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
70
73
  # exception to report-and-proceed; --allow-hollow-report overrides.
74
+ # 8 delivered close of an untouched scaffold refused (acceptance N3):
75
+ # nothing was worked, so there is nothing to deliver. Close it as
76
+ # abandoned, or do the work first. Authors nothing.
77
+ # 9 delivered close refused because its code branch is not merged into the
78
+ # repo checkout's current branch. Refused before any write: nothing is
79
+ # merged, written, or released. Merge the branch, then run the close again.
80
+ #
81
+ # --dry-run runs the same refusals as the real close (exits 8, 9, 7 and 5 with
82
+ # a "would refuse" line) and writes nothing: the hollow-report gate is judged
83
+ # on a scratch copy that went through the same outcome generation and
84
+ # backfill.
71
85
 
86
+ require_relative "lib/store_layout"
72
87
  require "fileutils"
73
88
  require "date"
74
89
  require "open3"
90
+ require "stringio"
91
+ require "tmpdir"
75
92
  require "pathname"
76
- require_relative "lib/bridge"
77
93
  require_relative "lib/arm"
94
+ require_relative "lib/exec_worktree"
95
+ require_relative "lib/index_entry"
78
96
  require_relative "lib/savepoint"
79
97
  require_relative "lib/lock"
80
98
  require_relative "lib/intent_validator"
81
99
  require_relative "lib/outcome_guard"
82
100
  require_relative "lib/report_screen"
83
101
  require_relative "lib/backfill_intent"
102
+ require_relative "lib/outcome_report"
103
+ require_relative "lib/untouched_scaffold"
104
+ require_relative "lib/scaffold_intent"
84
105
 
85
106
  DISPOSITIONS = %w[delivered abandoned].freeze
86
107
 
@@ -88,7 +109,7 @@ DISPOSITIONS = %w[delivered abandoned].freeze
88
109
  # dash (U+2014), the existing INDEX.md convention (store files are exempt from
89
110
  # the no-em-dash shipped-file rule). Built from the codepoint, not a literal
90
111
  # byte in this source file, so the script's own source stays em-dash free.
91
- # READING accepts a plain hyphen too, via the shared Bridge.index_entry_match
112
+ # READING accepts a plain hyphen too, via the shared IndexEntry.match
92
113
  # (intent 188, D9/D12): see move_index_to_terminal below.
93
114
  EM_DASH = "\u2014"
94
115
 
@@ -98,6 +119,10 @@ class UnresolvedIndexEntry < StandardError; end
98
119
 
99
120
  # --- Explicit flag parsing (no eval, no global injection) -------------------
100
121
 
122
+ def blank?(value)
123
+ value.nil? || value.to_s.strip.empty?
124
+ end
125
+
101
126
  def parse_args(argv)
102
127
  opts = {
103
128
  store: nil, id: nil, disposition: nil, index: nil,
@@ -166,14 +191,65 @@ end
166
191
  # "projects" (the StoreDiscovery convention, scripts/lib/store_discovery.rb), never by
167
192
  # hardcoding ".plastic" as a name.
168
193
  def resolve_plastic_home_and_scope(store)
169
- parent = File.dirname(store)
170
- grandparent = File.dirname(parent)
171
- if File.basename(grandparent) == "projects"
172
- slug = File.basename(parent)
173
- [File.dirname(grandparent), "project:#{slug}"]
194
+ Plastic::StoreLayout.home_and_scope(store)
195
+ end
196
+
197
+ # --- outcome generation, before the backfill (intent 339, G6, spec D10) -----
198
+ #
199
+ # Generates outcome.md from graph.md, nodes/, and the ledger, BEFORE the
200
+ # backfill step runs, and only into a file that is missing or still the
201
+ # scaffold placeholder (`Savepoint.stage_file_present?` false) - a real,
202
+ # hand-written outcome.md is never touched (row 7.2). Only when a graph.md
203
+ # exists at all (row 7.3): every intent without one closes exactly as it did
204
+ # before this intent. The model is parsed AFTER the `stage_file_present?`
205
+ # guard, not before: a real hand-written outcome.md returns at that guard
206
+ # regardless of graph.md's own health, so a close that was never going to
207
+ # generate anything must never print a parse warning about it. Refuses a
208
+ # report model that reports itself broken (row v1f.2, B2): a malformed
209
+ # `## Graph` fails `model[:ok]` and the close falls straight through to the
210
+ # backfill, rather than adopting parse damage as delivery fact - agreeing
211
+ # with the shipped `outcome-report` CLI, which exits 3 on the same
212
+ # directory. The generated text is then checked against
213
+ # BOTH close gates - `OutcomeGuard` and the hollow-report gate below - before
214
+ # being adopted (row 7.4); when either would refuse it, the write is
215
+ # reverted and the close falls through to intent 308's unchanged backfill
216
+ # (row 7.5), so no close gains an exit 7 it did not have before this intent.
217
+ # Fails open on any exception (row 7.6), the same report-and-proceed shape
218
+ # every other close step in this file uses. `generator:` is the injectable
219
+ # seam (mirrors run_structure_check's `gate:`).
220
+ def run_generate_outcome(intent_dir, disposition:, generator: OutcomeReport.method(:write))
221
+ graph_path = File.join(intent_dir, "graph.md")
222
+ return unless File.exist?(graph_path)
223
+
224
+ outcome_path = File.join(intent_dir, "outcome.md")
225
+ return if Savepoint.stage_file_present?(outcome_path)
226
+
227
+ report_model = OutcomeReport.model(intent_dir)
228
+ unless report_model[:ok]
229
+ warn "end-intent: graph.md failed to parse (#{report_model[:errors].join('; ')}); falling back to the backfill"
230
+ return
231
+ end
232
+
233
+ original_exists = File.exist?(outcome_path)
234
+ original_content = original_exists ? File.read(outcome_path) : nil
235
+
236
+ generator.call(intent_dir, disposition: disposition)
237
+
238
+ guard_reason = OutcomeGuard.reason(intent_dir, disposition)
239
+ hollow = hollow_report_reason(intent_dir, disposition)
240
+ if guard_reason || hollow
241
+ if original_exists
242
+ File.write(outcome_path, original_content)
243
+ elsif File.exist?(outcome_path)
244
+ File.delete(outcome_path)
245
+ end
246
+ warn "end-intent: generated outcome.md would be refused (#{[guard_reason, hollow].compact.join('; ')}); " \
247
+ "falling back to the backfill"
174
248
  else
175
- [parent, "global"]
249
+ warn "end-intent: outcome.md generated from graph.md and the ledger"
176
250
  end
251
+ rescue StandardError => e
252
+ warn "end-intent: outcome generation crashed (#{e.message}); proceeding without it"
177
253
  end
178
254
 
179
255
  # Backfill (intent 308): write the judgment documents that are missing or still the
@@ -227,6 +303,48 @@ end
227
303
  # closed exactly this way and the owner read a screen full of "not recorded".
228
304
  # Abandoned closes and machine-backfilled outcomes (the backfill marker) are
229
305
  # exempt: the reader cannot expect labels the writer never had.
306
+ #
307
+ # Intent 334 (G1, review A3; post-execution review, blocking 2): the legacy
308
+ # "S3" label grammar applies only to actions/*.md headings, and the node id
309
+ # grammar (kind prefix n/v/d/r plus digits, D1r) applies only to nodes/*.md
310
+ # headings - never both over both directories. This repo's own house words
311
+ # for plan review and post-execution review are "v1" and "v2"; widening the
312
+ # node-id alternative onto actions/ headings turned that prose into a
313
+ # manufactured label and refused legitimate legacy closes at exit 7.
314
+ ACTION_ID_LABEL_RE = /\AS\d+\z/.freeze
315
+ NODE_ID_LABEL_RE = /\A[nvdr][1-9]\d*\z/.freeze
316
+
317
+ # The id-shaped tokens found in `paths`' headings, matched against
318
+ # `id_label_re` only - the caller picks the grammar for the directory.
319
+ def heading_id_labels(paths, id_label_re)
320
+ paths.flat_map do |path|
321
+ ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
322
+ heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).select { |tok| tok.match?(id_label_re) }
323
+ end
324
+ end
325
+ end
326
+
327
+ # 339 S9 (D18): a node id counts as a label in an actions/*.md heading too,
328
+ # but only when it stands alone as one of the heading's " - "-delimited
329
+ # segments - this repo's own convention, "### S1 - n1 - Title" (this action
330
+ # file's own headings, post-merge) or the bare "### n1 - Title". Never as a
331
+ # free word inside ordinary prose: the comment above ACTION_ID_LABEL_RE
332
+ # records exactly why intent 334 restricted the node-id grammar to
333
+ # nodes/*.md headings in the first place, and that reasoning does not change
334
+ # here - a word-scan widened onto actions/ headings would let this repo's own
335
+ # house words ("v1", "v2" for plan review and post-execution review)
336
+ # manufacture a label out of ordinary prose and refuse a legitimate legacy
337
+ # close. Segment position is what a word-scan cannot see: "v2" embedded
338
+ # mid-sentence is never its own " - "-delimited segment, but a heading built
339
+ # to this convention always puts the node id in one.
340
+ def heading_node_id_segments(paths, id_label_re)
341
+ paths.flat_map do |path|
342
+ ReportScreen.split_by_headings(File.read(path)).flat_map do |heading, _body|
343
+ heading.to_s.sub(/\A#+\s*/, "").split(/\s+-\s+/).map(&:strip).select { |seg| seg.match?(id_label_re) }
344
+ end
345
+ end
346
+ end
347
+
230
348
  def hollow_report_reason(intent_dir, disposition)
231
349
  return nil unless disposition == "delivered"
232
350
 
@@ -235,14 +353,34 @@ def hollow_report_reason(intent_dir, disposition)
235
353
  text = File.read(outcome)
236
354
  return nil if text.include?("<!-- backfilled from the record by end-intent on ")
237
355
 
238
- # The gate's evidence bar (A7/B7): it judges only records that CLAIM the
239
- # modern convention - at least one actions/*.md heading carrying an S-label.
240
- # A terse legacy close with no labeled matrix stays report-and-proceed.
241
- heading_labels = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.flat_map do |path|
242
- ReportScreen.split_by_headings(File.read(path)).filter_map do |heading, _body|
243
- heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/).find { |tok| tok.match?(/\AS\d+\z/) }
244
- end
245
- end.uniq
356
+ # The gate's evidence bar (A7/B7): it judges only records that CLAIM a
357
+ # labeled convention - at least one actions/*.md heading carrying an
358
+ # S-label or a node-id segment, or at least one nodes/*.md heading carrying
359
+ # a node id ("n1", "v2", "d3", "r4"). A terse legacy close with no labeled
360
+ # matrix stays report-and-proceed (intent 334, G1, D10r).
361
+ #
362
+ # Every matching token per heading is kept, not just the first (review A3):
363
+ # a legacy actions/ heading can carry BOTH an S-label and prose that reads
364
+ # like a node id ("### The v2 rewrite (S3)"). Restricting the word-scan
365
+ # node-id grammar to nodes/*.md headings (post-execution review, blocking
366
+ # 2) means "v2" in that heading is never even tested against it, so the
367
+ # heading's real "S3" label is never at risk.
368
+ #
369
+ # 339 S9 (D18): a graph-era action file headed by node id alone ("### n1 -
370
+ # Title", no S-label) used to yield an empty label set here and the gate
371
+ # returned before judging anything - every such record closed unjudged by
372
+ # silent exemption. `heading_node_id_segments` widens the actions/*.md side
373
+ # to see that shape without reopening the word-scan hole above: it matches
374
+ # a node id only when the heading's own " - "-delimited segments carry one,
375
+ # never a free word in prose. The node_paths call stays exactly as it was;
376
+ # nodes/*.md headings already resolve node ids correctly (test_node_id_
377
+ # labels_are_recognized) and their heading grammar is controlled, so a
378
+ # word-scan there carries none of the prose risk.
379
+ action_paths = Dir.glob(File.join(intent_dir, "actions", "*.md")).sort
380
+ node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
381
+ heading_labels = (heading_id_labels(action_paths, ACTION_ID_LABEL_RE) +
382
+ heading_node_id_segments(action_paths, NODE_ID_LABEL_RE) +
383
+ heading_id_labels(node_paths, NODE_ID_LABEL_RE)).uniq
246
384
  return nil if heading_labels.empty?
247
385
 
248
386
  rows = ReportScreen.delivered_rows(intent_dir)
@@ -317,10 +455,10 @@ def section_stop(lines, heading_idx)
317
455
  stop
318
456
  end
319
457
 
320
- # Shared matcher (Bridge.index_entry_match, intent 188 D12): accepts a real em
458
+ # Shared matcher (IndexEntry.match, intent 188 D12): accepts a real em
321
459
  # dash OR a plain hyphen as the id/title separator on READ.
322
460
  def active_line_id(line)
323
- m = Bridge.index_entry_match(line)
461
+ m = IndexEntry.match(line)
324
462
  m && m[1]
325
463
  end
326
464
 
@@ -399,7 +537,7 @@ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
399
537
  raise UnresolvedIndexEntry, "INDEX.md has no #{target_heading} heading; cannot complete the move"
400
538
  end
401
539
 
402
- m = Bridge.index_entry_match(lines[entry_idx])
540
+ m = IndexEntry.match(lines[entry_idx])
403
541
  title, link = m[2], m[3]
404
542
  note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
405
543
  new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
@@ -488,13 +626,13 @@ end
488
626
  # exclusion with a raw backtrace. nil is the only safe "could not resolve"
489
627
  # signal; main refuses explicitly on it rather than ever reaching takeover.
490
628
  def resolve_end_session(explicit, intent_dir)
491
- return explicit.to_s.strip unless Bridge.blank?(explicit)
629
+ return explicit.to_s.strip unless blank?(explicit)
492
630
  env = ENV["CLAUDE_CODE_SESSION_ID"]
493
- return env.to_s.strip unless Bridge.blank?(env)
631
+ return env.to_s.strip unless blank?(env)
494
632
  lock = Lock.read(intent_dir)
495
633
  return nil unless lock
496
634
  owner = lock["owner_session"].to_s
497
- Bridge.blank?(owner) ? nil : owner
635
+ blank?(owner) ? nil : owner
498
636
  end
499
637
 
500
638
  # Read-only pre-flight verdict (D4). Returns [:proceed, lock_or_nil],
@@ -542,37 +680,15 @@ end
542
680
  def run_disarm(intent_dir, id, session, discard_worktree_changes:,
543
681
  worktree_reader: ->(dir) { Arm.worktree_block(intent_dir: dir) },
544
682
  disarm: ->(sess, dir) { Arm.disarm(intent_dir: dir, session: sess) })
545
- return :ok if Bridge.blank?(session)
683
+ return :ok if blank?(session)
546
684
 
547
685
  block = worktree_reader.call(intent_dir)
548
686
  worktree_code = block.is_a?(Hash) ? block["code"] : nil
549
687
 
550
- if worktree_code && Dir.exist?(worktree_code)
551
- begin
552
- out, err, status = Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
553
- rescue StandardError => e
554
- out, err, status = nil, e.message, nil
555
- end
556
-
557
- # Fail CLOSED (intent 188, post-review hardening). A failed or impossible
558
- # `git status` (corrupt per-worktree index, git missing from PATH, not a
559
- # repo) means we cannot PROVE the worktree is clean, and the removal below
560
- # force-removes on a plain `git worktree remove` failure, which destroys
561
- # uncommitted work. Never force-remove an unproven worktree: the guard
562
- # exists to protect uncommitted code, so an inconclusive check must refuse,
563
- # not silently proceed as if it were clean.
564
- if status.nil? || !status.success?
565
- unless discard_worktree_changes
566
- warn "end-intent: could not inspect the code worktree at #{worktree_code} " \
567
- "(git status failed: #{err.to_s.strip}); refusing to remove it, because " \
568
- "removal would force-discard any uncommitted work. Pass " \
569
- "--discard-worktree-changes to remove it anyway, or repair the worktree."
570
- return :dirty
571
- end
572
- elsif !out.strip.empty? && !discard_worktree_changes
573
- warn "end-intent: code worktree #{worktree_code} has uncommitted changes; refusing " \
574
- "to remove it. Pass --discard-worktree-changes to force the close deliberately, " \
575
- "or commit/stash the changes and re-run."
688
+ unless discard_worktree_changes
689
+ refusal = worktree_refusal(worktree_code)
690
+ if refusal
691
+ warn refusal
576
692
  return :dirty
577
693
  end
578
694
  end
@@ -640,6 +756,173 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
640
756
  :lock_remains
641
757
  end
642
758
 
759
+ # The disarm step's dirty-worktree guard, as a message (nil when the worktree
760
+ # is clean or absent). Shared by run_disarm and --dry-run so the preview
761
+ # refuses exactly what the close refuses.
762
+ #
763
+ # Fail CLOSED (intent 188, post-review hardening). A failed or impossible
764
+ # `git status` (corrupt per-worktree index, git missing from PATH, not a
765
+ # repo) means we cannot PROVE the worktree is clean, and the removal
766
+ # force-removes on a plain `git worktree remove` failure, which destroys
767
+ # uncommitted work. Never force-remove an unproven worktree: the guard
768
+ # exists to protect uncommitted code, so an inconclusive check must refuse,
769
+ # not silently proceed as if it were clean.
770
+ def worktree_refusal(worktree_code)
771
+ return nil unless worktree_code && Dir.exist?(worktree_code)
772
+
773
+ begin
774
+ out, err, status = Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
775
+ rescue StandardError => e
776
+ out, err, status = nil, e.message, nil
777
+ end
778
+
779
+ if status.nil? || !status.success?
780
+ "end-intent: could not inspect the code worktree at #{worktree_code} " \
781
+ "(git status failed: #{err.to_s.strip}); refusing to remove it, because " \
782
+ "removal would force-discard any uncommitted work. Pass " \
783
+ "--discard-worktree-changes to remove it anyway, or repair the worktree."
784
+ elsif !out.strip.empty?
785
+ "end-intent: code worktree #{worktree_code} has uncommitted changes; refusing " \
786
+ "to remove it. Pass --discard-worktree-changes to force the close deliberately, " \
787
+ "or commit/stash the changes and re-run."
788
+ end
789
+ end
790
+
791
+ # True when the intent's code worktree carries any work: uncommitted changes
792
+ # or commits past its base branch. Anything that cannot be proven reads as
793
+ # changed, so an unreadable worktree never turns a close into a refusal.
794
+ def worktree_changed?(intent_dir)
795
+ block = Arm.worktree_block(intent_dir: intent_dir)
796
+ code = block.is_a?(Hash) ? block["code"] : nil
797
+ return false unless code && Dir.exist?(code)
798
+
799
+ out, status = Open3.capture2e("git", "-C", code, "status", "--porcelain")
800
+ return true unless status.success? && out.strip.empty?
801
+
802
+ base = ScaffoldIntent.detect_base_branch(code)
803
+ return true if base.nil?
804
+
805
+ ahead, status = Open3.capture2e("git", "-C", code, "rev-list", "--count", "#{base}..HEAD")
806
+ !status.success? || ahead.strip != "0"
807
+ rescue StandardError
808
+ true
809
+ end
810
+
811
+ # A delivered close whose code is not on the repo checkout's current branch,
812
+ # as a refusal message (nil when it is, or for a store-only project).
813
+ # end-intent never merges: the owner merges or releases the work, then runs
814
+ # the close again. A real code worktree must prove that its HEAD commit is
815
+ # merged, so a detached or renamed branch is still checked. The code branch
816
+ # must be merged too, even after its worktree is gone. A git failure refuses.
817
+ # A directory that is not a working git worktree, with no code branch, is left
818
+ # to the dirty-worktree guard, which refuses what it cannot inspect.
819
+ def unmerged_refusal(intent_dir, runner: Worktree::ShellRunner.new)
820
+ paths = Arm.code_paths(intent_dir: intent_dir)
821
+ code = paths["code"]
822
+ return nil if code.nil?
823
+
824
+ repo = ExecWorktree.repo_from_worktree_code(code)
825
+ unless runner.run("-C", repo, "rev-parse", "--git-dir").success?
826
+ # No .git at all is a project without code; a .git that Git cannot read is not.
827
+ return nil unless File.exist?(File.join(repo, ".git"))
828
+
829
+ return "could not inspect the Git repository #{repo}, so the code cannot be shown to be merged. " \
830
+ "Check the repository, then run the close again"
831
+ end
832
+
833
+ unmerged_worktree_head(runner, code, repo, paths["code_branch"]) ||
834
+ unmerged_branch(runner, repo, paths["code_branch"])
835
+ end
836
+
837
+ # True when `code` is the top of its own git work tree, not a plain
838
+ # directory inside the repo or a broken checkout.
839
+ def real_worktree?(runner, code)
840
+ return false unless File.directory?(code)
841
+
842
+ top = runner.run("-C", code, "rev-parse", "--show-toplevel")
843
+ top.success? && File.realpath(top.stdout.strip) == File.realpath(code)
844
+ rescue SystemCallError
845
+ false
846
+ end
847
+
848
+ def unmerged_worktree_head(runner, code, repo, branch)
849
+ return nil unless real_worktree?(runner, code)
850
+
851
+ head = runner.run("-C", code, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
852
+ unless head.success?
853
+ return "could not read the HEAD commit of the code worktree #{code}, so its work cannot be " \
854
+ "shown to be merged. Commit it, merge it into the current branch of #{repo}, then run the close again"
855
+ end
856
+ sha = head.stdout.strip
857
+ on = runner.run("-C", code, "symbolic-ref", "--quiet", "--short", "HEAD")
858
+ name = on.success? ? on.stdout.strip : nil
859
+ ref = (name == branch) ? branch : sha[0, 12]
860
+ where = name ? "on branch #{name}" : "detached"
861
+ ancestry_refusal(runner, repo, sha, ref, "the code worktree #{code} (#{where} at #{sha[0, 12]})", branch)
862
+ end
863
+
864
+ def unmerged_branch(runner, repo, branch)
865
+ found = runner.run("-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
866
+ return nil if found.status == 1
867
+ unless found.success?
868
+ return "could not look up code branch #{branch} in #{repo}. Check the repository, then run the close again"
869
+ end
870
+ ancestry_refusal(runner, repo, branch, branch, "code branch #{branch}", branch)
871
+ end
872
+
873
+ # nil when `rev` is an ancestor of the branch the repo checkout is on, else a
874
+ # refusal naming `what` and the ordinary merge of `ref` that fixes it.
875
+ def ancestry_refusal(runner, repo, rev, ref, what, branch)
876
+ target = target_refusal(runner, repo, branch)
877
+ return target if target
878
+
879
+ check = runner.run("-C", repo, "merge-base", "--is-ancestor", rev, "HEAD")
880
+ return nil if check.success?
881
+
882
+ verdict = (check.status == 1) ? "is not merged into" : "could not be checked against"
883
+ "#{what} #{verdict} the current branch of #{repo}. " \
884
+ "Merge it there first (git -C #{repo} merge #{ref}), then run the close again"
885
+ end
886
+
887
+ # Integration means merged into a branch other than the code branch, so a
888
+ # detached checkout, or one on the code branch itself, proves nothing.
889
+ def target_refusal(runner, repo, branch)
890
+ on = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD")
891
+ unless on.success?
892
+ return "the repo checkout #{repo} is not on a branch, so no branch holds the merged code. " \
893
+ "Check out the branch you release from, merge #{branch} into it, then run the close again"
894
+ end
895
+ return nil unless on.stdout.strip == branch
896
+
897
+ "the repo checkout #{repo} is on the code branch #{branch} itself, so the code is not merged anywhere. " \
898
+ "Check out the branch you release from, merge #{branch} into it, then run the close again"
899
+ end
900
+
901
+ # The hollow-report verdict the real close would reach, judged on a scratch
902
+ # copy of the intent after the same outcome generation and backfill, so the
903
+ # dry run writes nothing.
904
+ def predicted_hollow_reason(intent_dir, store:, id:, disposition:, summary:)
905
+ return nil unless disposition == "delivered"
906
+
907
+ Dir.mktmpdir("end-intent-dry-run") do |scratch|
908
+ copy = File.join(scratch, File.basename(intent_dir))
909
+ FileUtils.cp_r(intent_dir, copy)
910
+ quietly do
911
+ run_generate_outcome(copy, disposition: disposition)
912
+ run_backfill(copy, store: store, id: id, disposition: disposition, summary: summary)
913
+ end
914
+ hollow_report_reason(copy, disposition)
915
+ end
916
+ end
917
+
918
+ def quietly
919
+ saved = $stderr
920
+ $stderr = StringIO.new
921
+ yield
922
+ ensure
923
+ $stderr = saved
924
+ end
925
+
643
926
  # --- main --------------------------------------------------------------------
644
927
 
645
928
  def main(argv)
@@ -667,7 +950,7 @@ def main(argv)
667
950
  # Lock.takeover (blank session reaches Lock.acquire's blank? guard AFTER the
668
951
  # existing lock file was already deleted) or silently mis-arbitrate. Refuse
669
952
  # rather than guess; nothing has been written yet.
670
- if Bridge.blank?(session) && File.exist?(Lock.path(intent_dir))
953
+ if blank?(session) && File.exist?(Lock.path(intent_dir))
671
954
  warn "end-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
672
955
  "identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's " \
673
956
  "own recorded owner are all blank); refusing rather than guessing. Pass " \
@@ -692,7 +975,41 @@ def main(argv)
692
975
  exit 4
693
976
  end
694
977
 
978
+ # Nothing worked, nothing to deliver (acceptance N3). Before the dry run and
979
+ # before any write, so the preview and the close refuse together.
980
+ if disposition == "delivered"
981
+ untouched = UntouchedScaffold.reason(intent_dir, templates: BackfillIntent.load_templates,
982
+ worktree_changed: method(:worktree_changed?))
983
+ if untouched
984
+ warn "end-intent: refusing to deliver an untouched scaffold: #{untouched}"
985
+ warn "end-intent: do the work first, or close it with --disposition abandoned"
986
+ exit 8
987
+ end
988
+
989
+ unmerged = unmerged_refusal(intent_dir)
990
+ if unmerged
991
+ warn "end-intent: #{opts[:dry_run] ? "dry run: would refuse" : "refusing"} a delivered close: #{unmerged}"
992
+ warn "end-intent: nothing was merged, written, or released"
993
+ exit 9
994
+ end
995
+ end
996
+
695
997
  if opts[:dry_run]
998
+ hollow = predicted_hollow_reason(intent_dir, store: store, id: id, disposition: disposition,
999
+ summary: opts[:outcome_summary])
1000
+ if hollow && !opts[:allow_hollow_report]
1001
+ warn "end-intent: dry run: would refuse a hollow delivered close: #{hollow}"
1002
+ exit 7
1003
+ end
1004
+ unless blank?(session) || opts[:discard_worktree_changes]
1005
+ block = Arm.worktree_block(intent_dir: intent_dir)
1006
+ refusal = worktree_refusal(block.is_a?(Hash) ? block["code"] : nil)
1007
+ if refusal
1008
+ warn "end-intent: dry run: would refuse at disarm (exit 5), after the store commit: " \
1009
+ "#{refusal.delete_prefix("end-intent: ")}"
1010
+ exit 5
1011
+ end
1012
+ end
696
1013
  puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
697
1014
  targets = BackfillIntent.targets_for(intent_dir)
698
1015
  puts " would backfill: #{targets.empty? ? "(nothing)" : targets.join(", ")}"
@@ -718,6 +1035,11 @@ def main(argv)
718
1035
  end
719
1036
  end
720
1037
 
1038
+ # 0a. Generate outcome.md from graph.md and the ledger, when there is one
1039
+ # (intent 339, G6, spec D10) - before the backfill, so a real graph never
1040
+ # falls through to the terse backfill it exists to replace.
1041
+ run_generate_outcome(intent_dir, disposition: disposition)
1042
+
721
1043
  # 1. Backfill from the record (intent 308), then the structure self-check and the
722
1044
  # outcome guard, both report-only since 308.
723
1045
  run_backfill(intent_dir, store: store, id: id, disposition: disposition,
@@ -21,15 +21,15 @@
21
21
  # (--session, else CLAUDE_CODE_SESSION_ID, else the existing delivery.lock's own
22
22
  # recorded owner_session when non-blank, else nil; the lock-owner fallback IS
23
23
  # used here, matching end-intent's teardown resolution order).
24
- # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block;
25
- # the /tmp bridge this once read was removed in 2.0, intent 307). No code worktree on
24
+ # Derive the worktree block from projects.yml and the intent id (Arm.worktree_block).
25
+ # No code worktree on
26
26
  # disk means nothing was provisioned (exit 0).
27
27
  # 1. The dirty-worktree guard (delivered only): git status --porcelain on the code
28
28
  # worktree. Not clean, or the check itself fails, fails CLOSED (exit 3), mirroring
29
29
  # end-intent's dirty-worktree handling. abandoned skips this guard entirely: the
30
30
  # branch survives and can be reclaimed, so a dirty worktree is not a reason to
31
31
  # refuse.
32
- # 2. Finish: Worktree.finish(bridge_data, home:, runner:, merge: disposition ==
32
+ # 2. Finish: Worktree.finish(delivery, home:, runner:, merge: disposition ==
33
33
  # "delivered"). Worktree.finish is fail-open and never raises, so failure is
34
34
  # detected by checking, for a delivered disposition only, whether the worktree
35
35
  # directory still exists on disk afterward (release removes it on success).
@@ -38,8 +38,8 @@
38
38
  # one concern per script (spec D8).
39
39
  #
40
40
  # Exit codes:
41
- # 0 finished (merged or removed per disposition), or nothing to finish (no bridge
42
- # resolved, or no code worktree recorded on the bridge)
41
+ # 0 finished (merged or removed per disposition), or nothing to finish (no delivery
42
+ # resolved, or no code worktree recorded on it)
43
43
  # 1 usage or path-resolution failure
44
44
  # 2 retired in 2.0 (intent 302): was the order precondition; never emitted, never reused
45
45
  # 3 the worktree is not clean, or the merge/removal did not complete (delivered only)
@@ -14,6 +14,7 @@
14
14
  # and on any filing error (reported on stderr; the next boot tries again), 2 on
15
15
  # a usage error.
16
16
 
17
+ require_relative "lib/store_layout"
17
18
  require_relative "lib/session_ledger"
18
19
  require_relative "lib/session_backfill"
19
20
 
@@ -48,7 +49,7 @@ if opts[:carry_to] && !SessionLedger.valid_day_id?(opts[:carry_to])
48
49
  usage_abort("--carry-to must be a real calendar date, YYYYMMDD")
49
50
  end
50
51
 
51
- store = opts[:store] || File.join(File.expand_path(ENV.fetch("PLASTIC_HOME", "~/.plastic")), "store")
52
+ store = opts[:store] || Plastic::StoreLayout.global_store(File.expand_path(ENV.fetch("PLASTIC_HOME", "~/.plastic")))
52
53
  templates = opts[:templates] || File.expand_path("../templates", __dir__)
53
54
 
54
55
  begin