@zalom/plastic 2.0.0-alpha.9 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (322) hide show
  1. package/PLASTIC.md +13 -136
  2. package/README.md +357 -133
  3. package/agents/plastic-enforcer.md +20 -15
  4. package/agents/plastic-executor.md +15 -4
  5. package/agents/plastic-node-research.md +30 -0
  6. package/agents/plastic-node-verify.md +28 -0
  7. package/agents/plastic-node-work.md +33 -0
  8. package/agents/{plastic-advisor.md → plastic-primary-advisor.md} +7 -9
  9. package/agents/{plastic-faux-advisor.md → plastic-secondary-advisor.md} +9 -12
  10. package/assets/plastic-logo.svg +1 -0
  11. package/bin/crap +4 -0
  12. package/bin/lib/context_budget.rb +43 -6
  13. package/bin/lib/skill_census.rb +839 -0
  14. package/bin/plastic +6 -0
  15. package/bin/plastic-skill-census +114 -0
  16. package/bin/test +24 -4
  17. package/bin/verify-change +345 -0
  18. package/config_asks.yml +4 -4
  19. package/deprecations.yml +1 -1
  20. package/{skills/agent-advisor/references → docs/help}/advisor-protocol.md +19 -24
  21. package/{skills/auto/references → docs/help}/agent-architecture.md +16 -14
  22. package/{skills/conventions/references → docs/help}/completion-and-done.md +15 -16
  23. package/docs/help/human-report-contract.md +152 -0
  24. package/{skills/conventions/references → docs/help}/knowledge-graph.md +9 -0
  25. package/{skills/conventions/references → docs/help}/locks-and-worktrees.md +11 -13
  26. package/{skills/conventions/references → docs/help}/maintenance-and-revisions.md +1 -1
  27. package/{skills/conventions/references → docs/help}/roadmaps.md +2 -2
  28. package/{skills/tutorial/references → docs/help}/track-1-guided.md +28 -47
  29. package/{skills/tutorial/references → docs/help}/track-2-auto.md +8 -8
  30. package/{skills/tutorial/references → docs/help}/track-3-projects-and-roadmaps.md +24 -17
  31. package/hooks/call-budget +4 -0
  32. package/hooks/hooks.json +24 -0
  33. package/hooks/message-display +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 +153 -30
  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 +48 -83
  61. package/scripts/lib/atomic_write.rb +31 -0
  62. package/scripts/lib/backup.rb +65 -0
  63. package/scripts/lib/cli/command.rb +92 -0
  64. package/scripts/lib/cli/commands/auto.rb +18 -0
  65. package/scripts/lib/cli/commands/auto_brief.rb +44 -0
  66. package/scripts/lib/cli/commands/auto_lock.rb +99 -0
  67. package/scripts/lib/cli/commands/auto_report.rb +50 -0
  68. package/scripts/lib/cli/commands/auto_take.rb +31 -0
  69. package/scripts/lib/cli/commands/backup.rb +43 -0
  70. package/scripts/lib/cli/commands/checkout.rb +25 -0
  71. package/scripts/lib/cli/commands/continue.rb +66 -0
  72. package/scripts/lib/cli/commands/doctor.rb +81 -0
  73. package/scripts/lib/cli/commands/feedback.rb +40 -0
  74. package/scripts/lib/cli/commands/help.rb +69 -0
  75. package/scripts/lib/cli/commands/hook.rb +32 -0
  76. package/scripts/lib/cli/commands/index.rb +23 -0
  77. package/scripts/lib/cli/commands/install.rb +21 -0
  78. package/scripts/lib/cli/commands/installer_verb.rb +37 -0
  79. package/scripts/lib/cli/commands/intent.rb +19 -0
  80. package/scripts/lib/cli/commands/intent_answer.rb +37 -0
  81. package/scripts/lib/cli/commands/intent_command.rb +53 -0
  82. package/scripts/lib/cli/commands/intent_end.rb +62 -0
  83. package/scripts/lib/cli/commands/intent_new.rb +62 -0
  84. package/scripts/lib/cli/commands/intent_note.rb +43 -0
  85. package/scripts/lib/cli/commands/intent_rule.rb +36 -0
  86. package/scripts/lib/cli/commands/intent_show.rb +32 -0
  87. package/scripts/lib/cli/commands/intent_spec.rb +44 -0
  88. package/scripts/lib/cli/commands/intent_step.rb +71 -0
  89. package/scripts/lib/cli/commands/intent_verify.rb +26 -0
  90. package/scripts/lib/cli/commands/migrate.rb +16 -0
  91. package/scripts/lib/cli/commands/migrate_stores.rb +31 -0
  92. package/scripts/lib/cli/commands/next.rb +54 -0
  93. package/scripts/lib/cli/commands/project.rb +19 -0
  94. package/scripts/lib/cli/commands/project_links.rb +42 -0
  95. package/scripts/lib/cli/commands/project_list.rb +20 -0
  96. package/scripts/lib/cli/commands/project_new.rb +72 -0
  97. package/scripts/lib/cli/commands/query.rb +31 -0
  98. package/scripts/lib/cli/commands/render.rb +33 -0
  99. package/scripts/lib/cli/commands/roadmap.rb +19 -0
  100. package/scripts/lib/cli/commands/roadmap_check.rb +54 -0
  101. package/scripts/lib/cli/commands/roadmap_log.rb +54 -0
  102. package/scripts/lib/cli/commands/roadmap_migrate.rb +53 -0
  103. package/scripts/lib/cli/commands/roadmap_next.rb +70 -0
  104. package/scripts/lib/cli/commands/roadmap_show.rb +45 -0
  105. package/scripts/lib/cli/commands/rollback.rb +20 -0
  106. package/scripts/lib/cli/commands/search.rb +60 -0
  107. package/scripts/lib/cli/commands/session.rb +18 -0
  108. package/scripts/lib/cli/commands/session_commit.rb +42 -0
  109. package/scripts/lib/cli/commands/session_handoff.rb +34 -0
  110. package/scripts/lib/cli/commands/session_summary.rb +35 -0
  111. package/scripts/lib/cli/commands/status.rb +68 -0
  112. package/scripts/lib/cli/commands/subcommand_list.rb +36 -0
  113. package/scripts/lib/cli/commands/sync.rb +46 -0
  114. package/scripts/lib/cli/commands/uninstall.rb +20 -0
  115. package/scripts/lib/cli/commands/update.rb +20 -0
  116. package/scripts/lib/cli/commands/version.rb +53 -0
  117. package/scripts/lib/cli/frontier.rb +86 -0
  118. package/scripts/lib/cli/intent_progress.rb +36 -0
  119. package/scripts/lib/cli/legacy.rb +80 -0
  120. package/scripts/lib/cli/output.rb +137 -0
  121. package/scripts/lib/cli/scope.rb +134 -0
  122. package/scripts/lib/cli/table.rb +65 -0
  123. package/scripts/lib/cli.rb +94 -0
  124. package/scripts/lib/codex_adapter.rb +198 -0
  125. package/scripts/lib/compact_instructions.rb +13 -5
  126. package/scripts/lib/core_integrity.rb +71 -0
  127. package/scripts/lib/dashboard_screen.rb +40 -0
  128. package/scripts/lib/data_boundary.rb +132 -0
  129. package/scripts/lib/day_summary.rb +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 +229 -0
  148. package/scripts/lib/index_entry.rb +62 -0
  149. package/scripts/lib/index_projection.rb +201 -0
  150. package/scripts/lib/insights.rb +1 -1
  151. package/scripts/lib/installer_core.rb +531 -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 +913 -0
  161. package/scripts/lib/node_input_compatibility.rb +62 -0
  162. package/scripts/lib/node_ledger.rb +386 -0
  163. package/scripts/lib/node_progress.rb +153 -0
  164. package/scripts/lib/node_return.rb +204 -0
  165. package/scripts/lib/node_worktree.rb +337 -0
  166. package/scripts/lib/outcome_report.rb +440 -0
  167. package/scripts/lib/preflight.rb +4 -6
  168. package/scripts/lib/project_config.rb +46 -0
  169. package/scripts/lib/project_validator.rb +3 -2
  170. package/scripts/lib/qmd_sync.rb +8 -7
  171. package/scripts/lib/ready_set.rb +462 -0
  172. package/scripts/lib/reference_archive.rb +45 -0
  173. package/scripts/lib/release_guard.rb +18 -0
  174. package/scripts/lib/report_screen.rb +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 +158 -8
  181. package/scripts/lib/roadmap_render.rb +150 -0
  182. package/scripts/lib/roadmap_savepoint.rb +64 -14
  183. package/scripts/lib/runner_absorb.rb +703 -0
  184. package/scripts/lib/runner_answer.rb +206 -0
  185. package/scripts/lib/runner_core.rb +194 -0
  186. package/scripts/lib/runner_dispatch.rb +525 -0
  187. package/scripts/lib/runner_policy.rb +191 -0
  188. package/scripts/lib/runner_proposals.rb +275 -0
  189. package/scripts/lib/runner_rewind.rb +201 -0
  190. package/scripts/lib/runner_sweep.rb +231 -0
  191. package/scripts/lib/runner_until_empty.rb +252 -0
  192. package/scripts/lib/runner_watch.rb +389 -0
  193. package/scripts/lib/savepoint.rb +141 -19
  194. package/scripts/lib/scaffold_intent.rb +6 -3
  195. package/scripts/lib/screen_paint.rb +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 +25 -18
  202. package/scripts/lib/session_ledger.rb +48 -4
  203. package/scripts/lib/session_usage.rb +190 -0
  204. package/scripts/lib/sqlite.rb +22 -0
  205. package/scripts/lib/stop_gate.rb +95 -0
  206. package/scripts/lib/store_discovery.rb +7 -6
  207. package/scripts/lib/store_layout.rb +54 -0
  208. package/scripts/lib/store_provisioning.rb +2 -1
  209. package/scripts/lib/store_sync.rb +85 -0
  210. package/scripts/lib/stores_move.rb +93 -0
  211. package/scripts/lib/verify_intent.rb +36 -8
  212. package/scripts/lib/version_number.rb +48 -0
  213. package/scripts/lib/work_graph.rb +59 -0
  214. package/scripts/lib/work_graph_validator.rb +201 -0
  215. package/scripts/lib/worktree.rb +27 -32
  216. package/scripts/lib/worktree_sweep.rb +6 -5
  217. package/scripts/link-suggest +2 -1
  218. package/scripts/meter-watch +57 -0
  219. package/scripts/migrate-to-global +1 -1
  220. package/scripts/new-intent +4 -13
  221. package/scripts/node-input +92 -0
  222. package/scripts/node-run +225 -0
  223. package/scripts/node-transition +291 -0
  224. package/scripts/outcome-report +74 -0
  225. package/scripts/plastic-lock +42 -33
  226. package/scripts/promote-session-item +3 -2
  227. package/scripts/read-config +53 -9
  228. package/scripts/ready-set +126 -0
  229. package/scripts/release-check +123 -0
  230. package/scripts/report-screen +177 -16
  231. package/scripts/roadmap-graph +119 -0
  232. package/scripts/roadmap-savepoint +7 -0
  233. package/scripts/runner +581 -0
  234. package/scripts/savepoint-note +11 -9
  235. package/scripts/session-commit +2 -1
  236. package/scripts/session-usage +56 -0
  237. package/scripts/skill-lint +115 -6
  238. package/scripts/spawn-preamble +2 -2
  239. package/scripts/update.rb +25 -4
  240. package/scripts/validate-work-graph +39 -0
  241. package/scripts/verify-intent +3 -2
  242. package/scripts/write-handoff +2 -1
  243. package/templates/agents.md +7 -7
  244. package/templates/config.yml +16 -9
  245. package/templates/dashboard-screen.md +22 -0
  246. package/templates/display-fixture.md +21 -0
  247. package/templates/graph.md +16 -0
  248. package/templates/index.md +1 -1
  249. package/templates/intent-screen.md +1 -1
  250. package/templates/node-decision.md +11 -0
  251. package/templates/node-research.md +13 -0
  252. package/templates/node-verify.md +13 -0
  253. package/templates/node-work.md +22 -0
  254. package/templates/outcome.md +8 -3
  255. package/templates/project.yml +1 -1
  256. package/templates/render.css +10 -0
  257. package/templates/report-plan.md +15 -0
  258. package/templates/report-roadmap-delivered.md +10 -0
  259. package/templates/report-roadmap-plan.md +9 -0
  260. package/templates/report-roadmap-state.md +9 -0
  261. package/templates/report-state.md +1 -1
  262. package/templates/roadmap.md +13 -0
  263. package/bin/plastic.js +0 -70
  264. package/scripts/lib/bridge.rb +0 -116
  265. package/skills/agent-advisor/SKILL.md +0 -92
  266. package/skills/auto/SKILL.md +0 -296
  267. package/skills/auto/evals/evals.json +0 -255
  268. package/skills/auto/references/end-tail.md +0 -66
  269. package/skills/auto/references/human-report-contract.md +0 -78
  270. package/skills/conventions/SKILL.md +0 -29
  271. package/skills/dashboard/SKILL.md +0 -169
  272. package/skills/dashboard/evals/evals.json +0 -38
  273. package/skills/dashboard/references/classification.md +0 -22
  274. package/skills/dashboard/templates/dashboard-global.md +0 -20
  275. package/skills/dashboard/templates/dashboard-project.md +0 -19
  276. package/skills/direct/SKILL.md +0 -66
  277. package/skills/direct/references/request-signals.md +0 -59
  278. package/skills/doctor/SKILL.md +0 -299
  279. package/skills/doctor/report.md +0 -102
  280. package/skills/feedback/SKILL.md +0 -98
  281. package/skills/feedback/references/transport-and-privacy.md +0 -65
  282. package/skills/feedback/report.md +0 -36
  283. package/skills/install/SKILL.md +0 -217
  284. package/skills/intent-continuing/SKILL.md +0 -154
  285. package/skills/intent-continuing/references/board-fill.md +0 -43
  286. package/skills/intent-continuing/references/boarding-matrix.md +0 -34
  287. package/skills/intent-continuing/references/context-management.md +0 -28
  288. package/skills/intent-continuing/references/liveness-ranking.md +0 -57
  289. package/skills/intent-creating/SKILL.md +0 -164
  290. package/skills/intent-creating/evals/evals.json +0 -72
  291. package/skills/intent-creating/references/lifecycle.md +0 -81
  292. package/skills/intent-creating/references/wikilinks.md +0 -8
  293. package/skills/intent-ending/SKILL.md +0 -176
  294. package/skills/intent-ending/evals/evals.json +0 -74
  295. package/skills/intent-executing/SKILL.md +0 -170
  296. package/skills/intent-executing/evals/evals.json +0 -66
  297. package/skills/intent-executing/implementer-prompt.md +0 -42
  298. package/skills/intent-executing/spec-reviewer-prompt.md +0 -27
  299. package/skills/intent-speccing/SKILL.md +0 -130
  300. package/skills/intent-speccing/evals/evals.json +0 -126
  301. package/skills/intent-speccing/references/design-principles.md +0 -44
  302. package/skills/intent-speccing/references/per-section-fill-rules.md +0 -92
  303. package/skills/intent-speccing/references/self-verify-checklist.md +0 -37
  304. package/skills/project-creating/SKILL.md +0 -162
  305. package/skills/project-creating/references/hubs-projects.md +0 -55
  306. package/skills/project-creating/references/project-scaffolding.md +0 -97
  307. package/skills/releasing/SKILL.md +0 -337
  308. package/skills/releasing/references/deprecations.md +0 -60
  309. package/skills/releasing/references/promotion-and-tagging.md +0 -66
  310. package/skills/releasing/references/release-lines.md +0 -105
  311. package/skills/roadmap/SKILL.md +0 -64
  312. package/skills/roadmap/references/file-format.md +0 -124
  313. package/skills/roadmap/references/operations.md +0 -112
  314. package/skills/rollback/SKILL.md +0 -91
  315. package/skills/tutorial/SKILL.md +0 -65
  316. package/skills/tutorial/evals/evals.json +0 -186
  317. package/skills/uninstall/SKILL.md +0 -75
  318. package/skills/update/SKILL.md +0 -126
  319. /package/{skills/auto/references → docs/help}/agent-report-contract.md +0 -0
  320. /package/{skills/intent-executing → docs/help}/code-quality-reviewer-prompt.md +0 -0
  321. /package/{skills/conventions/references → docs/help}/lifecycle-and-savepoints.md +0 -0
  322. /package/{skills/intent-executing → docs/help}/plan-reviewer-prompt.md +0 -0
