@zalom/plastic 2.0.0-alpha.19 → 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.
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # roadmap-graph - the CLI over the roadmap graph model (intent 337, G4):
6
+ # `check`, `render`, `migrate`, every verb honouring --dry-run.
7
+ #
8
+ # Usage:
9
+ # roadmap-graph check <roadmap.md> [--dry-run]
10
+ # roadmap-graph render <roadmap.md> [--dry-run]
11
+ # roadmap-graph migrate <roadmap.md> [--dry-run]
12
+ #
13
+ # Exit codes:
14
+ # 0 - printed / rendered / migrated cleanly (or a migrate skip: the
15
+ # roadmap already carries a graph)
16
+ # 1 - a real finding: a cyclic graph, a dangling id, or a render/migrate
17
+ # refusal (no ## Graph, no grouping heading)
18
+ # 2 - usage: no argument, an unknown verb, or a missing/non-file path
19
+
20
+ require_relative "lib/roadmap_graph"
21
+ require_relative "lib/roadmap_render"
22
+ require_relative "lib/roadmap_migration"
23
+
24
+ module RoadmapGraphCli
25
+ module_function
26
+
27
+ VERBS = %w[check render migrate].freeze
28
+
29
+ def usage
30
+ warn "Usage: roadmap-graph <#{VERBS.join('|')}> <roadmap.md> [--dry-run]"
31
+ end
32
+
33
+ def main(argv)
34
+ args = argv.dup
35
+ dry_run = !!args.delete("--dry-run")
36
+
37
+ verb = args.shift
38
+ unless verb && VERBS.include?(verb)
39
+ warn "roadmap-graph: unknown verb #{verb.inspect} (use #{VERBS.join(', ')})" if verb
40
+ usage
41
+ return 2
42
+ end
43
+
44
+ path = args.shift
45
+ unless path && File.file?(path)
46
+ warn "roadmap-graph: #{path.inspect} is not a file"
47
+ return 2
48
+ end
49
+
50
+ case verb
51
+ when "check" then check(path)
52
+ when "render" then render(path, dry_run)
53
+ when "migrate" then migrate(path, dry_run)
54
+ end
55
+ end
56
+
57
+ def check(path)
58
+ result = RoadmapGraph.analyze(path)
59
+ unless result[:has_graph]
60
+ warn "roadmap-graph check: #{result[:reason]}"
61
+ return 1
62
+ end
63
+ if result[:cycle]
64
+ warn "roadmap-graph check: cyclic graph: #{result[:cycle].join(' > ')}"
65
+ return 1
66
+ end
67
+ if result[:reason]
68
+ warn "roadmap-graph check: #{result[:reason]}"
69
+ return 1
70
+ end
71
+
72
+ puts "Batches: #{result[:batches].map { |b| b.join(', ') }.join(' | ')}"
73
+ puts "Ready: #{result[:ready].join(', ')}"
74
+ puts "Dead ends: #{result[:dead_ends].join(', ')}" unless result[:dead_ends].empty?
75
+
76
+ unless result[:dangling].empty?
77
+ warn "roadmap-graph check: graph names #{result[:dangling].map(&:inspect).join(', ')}, no batch entry"
78
+ return 1
79
+ end
80
+
81
+ 0
82
+ end
83
+
84
+ def render(path, dry_run)
85
+ result = RoadmapRender.write(path, dry_run: dry_run)
86
+ unless result[:ok]
87
+ warn "roadmap-graph render: #{result[:error]}"
88
+ return 1
89
+ end
90
+
91
+ if dry_run
92
+ puts result[:content]
93
+ else
94
+ puts "roadmap-graph render: wrote #{path}"
95
+ end
96
+ 0
97
+ end
98
+
99
+ def migrate(path, dry_run)
100
+ result = RoadmapMigration.write(path, dry_run: dry_run)
101
+ unless result[:ok]
102
+ if result[:skipped]
103
+ puts "roadmap-graph migrate: #{result[:reason]}"
104
+ return 0
105
+ end
106
+ warn "roadmap-graph migrate: #{result[:reason]}"
107
+ return 1
108
+ end
109
+
110
+ if dry_run
111
+ puts result[:content]
112
+ else
113
+ puts "roadmap-graph migrate: wrote #{path}"
114
+ end
115
+ 0
116
+ end
117
+ end
118
+
119
+ exit(RoadmapGraphCli.main(ARGV)) if $PROGRAM_NAME == __FILE__
package/scripts/runner ADDED
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ require_relative "lib/savepoint"
6
+ require_relative "lib/ready_set"
7
+ require_relative "lib/runner_core"
8
+
9
+ # runner - the one executable over the graph-ready loop's declared node graph
10
+ # (intent 340, G7, n1). A subcommand table: the public verbs (step, status,
11
+ # answer) are the only ones the skill body ever names; the internal verbs
12
+ # (ready, sweep, rewind) route and work so `step` can compose them and so
13
+ # each can be tested alone, but stay out of the usage text (327 spec).
14
+ #
15
+ # `step`, `sweep`, `answer` and `rewind` route to modules later nodes
16
+ # deliver (RunnerDispatch and RunnerAbsorb for `step`; RunnerSweep,
17
+ # RunnerAnswer, RunnerRewind for the rest). Each is required LAZILY, inside
18
+ # its own verb branch: a verb whose module has not landed yet reports
19
+ # plainly and exits nonzero, and every other verb - most importantly
20
+ # `status`, which an operator polls constantly across all five of this
21
+ # intent's dispatches - keeps working.
22
+ #
23
+ # Usage:
24
+ # runner <step|status|answer> <intent_dir> [--node ID] [--answer TEXT]
25
+ #
26
+ # Exit codes (matching the family scripts/node-transition and
27
+ # scripts/ready-set already use):
28
+ # 0 - success
29
+ # 1 - the verb ran but reports failure (an invalid graph for `ready`)
30
+ # 2 - usage: unknown verb, or not an intent directory
31
+ # 3 - a routed verb's module has not been delivered yet
32
+ module Runner
33
+ module_function
34
+
35
+ PUBLIC_VERBS = %w[step status answer].freeze
36
+ INTERNAL_VERBS = %w[ready sweep rewind].freeze
37
+ VERBS = (PUBLIC_VERBS + INTERNAL_VERBS).freeze
38
+
39
+ # verb -> [[ModuleName, lib_file], ...], required lazily inside the verb's
40
+ # own branch (row 1.6). `step` composes two modules (dispatching new work,
41
+ # then absorbing what a sub-agent reported back); every other routed verb
42
+ # names exactly one.
43
+ LAZY_MODULES = {
44
+ "step" => [["RunnerSweep", "runner_sweep"], ["RunnerAbsorb", "runner_absorb"],
45
+ ["RunnerDispatch", "runner_dispatch"]],
46
+ "sweep" => [["RunnerSweep", "runner_sweep"]],
47
+ "answer" => [["RunnerAnswer", "runner_answer"]],
48
+ "rewind" => [["RunnerRewind", "runner_rewind"]],
49
+ }.freeze
50
+
51
+ def usage
52
+ warn "Usage: runner <#{PUBLIC_VERBS.join('|')}> <intent_dir> [--node ID] [--answer TEXT]"
53
+ end
54
+
55
+ def intent_directory?(dir)
56
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
57
+ end
58
+
59
+ def opt(args, name)
60
+ (i = args.index(name)) && args[i + 1]
61
+ end
62
+
63
+ # opt_all(args, name) -> every value given for a repeated flag, in the order
64
+ # they appear, or [] when the flag never occurs (intent 340, G7, n8, row
65
+ # 8.1-8.3). `run_step` needs this, not `opt`, for `--return`: one step may
66
+ # carry more than one `--return NODE=PATH` pair (one per node the session
67
+ # is reporting back on), and `opt`'s single-value semantics would silently
68
+ # keep only the first, absorbing one node while stranding the other's
69
+ # lease until it expires.
70
+ def opt_all(args, name)
71
+ args.each_index.select { |i| args[i] == name }.map { |i| args[i + 1] }.compact
72
+ end
73
+
74
+ # KNOWN_FLAGS (minor 2): the exact flag vocabulary each verb accepts. A
75
+ # flag not in a verb's own list is refused rather than silently ignored -
76
+ # a mistyped `--session` used to vanish with no effect and the runner
77
+ # simply acted as whichever session `CLAUDE_CODE_SESSION_ID` names.
78
+ KNOWN_FLAGS = {
79
+ "step" => %w[--return --allow-core-drift],
80
+ "answer" => %w[--node --answer],
81
+ "rewind" => %w[--node --confirm],
82
+ }.freeze
83
+
84
+ # unrecognized_flag(verb, args) -> the first token in `args` that looks
85
+ # like a flag (`--...`) and is not in `verb`'s own KNOWN_FLAGS, or nil. A
86
+ # verb absent from KNOWN_FLAGS (status, ready, sweep) accepts none.
87
+ def unrecognized_flag(verb, args)
88
+ allowed = KNOWN_FLAGS.fetch(verb, [])
89
+ args.find { |a| a.is_a?(String) && a.start_with?("--") && !allowed.include?(a) }
90
+ end
91
+
92
+ def main(argv)
93
+ args = argv.dup
94
+ verb = args.shift
95
+
96
+ unless VERBS.include?(verb)
97
+ warn "runner: unknown verb #{verb.inspect}"
98
+ usage
99
+ exit 2
100
+ end
101
+
102
+ intent_dir_arg = args.shift
103
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
104
+ warn "runner: #{intent_dir_arg.inspect} is not an intent directory"
105
+ usage
106
+ exit 2
107
+ end
108
+ intent_dir = File.expand_path(intent_dir_arg)
109
+
110
+ bad_flag = unrecognized_flag(verb, args)
111
+ if bad_flag
112
+ warn "runner: unknown flag #{bad_flag.inspect} for #{verb}"
113
+ usage
114
+ exit 2
115
+ end
116
+
117
+ case verb
118
+ when "status"
119
+ run_status(intent_dir)
120
+ when "ready"
121
+ run_ready(intent_dir)
122
+ else
123
+ run_lazy(verb, intent_dir, args)
124
+ end
125
+ end
126
+
127
+ def run_status(intent_dir)
128
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
129
+ rows = RunnerCore.status(context)
130
+
131
+ if rows.empty?
132
+ puts "No declared nodes."
133
+ else
134
+ rows.each do |id, view|
135
+ puts "#{id} kind=#{view[:kind]} state=#{view[:state]} ready=#{view[:ready]}"
136
+ last = view[:last_transition]
137
+ puts " last: #{last[:raw]}" if last
138
+ end
139
+ end
140
+
141
+ complete = RunnerCore.complete?(context)
142
+ puts
143
+ puts complete ? "complete" : "stalled"
144
+ unless complete
145
+ rows.each do |id, view|
146
+ next if ReadySet::TERMINAL_STATES.include?(view[:state])
147
+
148
+ view[:blockers].each { |b| puts "#{id} blocked: #{b}" }
149
+ end
150
+ end
151
+
152
+ exit 0
153
+ end
154
+
155
+ def run_ready(intent_dir)
156
+ analysis = ReadySet.analyze(intent_dir)
157
+ unless analysis[:ok]
158
+ warn "runner: #{analysis[:errors].join('; ')}"
159
+ exit 1
160
+ end
161
+
162
+ if analysis[:ranked_ready].empty?
163
+ puts "Ready: (none)"
164
+ else
165
+ puts "Ready: #{analysis[:ranked_ready].map { |r| r[:id] }.join(', ')}"
166
+ end
167
+ exit 0
168
+ end
169
+
170
+ # Requires this verb's module(s) lazily, then hands off. Nothing in n1
171
+ # delivers runner_dispatch.rb, runner_absorb.rb, runner_sweep.rb,
172
+ # runner_answer.rb, or runner_rewind.rb, so every LAZY_MODULES require
173
+ # raises LoadError today and every routed verb reports "not yet
174
+ # delivered" - the only thing this node needs to prove (row 1.6). Once a
175
+ # later node ships its file, the require succeeds and falls through to
176
+ # dispatch_lazy, which that node replaces with the real call.
177
+ def run_lazy(verb, intent_dir, args)
178
+ # The require target below is a runtime value, never an inline quoted
179
+ # literal, so it falls outside test/install_packaging_test.rb's
180
+ # require-closure guard on purpose: every file LAZY_MODULES names is a
181
+ # genuine later-node deliverable, none of which core_files should track
182
+ # until that node actually lands it.
183
+ LAZY_MODULES.fetch(verb, []).each { |(_name, file)| require_relative(File.join("lib", file)) }
184
+ rescue LoadError
185
+ warn "runner: #{verb} is not yet delivered (its module has not landed)"
186
+ exit 3
187
+ else
188
+ dispatch_lazy(verb, intent_dir, args)
189
+ end
190
+
191
+ def dispatch_lazy(verb, intent_dir, args)
192
+ case verb
193
+ when "step"
194
+ run_step(intent_dir, args)
195
+ when "sweep"
196
+ run_sweep(intent_dir, args)
197
+ when "answer"
198
+ run_answer(intent_dir, args)
199
+ when "rewind"
200
+ run_rewind(intent_dir, args)
201
+ else
202
+ warn "runner: #{verb}'s module loaded but no dispatcher is wired up yet"
203
+ exit 3
204
+ end
205
+ end
206
+
207
+ # run_sweep (intent 340, G7, n8): the standalone `sweep` verb, unreachable
208
+ # from the CLI until this node - `dispatch_lazy` had no `when "sweep"` arm
209
+ # even though `RunnerSweep.run` (n2) is a complete, documented standalone
210
+ # entry point, exactly the shape a bare `runner sweep` call needs (abort
211
+ # check, THEN the lease heartbeat, THEN reclaim - RunnerSweep's own fixed
212
+ # order). `step` never calls this method: it composes `abort_if_merging`
213
+ # and `reclaim` itself, interleaved around absorb.
214
+ def run_sweep(intent_dir, args)
215
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
216
+ result = RunnerSweep.run(context)
217
+
218
+ unless result[:ok]
219
+ warn "runner: #{result[:error]}"
220
+ warn "runner: run `#{result[:recovery_command]}` before the next sweep can run" if result[:recovery_command]
221
+ exit 1
222
+ end
223
+
224
+ result[:reclaimed].each { |r| puts "reclaimed #{r[:node]} (holder=#{r[:holder]})" }
225
+ result[:extended].each { |e| puts "extended #{e[:node]} (head=#{e[:head]})" }
226
+ puts "nothing to sweep" if result[:reclaimed].empty? && result[:extended].empty?
227
+ exit 0
228
+ end
229
+
230
+ # run_answer (intent 340, G7, n6): closes a decision node or unparks a
231
+ # work node the runner parked at `needs_decision` (327 D22, C26), then
232
+ # prints what became ready so the owner knows the loop can continue.
233
+ def run_answer(intent_dir, args)
234
+ node = opt(args, "--node")
235
+ text = opt(args, "--answer")
236
+
237
+ unless node
238
+ warn "runner: answer requires --node ID"
239
+ exit 2
240
+ end
241
+
242
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
243
+ result = RunnerAnswer.answer(context, node: node, text: text.to_s)
244
+
245
+ unless result[:ok]
246
+ warn "runner: answer refused (#{result[:reason]})"
247
+ exit 1
248
+ end
249
+
250
+ puts "answered #{node}"
251
+ puts "respun to #{result[:respun_to]}" if result[:respun_to]
252
+ puts "newly ready: #{result[:newly_ready].join(', ')}" if result[:newly_ready].any?
253
+ exit 0
254
+ end
255
+
256
+ # run_rewind (intent 340, G7, n6): confirm-gated reset of the intent
257
+ # branch to a node's own recorded commit, superseding every downstream
258
+ # node and respinning the rewound node itself (327 D15's rewind clause).
259
+ def run_rewind(intent_dir, args)
260
+ node = opt(args, "--node")
261
+ confirm = args.include?("--confirm")
262
+
263
+ unless node
264
+ warn "runner: rewind requires --node ID"
265
+ exit 2
266
+ end
267
+
268
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
269
+ result = RunnerRewind.rewind(context, node: node, confirm: confirm)
270
+
271
+ unless result[:ok]
272
+ warn "runner: rewind refused (#{result[:reason]})"
273
+ exit 1
274
+ end
275
+
276
+ puts "reset #{node} to #{result[:reset_to]}"
277
+ puts "superseded: #{result[:superseded].join(', ')}" if result[:superseded].any?
278
+ puts "respun to #{result[:respun_to]}"
279
+ exit 0
280
+ end
281
+
282
+ # run_step (intent 340, G7, n5): one turn of the loop, in the fixed order
283
+ # 327 D14 and RunnerSweep's own docstring name - abort on a stuck merge,
284
+ # the delivery-lease heartbeat, absorb every `--return NODE=PATH` this call
285
+ # carries, THEN reclaim (so a node this very call just absorbed is never
286
+ # reclaimed out from under its own return, matrix row 2.19/2.20) - and only
287
+ # then dispatch whatever is left ready. Never spawns an agent (327 D42):
288
+ # the session makes every subagent call from the plan this prints.
289
+ def run_step(intent_dir, args)
290
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
291
+
292
+ # v1 minor 3/row 11.18: RunnerSweep.abort_if_merging already prints the
293
+ # whole sentence itself (its own error PLUS the recovery command) - this
294
+ # used to print both halves again, so the operator read the same merge
295
+ # warning twice for one refusal.
296
+ abort_result = RunnerSweep.abort_if_merging(context)
297
+ exit 1 unless abort_result[:ok]
298
+
299
+ Lock.heartbeat(intent_dir, session: context.session) unless context.session.to_s.strip.empty?
300
+
301
+ # B4: this is the runner's ACTUAL lock check now - the absorb loop below
302
+ # writes node transitions, and RunnerDispatch's own check (row 5.31/5.32)
303
+ # runs too late to stop that: a session holding no lock would otherwise
304
+ # have every `--return` it carries written anyway, attributed to
305
+ # whichever session's holder= happens to be on the node's `running`
306
+ # line. RunnerDispatch keeps its own check too, as defence in depth for
307
+ # a caller that reaches it some other way.
308
+ unless context.session
309
+ warn "runner: step refused (lock_not_held)"
310
+ warn RunnerDispatch.rearm_command(intent_dir)
311
+ exit 1
312
+ end
313
+
314
+ # M3/row 10.1: `--allow-core-drift` (D11) - the one escape hatch letting
315
+ # Plastic's own repository, which legitimately drifts from the installed
316
+ # manifest, dogfood its own runner. Threaded straight into the absorb,
317
+ # never defaulted true.
318
+ allow_core_drift = args.include?("--allow-core-drift")
319
+
320
+ returns = {}
321
+ opt_all(args, "--return").each do |pair|
322
+ node, path = pair.to_s.split("=", 2)
323
+ if node.to_s.empty? || path.to_s.empty?
324
+ warn "runner: malformed --return #{pair.inspect}, expected NODE=PATH"
325
+ exit 2
326
+ end
327
+ returns[node] = path
328
+ end
329
+
330
+ returns.each do |node, path|
331
+ absorbed = RunnerAbsorb.absorb(context, node: node, return_path: path, allow_core_drift: allow_core_drift)
332
+ puts "absorbed #{node}: #{absorbed[:state]}"
333
+ # M4/row 10.3: a refused proposal is surfaced here, not left to a
334
+ # savepoint comment the operator never sees - the exact reason the
335
+ # proposer's next attempt otherwise proposes the same thing again.
336
+ proposal = absorbed[:proposal]
337
+ puts " proposal refused: #{Array(proposal[:errors]).join('; ')}" if proposal && !proposal[:ok]
338
+ end
339
+
340
+ swept = RunnerSweep.reclaim(context, skip: returns.keys)
341
+ swept[:reclaimed].each { |r| puts "reclaimed #{r[:node]} (holder=#{r[:holder]})" }
342
+ swept[:extended].each { |e| puts "extended #{e[:node]} (head=#{e[:head]})" }
343
+
344
+ # M5/row 10.4: NodeWorktree.reap runs once per step, right after the
345
+ # reclaim pass - D26's reaper, unreachable from anywhere until now.
346
+ reaped = NodeWorktree.reap(context)
347
+ reaped[:removed].each { |r| puts "reaped #{r[:node]} worktree" }
348
+
349
+ result = RunnerDispatch.dispatch(context)
350
+ unless result[:ok]
351
+ warn "runner: step refused (#{result[:reason]}): #{Array(result[:errors]).join('; ')}"
352
+ warn result[:rearm_command] if result[:rearm_command]
353
+ exit 1
354
+ end
355
+
356
+ case result[:status]
357
+ when "dispatched"
358
+ puts result[:plan]
359
+ when "queued"
360
+ puts "queued (concurrency ceiling reached)"
361
+ when "complete"
362
+ puts "complete"
363
+ when "needs_decision"
364
+ # printed below, alongside a dispatch plan when the same step also
365
+ # dispatched (M11/row 10.14) - `result[:stop]` is set whenever
366
+ # status is needs_decision.
367
+ else
368
+ puts "stalled"
369
+ result[:blockers].each { |b| puts "blocked: #{b}" }
370
+ end
371
+
372
+ # M11/row 10.14: a `needs_decision` stop must print even when THIS SAME
373
+ # step also dispatched other nodes ahead of the decision node in ranked
374
+ # order - `result[:status]` reads "dispatched" in that case (dispatched
375
+ # takes priority in RunnerDispatch's own report), which used to swallow
376
+ # the stop and its `runner answer` command entirely.
377
+ if result[:stop]
378
+ stop = result[:stop]
379
+ puts "needs_decision: #{stop[:node]} - #{stop[:question]}"
380
+ puts stop[:answer_command]
381
+ end
382
+
383
+ result[:parked].each do |p|
384
+ puts "parked #{p[:node]} (#{p[:reason]}): #{p[:question]}"
385
+ puts p[:answer_command]
386
+ end
387
+
388
+ exit 0
389
+ end
390
+ end
391
+
392
+ Runner.main(ARGV) if $PROGRAM_NAME == __FILE__
@@ -169,7 +169,7 @@ ledger is missing (then rebuild it with `Savepoint.rebuild_savepoint`).
169
169
  | `How plan.md created` / `How checklist.md created` / `Exec started` | Exec (verify plan, matrix, checklist) |
