@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20
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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- 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
|
-
|
|
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
|
-
{
|
|
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],
|
|
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
|