@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,210 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "ready_set"
7
+ require_relative "roadmap_savepoint"
8
+
9
+ # RoadmapGraph (intent 337, n1): reads one roadmap file into the same shape
10
+ # the node scope already uses - entries with their INDEX-reconciled status,
11
+ # the edge map from "## Graph", the cycle path when one exists, the
12
+ # topological batches, the critical paths, and the ready set. Edges are
13
+ # parsed by GraphEdges; batches, critical paths, and downstream hops come
14
+ # from ReadySet (327 D1, C1: the one parser, the one topological sort - a
15
+ # local copy of either here is exactly the drift 327 forbids). This module
16
+ # writes no file and reads no clock or environment variable (row 1.14);
17
+ # every fact is either a value passed in or read once from the two paths
18
+ # `analyze` is given.
19
+ module RoadmapGraph
20
+ module_function
21
+
22
+ ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+(.*?)[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
23
+ WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
24
+
25
+ INDEX_TAGS = { "Completed" => "delivered", "Abandoned" => "abandoned", "Active" => :active, "Future" => "queued" }.freeze
26
+
27
+ # analyze(roadmap_path, index_path:) -> a Result hash. Never raises across
28
+ # the boundary: a missing ## Graph section, a missing grouping heading, a
29
+ # cyclic graph, or an invalid byte in either file all become a named
30
+ # `reason` or an empty result, never an exception (rows 1.8, 1.9, 1.13).
31
+ def analyze(roadmap_path, index_path: nil)
32
+ text = read_utf8(roadmap_path)
33
+
34
+ graph_section = GraphFile.section_body(text, "## Graph")
35
+ unless graph_section
36
+ return result(has_graph: false, reason: "no ## Graph section in #{roadmap_path}")
37
+ end
38
+
39
+ parsed_edges = GraphEdges.parse(GraphFile.strip_fenced_blocks(graph_section))
40
+
41
+ entries_by_id, order = begin
42
+ parse_entries(text, roadmap_path)
43
+ rescue RoadmapSavepoint::MissingGroupingHeading => e
44
+ return result(has_graph: true, reason: e.message)
45
+ end
46
+
47
+ index_map = load_index(index_path)
48
+ entries_by_id.each_value { |e| e[:status] = reconcile_status(index_map[e[:id]], e[:raw_status]) }
49
+
50
+ declared_and_targeted = parsed_edges[:nodes]
51
+ entry_ids = order.dup
52
+
53
+ # 1.6: an entry the graph never named (not declared, not targeted) is
54
+ # folded in as a root, so a partly migrated roadmap never silently
55
+ # drops real work from the batches.
56
+ edges = parsed_edges[:edges].dup
57
+ entry_ids.each do |id|
58
+ next if declared_and_targeted.include?(id)
59
+
60
+ edges[id] = []
61
+ entries_by_id[id][:in_graph] = false
62
+ end
63
+ entry_ids.each do |id|
64
+ entries_by_id[id][:in_graph] = true if declared_and_targeted.include?(id) && entries_by_id[id][:in_graph].nil?
65
+ end
66
+
67
+ # 1.7: a graph id with no batch entry is named in `dangling`, never
68
+ # invented as a fake entry with no title.
69
+ dangling = declared_and_targeted.reject { |id| entries_by_id.key?(id) }
70
+
71
+ cyc = GraphEdges.cycle(edges)
72
+ if cyc
73
+ return result(has_graph: true, entries: entries_by_id, edges: edges, dangling: dangling,
74
+ cycle: cyc, errors: parsed_edges[:errors])
75
+ end
76
+
77
+ batch_result = ReadySet.batches(edges)
78
+ batches = batch_result[:ok] ? order_batches(batch_result[:batches], order) : []
79
+ path_result = ReadySet.critical_paths(edges)
80
+
81
+ status_map = entries_by_id.each_with_object({}) { |(id, e), h| h[id] = e[:status] }
82
+ dead_ends = entry_ids.select do |id|
83
+ entries_by_id[id][:status] != "delivered" && ReadySet.dead_end?(id, edges, status_map)
84
+ end
85
+
86
+ ready = entry_ids.select do |id|
87
+ entries_by_id[id][:status] == "queued" &&
88
+ (edges[id] || []).all? { |t| status_map[t] == "delivered" }
89
+ end
90
+
91
+ result(
92
+ has_graph: true,
93
+ entries: entries_by_id,
94
+ edges: edges,
95
+ dangling: dangling,
96
+ cycle: nil,
97
+ batches: batches,
98
+ batches_error: batch_result[:ok] ? nil : batch_result[:error],
99
+ critical_paths: path_result[:ok] ? path_result : nil,
100
+ dead_ends: dead_ends,
101
+ ready: ready,
102
+ errors: parsed_edges[:errors]
103
+ )
104
+ end
105
+
106
+ # --- shared building blocks other callers (RoadmapQueue, n9) reuse --------
107
+
108
+ # The one place that knows how a roadmap's own "## Graph" section is read:
109
+ # an exact heading (GraphFile), fenced examples stripped, and an edgeless
110
+ # section treated the same as no section at all. Returns GraphEdges.parse's
111
+ # own Result hash, or nil.
112
+ def parse_graph_section(text)
113
+ section = GraphFile.section_body(text, "## Graph")
114
+ return nil if section.nil?
115
+
116
+ parsed = GraphEdges.parse(GraphFile.strip_fenced_blocks(section))
117
+ return nil if parsed[:nodes].empty?
118
+
119
+ parsed
120
+ end
121
+
122
+ # ReadySet.batches's own topological layers (the one sort, D1), reordered
123
+ # WITHIN each layer to match `order` - typically the roadmap file's own
124
+ # entry-encounter order (row 1.16/9.8). A computed batch names which layer
125
+ # an id is in; it must never silently become a ranking decision by
126
+ # defaulting to a lexical sort of ids that happen to share a layer.
127
+ def order_batches(batches, order)
128
+ rank = {}
129
+ order.each_with_index { |id, i| rank[id] = i }
130
+ batches.map { |layer| layer.sort_by { |id| [rank[id] || order.length, id] } }
131
+ end
132
+
133
+ # --- entries -----------------------------------------------------------------
134
+
135
+ def parse_entries(text, roadmap_path)
136
+ body = RoadmapSavepoint.grouping_section_body(text, path: roadmap_path)
137
+ entries = {}
138
+ order = []
139
+ body.each_line do |line|
140
+ stripped = line.chomp.strip
141
+ m = stripped.match(ENTRY)
142
+ next unless m
143
+
144
+ id = m[2]
145
+ next if entries.key?(id)
146
+
147
+ entries[id] = { id: id, title: m[3].strip, raw_status: m[4].downcase, in_graph: nil }
148
+ order << id
149
+ end
150
+ [entries, order]
151
+ end
152
+
153
+ def reconcile_status(tag, raw_status)
154
+ case tag
155
+ when "delivered" then "delivered"
156
+ when "abandoned" then "abandoned"
157
+ when "queued" then "queued"
158
+ when :active then raw_status == "delivered" ? "delivering" : raw_status
159
+ else raw_status
160
+ end
161
+ end
162
+
163
+ def load_index(index_path)
164
+ map = {}
165
+ return map unless index_path && File.exist?(index_path)
166
+
167
+ text = read_utf8(index_path)
168
+ INDEX_TAGS.each do |heading, tag|
169
+ section_body(text, heading).each_line do |line|
170
+ stripped = line.strip
171
+ next unless stripped.start_with?("- [")
172
+
173
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
174
+ map[m[1]] = tag if m
175
+ end
176
+ end
177
+ map
178
+ end
179
+
180
+ def section_body(text, heading)
181
+ m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
182
+ m ? m[1] : ""
183
+ end
184
+
185
+ # 1.13: an invalid byte in either file is scrubbed, never raised across
186
+ # the boundary.
187
+ def read_utf8(path)
188
+ text = File.read(path)
189
+ text.force_encoding(Encoding::UTF_8)
190
+ text.valid_encoding? ? text : text.scrub("")
191
+ end
192
+
193
+ def result(has_graph:, reason: nil, entries: {}, edges: {}, dangling: [], cycle: nil,
194
+ batches: [], batches_error: nil, critical_paths: nil, dead_ends: [], ready: [], errors: [])
195
+ {
196
+ has_graph: has_graph,
197
+ reason: reason,
198
+ entries: entries,
199
+ edges: edges,
200
+ dangling: dangling,
201
+ cycle: cycle,
202
+ batches: batches,
203
+ batches_error: batches_error,
204
+ critical_paths: critical_paths,
205
+ dead_ends: dead_ends,
206
+ ready: ready,
207
+ errors: errors,
208
+ }
209
+ end
210
+ end
@@ -0,0 +1,95 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "roadmap_graph"
5
+ require_relative "roadmap_render"
6
+ require_relative "roadmap_savepoint"
7
+ require_relative "graph_edges"
8
+ require_relative "graph_file"
9
+ require_relative "atomic_write"
10
+
11
+ # RoadmapMigration (intent 337, n4): reads a graphless roadmap's existing
12
+ # batch order and returns the conservative edge set that preserves it -
13
+ # every entry of batch N needs every entry of batch N-1, batch 1 needs
14
+ # nothing. Never overwrites a roadmap that already carries a "## Graph" (or
15
+ # graph-like, row 4.15) heading: the owner's hand-edited edges are the only
16
+ # place a cross-intent edge lives, and a derived guess must never replace
17
+ # them. Block splitting and entry-id extraction are the same surgical
18
+ # parser RoadmapRender uses (n3) - never a second one.
19
+ module RoadmapMigration
20
+ module_function
21
+
22
+ GRAPH_HEADING_RE = /\A##\s+Graph\b/.freeze
23
+
24
+ # {ok:, skipped:, edges:, reason:}. Computes only; writes nothing.
25
+ def derive(path, index_path: nil)
26
+ text = File.read(path)
27
+
28
+ if text.each_line.any? { |l| l.strip.match?(GRAPH_HEADING_RE) }
29
+ return { ok: false, skipped: true, edges: {}, reason: "already carries a ## Graph (or graph-like) heading, left untouched" }
30
+ end
31
+
32
+ begin
33
+ body = RoadmapSavepoint.grouping_section_body(text, path: path)
34
+ rescue RoadmapSavepoint::MissingGroupingHeading => e
35
+ return { ok: false, skipped: false, edges: {}, reason: e.message }
36
+ end
37
+
38
+ _preamble, blocks = RoadmapRender.split_into_blocks(body)
39
+ batch_ids = blocks.map { |b| b[:lines].filter_map { |l| RoadmapRender.entry_id(l) } }
40
+
41
+ entries_by_id = entry_statuses(text, path, index_path)
42
+
43
+ edges = {}
44
+ batch_ids.each_with_index do |ids, i|
45
+ prev_ids = i.zero? ? [] : batch_ids[i - 1].reject { |id| abandoned?(entries_by_id, id) }
46
+ # An id repeated across two batches (row 4.16) must never need itself:
47
+ # exclude it from its own prev_ids before assigning.
48
+ ids.each { |id| edges[id] = prev_ids.reject { |p| p == id } }
49
+ end
50
+
51
+ cyc = GraphEdges.cycle(edges)
52
+ return { ok: false, skipped: false, edges: {}, reason: "derived graph is cyclic: #{cyc.join(' > ')}" } if cyc
53
+
54
+ { ok: true, skipped: false, edges: edges, reason: nil }
55
+ end
56
+
57
+ # derive + render "## Graph" through AtomicWrite. A skip (row 4.3) or a
58
+ # refusal (cyclic, row 4.5) writes nothing and reports why.
59
+ def write(path, renamer: File.method(:rename), dry_run: false, index_path: nil)
60
+ result = derive(path, index_path: index_path)
61
+ return { ok: result[:ok], written: false, content: nil, skipped: result[:skipped], reason: result[:reason] } unless result[:ok]
62
+
63
+ graph_text = render_edges(result[:edges])
64
+ original = File.read(path)
65
+ content = GraphFile.replace_or_append_section(original, "## Graph", graph_text)
66
+
67
+ if dry_run
68
+ { ok: true, written: false, content: content, skipped: false, reason: nil }
69
+ else
70
+ AtomicWrite.write(path, content, renamer: renamer)
71
+ { ok: true, written: true, content: content, skipped: false, reason: nil }
72
+ end
73
+ end
74
+
75
+ def render_edges(edges)
76
+ lines = edges.map do |id, needs|
77
+ "- #{id} needs #{needs.empty? ? 'nothing' : needs.join(' ')}\n"
78
+ end
79
+ lines.join
80
+ end
81
+
82
+ def entry_statuses(text, path, index_path)
83
+ entries_by_id, = RoadmapGraph.parse_entries(text, path)
84
+ resolved_index = index_path || RoadmapRender.default_index_path(path)
85
+ index_map = RoadmapGraph.load_index(resolved_index)
86
+ entries_by_id.each_value { |e| e[:status] = RoadmapGraph.reconcile_status(index_map[e[:id]], e[:raw_status]) }
87
+ entries_by_id
88
+ rescue RoadmapSavepoint::MissingGroupingHeading
89
+ {}
90
+ end
91
+
92
+ def abandoned?(entries_by_id, id)
93
+ entries_by_id[id] && entries_by_id[id][:status] == "abandoned"
94
+ end
95
+ end
@@ -4,6 +4,10 @@
4
4
  require "time"
5
5
  require "json"
6
6
  require_relative "roadmap_savepoint"
7
+ require_relative "graph_file"
8
+ require_relative "graph_edges"
9
+ require_relative "ready_set"
10
+ require_relative "roadmap_graph"
7
11
 
8
12
  # FileOrderRanker - the default value-ordering strategy: today's roadmap file order,
9
13
  # unchanged. This is the intent-173 ranking-swap seam (sibling to the 147 DB-swap seam): a
@@ -34,7 +38,9 @@ class RoadmapQueue
34
38
  # Entry line parser, anchored on the status vocabulary rather than end of line, so a trailing
35
39
  # parenthetical ("delivering (owner ruling...)") does not defeat the match. Accepts the em
36
40
  # dash or a hyphen as the separator; roadmap .md files are store-internal and use the em dash.
37
- ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+.*?[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
41
+ # Intent 331c: group 3 captures the entry's own title text (between the id and the status
42
+ # separator), so a screen reader has it without a second parser; group 4 (was 3) is the status.
43
+ ENTRY = /\A-\s*\[([ xX])\]\s+(\S+)\s+(.*?)[—-]\s*(queued|delivering|delivered|abandoned|blocked)\b/.freeze
38
44
 
39
45
  WAVE_HEADING = /\A###\s+(.+?)\s*\z/.freeze
40
46
 
@@ -57,6 +63,21 @@ class RoadmapQueue
57
63
  analyze(mode: "which")
58
64
  end
59
65
 
66
+ # Intent 331c (D6/R1): the one public reader for ONE roadmap's parsed, INDEX-reconciled shape,
67
+ # so a screen never carries a second parser that can drift from this class's own grammar. Same
68
+ # reconciliation (`reconcile`) and the same frontier selection (`frontier_for`, R17) `queue`/
69
+ # `which` use for the whole tier, scoped to the single file at `path`.
70
+ def roadmap(path)
71
+ parsed = reconcile([parse_roadmap(path)]).first
72
+ {
73
+ slug: parsed[:slug],
74
+ path: parsed[:path],
75
+ grouping: RoadmapSavepoint.grouping_heading(File.read(path)),
76
+ batches: parsed[:waves],
77
+ frontier: frontier_for(parsed),
78
+ }
79
+ end
80
+
60
81
  private
61
82
 
62
83
  def analyze(mode:)
@@ -81,6 +102,15 @@ class RoadmapQueue
81
102
 
82
103
  winner = ranked.first
83
104
  is_tie = tied.length > 1
105
+
106
+ cyc = winner[:graph_edges] && GraphEdges.cycle(winner[:graph_edges][:edges])
107
+ if cyc
108
+ return payload(mode: mode, state: "error", roadmap: winner[:slug],
109
+ frontier_wave: "cyclic roadmap graph, cannot compute a frontier: #{cyc.join(' > ')}",
110
+ dispatchable: [], in_flight: [], blocked: blocked_for(winner),
111
+ tie: is_tie, tie_candidates: [])
112
+ end
113
+
84
114
  frontier = frontier_for(winner)
85
115
 
86
116
  state =
@@ -112,7 +142,18 @@ class RoadmapQueue
112
142
 
113
143
  def parse_roadmap(path)
114
144
  text = File.read(path)
115
- { slug: File.basename(path, ".md"), path: path, waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)) }
145
+ {
146
+ slug: File.basename(path, ".md"), path: path,
147
+ waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)),
148
+ # D15: an exact "## Graph" heading line only, never a prefix - a live
149
+ # roadmap can carry "## Graph (2026-09-01, superseded by ...)". A
150
+ # section that yields no real edge lines is treated the same as no
151
+ # section at all: the fallback to wave order, both silent (D15).
152
+ # Fence-aware (D18). Intent 337 n9: reads through the shared model
153
+ # (RoadmapGraph.parse_graph_section) rather than a second local
154
+ # parser, so this heading/fence/emptiness rule is made in one place.
155
+ graph_edges: RoadmapGraph.parse_graph_section(text),
156
+ }
116
157
  end