@@ -0,0 +1,389 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "digest"
6
+ require "time"
7
+ require "rbconfig"
8
+ require_relative "worktree"
9
+ require_relative "runner_core"
10
+ require_relative "runner_sweep"
11
+ require_relative "ready_set"
12
+ require_relative "node_ledger"
13
+ require_relative "savepoint"
14
+ require_relative "atomic_write"
15
+ require_relative "runner_until_empty"
16
+ require_relative "harness_adapter"
17
+ require_relative "meter_watch"
18
+
19
+ # RunnerWatch (intent 340a, G7b, n1): one tick over disk truth. The whole
20
+ # watch minus the CLI and the dispatch branch (340a n2). Composes the
21
+ # existing modules the way RunnerSweep and MeterWatch already do - the
22
+ # git runner, the clock and the sweep module are all injected, nothing
23
+ # reads ENV, nothing evals.
24
+ #
25
+ # Order, fixed by graph.md D2: take a non-blocking lock on
26
+ # `<intent_dir>/watch.lock` for the whole tick (a losing tick is `busy`,
27
+ # writes nothing); `RunnerSweep.abort_if_merging`; refuse the tick outright
28
+ # on a half-finished merge (row 1.3), writing nothing; `RunnerSweep.reclaim`
29
+ # (never `RunnerSweep.run`, D3: that method heartbeats the delivery lease,
30
+ # which would keep a dead lead's lock fresh forever); classify (D4);
31
+ # persist the snapshot and the record line only when `record:` holds.
32
+ module RunnerWatch
33
+ module_function
34
+
35
+ LOCK_FILENAME = "watch.lock"
36
+ STATE_FILENAME = "watch.state"
37
+ RECORD_FILENAME = "watch.record"
38
+
39
+ # Classes that never dispatch (D8): a finished delivery re-running its own
40
+ # graph on every tick is exactly the bug a timer must not have.
41
+ FINISHED_CLASSES = %w[closed done_unreported].freeze
42
+
43
+ # tick(context, record:, dispatch:, harness:, now:, runner:, sweep:,
44
+ # until_empty:) -> {class:, blockers:, ready:, reclaimed:, dispatched:,
45
+ # tick:, busy:}. `dispatch:` (327 Q6, D7, D8) only ever runs under an
46
+ # explicit ask - never inferred from the class - and only when the lock is
47
+ # held, the class is not finished, and the meter does not read `stop`.
48
+ # `until_empty:` is `RunnerUntilEmpty` by default; a test can inject a
49
+ # double so the whole dispatch path never spawns a real `node-run`
50
+ # subprocess. `harness=` and `meter=` on the record line read `-` only
51
+ # when `dispatch:` itself was never asked for (D5) - every other refusal
52
+ # (an unheld lock, a finished class, a stopped meter) still consults and
53
+ # records the meter, since the tick DID look, it just chose not to act.
54
+ def tick(context, record: true, dispatch: false, harness: nil, now: Time.now,
55
+ runner: Worktree::ShellRunner.new, sweep: RunnerSweep, until_empty: RunnerUntilEmpty)
56
+ intent_dir = context.intent_dir.to_s
57
+ lock_handle = acquire_lock(File.join(intent_dir, LOCK_FILENAME))
58
+ return busy_result unless lock_handle
59
+
60
+ begin
61
+ abort_result = sweep.abort_if_merging(context, runner: runner)
62
+ unless abort_result[:ok]
63
+ return {
64
+ class: "merge_in_progress", blockers: [abort_result[:error]], ready: [],
65
+ reclaimed: [], dispatched: [], tick: nil, busy: false,
66
+ }
67
+ end
68
+
69
+ reclaim_result = sweep.reclaim(context, runner: runner, skip: [], now: now)
70
+ reclaimed_ids = Array(reclaim_result[:reclaimed]).map { |r| r[:node] }
71
+ extended = !Array(reclaim_result[:extended]).empty?
72
+
73
+ view = classify(context, runner: runner, now: now, intent_dir: intent_dir, extended: extended)
74
+
75
+ lock_state = context.session ? "held" : "not_held"
76
+ harness_field = "-"
77
+ meter_state = "-"
78
+ dispatched_ids = []
79
+
80
+ # B1: the dispatch loop and the persist step share one inner
81
+ # begin/ensure so a raise mid `until_empty.run` (a launchd SIGTERM, a
82
+ # Process.spawn Errno) still lands the snapshot and the record line
83
+ # with whatever `dispatched_ids` was collected before the raise, and
84
+ # then the exception keeps propagating past this method (the outer
85
+ # `ensure` below still releases the tick lock).
86
+ begin
87
+ if dispatch
88
+ harness_field = blank?(harness) ? "-" : harness.to_s
89
+ meter_state = read_meter_state(context)
90
+
91
+ if context.session && !FINISHED_CLASSES.include?(view[:class]) && meter_state != "stop"
92
+ run_until_empty_dispatch(context, harness: harness, until_empty: until_empty,
93
+ dispatched_ids: dispatched_ids)
94
+ end
95
+ end
96
+ ensure
97
+ if record
98
+ Worktree.ensure_gitignored(context.plastic_home, STATE_FILENAME, runner: runner)
99
+ write_snapshot(intent_dir, view[:snapshot])
100
+ append_record(
101
+ intent_dir, now: now, tick: view[:tick], klass: view[:class],
102
+ reclaimed: reclaimed_ids, ready: view[:ready], dispatched: dispatched_ids,
103
+ harness: harness_field, meter: meter_state, lock: lock_state
104
+ )
105
+ end
106
+ end
107
+
108
+ {
109
+ class: view[:class], blockers: view[:blockers], ready: view[:ready],
110
+ reclaimed: reclaimed_ids, dispatched: dispatched_ids, tick: view[:tick], busy: false,
111
+ }
112
+ ensure
113
+ release_lock(lock_handle)
114
+ end
115
+ end
116
+
117
+ # install_timer(context, home:, harness_key:, installer:) -> the written
118
+ # plist path. Graph.md D9: the Codex carrier is `runner watch
119
+ # --install-timer`, and it reuses MeterWatch's own writer through
120
+ # `label:`/`arguments:` rather than rendering plist XML here (row 3.5) - a
121
+ # second writer is exactly the drift D9 rules out. The label carries the
122
+ # intent id (row 3.4), so a second intent's timer never overwrites the
123
+ # first's job; `--dispatch --harness codex` rides the arguments only when
124
+ # `HarnessAdapter.unattended_start?` holds for the resolved harness (row
125
+ # 3.3), the same predicate `run_watch` itself already gates `--dispatch`
126
+ # on. `installer:` is `MeterWatch` by default so a test can inject a
127
+ # double that never touches a real home.
128
+ def install_timer(context, home:, harness_key:, installer: MeterWatch)
129
+ runner_path = File.expand_path(File.join(__dir__, "..", "runner"))
130
+ arguments = [RbConfig.ruby, runner_path, "watch", context.intent_dir.to_s]
131
+ arguments += ["--dispatch", "--harness", harness_key.to_s] if HarnessAdapter.unattended_start?(harness_key)
132
+
133
+ installer.install_timer(home: home, script_path: runner_path,
134
+ label: "com.plastic.delivery-watch.#{context.intent_id}", arguments: arguments)
135
+ end
136
+
137
+ # run_until_empty_dispatch(context, harness:, until_empty:) -> every node
138
+ # id the loop dispatched (C30). `until_empty.run` gets `step:` wrapped
139
+ # around `until_empty.step_once` so this method sees every turn's own
140
+ # `:dispatched` ids, the same shape RunnerDispatch.dispatch returns
141
+ # (graph.md D8); `until_empty.run` itself still owns spawning and waiting
142
+ # on the real `node-run` subprocesses (never duplicated here).
143
+ def run_until_empty_dispatch(context, harness:, until_empty:, dispatched_ids:)
144
+ wrapped_step = lambda do |ctx, harness:, returns:|
145
+ result = until_empty.step_once(ctx, harness: harness, returns: returns)
146
+ dispatched_ids.concat(Array(result[:dispatched]))
147
+ result
148
+ end
149
+
150
+ until_empty.run(context, harness: harness, step: wrapped_step)
151
+ end
152
+ private_class_method :run_until_empty_dispatch
153
+
154
+ # read_meter_state(context) -> "ok", "reduce", "stop", "resume", "stale"
155
+ # (whatever MeterWatch's own tick last wrote), or "unavailable" for a
156
+ # missing or unparseable file (D8) - read-only, at MeterWatch's own state
157
+ # path under `context.plastic_home`, never through a MeterWatch instance
158
+ # (that class computes a FRESH state from the rate-limit cache; this tick
159
+ # only ever reads what it already wrote).
160
+ def read_meter_state(context)
161
+ path = File.join(context.plastic_home.to_s, ".cache", "meter-state.json")
162
+ return "unavailable" unless File.file?(path)
163
+
164
+ state = JSON.parse(File.read(path))["state"]
165
+ blank?(state) ? "unavailable" : state.to_s
166
+ rescue StandardError
167
+ "unavailable"
168
+ end
169
+ private_class_method :read_meter_state
170
+
171
+ def blank?(value)
172
+ value.nil? || value.to_s.strip.empty?
173
+ end
174
+ private_class_method :blank?
175
+
176
+ # fingerprint(context, runner:) -> the SHA256 D4 defines: savepoint.md's
177
+ # current content plus the intent branch head, so a commit that lands no
178
+ # ledger line still counts as movement (row 1.13). Public so a caller (and
179
+ # this file's own tests) can compute the exact value a tick would compute
180
+ # without duplicating the hashing here.
181
+ def fingerprint(context, runner: Worktree::ShellRunner.new)
182
+ content = savepoint_content(context.intent_dir)
183
+ Digest::SHA256.hexdigest("#{content}\x1f#{branch_head_sha(context, runner)}")
184
+ end
185
+
186
+ # --- the lock ----------------------------------------------------------------
187
+
188
+ def acquire_lock(path)
189
+ handle = File.open(path, File::CREAT | File::RDWR, 0o644)
190
+ return handle if handle.flock(File::LOCK_EX | File::LOCK_NB)
191
+
192
+ handle.close
193
+ nil
194
+ rescue SystemCallError
195
+ nil
196
+ end
197
+ private_class_method :acquire_lock
198
+
199
+ def release_lock(handle)
200
+ return unless handle
201
+
202
+ handle.flock(File::LOCK_UN)
203
+ rescue SystemCallError
204
+ nil
205
+ ensure
206
+ handle&.close
207
+ end
208
+ private_class_method :release_lock
209
+
210
+ def busy_result
211
+ { class: nil, blockers: [], ready: [], reclaimed: [], dispatched: [], tick: nil, busy: true }
212
+ end
213
+ private_class_method :busy_result
214
+
215
+ # --- classification (D4), first match wins ------------------------------------
216
+
217
+ def classify(context, runner:, now:, intent_dir:, extended: false)
218
+ content = savepoint_content(intent_dir)
219
+ recorded_pairs = Savepoint.savepoint_recorded_pairs(intent_dir)
220
+ previous = read_snapshot(intent_dir)
221
+ tick_number = (previous ? previous[:tick] : 0) + 1
222
+
223
+ if closed?(recorded_pairs)
224
+ return settle("closed", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
225
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
226
+ end
227
+
228
+ unless context.graph[:ok]
229
+ errors = Array(context.graph[:errors])
230
+ errors = ["graph.md could not be parsed"] if errors.empty?
231
+ return settle("stalled", blockers: errors, ready_ids: [], previous: previous, tick_number: tick_number,
232
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
233
+ end
234
+
235
+ if RunnerCore.complete?(context)
236
+ return settle("done_unreported", blockers: [], ready_ids: [], previous: previous, tick_number: tick_number,
237
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now)
238
+ end
239
+
240
+ ready_ids = ReadySet.analyze(intent_dir, now: now)[:ranked_ready].map { |r| r[:id] }
241
+ running = any_running?(content)
242
+
243
+ if !running && ready_ids.empty?
244
+ return settle("stalled", blockers: blocked_reasons(context), ready_ids: ready_ids, previous: previous,
245
+ tick_number: tick_number, quiet_ticks: 0, content: content, context: context, runner: runner,
246
+ now: now)
247
+ end
248
+
249
+ current_fingerprint = fingerprint(context, runner: runner)
250
+
251
+ # B2: a reclaim that extended a lease is evidence of live work (the
252
+ # sweep extends only when the node branch has commits newer than the
253
+ # expiry), so it counts as movement here, at the fingerprint
254
+ # comparison, after closed/malformed-graph/done_unreported/the
255
+ # nothing-running-nothing-ready stall have already returned above.
256
+ if extended || previous.nil? || previous[:fingerprint] != current_fingerprint
257
+ return settle("moving", blockers: [], ready_ids: ready_ids, previous: previous, tick_number: tick_number,
258
+ quiet_ticks: 0, content: content, context: context, runner: runner, now: now,
259
+ fingerprint: current_fingerprint)
260
+ end
261
+
262
+ next_quiet_ticks = previous[:quiet_ticks] + 1
263
+ if previous[:quiet_ticks] >= 1 && !unexpired_running_lease?(content, now)
264
+ klass = "stalled"
265
+ blockers = ["no observed movement for #{next_quiet_ticks} consecutive ticks and no unexpired running lease"]
266
+ else
267
+ klass = "quiet"
268
+ blockers = []
269
+ end
270
+
271
+ settle(klass, blockers: blockers, ready_ids: ready_ids, previous: previous, tick_number: tick_number,
272
+ quiet_ticks: next_quiet_ticks, content: content, context: context, runner: runner, now: now,
273
+ fingerprint: current_fingerprint)
274
+ end
275
+ private_class_method :classify
276
+
277
+ # Assembles the return view plus the snapshot that will be persisted, when
278
+ # `record:` is honored by the caller. `fingerprint:` defaults to a fresh
279
+ # computation so every class - not only moving/quiet/stalled-by-quiet -
280
+ # persists a value later ticks can compare against.
281
+ def settle(klass, blockers:, ready_ids:, previous:, tick_number:, quiet_ticks:, content:, context:, runner:,
282
+ now:, fingerprint: nil)
283
+ fp = fingerprint || RunnerWatch.fingerprint(context, runner: runner)
284
+ {
285
+ class: klass, blockers: blockers, ready: ready_ids, tick: tick_number,
286
+ snapshot: { fingerprint: fp, quiet_ticks: quiet_ticks, tick: tick_number, at: now.utc.iso8601 },
287
+ }
288
+ end
289
+ private_class_method :settle
290
+
291
+ def closed?(recorded_pairs)
292
+ recorded_pairs.include?(["Done", "delivered"]) || recorded_pairs.include?(["Done", "abandoned"])
293
+ end
294
+ private_class_method :closed?
295
+
296
+ def blocked_reasons(context)
297
+ rows = RunnerCore.status(context)
298
+ rows.values.reject { |v| ReadySet::TERMINAL_STATES.include?(v[:state]) || v[:ready] }
299
+ .flat_map { |v| v[:blockers] }
300
+ .uniq
301
+ end
302
+ private_class_method :blocked_reasons
303
+
304
+ def any_running?(content)
305
+ NodeLedger.status_from_content(content).value?("running")
306
+ end
307
+ private_class_method :any_running?
308
+
309
+ def unexpired_running_lease?(content, now)
310
+ status_map = NodeLedger.status_from_content(content)
311
+ entries = NodeLedger.entries_from_content(content)
312
+ status_map.any? do |subject, state|
313
+ next false unless state == "running"
314
+
315
+ last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
316
+ next false unless last
317
+
318
+ at = parse_time((last[:fields] || {})["expires"])
319
+ at && now < at
320
+ end
321
+ end
322
+ private_class_method :unexpired_running_lease?
323
+
324
+ def branch_head_sha(context, runner)
325
+ worktree = context.worktree
326
+ return "" if worktree.nil? || worktree.to_s.strip.empty?
327
+
328
+ res = runner.run("-C", worktree, "rev-parse", "HEAD")
329
+ res.success? ? res.stdout.to_s.strip : ""
330
+ rescue StandardError
331
+ ""
332
+ end
333
+ private_class_method :branch_head_sha
334
+
335
+ def parse_time(raw)
336
+ return nil if raw.nil? || raw.to_s.strip.empty?
337
+
338
+ Time.iso8601(raw.to_s)
339
+ rescue ArgumentError
340
+ nil
341
+ end
342
+ private_class_method :parse_time
343
+
344
+ def savepoint_content(intent_dir)
345
+ path = File.join(intent_dir.to_s, "savepoint.md")
346
+ File.exist?(path) ? File.read(path) : ""
347
+ end
348
+ private_class_method :savepoint_content
349
+
350
+ # --- the snapshot (D5) ---------------------------------------------------------
351
+
352
+ def read_snapshot(intent_dir)
353
+ path = File.join(intent_dir, STATE_FILENAME)
354
+ return nil unless File.file?(path)
355
+
356
+ data = JSON.parse(File.read(path))
357
+ { fingerprint: data["fingerprint"], quiet_ticks: data["quiet_ticks"].to_i, tick: data["tick"].to_i,
358
+ at: data["at"] }
359
+ rescue StandardError
360
+ nil
361
+ end
362
+ private_class_method :read_snapshot
363
+
364
+ def write_snapshot(intent_dir, snapshot)
365
+ path = File.join(intent_dir, STATE_FILENAME)
366
+ data = {
367
+ "fingerprint" => snapshot[:fingerprint], "quiet_ticks" => snapshot[:quiet_ticks],
368
+ "tick" => snapshot[:tick], "at" => snapshot[:at],
369
+ }
370
+ AtomicWrite.write(path, JSON.generate(data))
371
+ end
372
+ private_class_method :write_snapshot
373
+
374
+ # --- the record (D5) -----------------------------------------------------------
375
+
376
+ def append_record(intent_dir, now:, tick:, klass:, reclaimed:, ready:, dispatched:, harness: "-", meter: "-",
377
+ lock:)
378
+ line = "#{now.utc.iso8601} tick=#{tick} class=#{klass} reclaimed=#{list_or_dash(reclaimed)} " \
379
+ "ready=#{list_or_dash(ready)} dispatched=#{list_or_dash(dispatched)} harness=#{harness} " \
380
+ "meter=#{meter} lock=#{lock}\n"
381
+ File.open(File.join(intent_dir, RECORD_FILENAME), "a") { |f| f.write(line) }
382
+ end
383
+ private_class_method :append_record
384
+
385
+ def list_or_dash(list)
386
+ list.nil? || list.empty? ? "-" : list.join(",")
387
+ end
388
+ private_class_method :list_or_dash
389
+ end
@@ -3,14 +3,13 @@
3
3
  # frozen_string_literal: true
4
4
 
5
5
  # Savepoint: the intent-directory savepoint ledger and the stage derivation it
6
- # rests on (intent 303, split out of bridge.rb). Everything here is derived from
6
+ # rests on (intent 303). Everything here is derived from
7
7
  # the files on disk inside one intent directory: which lifecycle files are real
8
8
  # (not the placeholder sentinel), which stage that makes, and the append-only
9
- # savepoint.md ledger that records each milestone. No session, no bridge, no
9
+ # savepoint.md ledger that records each milestone. No session, no lock, no
10
10
  # lock: a caller that only wants to append a ledger line or ask "which stage is
11
- # this intent at" loads this file and nothing else. bridge.rb (the session
12
- # pointer, arm and disarm, lock repair) requires this file; this file never
13
- # requires the bridge.
11
+ # this intent at" loads this file and nothing else. `scripts/lib/arm.rb` (arm,
12
+ # disarm, lock repair) requires this file; this file never requires arm.rb.
14
13
  require "fileutils"
15
14
  require "time" # Time#iso8601 for the ledger timestamps
16
15
 
@@ -23,6 +22,36 @@ module Savepoint
23
22
  # (<id>--<slug>.md) is never sentineled; it is born complete.
24
23
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
25
24
 
25
+ # --- Node-graph transition subject vocabulary (intent 335, spec D17) -------
26
+ #
27
+ # Owned HERE, not on NodeLedger, because test/savepoint_split_test.rb:57 pins
28
+ # savepoint.rb to loading no other project file: NodeLedger requires this
29
+ # file and reuses these three names rather than duplicating them, so the
30
+ # dependency runs one way only. Intent 334 (G1) mints node ids and must
31
+ # agree with NODE_SUBJECT_RE: it is the single seam for the node id shape.
32
+
33
+ # The literal subject token for an intent-scope transition line ("Intent
34
+ # needs_decision question=..."), as opposed to a node-scope line.
35
+ INTENT_SUBJECT = "Intent"
36
+
37
+ # A node id: one or two lowercase letters (the node's kind prefix, e.g. "n"
38
+ # for work, "v" for verify) followed by digits.
39
+ NODE_SUBJECT_RE = /\A[a-z]{1,2}\d+\z/.freeze
40
+
41
+ # True iff a raw savepoint ledger line's subject (field 2, split on
42
+ # /\s{2,}/) is a transition candidate: the literal Intent token or a node
43
+ # id. A stage line ("How checklist.md created") or a Lock takeover audit
44
+ # line never matches, by construction (spec Acceptance Criteria: "no stage
45
+ # token this tree writes collides with Intent or with the node id
46
+ # pattern").
47
+ def self.transition_candidate?(line)
48
+ parts = line.to_s.split(/\s{2,}/)
49
+ return false unless parts.length >= 2
50
+
51
+ subject = parts[1]
52
+ subject == INTENT_SUBJECT || subject.match?(NODE_SUBJECT_RE)
53
+ end
54
+
26
55
  def self.intent_file(intent_dir)
27
56
  dir_name = File.basename(intent_dir)
28
57
  "#{intent_dir}/#{dir_name}.md"
@@ -30,7 +59,7 @@ module Savepoint
30
59
 
31
60
  # Walk up from file_path; return the first ancestor that looks like an intent
32
61
  # directory (`.../store/<id>--<slug>`), else nil. Used to derive the savepoint
33
- # target without needing a bridge. The input is always a file inside the intent
62
+ # target without needing the lock or arm machinery. The input is always a file inside the intent
34
63
  # dir (never the dir itself), so the walk-up starts at its parent.
35
64
  def self.intent_dir_for(file_path)
36
65
  dir = File.expand_path(file_path)
@@ -57,22 +86,47 @@ module Savepoint
57
86
  File.exist?(path)
58
87
  end
59
88
 
60
- # True iff actions/ holds AT LEAST ONE real action file: a non-empty *.md whose
61
- # first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
62
- # never counts, an empty *.md never counts, and a sentinel-only *.md never
63
- # counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
64
- # a missing actions/ dir globs to nothing and returns false (the gate then
65
- # reports it needs a real action file); it never raises.
66
- def self.has_real_action?(intent_dir)
67
- Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
89
+ # True iff DIR_NAME (actions/ or nodes/) holds AT LEAST ONE real *.md file: non-empty,
90
+ # first line not the placeholder sentinel. A `.gitkeep` (no .md extension) never counts.
91
+ # Pure and side-effect-free; fail-open (a missing dir globs to nothing, never raises).
92
+ def self.has_real_files_in?(dir_name, intent_dir)
93
+ Dir.glob("#{intent_dir}/#{dir_name}/*.md").any? do |f|
68
94
  File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
69
95
  end
70
96
  rescue StandardError
71
97
  false
72
98
  end
73
99
 
100
+ # True iff the intent has at least one real action file, whether delivered as
101
+ # legacy actions/*.md or as a node graph's nodes/*.md (intent 334, G1, D10r):
102
+ # an intent delivered as nodes is exactly as real as one delivered as
103
+ # actions, so doctor and the exec-stage gate never report a backfill gap on
104
+ # a fully delivered node-graph intent. Checks actions/ first (the common
105
+ # path today), falling through to nodes/ only when actions/ has nothing.
106
+ def self.has_real_action?(intent_dir)
107
+ has_real_files_in?("actions", intent_dir) || has_real_files_in?("nodes", intent_dir)
108
+ rescue StandardError
109
+ false
110
+ end
111
+
112
+ # Intent 336 (G3, D13): a real graph.md is 327 D41's replacement for
113
+ # plan.md/checklist.md on the common path, so a node-shaped intent is
114
+ # judged on graph.md and nodes/ alone, whether or not spec.md is real - the
115
+ # graph IS the spec under D41. Checked using only this file's own
116
+ # primitives (stage_file_present?, has_real_files_in?), because
117
+ # test/savepoint_split_test.rb pins this file to loading no other project
118
+ # file and no YAML: GraphFile/NodeFile/ReadySet are never required here. An
119
+ # intent with no real graph.md (or a sentinel-placeholder one,
120
+ # stage_file_present? already reads that as absent) derives exactly what it
121
+ # derived before this intent.
74
122
  def self.derive_stage(intent_dir)
75
123
  return "done" if stage_file_present?("#{intent_dir}/outcome.md")
124
+
125
+ if stage_file_present?("#{intent_dir}/graph.md")
126
+ return "exec" if has_real_files_in?("nodes", intent_dir)
127
+ return "how"
128
+ end
129
+
76
130
  if stage_file_present?("#{intent_dir}/plan.md") &&
77
131
  has_real_action?(intent_dir) &&
78
132
  stage_file_present?("#{intent_dir}/checklist.md")
@@ -87,19 +141,49 @@ module Savepoint
87
141
  files = []
88
142
  ifile = File.basename(intent_file(intent_dir))
89
143
  files << ifile if File.exist?("#{intent_dir}/#{ifile}")
90
- ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
144
+ ["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
91
145
  files << f if stage_file_present?("#{intent_dir}/#{f}")
92
146
  end
93
- files << "actions/" if has_real_action?(intent_dir)
147
+ # Name the directory that actually exists (review B3): a nodes-only intent
148
+ # must never claim the literal "actions/" artifact it does not have.
149
+ # Checks actions/ first, matching D15r's read order.
150
+ if has_real_files_in?("actions", intent_dir)
151
+ files << "actions/"
152
+ elsif has_real_files_in?("nodes", intent_dir)
153
+ files << "nodes/"
154
+ end
94
155
  files
95
156
  end
96
157
 
97
158
  def self.missing_for_stage(stage, intent_dir = nil)
98
159
  ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
160
+ # A How-stage intent that already started a nodes/ directory is named
161
+ # accordingly, so the next-step hint never tells a node-graph intent to
162
+ # go make an actions/ directory it will never use (review B3). Mirrors
163
+ # has_real_files_in?'s actions-first order and its real-file requirement
164
+ # (post-execution review, non-blocking 4): an intent carrying real files
165
+ # in both directories, or a real actions/ file beside an empty or
166
+ # .gitkeep-only nodes/, is named actions/, never nodes/.
167
+ action_label = if intent_dir && has_real_files_in?("actions", intent_dir)
168
+ "actions/"
169
+ elsif intent_dir && has_real_files_in?("nodes", intent_dir)
170
+ "nodes/"
171
+ else
172
+ "actions/"
173
+ end
99
174
  case stage
100
175
  when "what" then [ifile]
101
176
  when "why" then ["spec.md"]
102
- when "how" then ["plan.md", "actions/", "checklist.md"]
177
+ when "how"
178
+ # A node-shaped How intent (a real graph.md already) is named after its
179
+ # own two artifacts, never the three D41 removed from its path (review
180
+ # B3, extended by 336 D13): checked via stage_file_present? alone, the
181
+ # same primitive derive_stage itself uses.
182
+ if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
183
+ ["graph.md", "nodes/"]
184
+ else
185
+ ["plan.md", action_label, "checklist.md"]
186
+ end
103
187
  when "exec" then ["outcome.md"]
104
188
  else []
105
189
  end
@@ -227,6 +311,18 @@ module Savepoint
227
311
  append_savepoint_line(intent_dir, "Commit", text, now)
228
312
  end
229
313
 
314
+ # The day the Report kind (below) shipped. `doctor`'s intent_reports_printed_check reads
315
+ # this so it never re-litigates a ledger recorded before the kind existed (intent 331f, R6).
316
+ REPORT_KIND_SINCE = "2026-09-05"
317
+
318
+ # Append a `Report` line: one per report screen printed (intent 331f, D2/D3). Same primitive
319
+ # as append_review_savepoint/append_commit_savepoint above, so the line shape, dedup, and
320
+ # timestamp format never drift. `savepoint_milestone` maps FILENAMES only, so a Report line
321
+ # is never mistaken for a file-landing lifecycle line by construction.
322
+ def self.append_report_savepoint(intent_dir, text, now: Time.now)
323
+ append_savepoint_line(intent_dir, "Report", text, now)
324
+ end
325
+
230
326
  TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
231
327
 
232
328
  # Append the terminal bookend `Done delivered|abandoned`, written by the
@@ -246,6 +342,17 @@ module Savepoint
246
342
  # A Plastic 1.x ledger may carry a `Tier <value>` line after the spec.md
247
343
  # milestone (removed in 2.0, intent 304); a rebuild drops it, and the phantom
248
344
  # detector ignores it, so a 1.x store reads clean.
345
+ #
346
+ # Every transition line (intent 335, spec D13) is preserved VERBATIM, in its
347
+ # original relative order, after the reconstructed stage skeleton. It is
348
+ # never dropped and never refused: a transition line's evidence fields
349
+ # (`holder=`, `expires=`, `gates=`, ...) have no file-mtime analog to
350
+ # reconstruct from, so refusing instead of preserving would make this method
351
+ # destroy the graph's only status on every intent that carries one. Relative
352
+ # order BETWEEN a stage line and a transition line is not preserved (safe:
353
+ # status is computed per subject, and the two families share no subject);
354
+ # relative order WITHIN the transition lines is preserved, which is what
355
+ # "last line per subject in file order" depends on.
249
356
  def self.rebuild_savepoint(intent_dir)
250
357
  ordered = [
251
358
  File.basename(intent_file(intent_dir)),
@@ -259,13 +366,24 @@ module Savepoint
259
366
  stamp = File.mtime(path).utc.iso8601
260
367
  ["#{stamp} #{stage} #{milestone}\n"]
261
368
  end
262
- File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
369
+
370
+ savepoint_path = File.join(intent_dir, SAVEPOINT_FILE)
371
+ if File.exist?(savepoint_path)
372
+ # #scrub before scanning (post-execution review row 7.8), the same way
373
+ # NodeLedger.entries does (matrix 2.44): a stray non-UTF-8 byte anywhere
374
+ # in the ledger must not raise out of the one repair tool three doctor
375
+ # fix hints and maintenance-run --tool rebuild-savepoint point at.
376
+ transition_lines = File.read(savepoint_path).scrub.each_line.select { |raw| transition_candidate?(raw) }
377
+ lines += transition_lines.map { |raw| raw.end_with?("\n") ? raw : "#{raw}\n" }
378
+ end
379
+
380
+ File.write(savepoint_path, lines.join)
263
381
  lines.length
264
382
  end
265
383
 
266
384
  # --- Phantom-line detection (intent 134) ------------------------------------
267
385
  #
268
- # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no bridge or
386
+ # A companion to the ledger, not a new writer: pure, disk-only, hermetic (no lock or
269
387
  # session resolution, no writes), matching intent 52's savepoint-decoupling precedent. Under
270
388
  # a gate-routing misfire (bug 131) or an out-of-band merge (124a's precedent), a ledger line
271
389
  # can go stale or duplicate without the file evidence agreeing. This detects, never repairs;
@@ -308,6 +426,10 @@ module Savepoint
308
426
  File.read(path).each_line do |raw|
309
427
  line = raw.strip
310
428
  next if line.empty?
429
+ # A transition line (intent 335) is never a stage phantom candidate: its
430
+ # own repeated-line semantics (dedup-free by design, spec D11) are
431
+ # NodeLedger's concern, not this detector's.
432
+ next if transition_candidate?(line)
311
433
  parts = line.split(/\s{2,}/)
312
434
  next if parts.length < 3
313
435
  pair = [parts[1], parts[2]]
@@ -3,7 +3,6 @@
3
3
 
4
4
  require "fileutils"
5
5
  require_relative "worktree"
6
- require_relative "bridge"
7
6
  require_relative "savepoint"
8
7
  require_relative "intent_validator"
9
8
 
@@ -21,7 +20,7 @@ require_relative "intent_validator"
21
20
  # copy or a mechanical rendering of a committed artifact.
22
21
  #
23
22
  # Pure and dependency-injected: never calls `exit` or `abort`, never reads `ARGV` or
24
- # `ENV` directly (only via the ambient `Dir.home` default, matching Bridge/Worktree
23
+ # `ENV` directly (only via the ambient `Dir.home` default, matching this module's own
25
24
  # convention). A git seam is injected as `runner:`, defaulting to
26
25
  # `Worktree::ShellRunner.new`, so tests drive this in process with a fake runner and
27
26
  # never touch real git. Every method returns a value; `scripts/scaffold-intent` maps the
@@ -29,6 +28,10 @@ require_relative "intent_validator"
29
28
  module ScaffoldIntent
30
29
  module_function
31
30
 
31
+ def blank?(value)
32
+ value.nil? || value.to_s.strip.empty?
33
+ end
34
+
32
35
  # --- path resolution (pure) --------------------------------------------------
33
36
 
34
37
  def expand(path)
@@ -211,7 +214,7 @@ module ScaffoldIntent
211
214
  end
212
215
  end
213
216
 
214
- unless Bridge.blank?(test_summary)
217
+ unless blank?(test_summary)
215
218
  out << "\n"
216
219
  out << "Test summary from #{test_summary}:\n"
217
220
  out << "```\n"