@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
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__
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# savepoint-note - the writer CLI for three savepoint kinds: Review (one line per
|
|
6
|
+
# plan-review or post-execution-review verdict) and Commit (one line per commit landing
|
|
7
|
+
# during Exec), both from intent 317, D17; and Report (one line per report screen printed,
|
|
8
|
+
# intent 331f). None has an automatic seam (session-commit writes the day ledger, not the
|
|
9
|
+
# intent ledger; the executor's own commits are plain git; a printed screen is a skill's own
|
|
10
|
+
# reply, not a file write), so this is an explicit thin wrapper on
|
|
11
|
+
# Savepoint.append_review_savepoint / append_commit_savepoint / append_report_savepoint.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# savepoint-note <intent_dir> --kind Review|Commit|Report --text "<text>"
|
|
15
|
+
#
|
|
16
|
+
# --text is normalized (D21): runs of two or more spaces collapse to one, so a
|
|
17
|
+
# free-text kind that happens to contain a double space cannot key the dedup
|
|
18
|
+
# primitive's split(/\s{2,}/) on the prefix alone and silently drop a later
|
|
19
|
+
# line. A newline in --text is refused outright (exit 2, ledger unchanged)
|
|
20
|
+
# rather than silently splitting one entry into two malformed ledger lines.
|
|
21
|
+
#
|
|
22
|
+
# Exit codes:
|
|
23
|
+
# 0 - the line was appended (or was already recorded; both are success)
|
|
24
|
+
# 2 - usage error, bad --kind, missing --text, a newline in --text, or the
|
|
25
|
+
# path is not an intent directory (one line on stderr, ledger unchanged)
|
|
26
|
+
|
|
27
|
+
require_relative "lib/savepoint"
|
|
28
|
+
|
|
29
|
+
def usage_abort(message)
|
|
30
|
+
warn "savepoint-note: #{message}"
|
|
31
|
+
exit 2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
args = ARGV.dup
|
|
35
|
+
kind = nil
|
|
36
|
+
text = nil
|
|
37
|
+
positional = []
|
|
38
|
+
while (arg = args.shift)
|
|
39
|
+
case arg
|
|
40
|
+
when "--kind"
|
|
41
|
+
kind = args.shift
|
|
42
|
+
when "--text"
|
|
43
|
+
text = args.shift
|
|
44
|
+
else
|
|
45
|
+
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
|
|
46
|
+
positional << arg
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
usage_abort("usage: savepoint-note <intent_dir> --kind Review|Commit|Report --text \"<text>\"") unless positional.length == 1
|
|
51
|
+
|
|
52
|
+
intent_dir = File.expand_path(positional.first)
|
|
53
|
+
usage_abort("#{intent_dir} is not an intent directory") unless File.exist?(Savepoint.intent_file(intent_dir))
|
|
54
|
+
|
|
55
|
+
KINDS = %w[Review Commit Report].freeze
|
|
56
|
+
usage_abort("--kind must be one of #{KINDS.join(', ')}, got #{kind.inspect}") unless KINDS.include?(kind)
|
|
57
|
+
usage_abort("--text is required") if text.nil? || text.empty?
|
|
58
|
+
usage_abort("--text must not contain a newline") if text.include?("\n")
|
|
59
|
+
|
|
60
|
+
normalized = text.gsub(/ {2,}/, " ").strip
|
|
61
|
+
usage_abort("--text is empty after normalization") if normalized.empty?
|
|
62
|
+
|
|
63
|
+
case kind
|
|
64
|
+
when "Review" then Savepoint.append_review_savepoint(intent_dir, normalized)
|
|
65
|
+
when "Commit" then Savepoint.append_commit_savepoint(intent_dir, normalized)
|
|
66
|
+
when "Report" then Savepoint.append_report_savepoint(intent_dir, normalized)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
exit 0
|
package/scripts/spawn-preamble
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
|
|
25
25
|
require_relative "lib/savepoint"
|
|
26
26
|
require_relative "lib/worktree"
|
|
27
|
+
require_relative "lib/intent_screen"
|
|
27
28
|
|
|
28
29
|
# Verbatim honoring instruction. Kept as one constant so the contract doc and the
|
|
29
30
|
# test assert against the exact same string.
|
|
@@ -115,11 +116,17 @@ STAGE_LABELS = {
|
|
|
115
116
|
"exec" => "Exec", "done" => "Done"
|
|
116
117
|
}.freeze
|
|
117
118
|
|
|
119
|
+
# Intent 317, D6: a dispatched agent's "Current stage:" line must name the last
|
|
120
|
+
# LIFECYCLE line (What/Why/How/Exec/Done), never a trailing Lock/Review/Commit
|
|
121
|
+
# line - the bug spawn-preamble shared with IntentScreen.savepoint_fields
|
|
122
|
+
# before this guard (plan review finding B1).
|
|
118
123
|
def current_stage(intent_dir)
|
|
119
124
|
ledger = File.join(intent_dir, Savepoint::SAVEPOINT_FILE)
|
|
120
125
|
if File.exist?(ledger)
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
lines = File.read(ledger).each_line.map(&:strip).reject(&:empty?)
|
|
127
|
+
lifecycle = lines.select { |l| IntentScreen.lifecycle_line?(l) }
|
|
128
|
+
return lifecycle.last if lifecycle.any?
|
|
129
|
+
return lines.last if lines.any?
|
|
123
130
|
end
|
|
124
131
|
STAGE_LABELS.fetch(Savepoint.derive_stage(intent_dir), Savepoint.derive_stage(intent_dir))
|
|
125
132
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# validate-work-graph - deterministic CLI over WorkGraphValidator (intent 334, n4).
|
|
6
|
+
#
|
|
7
|
+
# Checks whether one intent directory's graph.md and nodes/ form a valid work
|
|
8
|
+
# graph: needs targets resolve, node files reconcile with the declared graph,
|
|
9
|
+
# per-kind body sections are present, and (above the trivial bar) a verify
|
|
10
|
+
# node gates two or more work nodes with a failure-mode matrix per work node.
|
|
11
|
+
# Modelled on scripts/validate-intent: errors to stderr, same exit-code shape.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# validate-work-graph <intent_dir>
|
|
15
|
+
#
|
|
16
|
+
# Exit codes: 0 (valid), 1 (invalid or unreadable; report on stderr),
|
|
17
|
+
# 2 (usage - no argument given).
|
|
18
|
+
|
|
19
|
+
require_relative "lib/work_graph_validator"
|
|
20
|
+
|
|
21
|
+
intent_dir = ARGV.find { |a| !a.start_with?("--") }
|
|
22
|
+
|
|
23
|
+
if intent_dir.nil?
|
|
24
|
+
warn "usage: validate-work-graph <intent_dir>"
|
|
25
|
+
exit 2
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
dir = File.expand_path(intent_dir)
|
|
29
|
+
result = WorkGraphValidator.validate(dir)
|
|
30
|
+
|
|
31
|
+
if result[:ok]
|
|
32
|
+
puts "OK: #{dir}"
|
|
33
|
+
exit 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
warn "INVALID: #{dir}"
|
|
37
|
+
result[:missing].each { |m| warn "missing: #{m}" }
|
|
38
|
+
result[:errors].each { |e| warn e }
|
|
39
|
+
exit 1
|
package/scripts/verify-intent
CHANGED
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
# other flags exist.
|
|
16
16
|
#
|
|
17
17
|
# Checks, all run every invocation (the fourth only when --suite is given):
|
|
18
|
-
# 1. doctor - Doctor#run_intent_check(id, store: scope), fail-open on a crash
|
|
18
|
+
# 1. doctor - Doctor#run_intent_check(id, store: scope), fail-open on a crash; the doctor
|
|
19
|
+
# scan includes the intent_ticks_lag warning (intent 329)
|
|
19
20
|
# 2. em-dash - added lines only of `git diff <base>...HEAD`, never the whole tree
|
|
20
21
|
# 3. diffstat - `git diff --stat <base>...HEAD`, never a failure on its own
|
|
21
22
|
# 4. suite - the supplied --suite command, run with RUBYOPT cleared
|
package/skills/auto/SKILL.md
CHANGED
|
@@ -67,8 +67,9 @@ Replace `<STORE>` (`~/.plastic/projects/<slug>/store` or `~/.plastic/store`) and
|
|
|
67
67
|
nonblank `CLAUDE_CODE_SESSION_ID` as Claude, otherwise passes no identity and the verb keys the
|
|
68
68
|
lock by a derived session key. Never guess identity from an absent runtime variable; an
|
|
69
69
|
unknown harness or thread stays unknown. Exit 1 means the lock is held, stale, excluded, or
|
|
70
|
-
corrupt
|
|
71
|
-
|
|
70
|
+
corrupt - or `inline_refused`: a conversation session may not arm an intent at all (owner rule
|
|
71
|
+
2026-08-31); dispatch the delivery team instead. `--allow-inline` exists only for an explicit
|
|
72
|
+
owner override. Do not proceed as the owner after an exit 1.
|
|
72
73
|
|
|
73
74
|
Read `../plastic-conventions/references/locks-and-worktrees.md` for what the lock and the
|
|
74
75
|
worktree mean and the station table behind them. Code edits happen only inside the worktree.
|
|
@@ -168,6 +169,7 @@ ledger is missing (then rebuild it with `Savepoint.rebuild_savepoint`).
|
|
|
168
169
|
| `How plan.md created` / `How checklist.md created` / `Exec started` | Exec (verify plan, matrix, checklist) |
|
|
169
170
|
| `Exec outcome.md created` | Exec done; complete the intent |
|
|
170
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 - 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 |
|
|
171
173
|
|
|
172
174
|
Filesystem fallback, in order: `checklist.md` with items checked means resume Exec from the
|
|
173
175
|
first unchecked item; `plan.md` plus `checklist.md` means enter Exec; `spec.md` alone means
|
|
@@ -183,9 +185,7 @@ Announce which stage you are entering and why.
|
|
|
183
185
|
3. Decide: pick the best option per gap, record it in `## Context > ### Decisions` with the
|
|
184
186
|
rationale, and log it in `## Insights` with the `(autonomous)` marker through
|
|
185
187
|
`scripts/insight-append`.
|
|
186
|
-
4. Write `spec.md`.
|
|
187
|
-
|
|
188
|
-
Then How.
|
|
188
|
+
4. Write `spec.md`. Then How.
|
|
189
189
|
|
|
190
190
|
## How (the lead), then the plan review
|
|
191
191
|
|
|
@@ -200,9 +200,9 @@ Then How.
|
|
|
200
200
|
into the spec, the matrix, and the tests; record what was dropped and why in the action
|
|
201
201
|
file's review fold. A REVISE verdict is folded and not re-reviewed unless a finding changes
|
|
202
202
|
a decision.
|
|
203
|
-
5.
|
|
204
|
-
|
|
205
|
-
|
|
203
|
+
5. Print `ruby ~/.plastic/scripts/report-screen plan <intent_dir>` as the first characters of
|
|
204
|
+
the reply, nothing before it, no fence, before dispatching the executor (see
|
|
205
|
+
`references/human-report-contract.md` for the full binding table). It informs; it does not wait.
|
|
206
206
|
|
|
207
207
|
Then Exec.
|
|
208
208
|
|
|
@@ -214,7 +214,7 @@ Then Exec.
|
|
|
214
214
|
then builds, then drives the full suite green.
|
|
215
215
|
2. Read its return by code: DONE or DONE_WITH_CONCERNS proceeds; NEEDS_CONTEXT re-dispatches
|
|
216
216
|
with the missing context; BLOCKED stops under the error procedure.
|
|
217
|
-
3. Tick the checklist as items land (the executor does this; verify
|
|
217
|
+
3. Tick the checklist as items land (the executor does this); verify tick-versus-diff against the diff. A mismatch is a review finding, not a lead cleanup.
|
|
218
218
|
|
|
219
219
|
## Review by risk (boot 3, only when a rule fires)
|
|
220
220
|
|
|
@@ -251,8 +251,9 @@ every choice is non-destructive and the team has full autonomy.
|
|
|
251
251
|
|
|
252
252
|
Read `../plastic-conventions/references/completion-and-done.md` for what "intent done" means.
|
|
253
253
|
|
|
254
|
-
1.
|
|
255
|
-
2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered
|
|
254
|
+
1. This is the merge gate: verify every checklist item is checked, verify tick-versus-diff against the diff, and confirm the suite is green once on the branch.
|
|
255
|
+
2. Write `outcome.md` from `~/.plastic/templates/outcome.md` with `disposition: delivered`,
|
|
256
|
+
`## Delivered` as the labeled table whose row labels match the action-file headings (317a).
|
|
256
257
|
3. Release, if configured: match the working directory against `~/.plastic/projects.yml`, read
|
|
257
258
|
`project.yml`'s `release` block, and act on `on_complete` (`commit`, `commit_and_push`,
|
|
258
259
|
`manual`), `verify` (green proceeds; red follows `on_red`: `fix_and_retry` up to twice,
|
|
@@ -268,10 +269,16 @@ Read `../plastic-conventions/references/completion-and-done.md` for what "intent
|
|
|
268
269
|
--session "$CLAUDE_CODE_SESSION_ID" \
|
|
269
270
|
--index-note "<what shipped>; <suite result>"
|
|
270
271
|
```
|
|
271
|
-
Exit 4
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
272
|
+
Exit 4: a live foreign session holds the lock. 5: the worktree is dirty (commit first, or
|
|
273
|
+
pass `--discard-worktree-changes` deliberately). 3: the lock survived the disarm
|
|
274
|
+
(`/plastic-doctor check the lock status`). 6: the structure check refused. Never leave an
|
|
275
|
+
orphaned worktree; run `git worktree prune` on a stale reference.
|
|
276
|
+
6. Print `ruby ~/.plastic/scripts/report-screen delivered <intent_dir>` once (D15/331f), and
|
|
277
|
+
`report-screen state` at each of the five triggers in `references/human-report-contract.md`
|
|
278
|
+
(a review verdict, a blocker, a merge/release, or an owner status ask; a mid-batch ask
|
|
279
|
+
instead runs `report-screen session <tier_root> --session "$CLAUDE_CODE_SESSION_ID"`, intent
|
|
280
|
+
330). Print each as the first characters of the reply: nothing before it, no fence, or the
|
|
281
|
+
hook cannot paint it.
|
|
275
282
|
|
|
276
283
|
## Error Handling
|
|
277
284
|
|
|
@@ -284,8 +291,8 @@ leaves the project broken.
|
|
|
284
291
|
|
|
285
292
|
- Read `references/agent-architecture.md` for the team model, the risk list, the headless note,
|
|
286
293
|
and the solo fallback when dispatching or when a harness has no agent dispatch.
|
|
287
|
-
- Read `references/human-report-contract.md` for the
|
|
288
|
-
How
|
|
294
|
+
- Read `references/human-report-contract.md` for the three report screens and the five
|
|
295
|
+
triggers before printing the How or Completion screen above.
|
|
289
296
|
- Read `references/agent-report-contract.md` for the completion report format when reading a
|
|
290
297
|
dispatched agent's return or synthesizing one.
|
|
291
298
|
- Read `references/end-tail.md` for what `Arm.disarm` does at the End tail and why the reindex
|