117
158
 
118
159
  def parse_waves(waves_body)
@@ -124,7 +165,7 @@ class RoadmapQueue
124
165
  current = { heading: m[1], entries: [] }
125
166
  waves << current
126
167
  elsif current && (em = stripped.match(ENTRY))
127
- current[:entries] << { id: em[2], raw_status: em[3].downcase }
168
+ current[:entries] << { id: em[2], text: em[3].strip, raw_status: em[4].downcase }
128
169
  end
129
170
  end
130
171
  waves
@@ -224,9 +265,17 @@ class RoadmapQueue
224
265
  Time.utc(y, mo, d, h, mi, 0)
225
266
  end
226
267
 
227
- # --- frontier + dispatchable selection (D-b) ----------------------------------
268
+ # --- frontier + dispatchable selection (D-b, D15) -------------------------------
228
269
 
270
+ # D15: a candidate with a real ## Graph section dispatches by its edges
271
+ # (a delivered entry counts as done); one without, or whose graph section
272
+ # yields no edges (parse_roadmap_graph already returns nil for that case),
273
+ # keeps the wave-order behavior unchanged.
229
274
  def frontier_for(candidate)
275
+ candidate[:graph_edges] ? graph_frontier_for(candidate) : wave_frontier_for(candidate)
276
+ end
277
+
278
+ def wave_frontier_for(candidate)
230
279
  candidate[:waves].each do |wave|
