@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -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-call-budget +222 -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 +45 -7
- 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/compact_instructions.rb +5 -5
- 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 +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -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 +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- 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/meter_watch.rb +179 -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 +908 -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 +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -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/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -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/read-config +3 -3
- 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 +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -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 +41 -34
- 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 +21 -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/config.yml +3 -3
- 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
|
@@ -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
|
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
|