@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.
- package/package.json +1 -1
- package/scripts/doctor.rb +36 -0
- package/scripts/index-projection +74 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +44 -0
- package/scripts/lib/node_packet.rb +15 -2
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/report_screen.rb +26 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +17 -42
- package/scripts/lib/roadmap_render.rb +150 -0
- 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/roadmap-graph +119 -0
- package/scripts/runner +392 -0
- package/skills/auto/SKILL.md +1 -1
- package/skills/roadmap/SKILL.md +17 -0
- package/templates/report-roadmap-plan.md +1 -1
- package/templates/roadmap.md +13 -0
|
@@ -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__
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -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
|
package/skills/roadmap/SKILL.md
CHANGED
|
@@ -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.
|
package/templates/roadmap.md
CHANGED
|
@@ -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
|