231
280
  statuses = wave[:entries].map { |e| e[:status] }
232
281
  next unless statuses.any? { |s| %w[queued delivering].include?(s) }
@@ -250,12 +299,113 @@ class RoadmapQueue
250
299
  nil
251
300
  end
252
301
 
302
+ # D15's edge-driven frontier: the topological layers of the graph's edges,
303
+ # walked in order; the first layer holding a dispatchable (all needs
304
+ # delivered) or in-flight entry is the frontier. An id the graph names but
305
+ # no batch lists is reported in `blocked` (finding 2, R-2), never a crash
306
+ # and never an invented dispatchable entry with no title. An id a batch
307
+ # lists that the graph does not name is never dropped either: it keeps
308
+ # the pre-change wave behavior (the G4 partial-migration path) by being
309
+ # folded in as needing nothing (never called on a cyclic graph: #analyze
310
+ # checks that first and reports "error" instead).
311
+ def graph_frontier_for(candidate)
312
+ edges = candidate[:graph_edges][:edges].dup
313
+ id_to_entry = {}
314
+ wave_of_id = {}
315
+ candidate[:waves].each do |wave|
316
+ wave[:entries].each do |e|
317
+ id_to_entry[e[:id]] = e
318
+ wave_of_id[e[:id]] = wave[:heading]
319
+ end
320
+ end
321
+
322
+ (id_to_entry.keys - all_graph_nodes(edges)).each { |id| edges[id] = [] }
323
+
324
+ # Intent 337 n9: the one topological sort (327 D1) is ReadySet.batches;
325
+ # RoadmapGraph.order_batches only reorders WITHIN a layer to match the
326
+ # roadmap file's own entry order (row 9.8), never a second sort.
327
+ batch_result = ReadySet.batches(edges)
328
+ layers = batch_result[:ok] ? RoadmapGraph.order_batches(batch_result[:batches], id_to_entry.keys) : []
329
+
330
+ layers.each do |layer|
331
+ layer_ids = layer.select { |id| id_to_entry.key?(id) }
332
+ next if layer_ids.empty?
333
+
334
+ queued_ready = layer_ids.select do |id|
335
+ entry = id_to_entry[id]
336
+ entry[:status] == "queued" &&
337
+ (edges[id] || []).all? { |t| id_to_entry[t] && id_to_entry[t][:status] == "delivered" }
338
+ end
339
+ delivering_ids = layer_ids.select { |id| id_to_entry[id][:status] == "delivering" }
340
+ next if queued_ready.empty? && delivering_ids.empty?
341
+
342
+ ordered = @ranker.rank(queued_ready.map { |id| id_to_entry[id] })
343
+ dispatchable = ordered.each_with_index.map do |e, i|
344
+ { "id" => e[:id], "scope" => scope_label, "roadmap" => candidate[:slug],
345
+ "wave" => wave_of_id[e[:id]], "status" => "queued", "rank" => i + 1 }
346
+ end
347
+ in_flight = delivering_ids.map do |id|
348
+ { "id" => id, "roadmap" => candidate[:slug], "wave" => wave_of_id[id], "status" => "delivering" }
349
+ end
350
+
351
+ heading = dispatchable.first ? dispatchable.first["wave"] : in_flight.first["wave"]
352
+ return { heading: heading, dispatchable: dispatchable, in_flight: in_flight }
353
+ end
354
+ nil
355
+ end
356
+
253
357
  def blocked_for(candidate)