170
170
  | `Exec outcome.md created` | Exec done; complete the intent |
171
171
  | `Done delivered|abandoned` | Terminal; do not resume |
172
- | A node or `Intent` transition line (`n1 running ...`, `Intent needs_decision ...`) | Exec; a graph delivery is in progress - read node status through `NodeLedger.status` before dispatching anything, never re-derive it by eye |
172
+ | A node or `Intent` transition line (`n1 running ...`, `Intent needs_decision ...`) | Exec; a graph delivery is in progress - drive it through `scripts/runner`'s three public verbs, `step` (one turn of the dispatch loop), `status` (renders ledger state, safe to poll constantly), and `answer` (closes a `needs_decision` node) - read node status through `NodeLedger.status` before dispatching anything, never re-derive it by eye |
173
173
 
174
174
  Filesystem fallback, in order: `checklist.md` with items checked means resume Exec from the
175
175
  first unchecked item; `plan.md` plus `checklist.md` means enter Exec; `spec.md` alone means
@@ -43,6 +43,23 @@ See `references/file-format.md` for the exact entry-line shape, status vocabular
43
43
  format, and a worked example. See `references/operations.md` for step-by-step mechanics of each
44
44
  verb above.
45
45
 
46
+ ## Graph (intent 337)
47
+
48
+ A roadmap may carry an optional `## Graph` section - the same `needs` edge grammar as an
49
+ intent's own `graph.md` (`- <id> needs <id> <id>`, or `- <id> needs nothing` for a root). When
50
+ present, batches are computed from it (`roadmap-graph check`/`render`), not hand-ordered; the
51
+ template scaffolds a fenced example so a new roadmap starts with the section already in place.
52
+ Three verbs, all `--dry-run`-able:
53
+
54
+ | Verb | What it does |
55
+ |------|--------------|
56
+ | `roadmap-graph check <roadmap.md>` | Prints the computed batches, the ready set, and any dangling id (a graph names it, no batch lists it); exits 1 on a cyclic graph or a dangling id. |
57
+ | `roadmap-graph render <roadmap.md>` | Writes `## Tree` (a box-drawing render of the graph) and regroups the batch/wave section from the computed batches, entry lines carried over verbatim. |
58
+ | `roadmap-graph migrate <roadmap.md>` | Derives a conservative `## Graph` for a graphless roadmap from its existing batch order (batch N needs every entry of batch N-1); never overwrites an existing graph. |
59
+
60
+ A roadmap with no `## Graph` section keeps working exactly as before (wave-order dispatch); the
61
+ graph is additive, never required.
62
+
46
63
  Read `../plastic-conventions/references/roadmaps.md` for the roadmap file format, batch
