@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,62 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ # NodeInputCompatibility (intent 338a, G6, n1, D10/D11): the one seam that
5
+ # lets a `running` line written before the node-input rename keep reading
6
+ # correctly. It is the ONLY shipped file allowed to name the retired field,
7
+ # and it names it exactly once, as LEGACY_FIELD below.
8
+ #
9
+ # It maps, never rewrites: the raw ledger line on disk is never touched.
10
+ # `NodeLedger.parse_transition_line` calls #fields on every parsed field hash
11
+ # before returning it, so torn detection, attempt counting, node-run,
12
+ # graph-measure and outcome-report all see `input` whether the line was
13
+ # written by an old dispatch or a new one.
14
+ #
15
+ # No project requires (spec D10): a plain module over a plain hash, so
16
+ # `node_ledger.rb` never gains a require cycle through this file.
17
+ module NodeInputCompatibility
18
+ module_function
19
+
20
+ # The retired field name, named once. No other shipped file names it.
21
+ LEGACY_FIELD = "packet"
22
+
23
+ # The retired directory and suffix (338a n3, D8), named once beside
24
+ # LEGACY_FIELD. No other shipped file spells either literal; a caller
25
+ # that needs the legacy input location builds it from these constants.
26
+ LEGACY_DIRECTORY = "packets"
27
+ LEGACY_SUFFIX = ".packet"
28
+
29
+ # A copy of `fields` where LEGACY_FIELD maps to "input" when "input" is
30
+ # absent or blank. "input" wins when both are present (D11): the newer,
31
+ # authoritative name is never overwritten by the retired one. LEGACY_FIELD
32
+ # itself is dropped from the copy either way, so a caller that renders
33
+ # every field it does not recognize (outcome-report's evidence line) never
34
+ # sees the retired key leak through as a second, unrecognized field.
35
+ def fields(fields)
36
+ source = fields || {}
37
+ return source.dup unless source.key?(LEGACY_FIELD)
38
+
39
+ mapped = source.dup
40
+ unless present?(mapped["input"])
41
+ mapped["input"] = mapped[LEGACY_FIELD]
42
+ end
43
+ mapped.delete(LEGACY_FIELD)
44
+ mapped
45
+ end
46
+
47
+ # 338a n3 (D8): the new path when it exists, the legacy directory and
48
+ # suffix when only that file exists, and the new path when neither does
49
+ # (a fresh attempt names its own soon-to-be-written destination).
50
+ def input_path(intent_dir:, node:, attempt:)
51
+ new_path = File.join(intent_dir, "attempts", "#{node}--a#{attempt}.input")
52
+ return new_path if File.exist?(new_path)
53
+
54
+ legacy_path = File.join(intent_dir, LEGACY_DIRECTORY, "#{node}--a#{attempt}#{LEGACY_SUFFIX}")
55
+ File.exist?(legacy_path) ? legacy_path : new_path
56
+ end
57
+
58
+ def present?(value)
59
+ !(value.nil? || value.to_s.strip.empty?)
60
+ end
61
+ private_class_method :present?
62
+ end
@@ -0,0 +1,386 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "strscan"
5
+ require_relative "savepoint"
6
+ require_relative "guarded_append"
7
+ require_relative "node_input_compatibility"
8
+
9
+ # NodeLedger - the node and intent transition line (intent 335, G2). Owns the
10
+ # line's byte-exact format, the closed state vocabulary, the required fields per
11
+ # state, the parser, torn-line and unattributed-line detection, status as the
12
+ # last line per subject in file order, and the guarded transition append that
13
+ # skips dedup entirely (spec D11).
14
+ #
15
+ # Per spec D17 the subject vocabulary (`Savepoint::INTENT_SUBJECT`,
16
+ # `Savepoint::NODE_SUBJECT_RE`, `Savepoint.transition_candidate?`) lives on
17
+ # Savepoint, not here: this file requires savepoint.rb and reuses them, the
18
+ # dependency running one way only (test/savepoint_split_test.rb:57 pins
19
+ # savepoint.rb to loading no other project file).
20
+ #
21
+ # Pure and dependency-injected: every path and clock is an argument, `guard:` is
22
+ # an injectable seam for the transition append, and this module reads no
23
+ # environment variable.
24
+ module NodeLedger
25
+ module_function
26
+
27
+ # Ten states, closed (spec D1). "ready" is computed and never written.
28
+ STATES = %w[
29
+ planned running done failed_verification needs_decision blocked deferred
30
+ superseded abandoned reclaimed
31
+ ].freeze
32
+
33
+ # A written state's RESOLVED state for the ready function 336 builds on top of
34
+ # this ledger (spec D2): every state resolves to itself except `reclaimed`,
35
+ # which resolves to `planned` ("a reclaimed node is planned again").
36
+ RESOLUTION = { "reclaimed" => "planned" }.freeze
37
+
38
+ # Required fields per state (spec D4), refused by #append_transition and read
39
+ # as torn by #torn? when absent. `done`'s evidence requirement (gates= plus at
40
+ # least one of commit=/verdict=) is asymmetric, so it is not representable as
41
+ # a flat list; DONE_EVIDENCE_FIELDS below carries the "at least one of" half.
42
+ REQUIRED_FIELDS = {
43
+ "planned" => [],
44
+ "running" => %w[holder expires input model],
45
+ "done" => %w[gates],
46
+ "failed_verification" => %w[gates reason],
47
+ "needs_decision" => %w[question],
48
+ "blocked" => %w[reason],
49
+ "deferred" => %w[reason],
50
+ "superseded" => %w[by],
51
+ "abandoned" => %w[reason],
52
+ "reclaimed" => %w[holder expired],
53
+ }.freeze
54
+
55
+ # `done` requires gates= plus at least one of these (spec D7). `model=` is
56
+ # deliberately absent from every required-fields list except `running` (spec
57
+ # D6) and is accepted, never required, everywhere else - no separate table
58
+ # entry is needed for that: an unlisted field is never required.
59
+ DONE_EVIDENCE_FIELDS = %w[commit verdict].freeze
60
+
61
+ # Canonical field render order (spec D8's "stable declared order", matrix
62
+ # 2.6): every key named in REQUIRED_FIELDS or DONE_EVIDENCE_FIELDS, plus
63
+ # `model` (accepted on any state). A field outside this list still renders,
64
+ # sorted after these, so an unrecognized key is never silently dropped.
65
+ #
66
+ # `harness` (intent 340b, G7c, n1, D21) sits right after `model`: both are
67
+ # dispatch-time metadata `running` writes and every terminal transition
68
+ # carries forward, accepted on every state and required by none - the same
69
+ # treatment `suite=`, `hop=` and `core_drift=` already get as extras, given
70
+ # a stable declared position instead of falling to the alphabetical extras
71
+ # tail (matrix row 1.17).
72
+ FIELD_ORDER = %w[holder expires input model harness gates commit verdict reason question by expired].freeze
73
+
74
+ SEPARATOR = " "
75
+
76
+ # The field key character class, shared by the emitter (#render_fields, below) and
77
+ # the parser (FIELD_TOKEN_RE, further down): a key outside this class is a key the
78
+ # parser can never read back as a pair, so it must never be emitted (post-execution
79
+ # review row 7.7). One source of truth for the char class keeps the two families
80
+ # from drifting the way they did before this row's fix.
81
+ FIELD_KEY_CHARS = "[a-z_]+"
82
+ FIELD_KEY_RE = /\A#{FIELD_KEY_CHARS}\z/.freeze
83
+
84
+ # timestamp<SEP>subject<SEP>state field=value... (comment). Two-space
85
+ # separators keep the line a three-field line under both `split(/\s{2,}/)`
86
+ # and every reader's own `SAVEPOINT_RE` (spec "Line shape"). Raises
87
+ # ArgumentError, writing nothing, for an unknown state or a missing required
88
+ # field (D4/D9): the caller of #append_transition relies on this check
89
+ # running BEFORE the file is ever touched.
90
+ def transition_line(subject:, state:, fields: {}, comment: nil, now: Time.now)
91
+ state = state.to_s
92
+ raise ArgumentError, "unknown transition state: #{state.inspect}" unless STATES.include?(state)
93
+
94
+ missing = missing_fields(state, fields)
95
+ raise ArgumentError, "state #{state} requires #{missing.join(', ')}" if missing.any?
96
+
97
+ timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
98
+ rest = ([state] + render_fields(fields)).join(" ")
99
+ rest = "#{rest} (#{normalize_value(comment)})" if comment && !comment.to_s.strip.empty?
100
+ "#{timestamp}#{SEPARATOR}#{subject}#{SEPARATOR}#{rest}\n"
101
+ end
102
+
103
+ # Collapse every run of two-or-more WHITESPACE characters in `text` to one
104
+ # (spec "Line shape": "collapses every whitespace run inside a field value
105
+ # to a single space"), so a caller-supplied value or comment can never
106
+ # reintroduce a two-or-more run the line's own separators rely on being
107
+ # unique to field boundaries. A tab or a newline is refused outright (raises
108
+ # ArgumentError) rather than collapsed: silently eating either would hide a
109
+ # value that could otherwise tear a ledger line. Post-execution review row
110
+ # 7.6: a bare `/ {2,}/` collapsed only literal spaces, so a carriage return,
111
+ # vertical tab or form feed beside a space still reached field 3 as a
112
+ # two-whitespace run that `split(/\s{2,}/)` reads as four fields while
113
+ # SAVEPOINT_RE reads as three - `\s{2,}` is what the acceptance criterion
114
+ # ("no run of two or more whitespace characters ever reaches field 3")
115
+ # actually requires.
116
+ def normalize_value(text)
117
+ value = text.to_s
118
+ raise ArgumentError, "value must not contain a tab or a newline: #{value.inspect}" if value =~ /[\t\n]/
119
+
120
+ value.gsub(/\s{2,}/, " ")
121
+ end
122
+
123
+ # A required-field D4 check independent of vocabulary validity: given a state
124
+ # (assumed valid) and a fields hash, the list of required field names still
125
+ # missing. `done`'s "at least one of commit=/verdict=" half renders as the
126
+ # literal string "commit or verdict" when both are absent.
127
+ def missing_fields(state, fields)
128
+ normalized = stringify_keys(fields)
129
+ required = REQUIRED_FIELDS.fetch(state.to_s, [])
130
+ missing = required.reject { |key| present?(normalized[key]) }
131
+ if state.to_s == "done" && DONE_EVIDENCE_FIELDS.none? { |key| present?(normalized[key]) }
132
+ missing += ["commit or verdict"]
133
+ end
134
+ missing
135
+ end
136
+
137
+ def present?(value)
138
+ !(value.nil? || value.to_s.strip.empty?)
139
+ end
140
+ private_class_method :present?
141
+
142
+ def stringify_keys(fields)
143
+ (fields || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
144
+ end
145
+ private_class_method :stringify_keys
146
+
147
+ def render_fields(fields)
148
+ normalized = stringify_keys(fields)
149
+ unreadable = normalized.keys.reject { |key| key.match?(FIELD_KEY_RE) }
150
+ if unreadable.any?
151
+ raise ArgumentError, "field key(s) the parser cannot read back: #{unreadable.join(', ')}"
152
+ end
153
+
154
+ known = FIELD_ORDER.select { |key| normalized.key?(key) }
155
+ extra = (normalized.keys - FIELD_ORDER).sort
156
+ (known + extra).map { |key| "#{key}=#{render_value(normalized[key])}" }
157
+ end
158
+ private_class_method :render_fields
159
+
160
+ def render_value(value)
161
+ text = normalize_value(value)
162
+ return text if bare_safe?(text)
163
+
164
+ escaped = text.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }
165
+ "\"#{escaped}\""
166
+ end
167
+ private_class_method :render_value
168
+
169
+ def bare_safe?(text)
170
+ !text.empty? && !text.match?(/[\s"\\]/)
171
+ end
172
+ private_class_method :bare_safe?
173
+
174
+ # --- Parsing ---------------------------------------------------------------
175
+
176
+ # timestamp<2sp>subject<2sp>rest, mirroring intent_screen.rb's SAVEPOINT_RE
177
+ # (the same three-field contract every reader in the tree already assumes).
178
+ TRANSITION_LINE_RE = /\A(\S+)#{SEPARATOR}(\S+)#{SEPARATOR}(.+?)\s*\z/.freeze
179
+ private_constant :TRANSITION_LINE_RE
180
+
181
+ FIELD_TOKEN_RE = /(#{FIELD_KEY_CHARS})=("(?:[^"\\]|\\.)*"|[^\s"]+)/.freeze
182
+ private_constant :FIELD_TOKEN_RE
183
+
184
+ # Parse one raw ledger line into {timestamp:, subject:, state:, fields:,
185
+ # comment:, raw:}, or nil when it does not even split into the three fields
186
+ # every transition line has. `#scrub`s the line first (mirrors
187
+ # SessionLedger.parse_checklist_line) so a stray non-UTF-8 byte anywhere in
188
+ # the ledger never raises out of the parser and takes down every reader
189
+ # (matrix 2.44); it only ever affects that one line's parsed text.
190
+ def parse_transition_line(line)
191
+ raw = line.to_s.chomp.scrub
192
+ m = TRANSITION_LINE_RE.match(raw)
193
+ return nil unless m
194
+
195
+ timestamp, subject, rest = m.captures
196
+ state, remainder = rest.split(/\s+/, 2)
197
+ fields, comment = scan_fields(remainder.to_s)
198
+ fields = NodeInputCompatibility.fields(fields)
199
+ { timestamp: timestamp, subject: subject, state: state, fields: fields, comment: comment, raw: raw }
200
+ end
201
+
202
+ # Scan `rest` (everything after the state token) for `key=value` pairs, quote-
203
+ # and-escape aware, greedily from the start; the first token that is not a
204
+ # pair, and everything after it, is the trailing comment (spec D8), with one
205
+ # layer of wrapping parens stripped when present (the shape #transition_line
206
+ # itself always emits). Returns [fields_hash, comment_or_nil].
207
+ def scan_fields(rest)
208
+ scanner = StringScanner.new(rest)
209
+ fields = {}
210
+ loop do
211
+ scanner.skip(/\s+/)
212
+ break if scanner.eos?
213
+
214
+ start_pos = scanner.pos
215
+ if scanner.scan(FIELD_TOKEN_RE)
216
+ fields[scanner[1]] = unquote(scanner[2])
217
+ else
218
+ scanner.pos = start_pos
219
+ break
220
+ end
221
+ end
222
+ tail = scanner.rest.to_s.strip
223
+ comment = tail.empty? ? nil : tail.sub(/\A\((.*)\)\z/m, '\1')
224
+ [fields, comment]
225
+ end
226
+ private_class_method :scan_fields
227
+
228
+ def unquote(raw)
229
+ return raw unless raw.start_with?('"')
230
+
231
+ raw[1..-2].gsub(/\\(.)/) { Regexp.last_match(1) }
232
+ end
233
+ private_class_method :unquote
234
+
235
+ # A transition line is torn (spec D9) when it does not parse into the three
236
+ # fields at all, when its state token is outside STATES, or when its state is
237
+ # in STATES but a field REQUIRED_FIELDS demands is missing. The second half
238
+ # matters more than the first: a crash-truncated write usually keeps a valid
239
+ # state token and loses the tail (`n1 running holder=auto-ce5`), which is
240
+ # the realistic torn line, not a truncated state token.
241
+ def torn?(line)
242
+ parsed = parse_transition_line(line)
243
+ return true unless parsed
244
+ return true unless parsed[:state] && STATES.include?(parsed[:state])
245
+
246
+ missing_fields(parsed[:state], parsed[:fields]).any?
247
+ end
248
+
249
+ def torn_reason(parsed)
250
+ return "does not parse as timestamp subject state ..." unless parsed && parsed[:state]
251
+ return "unknown state #{parsed[:state].inspect}" unless STATES.include?(parsed[:state])
252
+
253
+ "missing required field(s): #{missing_fields(parsed[:state], parsed[:fields]).join(', ')}"
254
+ end
255
+ private_class_method :torn_reason
256
+
257
+ # A transition line is attributed (spec D10) iff it carries a non-blank
258
+ # `holder=` field. Unattributed and torn are independent: a line can be
259
+ # unattributed and otherwise well formed at the same time.
260
+ def attributed?(parsed)
261
+ return false unless parsed
262
+
263
+ present?((parsed[:fields] || {})["holder"])
264
+ end
265
+
266
+ def resolved_state(state)
267
+ RESOLUTION.fetch(state.to_s, state.to_s)
268
+ end
269
+
270
+ # --- Reading the ledger ------------------------------------------------------
271
+
272
+ # Every transition candidate line in `path`, in file order, each annotated
273
+ # with its parse, torn-ness, and attribution. Non-candidate lines (a stage
274
+ # line, a Lock takeover line) are excluded entirely: they are never a
275
+ # transition and are never reported as torn by this module (spec Acceptance
276
+ # Criteria; Savepoint's own phantom detector covers the stage family).
277
+ # `#scrub`s the whole file up front (matrix 2.44) so one bad byte anywhere
278
+ # never raises out of this reader.
279
+ def entries(path)
280
+ return [] unless path && File.exist?(path)
281
+
282
+ entries_from_content(File.read(path))
283
+ end
284
+
285
+ # Same as #entries, but over an in-memory string rather than a path (post-
286
+ # execution review row 7.1/7.2): the readiness decision `node-transition`
287
+ # makes for `running` must be evaluated against the exact content
288
+ # GuardedAppend read under its lock hold, never a re-read of the path (which
289
+ # could observe a different file than the one the guard is holding closed
290
+ # against other writers, and reintroduces the check-then-append gap this row
291
+ # exists to close).
292
+ def entries_from_content(content)
293
+ content.to_s.scrub.each_line.filter_map do |raw|
294
+ line = raw.chomp
295
+ next nil if line.strip.empty?
296
+ next nil unless Savepoint.transition_candidate?(line)
297
+
298
+ parsed = parse_transition_line(line)
299
+ torn = torn?(line)
300
+ {
301
+ raw: line,
302
+ timestamp: parsed && parsed[:timestamp],
303
+ subject: parsed ? parsed[:subject] : line.split(/\s{2,}/)[1],
304
+ state: parsed && parsed[:state],
305
+ fields: parsed ? parsed[:fields] : {},
306
+ comment: parsed && parsed[:comment],
307
+ torn: torn,
308
+ attributed: parsed ? attributed?(parsed) : false,
309
+ }
310
+ end
311
+ end
312
+
313
+ # Status per subject: the last NON-TORN line for that subject in FILE order
314
+ # (never by timestamp, spec Acceptance Criteria), resolved (spec D2). An
315
+ # unattributed-but-well-formed line still counts for status (D10: status
316
+ # shows it; only the readiness check ignores it).
317
+ def status(path)
318
+ status_from_content(path && File.exist?(path) ? File.read(path) : "")
319
+ end
320
+
321
+ # Content-based counterpart to #status (see #entries_from_content).
322
+ def status_from_content(content)
323
+ entries_from_content(content).each_with_object({}) do |entry, memo|
324
+ next if entry[:torn]
325
+
326
+ memo[entry[:subject]] = resolved_state(entry[:state])
327
+ end
328
+ end
329
+
330
+ # A subject with no line at all reads as "planned" (spec D1's implicit
331
+ # starting state), never raises.
332
+ def status_for(path, subject)
333
+ status(path).fetch(subject.to_s, "planned")
334
+ end
335
+
336
+ # Content-based counterpart to #status_for (see #entries_from_content).
337
+ def status_for_content(content, subject)
338
+ status_from_content(content).fetch(subject.to_s, "planned")
339
+ end
340
+
341
+ # The last (file-order) non-torn `running` entry for `subject`, or nil. Used
342
+ # by the reclaim precondition (S3) to find the `expires=` this subject's live
343
+ # dispatch carries.
344
+ def last_running(path, subject)
345
+ entries(path).select { |e| !e[:torn] && e[:subject] == subject.to_s && e[:state] == "running" }.last
346
+ end
347
+
348
+ # Torn and unattributed lines, each paired with a reason (spec C2/C5's
349
+ # reader): [{line:, reason:}, ...]. A clean or absent ledger returns []. A
350
+ # torn line is reported once, as torn; a well-formed unattributed line is
351
+ # reported once, as unattributed - the two categories never conflate.
352
+ def anomalies(path)
353
+ entries(path).filter_map do |entry|
354
+ if entry[:torn]
355
+ { line: entry[:raw], reason: "torn: #{torn_reason(parse_transition_line(entry[:raw]))}" }
356
+ elsif !entry[:attributed]
357
+ { line: entry[:raw], reason: "unattributed (no holder=)" }
358
+ end
359
+ end
360
+ end
361
+
362
+ # --- Writing -----------------------------------------------------------------
363
+
364
+ # Refuse an unknown state or a missing required field BEFORE touching the
365
+ # file (spec C20), then append through `guard` (default GuardedAppend,
366
+ # strict), skipping dedup entirely (spec D11: transition lines never consult
367
+ # savepoint_recorded_pairs). Returns whatever `guard.call` returns
368
+ # (:written or :refused); propagates GuardedAppend::Unavailable rather than
369
+ # swallowing it (a caller must never believe a line landed when it did not).
370
+ #
371
+ # `precondition:` (post-execution review rows 7.1-7.3) is an optional
372
+ # callable evaluated INSIDE the guard's lock hold, against the exact
373
+ # `content` the guard just read - never a value captured before the call.
374
+ # When it returns falsy, the block returns nil (GuardedAppend's own
375
+ # refusal contract) and nothing is written; the caller sees :refused,
376
+ # distinguishable from :written, and must not believe the line landed. This
377
+ # is what makes "is the subject still ready" and "append running" atomic
378
+ # against a second writer: a check followed by a separate append never is.
379
+ def append_transition(path, subject:, state:, fields: {}, comment: nil, now: Time.now, guard: GuardedAppend,
380
+ precondition: nil)
381
+ line = transition_line(subject: subject, state: state, fields: fields, comment: comment, now: now)
382
+ guard.call(path, strict: true) do |content|
383
+ precondition && !precondition.call(content) ? nil : line
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,153 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "node_ledger"
7
+ require_relative "intent_screen"
8
+
9
+ # NodeProgress (intent 337a, n1): the graph-era progress reader every report
10
+ # screen's node count calls instead of counting checklist.md items. A
11
+ # graph-era intent (graph.md AND nodes/ both present, D4) counts declared
12
+ # nodes - GraphFile.parse's ## Graph section, via GraphEdges - against
13
+ # NodeLedger.status's last-non-torn-line-per-subject state; a checklist-era
14
+ # intent gets nil, so its screen keeps counting checklist.md exactly as
15
+ # before. No second parser: every number here comes from GraphFile,
16
+ # GraphEdges or NodeLedger, never a hand-rolled scan.
17
+ #
18
+ # Pure: an explicit intent_dir and store_root: in, a fields hash or nil out;
19
+ # no ENV, no Dir.pwd, no raise across the boundary (a malformed record
20
+ # answers nil or zeroed fields, matrix row 1.16).
21
+ module NodeProgress
22
+ module_function
23
+
24
+ # Same keys IntentScreen.progress_fields returns, plus "progress.unit".
25
+ # nil for a pre-graph intent (D4), so the caller keeps counting
26
+ # checklist.md.
27
+ def fields(intent_dir, store_root: nil)
28
+ return nil unless graph_era?(intent_dir)
29
+
30
+ nodes = declared_nodes(File.join(intent_dir, "graph.md"))
31
+ return nil if nodes.nil? || nodes.empty?
32
+
33
+ status = NodeLedger.status(File.join(intent_dir, "savepoint.md"))
34
+ superseded = nodes.count { |n| status[n] == "superseded" }
35
+ abandoned = nodes.count { |n| status[n] == "abandoned" }
36
+ remaining = nodes.reject { |n| status[n] == "superseded" || status[n] == "abandoned" }
37
+ done = remaining.count { |n| status[n] == "done" }
38
+ running = remaining.count { |n| status[n] == "running" }
39
+ total = remaining.length
40
+
41
+ inferred = done.zero? && total.positive? && delivered?(intent_dir, store_root)
42
+ done = total if inferred
43
+
44
+ {
45
+ "progress.bar" => bar_for(done, total),
46
+ "progress.done" => done.to_s,
47
+ "progress.total" => total.to_s,
48
+ "progress.note" => note_for(done, total, running, inferred, superseded, abandoned),
49
+ "progress.unit" => "nodes",
50
+ }
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ # D4: graph-era means graph.md AND nodes/ both exist.
56
+ def graph_era?(intent_dir)
57
+ File.exist?(File.join(intent_dir, "graph.md")) && Dir.exist?(File.join(intent_dir, "nodes"))
58
+ end
59
+
60
+ # Declared node ids from the ## Graph section (GraphFile/GraphEdges), never
61
+ # from Dir over nodes/. nil for a missing ## Graph section or one that
62
+ # declares no nodes (matrix row 1.16).
63
+ def declared_nodes(graph_md_path)
64
+ parsed = GraphFile.parse(graph_md_path)
65
+ graph = parsed && parsed[:graph]
66
+ return nil unless graph
67
+
68
+ ids = graph[:nodes]
69
+ ids.nil? || ids.empty? ? nil : ids
70
+ end
71
+ private_class_method :declared_nodes
72
+
73
+ def bar_for(done, total)
74
+ width = IntentScreen::BAR_WIDTH
75
+ on = total.zero? ? 0 : (done * width) / total
76
+ (IntentScreen::ON * on) + (IntentScreen::OFF * (width - on))
77
+ end
78
+ private_class_method :bar_for
79
+
80
+ # D8's four exact note strings, plus a superseded/abandoned suffix (n6, B1):
81
+ # a node whose resolved state is superseded or abandoned leaves the total
82
+ # and the numerator, and the note names it, superseded first, appended as
83
+ # ", K superseded" and/or ", J abandoned". Unchanged when there are none of
84
+ # either. The inferred case never carries a suffix: it counts the already-
85
+ # reduced total, so the note stays exactly "inferred: delivered before the
86
+ # node ledger".
87
+ def note_for(done, total, running, inferred, superseded, abandoned)
88
+ return "inferred: delivered before the node ledger" if inferred
89
+
90
+ base =
91
+ if total.zero?
92
+ "no nodes counted"
93
+ else
94
+ open = total - done
95
+ if open.zero?
96
+ "all nodes done"
97
+ elsif running.positive?
98
+ "#{open} nodes open, #{running} running"
99
+ else
100
+ "#{open} nodes open"
101
+ end
102
+ end
103
+
104
+ suffix = []
105
+ suffix << "#{superseded} superseded" if superseded.positive?
106
+ suffix << "#{abandoned} abandoned" if abandoned.positive?
107
+ suffix.empty? ? base : "#{base}, #{suffix.join(', ')}"
108
+ end
109
+ private_class_method :note_for
110
+
111
+ # Delivered, proven twice (the inferred fallback's precondition):
112
+ # outcome.md on disk plus either an INDEX ## Completed line (store_root:
113
+ # given) or a Done savepoint line (store_root: nil, the roadmap-entries
114
+ # path that pays for no INDEX read per entry).
115
+ def delivered?(intent_dir, store_root)
116
+ return false unless File.exist?(File.join(intent_dir, "outcome.md"))
117
+
118
+ store_root ? index_completed?(store_root, intent_id(intent_dir)) : done_savepoint_line?(intent_dir)
119
+ end
120
+ private_class_method :delivered?
121
+
122
+ def intent_id(intent_dir)
123
+ File.basename(intent_dir).split("--", 2).first
124
+ end
125
+ private_class_method :intent_id
126
+
127
+ def index_completed?(store_root, id)
128
+ path = File.join(store_root, "INDEX.md")
129
+ return false unless File.exist?(path)
130
+
131
+ content = File.read(path).scrub
132
+ section = content[/^## Completed\n(.*?)(?=^## |\z)/m, 1]
133
+ return false unless section
134
+
135
+ section.each_line.any? { |line| line.strip.match?(/\A-\s*\[#{Regexp.escape(id)}(?=[\s\]])/) }
136
+ end
137
+ private_class_method :index_completed?
138
+
139
+ # Only a Done line whose text begins with "delivered" proves delivered (n6,
140
+ # B3): end-intent writes "Done delivered" or "Done abandoned"
141
+ # (scripts/end-intent, Savepoint.append_terminal_savepoint), and only the
142
+ # first disposition is delivery.
143
+ def done_savepoint_line?(intent_dir)
144
+ path = File.join(intent_dir, "savepoint.md")
145
+ return false unless File.exist?(path)
146
+
147
+ File.read(path).scrub.each_line.any? do |line|
148
+ m = line.strip.match(IntentScreen::SAVEPOINT_RE)
149
+ m && m[2] == "Done" && m[3].to_s.start_with?("delivered")
150
+ end
151
+ end
152
+ private_class_method :done_savepoint_line?
153
+ end