@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21

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 (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. 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__
@@ -18,6 +18,7 @@
18
18
  # rebuilds an empty ledger.
19
19
 
20
20
  require_relative "lib/roadmap_savepoint"
21
+ require_relative "lib/guarded_append"
21
22
 
22
23
  def opt(args, name)
23
24
  (i = args.index(name)) && args[i + 1]
@@ -47,6 +48,12 @@ when "append"
47
48
  rescue ArgumentError => e
48
49
  warn "append: #{e.message}"
49
50
  exit 2
51
+ rescue GuardedAppend::Unavailable => e
52
+ # Intent 335 (spec D14): the write guard could not be taken; nothing was written. Exit 4
53
+ # rather than let a backtrace reach the six skill recipes that drive this CLI - it rescues
54
+ # ArgumentError alone today and would otherwise print one straight to the terminal.
55
+ warn "append: #{e.message}"
56
+ exit 4
50
57
  end
51
58
 
52
59
  when "rebuild"
package/scripts/runner ADDED
@@ -0,0 +1,397 @@
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
+ require_relative "lib/runner_proposals"
9
+
10
+ # runner - the one executable over the graph-ready loop's declared node graph
11
+ # (intent 340, G7, n1). A subcommand table: the public verbs (step, status,
12
+ # answer) are the only ones the skill body ever names; the internal verbs
13
+ # (ready, sweep, rewind) route and work so `step` can compose them and so
14
+ # each can be tested alone, but stay out of the usage text (327 spec).
15
+ #
16
+ # `step`, `sweep`, `answer` and `rewind` route to modules later nodes
17
+ # deliver (RunnerDispatch and RunnerAbsorb for `step`; RunnerSweep,
18
+ # RunnerAnswer, RunnerRewind for the rest). Each is required LAZILY, inside
19
+ # its own verb branch: a verb whose module has not landed yet reports
20
+ # plainly and exits nonzero, and every other verb - most importantly
21
+ # `status`, which an operator polls constantly across all five of this
22
+ # intent's dispatches - keeps working.
23
+ #
24
+ # Usage:
25
+ # runner <step|status|answer> <intent_dir> [--node ID] [--answer TEXT]
26
+ #
27
+ # Exit codes (matching the family scripts/node-transition and
28
+ # scripts/ready-set already use):
29
+ # 0 - success
30
+ # 1 - the verb ran but reports failure (an invalid graph for `ready`)
31
+ # 2 - usage: unknown verb, or not an intent directory
32
+ # 3 - a routed verb's module has not been delivered yet
33
+ module Runner
34
+ module_function
35
+
36
+ PUBLIC_VERBS = %w[step status answer].freeze
37
+ INTERNAL_VERBS = %w[ready sweep rewind].freeze
38
+ VERBS = (PUBLIC_VERBS + INTERNAL_VERBS).freeze
39
+
40
+ # verb -> [[ModuleName, lib_file], ...], required lazily inside the verb's
41
+ # own branch (row 1.6). `step` composes two modules (dispatching new work,
42
+ # then absorbing what a sub-agent reported back); every other routed verb
43
+ # names exactly one.
44
+ LAZY_MODULES = {
45
+ "step" => [["RunnerSweep", "runner_sweep"], ["RunnerAbsorb", "runner_absorb"],
46
+ ["RunnerDispatch", "runner_dispatch"]],
47
+ "sweep" => [["RunnerSweep", "runner_sweep"]],
48
+ "answer" => [["RunnerAnswer", "runner_answer"]],
49
+ "rewind" => [["RunnerRewind", "runner_rewind"]],
50
+ }.freeze
51
+
52
+ def usage
53
+ warn "Usage: runner <#{PUBLIC_VERBS.join('|')}> <intent_dir> [--node ID] [--answer TEXT]"
54
+ end
55
+
56
+ def intent_directory?(dir)
57
+ dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
58
+ end
59
+
60
+ def opt(args, name)
61
+ (i = args.index(name)) && args[i + 1]
62
+ end
63
+
64
+ # opt_all(args, name) -> every value given for a repeated flag, in the order
65
+ # they appear, or [] when the flag never occurs (intent 340, G7, n8, row
66
+ # 8.1-8.3). `run_step` needs this, not `opt`, for `--return`: one step may
67
+ # carry more than one `--return NODE=PATH` pair (one per node the session
68
+ # is reporting back on), and `opt`'s single-value semantics would silently
69
+ # keep only the first, absorbing one node while stranding the other's
70
+ # lease until it expires.
71
+ def opt_all(args, name)
72
+ args.each_index.select { |i| args[i] == name }.map { |i| args[i + 1] }.compact
73
+ end
74
+
75
+ # KNOWN_FLAGS (minor 2): the exact flag vocabulary each verb accepts. A
76
+ # flag not in a verb's own list is refused rather than silently ignored -
77
+ # a mistyped `--session` used to vanish with no effect and the runner
78
+ # simply acted as whichever session `CLAUDE_CODE_SESSION_ID` names.
79
+ KNOWN_FLAGS = {
80
+ "step" => %w[--return --allow-core-drift],
81
+ "answer" => %w[--node --answer],
82
+ "rewind" => %w[--node --confirm],
83
+ }.freeze
84
+
85
+ # unrecognized_flag(verb, args) -> the first token in `args` that looks
86
+ # like a flag (`--...`) and is not in `verb`'s own KNOWN_FLAGS, or nil. A
87
+ # verb absent from KNOWN_FLAGS (status, ready, sweep) accepts none.
88
+ def unrecognized_flag(verb, args)
89
+ allowed = KNOWN_FLAGS.fetch(verb, [])
90
+ args.find { |a| a.is_a?(String) && a.start_with?("--") && !allowed.include?(a) }
91
+ end
92
+
93
+ def main(argv)
94
+ args = argv.dup
95
+ verb = args.shift
96
+
97
+ unless VERBS.include?(verb)
98
+ warn "runner: unknown verb #{verb.inspect}"
99
+ usage
100
+ exit 2
101
+ end
102
+
103
+ intent_dir_arg = args.shift
104
+ unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
105
+ warn "runner: #{intent_dir_arg.inspect} is not an intent directory"
106
+ usage
107
+ exit 2
108
+ end
109
+ intent_dir = File.expand_path(intent_dir_arg)
110
+
111
+ bad_flag = unrecognized_flag(verb, args)
112
+ if bad_flag
113
+ warn "runner: unknown flag #{bad_flag.inspect} for #{verb}"
114
+ usage
115
+ exit 2
116
+ end
117
+
118
+ case verb
119
+ when "status"
120
+ run_status(intent_dir)
121
+ when "ready"
122
+ run_ready(intent_dir)
123
+ else
124
+ run_lazy(verb, intent_dir, args)
125
+ end
126
+ end
127
+
128
+ def run_status(intent_dir)
129
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
130
+ rows = RunnerCore.status(context)
131
+
132
+ if rows.empty?
133
+ puts "No declared nodes."
134
+ else
135
+ rows.each do |id, view|
136
+ puts "#{id} kind=#{view[:kind]} state=#{view[:state]} ready=#{view[:ready]}"
137
+ last = view[:last_transition]
138
+ puts " last: #{last[:raw]}" if last
139
+ end
140
+ end
141
+
142
+ graph = context.graph || {}
143
+ fixes = RunnerProposals.review_fix_count(graph[:edges] || {}, graph[:nodes] || {})
144
+ puts "review fixes: #{fixes} of #{RunnerProposals::REVIEW_FIX_CAP}"
145
+
146
+ complete = RunnerCore.complete?(context)
147
+ puts
148
+ puts complete ? "complete" : "stalled"
149
+ unless complete
150
+ rows.each do |id, view|
151
+ next if ReadySet::TERMINAL_STATES.include?(view[:state])
152
+
153
+ view[:blockers].each { |b| puts "#{id} blocked: #{b}" }
154
+ end
155
+ end
156
+
157
+ exit 0
158
+ end
159
+
160
+ def run_ready(intent_dir)
161
+ analysis = ReadySet.analyze(intent_dir)
162
+ unless analysis[:ok]
163
+ warn "runner: #{analysis[:errors].join('; ')}"
164
+ exit 1
165
+ end
166
+
167
+ if analysis[:ranked_ready].empty?
168
+ puts "Ready: (none)"
169
+ else
170
+ puts "Ready: #{analysis[:ranked_ready].map { |r| r[:id] }.join(', ')}"
171
+ end
172
+ exit 0
173
+ end
174
+
175
+ # Requires this verb's module(s) lazily, then hands off. Nothing in n1
176
+ # delivers runner_dispatch.rb, runner_absorb.rb, runner_sweep.rb,
177
+ # runner_answer.rb, or runner_rewind.rb, so every LAZY_MODULES require
178
+ # raises LoadError today and every routed verb reports "not yet
179
+ # delivered" - the only thing this node needs to prove (row 1.6). Once a
180
+ # later node ships its file, the require succeeds and falls through to
181
+ # dispatch_lazy, which that node replaces with the real call.
182
+ def run_lazy(verb, intent_dir, args)
183
+ # The require target below is a runtime value, never an inline quoted
184
+ # literal, so it falls outside test/install_packaging_test.rb's
185
+ # require-closure guard on purpose: every file LAZY_MODULES names is a
186
+ # genuine later-node deliverable, none of which core_files should track
187
+ # until that node actually lands it.
188
+ LAZY_MODULES.fetch(verb, []).each { |(_name, file)| require_relative(File.join("lib", file)) }
189
+ rescue LoadError
190
+ warn "runner: #{verb} is not yet delivered (its module has not landed)"
191
+ exit 3
192
+ else
193
+ dispatch_lazy(verb, intent_dir, args)
194
+ end
195
+
196
+ def dispatch_lazy(verb, intent_dir, args)
197
+ case verb
198
+ when "step"
199
+ run_step(intent_dir, args)
200
+ when "sweep"
201
+ run_sweep(intent_dir, args)
202
+ when "answer"
203
+ run_answer(intent_dir, args)
204
+ when "rewind"
205
+ run_rewind(intent_dir, args)
206
+ else
207
+ warn "runner: #{verb}'s module loaded but no dispatcher is wired up yet"
208
+ exit 3
209
+ end
210
+ end
211
+
212
+ # run_sweep (intent 340, G7, n8): the standalone `sweep` verb, unreachable
213
+ # from the CLI until this node - `dispatch_lazy` had no `when "sweep"` arm
214
+ # even though `RunnerSweep.run` (n2) is a complete, documented standalone
215
+ # entry point, exactly the shape a bare `runner sweep` call needs (abort
216
+ # check, THEN the lease heartbeat, THEN reclaim - RunnerSweep's own fixed
217
+ # order). `step` never calls this method: it composes `abort_if_merging`
218
+ # and `reclaim` itself, interleaved around absorb.
219
+ def run_sweep(intent_dir, args)
220
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
221
+ result = RunnerSweep.run(context)
222
+
223
+ unless result[:ok]
224
+ warn "runner: #{result[:error]}"
225
+ warn "runner: run `#{result[:recovery_command]}` before the next sweep can run" if result[:recovery_command]
226
+ exit 1
227
+ end
228
+
229
+ result[:reclaimed].each { |r| puts "reclaimed #{r[:node]} (holder=#{r[:holder]})" }
230
+ result[:extended].each { |e| puts "extended #{e[:node]} (head=#{e[:head]})" }
231
+ puts "nothing to sweep" if result[:reclaimed].empty? && result[:extended].empty?
232
+ exit 0
233
+ end
234
+
235
+ # run_answer (intent 340, G7, n6): closes a decision node or unparks a
236
+ # work node the runner parked at `needs_decision` (327 D22, C26), then
237
+ # prints what became ready so the owner knows the loop can continue.
238
+ def run_answer(intent_dir, args)
239
+ node = opt(args, "--node")
240
+ text = opt(args, "--answer")
241
+
242
+ unless node
243
+ warn "runner: answer requires --node ID"
244
+ exit 2
245
+ end
246
+
247
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
248
+ result = RunnerAnswer.answer(context, node: node, text: text.to_s)
249
+
250
+ unless result[:ok]
251
+ warn "runner: answer refused (#{result[:reason]})"
252
+ exit 1
253
+ end
254
+
255
+ puts "answered #{node}"
256
+ puts "respun to #{result[:respun_to]}" if result[:respun_to]
257
+ puts "newly ready: #{result[:newly_ready].join(', ')}" if result[:newly_ready].any?
258
+ exit 0
259
+ end
260
+
261
+ # run_rewind (intent 340, G7, n6): confirm-gated reset of the intent
262
+ # branch to a node's own recorded commit, superseding every downstream
263
+ # node and respinning the rewound node itself (327 D15's rewind clause).
264
+ def run_rewind(intent_dir, args)
265
+ node = opt(args, "--node")
266
+ confirm = args.include?("--confirm")
267
+
268
+ unless node
269
+ warn "runner: rewind requires --node ID"
270
+ exit 2
271
+ end
272
+
273
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
274
+ result = RunnerRewind.rewind(context, node: node, confirm: confirm)
275
+
276
+ unless result[:ok]
277
+ warn "runner: rewind refused (#{result[:reason]})"
278
+ exit 1
279
+ end
280
+
281
+ puts "reset #{node} to #{result[:reset_to]}"
282
+ puts "superseded: #{result[:superseded].join(', ')}" if result[:superseded].any?
283
+ puts "respun to #{result[:respun_to]}"
284
+ exit 0
285
+ end
286
+
287
+ # run_step (intent 340, G7, n5): one turn of the loop, in the fixed order
288
+ # 327 D14 and RunnerSweep's own docstring name - abort on a stuck merge,
289
+ # the delivery-lease heartbeat, absorb every `--return NODE=PATH` this call
290
+ # carries, THEN reclaim (so a node this very call just absorbed is never
291
+ # reclaimed out from under its own return, matrix row 2.19/2.20) - and only
292
+ # then dispatch whatever is left ready. Never spawns an agent (327 D42):
293
+ # the session makes every subagent call from the plan this prints.
294
+ def run_step(intent_dir, args)
295
+ context = RunnerCore.context(intent_dir: intent_dir, env: ENV["CLAUDE_CODE_SESSION_ID"])
296
+
297
+ # v1 minor 3/row 11.18: RunnerSweep.abort_if_merging already prints the
298
+ # whole sentence itself (its own error PLUS the recovery command) - this
299
+ # used to print both halves again, so the operator read the same merge
300
+ # warning twice for one refusal.
301
+ abort_result = RunnerSweep.abort_if_merging(context)
302
+ exit 1 unless abort_result[:ok]
303
+
304
+ Lock.heartbeat(intent_dir, session: context.session) unless context.session.to_s.strip.empty?
305
+
306
+ # B4: this is the runner's ACTUAL lock check now - the absorb loop below
307
+ # writes node transitions, and RunnerDispatch's own check (row 5.31/5.32)
308
+ # runs too late to stop that: a session holding no lock would otherwise
309
+ # have every `--return` it carries written anyway, attributed to
310
+ # whichever session's holder= happens to be on the node's `running`
311
+ # line. RunnerDispatch keeps its own check too, as defence in depth for
312
+ # a caller that reaches it some other way.
313
+ unless context.session
314
+ warn "runner: step refused (lock_not_held)"
315
+ warn RunnerDispatch.rearm_command(intent_dir)
316
+ exit 1
317
+ end
318
+
319
+ # M3/row 10.1: `--allow-core-drift` (D11) - the one escape hatch letting
320
+ # Plastic's own repository, which legitimately drifts from the installed
321
+ # manifest, dogfood its own runner. Threaded straight into the absorb,
322
+ # never defaulted true.
323
+ allow_core_drift = args.include?("--allow-core-drift")
324
+
325
+ returns = {}
326
+ opt_all(args, "--return").each do |pair|
327
+ node, path = pair.to_s.split("=", 2)
328
+ if node.to_s.empty? || path.to_s.empty?
329
+ warn "runner: malformed --return #{pair.inspect}, expected NODE=PATH"
330
+ exit 2
331
+ end
332
+ returns[node] = path
333
+ end
334
+
335
+ returns.each do |node, path|
336
+ absorbed = RunnerAbsorb.absorb(context, node: node, return_path: path, allow_core_drift: allow_core_drift)
337
+ puts "absorbed #{node}: #{absorbed[:state]}"
338
+ # M4/row 10.3: a refused proposal is surfaced here, not left to a
339
+ # savepoint comment the operator never sees - the exact reason the
340
+ # proposer's next attempt otherwise proposes the same thing again.
341
+ proposal = absorbed[:proposal]
342
+ puts " proposal refused: #{Array(proposal[:errors]).join('; ')}" if proposal && !proposal[:ok]
343
+ end
344
+
345
+ swept = RunnerSweep.reclaim(context, skip: returns.keys)
346
+ swept[:reclaimed].each { |r| puts "reclaimed #{r[:node]} (holder=#{r[:holder]})" }
347
+ swept[:extended].each { |e| puts "extended #{e[:node]} (head=#{e[:head]})" }
348
+
349
+ # M5/row 10.4: NodeWorktree.reap runs once per step, right after the
350
+ # reclaim pass - D26's reaper, unreachable from anywhere until now.
351
+ reaped = NodeWorktree.reap(context)
352
+ reaped[:removed].each { |r| puts "reaped #{r[:node]} worktree" }
353
+
354
+ result = RunnerDispatch.dispatch(context)
355
+ unless result[:ok]
356
+ warn "runner: step refused (#{result[:reason]}): #{Array(result[:errors]).join('; ')}"
357
+ warn result[:rearm_command] if result[:rearm_command]
358
+ exit 1
359
+ end
360
+
361
+ case result[:status]
362
+ when "dispatched"
363
+ puts result[:plan]
364
+ when "queued"
365
+ puts "queued (concurrency ceiling reached)"
366
+ when "complete"
367
+ puts "complete"
368
+ when "needs_decision"
369
+ # printed below, alongside a dispatch plan when the same step also
370
+ # dispatched (M11/row 10.14) - `result[:stop]` is set whenever
371
+ # status is needs_decision.
372
+ else
373
+ puts "stalled"
374
+ result[:blockers].each { |b| puts "blocked: #{b}" }
375
+ end
376
+
377
+ # M11/row 10.14: a `needs_decision` stop must print even when THIS SAME
378
+ # step also dispatched other nodes ahead of the decision node in ranked
379
+ # order - `result[:status]` reads "dispatched" in that case (dispatched
380
+ # takes priority in RunnerDispatch's own report), which used to swallow
381
+ # the stop and its `runner answer` command entirely.
382
+ if result[:stop]
383
+ stop = result[:stop]
384
+ puts "needs_decision: #{stop[:node]} - #{stop[:question]}"
385
+ puts stop[:answer_command]
386
+ end
387
+
388
+ result[:parked].each do |p|
389
+ puts "parked #{p[:node]} (#{p[:reason]}): #{p[:question]}"
390
+ puts p[:answer_command]
391
+ end
392
+
393
+ exit 0
394
+ end
395
+ end
396
+
397
+ 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
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # session-usage (intent 355, D10): read the harness transcripts since a cutoff
6
+ # and report per session the model, calls, boot context, last context, median
7
+ # step, steps over 5k, and cache read. Broken records are counted and named.
8
+ #
9
+ # Usage:
10
+ # session-usage [--since <iso8601>] [--format text|json] [--root <dir>] [--rate-limits <file>]
11
+ #
12
+ # Defaults: the root is ~/.claude/projects, the rate-limit cache is
13
+ # ~/.plastic/.cache/rate-limits.json, and the cutoff is that cache's reset
14
+ # time minus five hours, else the last five hours.
15
+ #
16
+ # Exit codes: 0 reported (unavailable included); 2 usage error.
17
+
18
+ require "json"
19
+ require "time"
20
+ require_relative "lib/session_usage"
21
+
22
+ FLAGS = %w[--since --format --root --rate-limits].freeze
23
+ FORMATS = %w[text json].freeze
24
+
25
+ def usage_abort(message)
26
+ warn "session-usage: #{message}"
27
+ warn "Usage: session-usage [--since <iso8601>] [--format text|json] [--root <dir>] [--rate-limits <file>]"
28
+ exit 2
29
+ end
30
+
31
+ def parse_args(argv)
32
+ usage_abort("every flag takes a value") if argv.length.odd?
33
+ opts = argv.each_slice(2).to_h
34
+ unknown = opts.keys - FLAGS
35
+ usage_abort("unknown flag #{unknown.first.inspect}") unless unknown.empty?
36
+ usage_abort("unknown format #{opts['--format'].inspect}") if opts["--format"] && !FORMATS.include?(opts["--format"])
37
+ opts
38
+ end
39
+
40
+ def parse_since(value)
41
+ value && Time.iso8601(value)
42
+ rescue ArgumentError
43
+ usage_abort("--since #{value.inspect} is not an ISO 8601 time")
44
+ end
45
+
46
+ opts = parse_args(ARGV)
47
+ usage = SessionUsage.new(
48
+ transcripts_root: File.expand_path(opts.fetch("--root", File.join(Dir.home, ".claude", "projects"))),
49
+ rate_limits_path: File.expand_path(opts.fetch("--rate-limits", File.join(Dir.home, ".plastic", ".cache", "rate-limits.json")))
50
+ )
51
+ report = usage.report(since: parse_since(opts["--since"]))
52
+ if opts["--format"] == "json"
53
+ puts JSON.pretty_generate(report)
54
+ else
55
+ print SessionUsage.render_text(report)
56
+ end
@@ -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