47
64
  semantics, and the status-mirror rule that this skill's own file-format reference builds on. This
48
65
  path resolves relative to this skill's own installed directory.
@@ -6,4 +6,4 @@
6
6
 
7
7
  **Batches**
8
8
 
9
- {{entries.table}}
9
+ {{entries.table}}{{tree}}
@@ -9,6 +9,19 @@ roadmap's goal is reached, move this file from `roadmaps/{slug}.md` to
9
9
  (a checkable prose condition — one or a few sentences a human or coordinator reads to decide the
10
10
  roadmap is done. Not an executable checker.)
11
11
 
12
+ ## Graph
13
+ Edges, `needs` only; the head needs the tail done. The literal target `nothing` declares a root
14
+ (an entry needing nothing). Batches below are computed from these edges, not hand-ordered; run
15
+ `roadmap-graph check <this file>` to see the computed batches and `roadmap-graph render <this
16
+ file>` to write them back, or `roadmap-graph migrate <this file>` on an existing graphless
17
+ roadmap to derive edges from its current batch order instead of hand-writing them here.
18
+
19
+ Grammar (fenced below so this example is never read as a real edge):
20
+ ```
21
+ - <intent-id> needs nothing
22
+ - <intent-id> needs <intent-id>
23
+ ```
24
+
12
25
  ## Batches
13
26
  Entries in a batch are parallel-safe; batches run top to bottom. The checkbox is checked once an
14
27
  entry is delivered, unchecked otherwise; the trailing token after the em-dash is the precise