254
- candidate[:waves].flat_map do |wave|
358
+ explicit = candidate[:waves].flat_map do |wave|
255
359
  wave[:entries].select { |e| e[:status] == "blocked" }.map do |e|
256
360
  { "id" => e[:id], "roadmap" => candidate[:slug], "wave" => wave[:heading], "status" => "blocked" }
257
361
  end
258
362
  end
363
+ explicit + graph_reporting_blocked(candidate)
364
+ end
365
+
366
+ # R-2 (finding 2): a graph naming an id no batch lists is reported, not
367
+ # swallowed - both the unnamed id itself, and any batch entry whose own
368
+ # declared need points straight at it, which would otherwise sit queued
369
+ # forever with nothing in the payload explaining why. Keeps the invariant
370
+ # that `state` is never "exhausted" while a queued entry sits unaccounted
371
+ # for: an entry that can never resolve still shows up here.
372
+ def graph_reporting_blocked(candidate)
373
+ return [] unless candidate[:graph_edges]
374
+
375
+ edges = candidate[:graph_edges][:edges]
376
+ id_to_entry = candidate[:waves].flat_map { |w| w[:entries] }.each_with_object({}) { |e, h| h[e[:id]] = e }
377
+ unreported = all_graph_nodes(edges).reject { |id| id_to_entry.key?(id) }
378
+
379
+ reported = unreported.map do |id|
380
+ { "id" => id, "roadmap" => candidate[:slug], "wave" => nil, "status" => "unreported",
381
+ "reason" => "graph names #{id.inspect}, no batch entry" }
382
+ end
383
+
384
+ stuck = []
385
+ id_to_entry.each do |id, entry|
386
+ next unless entry[:status] == "queued"
387
+
388
+ (edges[id] || []).each do |target|
389
+ next unless unreported.include?(target)
390
+
391
+ stuck << { "id" => id, "roadmap" => candidate[:slug], "wave" => wave_heading_for(candidate, id),
392
+ "status" => "unreported",
393
+ "reason" => "#{id} needs #{target}, which the graph declares but no batch lists" }
394
+ end
395
+ end
396
+
397
+ reported + stuck
398
+ end
399
+
400
+ def wave_heading_for(candidate, id)
401
+ candidate[:waves].each { |w| return w[:heading] if w[:entries].any? { |e| e[:id] == id } }
402
+ nil
403
+ end
404
+
405
+ def all_graph_nodes(edges)
406
+ nodes = edges.keys.dup
407
+ edges.each_value { |targets| (targets || []).each { |t| nodes << t unless nodes.include?(t) } }
408
+ nodes
259
409
  end
