@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.
Files changed (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. 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
@@ -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
- last = File.read(ledger).each_line.map(&:strip).reject(&:empty?).last
122
- return last if last
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
@@ -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
@@ -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; the message names the `plastic-doctor` verb that resolves it. Do not proceed as the
71
- owner after an exit 1.
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. Notify the user (the one mid-flight briefing, per `references/human-report-contract.md`):
204
- State, the plan shape and what it builds; Risk, the riskiest row of the matrix; Call,
205
- proceeding to build. In auto mode this briefing informs; it does not wait.
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 it).
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. Verify every checklist item is checked and the suite is green once on the branch.
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 means a live foreign session holds the lock; 5 means the worktree is dirty (commit
272
- first, or pass `--discard-worktree-changes` deliberately); 3 means the lock survived the
273
- disarm (`/plastic-doctor check the lock status`); 6 means the structure check refused. Never
274
- leave an orphaned worktree; run `git worktree prune` on a stale reference.
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 State/Risk/Call briefing before sending the
288
- How briefing.
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