@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. package/templates/roadmap.md +13 -0
@@ -0,0 +1,231 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "fileutils"
6
+ require_relative "worktree"
7
+ require_relative "lock"
8
+ require_relative "node_ledger"
9
+ require_relative "ready_set"
10
+ require_relative "savepoint"
11
+
12
+ # RunnerSweep (intent 340, G7, n2): the first thing every `step` does. Two
13
+ # separately callable entry points - #abort_if_merging and #reclaim - plus
14
+ # #run, which composes them for a caller that has no absorb step to interleave.
15
+ # `step` (a later node) calls #abort_if_merging, runs absorb, then calls
16
+ # #reclaim itself, so a reclaimed node's just-landed work from absorb is never
17
+ # thrown away by a stale read (matrix row 2.20).
18
+ #
19
+ # #abort_if_merging refuses the whole step when the intent worktree already
20
+ # has a merge in progress (`MERGE_HEAD` resolves): dispatching on top of a
21
+ # half-finished merge would hand the next node a diff full of someone else's
22
+ # conflict markers. Nothing is written when this fires - not the ledger, not
23
+ # graph.md, not even the delivery lease heartbeat (row 2.2, row 2.16: the
24
+ # heartbeat runs strictly after the abort check).
25
+ #
26
+ # #reclaim walks every node whose CURRENT status (the ledger's own resolution,
27
+ # never a raw scan) is `running`, skipping anything named in `skip:` (the
28
+ # nodes this step already absorbed, row 2.19) or already terminal (a `done`
29
+ # node is never touched, row 2.12 - it simply never shows up as `running`).
30
+ # An expired lease with no commits on the node's own branch newer than its
31
+ # expiry is reclaimed outright. An expired lease whose branch DOES carry
32
+ # newer commits is extended instead, up to twice per attempt (row 2.7); the
33
+ # extension is never a ledger transition (`running` cannot re-enter `running`
34
+ # under the transition layer), so it is one line appended to
35
+ # packets/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
36
+ # count (row 2.18), never trusted from the caller. A third expiry reclaims
37
+ # regardless of new commits.
38
+ #
39
+ # Pure and dependency-injected: every git call goes through an injected
40
+ # `runner:` (default Worktree::ShellRunner), never cwd; the delivery-lease
41
+ # heartbeat goes through an injected `heartbeat:` (default Lock.heartbeat) so
42
+ # ordering (row 2.16) is provable without a real lock file. No eval, no
43
+ # ENV/global-constant seam.
44
+ module RunnerSweep
45
+ module_function
46
+
47
+ # D-ish: at most two extensions per attempt (row 2.7); the third expiry
48
+ # reclaims regardless of new commits.
49
+ MAX_EXTENSIONS_PER_ATTEMPT = 2
50
+
51
+ # abort_if_merging(context, runner:) -> {ok:, error:, recovery_command:}.
52
+ # `context.worktree` is nil for a global-store-only intent (no git repo to
53
+ # merge into); that case is always ok - there is nothing to abort.
54
+ def abort_if_merging(context, runner: Worktree::ShellRunner.new)
55
+ worktree = context&.worktree
56
+ return { ok: true, error: nil, recovery_command: nil } if blank?(worktree)
57
+
58
+ res = runner.run("-C", worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD")
59
+ return { ok: true, error: nil, recovery_command: nil } unless res.success?
60
+
61
+ recovery_command = "git -C #{worktree} merge --abort"
62
+ warn "runner: a merge is already in progress in #{worktree}; run `#{recovery_command}` " \
63
+ "before the next step can dispatch"
64
+ { ok: false, error: "a merge is in progress in #{worktree}", recovery_command: recovery_command }
65
+ end
66
+
67
+ # reclaim(context, runner:, skip:, now:) -> {reclaimed: [{node:, holder:,
68
+ # expired:}], extended: [{node:, head:, time:}]}. Reads the ledger fresh on
69
+ # every call (no cache), which is what makes calling it AFTER absorb (row
70
+ # 2.20) actually see absorb's own just-landed work rather than a stale
71
+ # snapshot taken before it.
72
+ def reclaim(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now)
73
+ intent_dir = context.intent_dir
74
+ content = savepoint_content(intent_dir)
75
+ entries = NodeLedger.entries_from_content(content)
76
+ status_map = NodeLedger.status_from_content(content)
77
+ skip_set = Array(skip).map(&:to_s)
78
+
79
+ reclaimed = []
80
+ extended = []
81
+
82
+ status_map.each do |subject, state|
83
+ next unless state == "running"
84
+ next unless subject.match?(Savepoint::NODE_SUBJECT_RE)
85
+ next if skip_set.include?(subject)
86
+
87
+ last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
88
+ next unless last
89
+
90
+ fields = last[:fields] || {}
91
+ expires_raw = fields["expires"]
92
+ expires_at = parse_time(expires_raw)
93
+ next unless expires_at
94
+ next if now < expires_at # row 2.5: an unexpired lease is left alone
95
+
96
+ holder = fields["holder"]
97
+ branch = node_branch(context, subject)
98
+ head_sha, head_time = branch_head(runner, context.worktree, branch)
99
+ has_new_commits = head_time && head_time > expires_at
100
+
101
+ if has_new_commits
102
+ attempt = current_attempt(entries, subject)
103
+ count = extension_count(intent_dir, subject, attempt)
104
+ if count < MAX_EXTENSIONS_PER_ATTEMPT
105
+ record_extension(intent_dir, subject, attempt, head_sha, now)
106
+ extended << { node: subject, head: head_sha, time: now.utc.iso8601 }
107
+ next
108
+ end
109
+ # row 2.7: the cap is spent - fall through and reclaim anyway.
110
+ end
111
+
112
+ NodeLedger.append_transition(
113
+ savepoint_path(intent_dir),
114
+ subject: subject,
115
+ state: "reclaimed",
116
+ fields: { holder: holder, expired: expires_raw },
117
+ now: now
118
+ )
119
+ reclaimed << { node: subject, holder: holder, expired: expires_raw }
120
+ end
121
+
122
+ { reclaimed: reclaimed, extended: extended }
123
+ end
124
+
125
+ # run(context, runner:, skip:, now:, heartbeat:) -> the composed report a
126
+ # caller with no absorb step to interleave uses directly. Order is fixed on
127
+ # purpose (row 2.2, row 2.16): abort check, THEN the lease heartbeat, THEN
128
+ # reclaim - never the reverse, and never a write of any kind before the
129
+ # abort check has cleared.
130
+ def run(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now, heartbeat: Lock.method(:heartbeat))
131
+ abort_result = abort_if_merging(context, runner: runner)
132
+ unless abort_result[:ok]
133
+ return {
134
+ ok: false, aborted: true, error: abort_result[:error],
135
+ recovery_command: abort_result[:recovery_command], reclaimed: [], extended: [],
136
+ }
137
+ end
138
+
139
+ session = context&.session
140
+ heartbeat.call(context.intent_dir, session: session, now: now) unless blank?(session)
141
+
142
+ result = reclaim(context, runner: runner, skip: skip, now: now)
143
+ {
144
+ ok: true, aborted: false, error: nil, recovery_command: nil,
145
+ reclaimed: result[:reclaimed], extended: result[:extended],
146
+ }
147
+ end
148
+
149
+ # --- internals ---------------------------------------------------------------
150
+
151
+ def blank?(value)
152
+ value.nil? || value.to_s.strip.empty?
153
+ end
154
+ private_class_method :blank?
155
+
156
+ def savepoint_path(intent_dir)
157
+ File.join(intent_dir.to_s, "savepoint.md")
158
+ end
159
+ private_class_method :savepoint_path
160
+
161
+ def savepoint_content(intent_dir)
162
+ path = savepoint_path(intent_dir)
163
+ File.exist?(path) ? File.read(path) : ""
164
+ end
165
+ private_class_method :savepoint_content
166
+
167
+ # The node's own worktree branch (n3's naming: `plastic/<id>--<slug>--<node>`),
168
+ # derived from the intent id/slug alone - never through NodeWorktree, which
169
+ # this node does not depend on.
170
+ def node_branch(context, node)
171
+ "plastic/#{context.intent_id}--#{context.intent_slug}--#{node}"
172
+ end
173
+ private_class_method :node_branch
174
+
175
+ # [head_sha, committer_time] for `branch` in `worktree`'s repo, or [nil, nil]
176
+ # when the worktree is gone, the branch does not exist, or anything else
177
+ # about the git call fails (row 2.13: never raise).
178
+ def branch_head(runner, worktree, branch)
179
+ return [nil, nil] if blank?(worktree) || blank?(branch)
180
+
181
+ res = runner.run("-C", worktree, "log", "-1", "--format=%H%x1f%cI", branch)
182
+ return [nil, nil] unless res.success?
183
+
184
+ sha, iso = res.stdout.to_s.strip.split("\x1f")
185
+ [sha, parse_time(iso)]
186
+ rescue StandardError
187
+ [nil, nil]
188
+ end
189
+ private_class_method :branch_head
190
+
191
+ def parse_time(raw)
192
+ return nil if blank?(raw)
193
+
194
+ Time.iso8601(raw.to_s)
195
+ rescue ArgumentError
196
+ nil
197
+ end
198
+ private_class_method :parse_time
199
+
200
+ # Row 2.18: the attempt number comes from the ledger's own count of
201
+ # `running` lines since the subject's last terminal line - the exact same
202
+ # arithmetic NodePacket uses to name that attempt's packet file, so the
203
+ # extensions file for a `running` line always matches the packet it extends.
204
+ def current_attempt(entries, subject)
205
+ ReadySet.attempts_count(entries, subject)
206
+ end
207
+ private_class_method :current_attempt
208
+
209
+ def extensions_path(intent_dir, node, attempt)
210
+ File.join(intent_dir.to_s, "packets", "#{node}--a#{attempt}.extensions")
211
+ end
212
+ private_class_method :extensions_path
213
+
214
+ # Row 2.8: counted from the attempt-scoped file alone, so a prior attempt's
215
+ # extensions never count against a fresh dispatch.
216
+ def extension_count(intent_dir, node, attempt)
217
+ path = extensions_path(intent_dir, node, attempt)
218
+ return 0 unless File.exist?(path)
219
+
220
+ File.read(path).each_line.count { |l| !l.strip.empty? }
221
+ end
222
+ private_class_method :extension_count
223
+
224
+ # Row 2.9: the observed head sha and the time, one line, append-only.
225
+ def record_extension(intent_dir, node, attempt, head_sha, now)
226
+ path = extensions_path(intent_dir, node, attempt)
227
+ FileUtils.mkdir_p(File.dirname(path))
228
+ File.open(path, "a") { |f| f.write("#{now.utc.iso8601} head=#{head_sha}\n") }
229
+ end
230
+ private_class_method :record_extension
231
+ end
@@ -23,6 +23,36 @@ module Savepoint
23
23
  # (<id>--<slug>.md) is never sentineled; it is born complete.
24
24
  PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
25
25
 
26
+ # --- Node-graph transition subject vocabulary (intent 335, spec D17) -------
27
+ #
28
+ # Owned HERE, not on NodeLedger, because test/savepoint_split_test.rb:57 pins
29
+ # savepoint.rb to loading no other project file: NodeLedger requires this
30
+ # file and reuses these three names rather than duplicating them, so the
31
+ # dependency runs one way only. Intent 334 (G1) mints node ids and must
32
+ # agree with NODE_SUBJECT_RE: it is the single seam for the node id shape.
33
+
34
+ # The literal subject token for an intent-scope transition line ("Intent
35
+ # needs_decision question=..."), as opposed to a node-scope line.
36
+ INTENT_SUBJECT = "Intent"
37
+
38
+ # A node id: one or two lowercase letters (the node's kind prefix, e.g. "n"
39
+ # for work, "v" for verify) followed by digits.
40
+ NODE_SUBJECT_RE = /\A[a-z]{1,2}\d+\z/.freeze
41
+
42
+ # True iff a raw savepoint ledger line's subject (field 2, split on
43
+ # /\s{2,}/) is a transition candidate: the literal Intent token or a node
44
+ # id. A stage line ("How checklist.md created") or a Lock takeover audit
45
+ # line never matches, by construction (spec Acceptance Criteria: "no stage
46
+ # token this tree writes collides with Intent or with the node id
47
+ # pattern").
48
+ def self.transition_candidate?(line)
49
+ parts = line.to_s.split(/\s{2,}/)
50
+ return false unless parts.length >= 2
51
+
52
+ subject = parts[1]
53
+ subject == INTENT_SUBJECT || subject.match?(NODE_SUBJECT_RE)
54
+ end
55
+
26
56
  def self.intent_file(intent_dir)
27
57
  dir_name = File.basename(intent_dir)
28
58
  "#{intent_dir}/#{dir_name}.md"
@@ -57,22 +87,47 @@ module Savepoint
57
87
  File.exist?(path)
58
88
  end
59
89
 
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|
90
+ # True iff DIR_NAME (actions/ or nodes/) holds AT LEAST ONE real *.md file: non-empty,
91
+ # first line not the placeholder sentinel. A `.gitkeep` (no .md extension) never counts.
92
+ # Pure and side-effect-free; fail-open (a missing dir globs to nothing, never raises).
93
+ def self.has_real_files_in?(dir_name, intent_dir)
94
+ Dir.glob("#{intent_dir}/#{dir_name}/*.md").any? do |f|
68
95
  File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
69
96
  end
70
97
  rescue StandardError
71
98
  false
72
99
  end
73
100
 
101
+ # True iff the intent has at least one real action file, whether delivered as
102
+ # legacy actions/*.md or as a node graph's nodes/*.md (intent 334, G1, D10r):
103
+ # an intent delivered as nodes is exactly as real as one delivered as
104
+ # actions, so doctor and the exec-stage gate never report a backfill gap on
105
+ # a fully delivered node-graph intent. Checks actions/ first (the common
106
+ # path today), falling through to nodes/ only when actions/ has nothing.
107
+ def self.has_real_action?(intent_dir)
108
+ has_real_files_in?("actions", intent_dir) || has_real_files_in?("nodes", intent_dir)
109
+ rescue StandardError
110
+ false
111
+ end
112
+
113
+ # Intent 336 (G3, D13): a real graph.md is 327 D41's replacement for
114
+ # plan.md/checklist.md on the common path, so a node-shaped intent is
115
+ # judged on graph.md and nodes/ alone, whether or not spec.md is real - the
116
+ # graph IS the spec under D41. Checked using only this file's own
117
+ # primitives (stage_file_present?, has_real_files_in?), because
118
+ # test/savepoint_split_test.rb pins this file to loading no other project
119
+ # file and no YAML: GraphFile/NodeFile/ReadySet are never required here. An
120
+ # intent with no real graph.md (or a sentinel-placeholder one,
121
+ # stage_file_present? already reads that as absent) derives exactly what it
122
+ # derived before this intent.
74
123
  def self.derive_stage(intent_dir)
75
124
  return "done" if stage_file_present?("#{intent_dir}/outcome.md")
125
+
126
+ if stage_file_present?("#{intent_dir}/graph.md")
127
+ return "exec" if has_real_files_in?("nodes", intent_dir)
128
+ return "how"
129
+ end
130
+
76
131
  if stage_file_present?("#{intent_dir}/plan.md") &&
77
132
  has_real_action?(intent_dir) &&
78
133
  stage_file_present?("#{intent_dir}/checklist.md")
@@ -87,19 +142,49 @@ module Savepoint
87
142
  files = []
88
143
  ifile = File.basename(intent_file(intent_dir))
89
144
  files << ifile if File.exist?("#{intent_dir}/#{ifile}")
90
- ["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
145
+ ["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
91
146
  files << f if stage_file_present?("#{intent_dir}/#{f}")
92
147
  end
93
- files << "actions/" if has_real_action?(intent_dir)
148
+ # Name the directory that actually exists (fold B3): a nodes-only intent
149
+ # must never claim the literal "actions/" artifact it does not have.
150
+ # Checks actions/ first, matching D15r's read order.
151
+ if has_real_files_in?("actions", intent_dir)
152
+ files << "actions/"
153
+ elsif has_real_files_in?("nodes", intent_dir)
154
+ files << "nodes/"
155
+ end
94
156
  files
95
157
  end
96
158
 
97
159
  def self.missing_for_stage(stage, intent_dir = nil)
98
160
  ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
161
+ # A How-stage intent that already started a nodes/ directory is named
162
+ # accordingly, so the next-step hint never tells a node-graph intent to
163
+ # go make an actions/ directory it will never use (fold B3). Mirrors
164
+ # has_real_files_in?'s actions-first order and its real-file requirement
165
+ # (post-execution review, non-blocking 4): an intent carrying real files
166
+ # in both directories, or a real actions/ file beside an empty or
167
+ # .gitkeep-only nodes/, is named actions/, never nodes/.
168
+ action_label = if intent_dir && has_real_files_in?("actions", intent_dir)
169
+ "actions/"
170
+ elsif intent_dir && has_real_files_in?("nodes", intent_dir)
171
+ "nodes/"
172
+ else
173
+ "actions/"
174
+ end
99
175
  case stage
100
176
  when "what" then [ifile]
101
177
  when "why" then ["spec.md"]
102
- when "how" then ["plan.md", "actions/", "checklist.md"]
178
+ when "how"
179
+ # A node-shaped How intent (a real graph.md already) is named after its
180
+ # own two artifacts, never the three D41 removed from its path (fold
181
+ # B3, extended by 336 D13): checked via stage_file_present? alone, the
182
+ # same primitive derive_stage itself uses.
183
+ if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
184
+ ["graph.md", "nodes/"]
185
+ else
186
+ ["plan.md", action_label, "checklist.md"]
187
+ end
103
188
  when "exec" then ["outcome.md"]
104
189
  else []
105
190
  end
@@ -213,6 +298,32 @@ module Savepoint
213
298
  append_savepoint_line(intent_dir, "Exec", "started", now)
214
299
  end
215
300
 
301
+ # Append a `Review` line: one per plan-review or post-execution-review verdict
302
+ # (intent 317, D5/D17). Same shape as every other line, through the shared
303
+ # append_savepoint_line primitive, so dedup and the timestamp format never
304
+ # drift from the one line-writer every other kind already uses.
305
+ def self.append_review_savepoint(intent_dir, text, now: Time.now)
306
+ append_savepoint_line(intent_dir, "Review", text, now)
307
+ end
308
+
309
+ # Append a `Commit` line: one per commit landing during Exec (intent 317,
310
+ # D5/D17). Same primitive as append_review_savepoint above.
311
+ def self.append_commit_savepoint(intent_dir, text, now: Time.now)
312
+ append_savepoint_line(intent_dir, "Commit", text, now)
313
+ end
314
+
315
+ # The day the Report kind (below) shipped. `doctor`'s intent_reports_printed_check reads
316
+ # this so it never re-litigates a ledger recorded before the kind existed (intent 331f, R6).
317
+ REPORT_KIND_SINCE = "2026-09-05"
318
+
319
+ # Append a `Report` line: one per report screen printed (intent 331f, D2/D3). Same primitive
320
+ # as append_review_savepoint/append_commit_savepoint above, so the line shape, dedup, and
321
+ # timestamp format never drift. `savepoint_milestone` maps FILENAMES only, so a Report line
322
+ # is never mistaken for a file-landing lifecycle line by construction.
323
+ def self.append_report_savepoint(intent_dir, text, now: Time.now)
324
+ append_savepoint_line(intent_dir, "Report", text, now)
325
+ end
326
+
216
327
  TERMINAL_DISPOSITIONS = %w[delivered abandoned].freeze
217
328
 
218
329
  # Append the terminal bookend `Done delivered|abandoned`, written by the
@@ -232,6 +343,17 @@ module Savepoint
232
343
  # A Plastic 1.x ledger may carry a `Tier <value>` line after the spec.md
233
344
  # milestone (removed in 2.0, intent 304); a rebuild drops it, and the phantom
234
345
  # detector ignores it, so a 1.x store reads clean.
346
+ #
347
+ # Every transition line (intent 335, spec D13) is preserved VERBATIM, in its
348
+ # original relative order, after the reconstructed stage skeleton. It is
349
+ # never dropped and never refused: a transition line's evidence fields
350
+ # (`holder=`, `expires=`, `gates=`, ...) have no file-mtime analog to
351
+ # reconstruct from, so refusing instead of preserving would make this method
352
+ # destroy the graph's only status on every intent that carries one. Relative
353
+ # order BETWEEN a stage line and a transition line is not preserved (safe:
354
+ # status is computed per subject, and the two families share no subject);
355
+ # relative order WITHIN the transition lines is preserved, which is what
356
+ # "last line per subject in file order" depends on.
235
357
  def self.rebuild_savepoint(intent_dir)
236
358
  ordered = [
237
359
  File.basename(intent_file(intent_dir)),
@@ -245,7 +367,18 @@ module Savepoint
245
367
  stamp = File.mtime(path).utc.iso8601
246
368
  ["#{stamp} #{stage} #{milestone}\n"]
247
369
  end
248
- File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
370
+
371
+ savepoint_path = File.join(intent_dir, SAVEPOINT_FILE)
372
+ if File.exist?(savepoint_path)
373
+ # #scrub before scanning (post-execution review row 7.8), the same way
374
+ # NodeLedger.entries does (matrix 2.44): a stray non-UTF-8 byte anywhere
375
+ # in the ledger must not raise out of the one repair tool three doctor
376
+ # fix hints and maintenance-run --tool rebuild-savepoint point at.
377
+ transition_lines = File.read(savepoint_path).scrub.each_line.select { |raw| transition_candidate?(raw) }
378
+ lines += transition_lines.map { |raw| raw.end_with?("\n") ? raw : "#{raw}\n" }
379
+ end
380
+
381
+ File.write(savepoint_path, lines.join)
249
382
  lines.length
250
383
  end
251
384
 
@@ -294,6 +427,10 @@ module Savepoint
294
427
  File.read(path).each_line do |raw|
295
428
  line = raw.strip
296
429
  next if line.empty?
430
+ # A transition line (intent 335) is never a stage phantom candidate: its
431
+ # own repeated-line semantics (dedup-free by design, spec D11) are
432
+ # NodeLedger's concern, not this detector's.
433
+ next if transition_candidate?(line)
297
434
  parts = line.split(/\s{2,}/)
298
435
  next if parts.length < 3
299
436
  pair = [parts[1], parts[2]]