260
410
 
261
411
  # --- scope + payload -----------------------------------------------------------
@@ -0,0 +1,150 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "roadmap_graph"
5
+ require_relative "roadmap_savepoint"
6
+ require_relative "graph_file"
7
+ require_relative "graph_tree"
8
+ require_relative "atomic_write"
9
+
10
+ # RoadmapRender (intent 337, n3): renders "## Tree" and the roadmap's own
11
+ # grouping section (## Batches, or legacy ## Waves, owner ruling 145 -
12
+ # never renamed) from the RoadmapGraph model and writes both back through
13
+ # AtomicWrite, replacing exactly those two sections and leaving every other
14
+ # byte of the file alone.
15
+ #
16
+ # The regroup is surgical, never a rebuild from parsed fields (327's own
17
+ # lesson, folded at the 2026-09-10 plan review, rows 3.2/3.13/3.14): only
18
+ # entry lines RoadmapGraph itself recognized (the canonical checkbox +
19
+ # status grammar) ever move. A line the grammar cannot parse, and any prose
20
+ # a human wrote between batch headings, is untouched OUTPUT text - it is
21
+ # never even looked at, so it can never be lost. An id that already sits in
22
+ # its computed batch's heading block is never rewritten; only an id that
23
+ # needs to move is removed from its old block and appended, verbatim, to
24
+ # its new one. This is what makes a no-op render byte-identical (row 3.9)
25
+ # and a genuinely moved entry keep its own exact text (row 3.2).
26
+ module RoadmapRender
27
+ module_function
28
+
29
+ TREE_WIDTH = 100
30
+
31
+ def write(path, renamer: File.method(:rename), dry_run: false, index_path: nil)
32
+ original = File.read(path)
33
+ resolved_index = index_path || default_index_path(path)
34
+
35
+ analysis = RoadmapGraph.analyze(path, index_path: resolved_index)
36
+ return refusal(analysis[:reason]) if analysis[:reason]
37
+ return refusal("cyclic graph, refusing to render: #{analysis[:cycle].join(' > ')}") if analysis[:cycle]
38
+
39
+ tree_result = render_tree(analysis)
40
+ return refusal(tree_result[:error]) unless tree_result[:ok]
41
+
42
+ content = GraphFile.replace_or_append_section(original, "## Tree", tree_result[:text])
43
+
44
+ heading = RoadmapSavepoint.grouping_heading(original)
45
+ return refusal("neither ## Batches nor ## Waves grouping heading found") unless heading
46
+
47
+ heading_text = "## #{heading}"
48
+ original_body = RoadmapSavepoint.grouping_section_body(original, path: path)
49
+ regrouped = regroup(original_body, analysis)
50
+ content = GraphFile.replace_or_append_section(content, heading_text, regrouped)
51
+
52
+ if dry_run
53
+ { ok: true, content: content, written: false, error: nil }
54
+ else
55
+ AtomicWrite.write(path, content, renamer: renamer)
56
+ { ok: true, content: content, written: true, error: nil }
57
+ end
58
+ end
59
+
60
+ def refusal(reason)
61
+ { ok: false, content: nil, written: false, error: reason }
62
+ end
63
+
64
+ def default_index_path(roadmap_path)
65
+ File.join(File.dirname(File.dirname(roadmap_path)), "INDEX.md")
66
+ end
67
+
68
+ def render_tree(analysis)
69
+ labels = analysis[:entries].each_with_object({}) { |(id, e), h| h[id] = e[:title] }
70
+ marks = {
71
+ critical_path: analysis[:critical_paths] ? (analysis[:critical_paths][:critical_path] || []) : [],
72
+ ready: analysis[:ready] || [],
73
+ }
74
+ GraphTree.render(edges: analysis[:edges], labels: labels, marks: marks, width: TREE_WIDTH)
75
+ end
76
+
77
+ # --- the surgical regroup ------------------------------------------------------
78
+
79
+ ENTRY_ID_RE = /\A-\s*\[([ xX])\]\s+(\S+)\b/.freeze
80
+ HEADING_RE = /\A###\s+/.freeze
81
+
82
+ def regroup(body, analysis)
83
+ preamble, blocks = split_into_blocks(body)
84
+
85
+ real_batches = (analysis[:batches] || []).map { |layer| layer & analysis[:entries].keys }
86
+ original_line_for = build_original_line_index(blocks)
87
+
88
+ total = [blocks.length, real_batches.length].max
89
+ rendered_blocks = (0...total).map do |i|
90
+ block = blocks[i]
91
+ computed_ids = real_batches[i] || []
92
+
93
+ existing_ids = block ? block[:lines].filter_map { |l| entry_id(l) } : []
94
+ removed = existing_ids - computed_ids
95
+ added = computed_ids - existing_ids
96
+
97
+ heading_line = block ? block[:heading] : "### Batch #{i + 1}\n"
98
+ lines = block ? block[:lines].reject { |l| removed.include?(entry_id(l)) } : []
99
+ lines += added.filter_map { |id| original_line_for[id] }
100
+
101
+ heading_line + lines.join
102
+ end
103
+
104
+ # GraphFile.replace_or_append_section always adds its own single blank
105
+ # line before a non-empty tail; a body that already ends in blank lines
106
+ # (round-tripped from a previous render) would otherwise grow by one
107
+ # blank line on every render (row 3.9's byte-identical requirement).
108
+ (preamble.join + rendered_blocks.join).rstrip + "\n"
109
+ end
110
+
111
+ def entry_id(line)
112
+ m = line.match(ENTRY_ID_RE)
113
+ m && m[2]
114
+ end
115
+
116
+ def build_original_line_index(blocks)
117
+ index = {}
118
+ blocks.each do |block|
119
+ block[:lines].each do |line|
120
+ id = entry_id(line)
121
+ index[id] ||= line if id
122
+ end
123
+ end
124
+ index
125
+ end
126
+
127
+ # [preamble_lines, blocks] - blocks is [{heading:, lines: []}, ...] in file
128
+ # order. Every original byte is accounted for: it lives either in the
129
+ # preamble (before the first "### " heading) or in exactly one block's
130
+ # heading/lines.
131
+ def split_into_blocks(body)
132
+ preamble = []
133
+ blocks = []
134
+ current = nil
135
+
136
+ body.each_line do |line|
137
+ if line.match?(HEADING_RE)
138
+ blocks << current if current
139
+ current = { heading: line, lines: [] }
140
+ elsif current
141
+ current[:lines] << line
142
+ else
143
+ preamble << line
144
+ end
145
+ end
146
+ blocks << current if current
147
+
148
+ [preamble, blocks]
149
+ end
150
+ end