@zalom/plastic 2.0.0-alpha.17 → 2.0.0-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/dashboard.rb +20 -0
- package/scripts/doctor.rb +120 -2
- package/scripts/end-intent +134 -8
- package/scripts/hook-capture +4 -105
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/installer_core.rb +32 -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 +873 -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 +122 -12
- package/scripts/lib/roadmap_queue.rb +161 -3
- package/scripts/lib/roadmap_savepoint.rb +26 -5
- package/scripts/lib/savepoint.rb +123 -12
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +8 -1
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/validate-work-graph +39 -0
- package/skills/auto/SKILL.md +2 -3
- package/skills/auto/references/human-report-contract.md +3 -2
- package/skills/intent-continuing/references/boarding-matrix.md +1 -0
- package/skills/intent-ending/SKILL.md +30 -19
- package/skills/intent-executing/SKILL.md +1 -1
- 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/templates/graph.md +16 -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 +8 -6
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require_relative "lib/node_packet"
|
|
6
|
+
require_relative "lib/savepoint"
|
|
7
|
+
|
|
8
|
+
# node-packet - the CLI over NodePacket (intent 338, G5). Builds one node's
|
|
9
|
+
# whole input from disk and writes it to packets/<node>--a<N>.packet (or
|
|
10
|
+
# --out), printing a parsable summary and the exact node-transition running
|
|
11
|
+
# command intent 340's runner should record.
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N]
|
|
15
|
+
# [--holder H] [--expires E] [--model M] [--attempt N]
|
|
16
|
+
# [--out PATH] [--force]
|
|
17
|
+
#
|
|
18
|
+
# Exit codes (spec D17, shared with node-transition's family):
|
|
19
|
+
# 0 - the packet was written (or the identical bytes already existed)
|
|
20
|
+
# 2 - usage: not an intent directory, missing --node, or an unknown node
|
|
21
|
+
# 3 - an unreadable, cyclic or unparsable graph, node file, or record
|
|
22
|
+
# 4 - overflow past the third cut; nothing was written
|
|
23
|
+
# 5 - an existing attempt file whose bytes differ; nothing was written
|
|
24
|
+
module NodePacketCLI
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def usage
|
|
28
|
+
warn "Usage: node-packet <intent_dir> --node <id> [--budget N] [--hop-tokens N] " \
|
|
29
|
+
"[--holder H] [--expires E] [--model M] [--attempt N] [--out PATH] [--force]"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def intent_directory?(dir)
|
|
33
|
+
dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def opt(args, name)
|
|
37
|
+
(i = args.index(name)) && args[i + 1]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def main(argv)
|
|
41
|
+
args = argv.dup
|
|
42
|
+
intent_dir_arg = args.shift
|
|
43
|
+
|
|
44
|
+
unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
|
|
45
|
+
warn "node-packet: #{intent_dir_arg.inspect} is not an intent directory"
|
|
46
|
+
usage
|
|
47
|
+
exit 2
|
|
48
|
+
end
|
|
49
|
+
intent_dir = File.expand_path(intent_dir_arg)
|
|
50
|
+
|
|
51
|
+
node = opt(args, "--node")
|
|
52
|
+
if node.to_s.strip.empty?
|
|
53
|
+
warn "node-packet: --node is required"
|
|
54
|
+
usage
|
|
55
|
+
exit 2
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
budget = opt(args, "--budget")
|
|
59
|
+
hop_tokens = opt(args, "--hop-tokens")
|
|
60
|
+
attempt = opt(args, "--attempt")
|
|
61
|
+
out = opt(args, "--out")
|
|
62
|
+
holder = opt(args, "--holder")
|
|
63
|
+
expires = opt(args, "--expires")
|
|
64
|
+
model = opt(args, "--model")
|
|
65
|
+
force = args.include?("--force")
|
|
66
|
+
|
|
67
|
+
result = NodePacket.build(
|
|
68
|
+
intent_dir: intent_dir,
|
|
69
|
+
node: node,
|
|
70
|
+
budget_tokens: budget ? budget.to_i : NodePacket::DEFAULT_BUDGET_TOKENS,
|
|
71
|
+
hop_tokens: hop_tokens ? hop_tokens.to_i : NodePacket::DEFAULT_HOP_TOKENS,
|
|
72
|
+
holder: holder,
|
|
73
|
+
expires: expires,
|
|
74
|
+
model: model,
|
|
75
|
+
attempt: attempt ? attempt.to_i : nil,
|
|
76
|
+
out: out,
|
|
77
|
+
force: force,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if result[:ok]
|
|
81
|
+
puts NodePacket.summary_line(result)
|
|
82
|
+
puts result[:running_command]
|
|
83
|
+
exit 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
Array(result[:errors]).each { |e| warn "node-packet: #{e}" }
|
|
87
|
+
puts result[:needs_decision_command] if result[:needs_decision_command]
|
|
88
|
+
exit result[:exit_code]
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
NodePacketCLI.main(ARGV) if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "lib/savepoint"
|
|
7
|
+
require_relative "lib/node_ledger"
|
|
8
|
+
require_relative "lib/ready_set"
|
|
9
|
+
require_relative "lib/guarded_append"
|
|
10
|
+
require_relative "lib/lock"
|
|
11
|
+
require_relative "lib/arm"
|
|
12
|
+
|
|
13
|
+
# node-transition - the CLI over NodeLedger that REFUSES what the graph does not
|
|
14
|
+
# allow (intent 335, G2). An orchestrator can run this by hand today, before the
|
|
15
|
+
# runner (intent 340) exists.
|
|
16
|
+
#
|
|
17
|
+
# Usage:
|
|
18
|
+
# node-transition <intent_dir> --node <id|Intent> --state <state> [--field k=v]...
|
|
19
|
+
# [--comment "..."] [--needs a,b] [--session SID] [--now ISO8601]
|
|
20
|
+
# node-transition report <intent_dir>
|
|
21
|
+
#
|
|
22
|
+
# Exit codes (spec D18, matching the tree: scripts/append-ledger:125 uses 3 for a
|
|
23
|
+
# guard that could not be taken, scripts/end-intent:675 uses 4 for a lock-ownership
|
|
24
|
+
# refusal):
|
|
25
|
+
# 0 - the line was appended
|
|
26
|
+
# 2 - usage: not an intent directory, bad subject, unknown state, missing
|
|
27
|
+
# required field, a tab or newline in a value
|
|
28
|
+
# 3 - the ledger guard was unavailable; nothing was written
|
|
29
|
+
# 4 - running, and the caller does not hold delivery.lock
|
|
30
|
+
# 5 - running, and the subject is not ready
|
|
31
|
+
# 6 - reclaimed, and the subject's last running line has not expired
|
|
32
|
+
module NodeTransition
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# The readiness precondition for `running` (intent 336, G3): ReadySet.ready?
|
|
36
|
+
# applied to `subject` against `edges`/`nodes` already parsed once, before
|
|
37
|
+
# the guard is ever taken (spec: "parsed once, before the guard"). Takes
|
|
38
|
+
# `content` (the ledger's raw text), never a path, so this method can be run
|
|
39
|
+
# twice: once as a cheap unguarded pre-check for a fast, readable refusal,
|
|
40
|
+
# and once - the AUTHORITATIVE decision - as the guard's precondition,
|
|
41
|
+
# evaluated against the exact content GuardedAppend just read under its
|
|
42
|
+
# lock hold (post-execution review row 7.1, carried forward by 336). A
|
|
43
|
+
# path-based re-read here would defeat the guard: two callers could each
|
|
44
|
+
# open their own handle, both see "planned", and both go on to append
|
|
45
|
+
# `running`.
|
|
46
|
+
def running_ready?(content, subject, edges, nodes, caps: ReadySet::DEFAULT_CAPS)
|
|
47
|
+
ReadySet.ready?(content: content, subject: subject, graph: { edges: edges }, nodes: nodes, caps: caps)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# graph.md and nodes/ for `subject`'s readiness (intent 336, G3): a genuinely
|
|
51
|
+
# missing graph.md degrades gracefully to no known needs/kind/files (the
|
|
52
|
+
# same fallback the temporary needs_from_graph reader gave, so a legacy
|
|
53
|
+
# intent with no graph.md still dispatches ad hoc node ids), while a
|
|
54
|
+
# PRESENT but broken graph.md (no ## Graph section, a cycle) is a clean
|
|
55
|
+
# refusal, never a raise - {edges:, nodes:, error:}.
|
|
56
|
+
def resolve_graph_and_nodes(intent_dir)
|
|
57
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
58
|
+
return { edges: {}, nodes: {}, error: nil } unless File.exist?(graph_path)
|
|
59
|
+
|
|
60
|
+
loaded = ReadySet.load_graph(intent_dir)
|
|
61
|
+
return { edges: loaded[:edges] || {}, nodes: loaded[:nodes] || {}, error: nil } if loaded[:ok]
|
|
62
|
+
|
|
63
|
+
{ edges: {}, nodes: {}, error: loaded[:errors].join("; ") }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Session resolution per spec D16: an explicit --session is authoritative and
|
|
67
|
+
# NEVER falls through (an explicit non-owner is refused outright, never
|
|
68
|
+
# silently granted ownership through a fallback). Without it, try the
|
|
69
|
+
# environment's CLAUDE_CODE_SESSION_ID, then the derived `auto-` key, and keep
|
|
70
|
+
# the first candidate that actually HOLDS the lock (owner or delegate,
|
|
71
|
+
# Lock.holds?) - so the fallback can never grant ownership to a session that
|
|
72
|
+
# does not have it.
|
|
73
|
+
def resolve_owning_session(intent_dir, explicit:, env_session:)
|
|
74
|
+
if explicit && !explicit.to_s.strip.empty?
|
|
75
|
+
return Lock.holds?(intent_dir, session: explicit) ? explicit : nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
store = Arm.store_for(intent_dir)
|
|
79
|
+
intent_id = Arm.intent_id_for(intent_dir)
|
|
80
|
+
candidates = [env_session, Arm.derive_key(store, intent_id)].reject { |c| c.to_s.strip.empty? }
|
|
81
|
+
candidates.find { |c| Lock.holds?(intent_dir, session: c) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def intent_directory?(dir)
|
|
85
|
+
dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def valid_subject?(subject)
|
|
89
|
+
subject == Savepoint::INTENT_SUBJECT || subject.to_s.match?(Savepoint::NODE_SUBJECT_RE)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def opt(args, name)
|
|
93
|
+
(i = args.index(name)) && args[i + 1]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def opt_all(args, name)
|
|
97
|
+
args.each_index.select { |i| args[i] == name }.map { |i| args[i + 1] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_fields(args)
|
|
101
|
+
opt_all(args, "--field").each_with_object({}) do |pair, memo|
|
|
102
|
+
key, value = pair.to_s.split("=", 2)
|
|
103
|
+
memo[key] = value if key && !key.empty?
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def usage
|
|
108
|
+
warn "Usage: node-transition <intent_dir> --node <id|Intent> --state <state> " \
|
|
109
|
+
"[--field k=v]... [--comment \"...\"] [--needs a,b] [--session SID] [--now ISO8601]"
|
|
110
|
+
warn " node-transition report <intent_dir>"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def run_report(intent_dir)
|
|
114
|
+
unless intent_directory?(intent_dir)
|
|
115
|
+
warn "node-transition: #{intent_dir.inspect} is not an intent directory"
|
|
116
|
+
usage
|
|
117
|
+
exit 2
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
savepoint_path = File.join(intent_dir, "savepoint.md")
|
|
121
|
+
anomalies = NodeLedger.anomalies(savepoint_path)
|
|
122
|
+
if anomalies.empty?
|
|
123
|
+
puts "node-transition report: no torn or unattributed transition lines"
|
|
124
|
+
else
|
|
125
|
+
anomalies.each { |a| puts "#{a[:reason]}: #{a[:line]}" }
|
|
126
|
+
end
|
|
127
|
+
exit 0
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def main(argv)
|
|
131
|
+
args = argv.dup
|
|
132
|
+
head = args.shift
|
|
133
|
+
|
|
134
|
+
if head == "report"
|
|
135
|
+
report_dir_arg = args.shift
|
|
136
|
+
run_report(report_dir_arg && File.expand_path(report_dir_arg))
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
intent_dir_arg = head
|
|
141
|
+
unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
|
|
142
|
+
warn "node-transition: #{intent_dir_arg.inspect} is not an intent directory"
|
|
143
|
+
usage
|
|
144
|
+
exit 2
|
|
145
|
+
end
|
|
146
|
+
intent_dir = File.expand_path(intent_dir_arg)
|
|
147
|
+
|
|
148
|
+
subject = opt(args, "--node")
|
|
149
|
+
state = opt(args, "--state")
|
|
150
|
+
comment = opt(args, "--comment")
|
|
151
|
+
needs_flag = opt(args, "--needs")
|
|
152
|
+
session_flag = opt(args, "--session")
|
|
153
|
+
now_flag = opt(args, "--now")
|
|
154
|
+
fields = parse_fields(args)
|
|
155
|
+
|
|
156
|
+
if subject.nil? || state.nil?
|
|
157
|
+
warn "node-transition: --node and --state are required"
|
|
158
|
+
usage
|
|
159
|
+
exit 2
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
unless valid_subject?(subject)
|
|
163
|
+
warn "node-transition: #{subject.inspect} is neither #{Savepoint::INTENT_SUBJECT} nor a valid node id"
|
|
164
|
+
exit 2
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
unless NodeLedger::STATES.include?(state)
|
|
168
|
+
warn "node-transition: unknown state #{state.inspect}"
|
|
169
|
+
exit 2
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
now = begin
|
|
173
|
+
now_flag ? Time.iso8601(now_flag) : Time.now
|
|
174
|
+
rescue ArgumentError
|
|
175
|
+
warn "node-transition: --now must be ISO 8601"
|
|
176
|
+
exit 2
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
begin
|
|
180
|
+
fields.each_value { |v| NodeLedger.normalize_value(v) }
|
|
181
|
+
NodeLedger.normalize_value(comment) if comment
|
|
182
|
+
rescue ArgumentError => e
|
|
183
|
+
warn "node-transition: #{e.message}"
|
|
184
|
+
exit 2
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
missing = NodeLedger.missing_fields(state, fields)
|
|
188
|
+
unless missing.empty?
|
|
189
|
+
warn "node-transition: state #{state} requires #{missing.join(', ')}"
|
|
190
|
+
exit 2
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
savepoint_path = File.join(intent_dir, "savepoint.md")
|
|
194
|
+
precondition = nil
|
|
195
|
+
|
|
196
|
+
case state
|
|
197
|
+
when "running"
|
|
198
|
+
owning_session = resolve_owning_session(intent_dir, explicit: session_flag,
|
|
199
|
+
env_session: ENV["CLAUDE_CODE_SESSION_ID"])
|
|
200
|
+
unless owning_session
|
|
201
|
+
warn "node-transition: the caller does not hold delivery.lock for #{intent_dir}"
|
|
202
|
+
exit 4
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
if subject == Savepoint::INTENT_SUBJECT
|
|
206
|
+
# The node readiness rule (four conditions over the work graph) is
|
|
207
|
+
# scoped to node subjects; the intent-scope subject is never gated by
|
|
208
|
+
# it, so a legacy intent with no graph.md at all can still write an
|
|
209
|
+
# Intent line (spec Acceptance Criteria; matrix "Transition the
|
|
210
|
+
# Intent subject").
|
|
211
|
+
precondition = nil
|
|
212
|
+
else
|
|
213
|
+
resolved = resolve_graph_and_nodes(intent_dir)
|
|
214
|
+
if resolved[:error]
|
|
215
|
+
warn "node-transition: #{resolved[:error]}"
|
|
216
|
+
exit 5
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
needs_override = needs_flag ? needs_flag.split(",").map(&:strip).reject(&:empty?) : nil
|
|
220
|
+
edges = needs_override ? resolved[:edges].merge(subject => needs_override) : resolved[:edges]
|
|
221
|
+
nodes = resolved[:nodes]
|
|
222
|
+
|
|
223
|
+
# Fast, unguarded pre-check: a cheap, readable refusal for the common
|
|
224
|
+
# case, but NEVER the authoritative decision (post-execution review
|
|
225
|
+
# row 7.1, carried forward by 336). The authoritative check is the
|
|
226
|
+
# `precondition` below, run inside GuardedAppend's lock hold against
|
|
227
|
+
# the content it actually read. `edges` and `nodes` are parsed once,
|
|
228
|
+
# above, before the guard is ever taken (spec D1).
|
|
229
|
+
precheck_content = File.exist?(savepoint_path) ? File.read(savepoint_path) : ""
|
|
230
|
+
result = running_ready?(precheck_content, subject, edges, nodes)
|
|
231
|
+
unless result[:ready]
|
|
232
|
+
warn "node-transition: #{result[:blockers].join('; ')}"
|
|
233
|
+
exit 5
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
precondition = ->(content) { running_ready?(content, subject, edges, nodes)[:ready] }
|
|
237
|
+
end
|
|
238
|
+
when "reclaimed"
|
|
239
|
+
running_entry = NodeLedger.last_running(savepoint_path, subject)
|
|
240
|
+
unless running_entry
|
|
241
|
+
warn "node-transition: #{subject} has no running line to reclaim"
|
|
242
|
+
exit 6
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Post-execution review row 7.4: expiry alone is not enough. A `done`
|
|
246
|
+
# node whose last `running` line's expires= happens to be in the past
|
|
247
|
+
# must not revert to `planned` and become re-dispatchable - reclaim is
|
|
248
|
+
# deliberately lock-free (spec D43), so this status check is the only
|
|
249
|
+
# thing that stops it. Row 7.5 pins that a genuinely running, expired
|
|
250
|
+
# subject is still reclaimable, so the crash sweep keeps working.
|
|
251
|
+
current_status = NodeLedger.status_for(savepoint_path, subject)
|
|
252
|
+
unless current_status == "running"
|
|
253
|
+
warn "node-transition: #{subject} is #{current_status}, not running; nothing to reclaim"
|
|
254
|
+
exit 6
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
expires_raw = (running_entry[:fields] || {})["expires"]
|
|
258
|
+
expiry_time = begin
|
|
259
|
+
expires_raw && Time.iso8601(expires_raw)
|
|
260
|
+
rescue ArgumentError
|
|
261
|
+
nil
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
unless expiry_time && now > expiry_time
|
|
265
|
+
warn "node-transition: #{subject}'s running line has not expired yet"
|
|
266
|
+
exit 6
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
result = begin
|
|
271
|
+
NodeLedger.append_transition(savepoint_path, subject: subject, state: state, fields: fields,
|
|
272
|
+
comment: comment, now: now, precondition: precondition)
|
|
273
|
+
rescue GuardedAppend::Unavailable => e
|
|
274
|
+
warn "node-transition: #{e.message}"
|
|
275
|
+
exit 3
|
|
276
|
+
rescue ArgumentError => e
|
|
277
|
+
warn "node-transition: #{e.message}"
|
|
278
|
+
exit 2
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
if result == :refused
|
|
282
|
+
warn "node-transition: #{subject} was no longer ready by the time the write lock was taken"
|
|
283
|
+
exit 5
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
puts "node-transition: appended #{state} for #{subject}"
|
|
287
|
+
exit 0
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
NodeTransition.main(ARGV) if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# outcome-report - the CLI over scripts/lib/outcome_report.rb (intent 339, G6).
|
|
6
|
+
# Prints the generated outcome.md by default; --write puts it on disk through
|
|
7
|
+
# AtomicWrite. `end-intent` calls the library directly (never shells out to
|
|
8
|
+
# this), so this command is for a human or an agent checking the generated
|
|
9
|
+
# report before or instead of a close.
|
|
10
|
+
#
|
|
11
|
+
# Usage:
|
|
12
|
+
# outcome-report <intent_dir> [--write] [--disposition delivered|abandoned]
|
|
13
|
+
#
|
|
14
|
+
# Exit codes:
|
|
15
|
+
# 0 - printed (or written) successfully
|
|
16
|
+
# 2 - usage error: no path given, an unknown flag, or the path is not an
|
|
17
|
+
# intent directory
|
|
18
|
+
# 3 - the report model could not be built (a malformed or absent graph.md)
|
|
19
|
+
|
|
20
|
+
require_relative "lib/outcome_report"
|
|
21
|
+
require_relative "lib/intent_screen"
|
|
22
|
+
require_relative "lib/outcome_guard"
|
|
23
|
+
|
|
24
|
+
def usage_abort(message)
|
|
25
|
+
warn "outcome-report: #{message}"
|
|
26
|
+
exit 2
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
args = ARGV.dup
|
|
30
|
+
write_flag = false
|
|
31
|
+
disposition = "delivered"
|
|
32
|
+
positional = []
|
|
33
|
+
|
|
34
|
+
while (arg = args.shift)
|
|
35
|
+
case arg
|
|
36
|
+
when "--write"
|
|
37
|
+
write_flag = true
|
|
38
|
+
when "--disposition"
|
|
39
|
+
disposition = args.shift or usage_abort("--disposition needs a value")
|
|
40
|
+
else
|
|
41
|
+
usage_abort("unknown flag #{arg.inspect}") if arg.start_with?("--")
|
|
42
|
+
positional << arg
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
target = positional.first
|
|
47
|
+
usage_abort("usage: outcome-report <intent_dir> [--write] [--disposition delivered|abandoned]") unless target
|
|
48
|
+
# Row v1f.9 (N3): refuse an unknown --disposition here rather than write a
|
|
49
|
+
# record OutcomeGuard then refuses. OutcomeGuard::DISPOSITIONS is already
|
|
50
|
+
# loaded in-process, so this is one comparison, not a duplicated list.
|
|
51
|
+
unless OutcomeGuard::DISPOSITIONS.include?(disposition)
|
|
52
|
+
usage_abort("--disposition must be one of #{OutcomeGuard::DISPOSITIONS.join('|')}, got #{disposition.inspect}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
intent_dir = File.expand_path(target)
|
|
56
|
+
usage_abort("#{intent_dir} is not an intent directory") unless IntentScreen.intent_dir?(intent_dir)
|
|
57
|
+
|
|
58
|
+
model = OutcomeReport.model(intent_dir)
|
|
59
|
+
unless model[:ok]
|
|
60
|
+
warn "outcome-report: the report model could not be built: #{model[:errors].join('; ')}"
|
|
61
|
+
exit 3
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if write_flag
|
|
65
|
+
OutcomeReport.write(intent_dir, disposition: disposition)
|
|
66
|
+
puts File.join(intent_dir, "outcome.md")
|
|
67
|
+
else
|
|
68
|
+
outcome_path = File.join(intent_dir, "outcome.md")
|
|
69
|
+
existing = File.exist?(outcome_path) ? File.read(outcome_path) : nil
|
|
70
|
+
puts OutcomeReport.render(model, disposition: disposition, existing: existing,
|
|
71
|
+
findings: OutcomeReport.findings(intent_dir), intent_dir: intent_dir)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
exit 0
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# ready-set - the CLI over ReadySet (intent 336, G3). No computed field this
|
|
6
|
+
# intent adds ships without a script that reads it (327 D18, C19): prints the
|
|
7
|
+
# ready order, the blockers for every unready node, the batches, and every
|
|
8
|
+
# maximal-length critical path; emits JSON for a caller like the runner (G7).
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# ready-set <intent_dir> [--json] [--ranker finish-first|critical-path]
|
|
12
|
+
#
|
|
13
|
+
# Exit codes (matching scripts/validate-work-graph's shape):
|
|
14
|
+
# 0 - printed (or emitted as JSON)
|
|
15
|
+
# 1 - the graph is invalid (missing, cyclic, or a malformed node file)
|
|
16
|
+
# 2 - usage: not an intent directory, or an unknown --ranker value
|
|
17
|
+
|
|
18
|
+
require "json"
|
|
19
|
+
require_relative "lib/ready_set"
|
|
20
|
+
require_relative "lib/savepoint"
|
|
21
|
+
|
|
22
|
+
module ReadySetCli
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
RANKERS = {
|
|
26
|
+
"finish-first" => -> { ReadySet::FinishFirstRanker.new },
|
|
27
|
+
"critical-path" => -> { ReadySet::CriticalPathRanker.new },
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
def usage
|
|
31
|
+
warn "Usage: ready-set <intent_dir> [--json] [--ranker #{RANKERS.keys.join('|')}]"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def intent_directory?(dir)
|
|
35
|
+
dir && File.directory?(dir) && File.exist?(Savepoint.intent_file(dir))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def main(argv)
|
|
39
|
+
args = argv.dup
|
|
40
|
+
json = !!args.delete("--json")
|
|
41
|
+
|
|
42
|
+
ranker_name = nil
|
|
43
|
+
if (i = args.index("--ranker"))
|
|
44
|
+
ranker_name = args[i + 1]
|
|
45
|
+
args.slice!(i, 2)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
intent_dir_arg = args.shift
|
|
49
|
+
unless intent_directory?(intent_dir_arg && File.expand_path(intent_dir_arg))
|
|
50
|
+
warn "ready-set: #{intent_dir_arg.inspect} is not an intent directory"
|
|
51
|
+
usage
|
|
52
|
+
return 2
|
|
53
|
+
end
|
|
54
|
+
intent_dir = File.expand_path(intent_dir_arg)
|
|
55
|
+
|
|
56
|
+
ranker = nil
|
|
57
|
+
if ranker_name
|
|
58
|
+
factory = RANKERS[ranker_name]
|
|
59
|
+
unless factory
|
|
60
|
+
warn "ready-set: unknown ranker #{ranker_name.inspect} (use #{RANKERS.keys.join(', ')})"
|
|
61
|
+
return 2
|
|
62
|
+
end
|
|
63
|
+
ranker = factory.call
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
analysis = ReadySet.analyze(intent_dir, ranker: ranker)
|
|
67
|
+
unless analysis[:ok]
|
|
68
|
+
warn "ready-set: #{analysis[:errors].join('; ')}"
|
|
69
|
+
return 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if json
|
|
73
|
+
puts JSON.pretty_generate(json_payload(analysis))
|
|
74
|
+
else
|
|
75
|
+
print_report(analysis)
|
|
76
|
+
end
|
|
77
|
+
0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def json_payload(analysis)
|
|
81
|
+
unready = analysis[:nodes].reject { |_, v| v[:ready] }
|
|
82
|
+
{
|
|
83
|
+
"ready" => analysis[:ranked_ready].map { |r| r[:id] },
|
|
84
|
+
"ranker" => analysis[:ranker_name],
|
|
85
|
+
"batches" => analysis[:batches],
|
|
86
|
+
"critical_path" => analysis[:critical_path],
|
|
87
|
+
"paths" => analysis[:paths],
|
|
88
|
+
"hops" => analysis[:hops],
|
|
89
|
+
"max_paths_total" => analysis[:max_paths_total],
|
|
90
|
+
"blockers" => unready.transform_values { |v| v[:blockers] },
|
|
91
|
+
"errors" => analysis[:errors],
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def print_report(analysis)
|
|
96
|
+
unready = analysis[:nodes].reject { |_, v| v[:ready] }
|
|
97
|
+
|
|
98
|
+
puts "Ranker: #{analysis[:ranker_name]}"
|
|
99
|
+
puts
|
|
100
|
+
puts "Ready (#{analysis[:ranked_ready].length}):"
|
|
101
|
+
if analysis[:ranked_ready].empty?
|
|
102
|
+
puts " (none)"
|
|
103
|
+
else
|
|
104
|
+
analysis[:ranked_ready].each { |r| puts " #{r[:id]}" }
|
|
105
|
+
end
|
|
106
|
+
puts
|
|
107
|
+
puts "Blockers:"
|
|
108
|
+
if unready.empty?
|
|
109
|
+
puts " (none)"
|
|
110
|
+
else
|
|
111
|
+
unready.each do |id, view|
|
|
112
|
+
puts " #{id}:"
|
|
113
|
+
view[:blockers].each { |b| puts " - #{b}" }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
puts
|
|
117
|
+
puts "Batches:"
|
|
118
|
+
analysis[:batches].each_with_index { |batch, i| puts " #{i + 1}: #{batch.join(', ')}" }
|
|
119
|
+
puts
|
|
120
|
+
puts "Critical path (#{analysis[:hops]} hops): #{(analysis[:critical_path] || []).join(' > ')}"
|
|
121
|
+
puts "Every maximal-length path (#{analysis[:max_paths_total]} total, #{analysis[:paths].length} shown):"
|
|
122
|
+
analysis[:paths].each { |p| puts " #{p.join(' > ')}" }
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
exit(ReadySetCli.main(ARGV)) if $PROGRAM_NAME == __FILE__
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: UTF-8
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# release-check - deterministic CLI over ReleaseGuard (intent 347).
|
|
6
|
+
#
|
|
7
|
+
# Guards a tagged publish before the workflow ever reaches `npm publish`:
|
|
8
|
+
# the pushed tag must equal `v` plus the version in package.json, the three
|
|
9
|
+
# repo version files must agree, and the runner's npm must meet the 11.5.1
|
|
10
|
+
# floor npm requires for OIDC trusted publishing. Writes the derived
|
|
11
|
+
# dist-tag and version to $GITHUB_OUTPUT, so the alpha/beta/latest rule has
|
|
12
|
+
# exactly one implementation, ReleaseGuard.dist_tag, shared with the test
|
|
13
|
+
# suite.
|
|
14
|
+
#
|
|
15
|
+
# Usage:
|
|
16
|
+
# release-check --tag <tag> --npm-version <version>
|
|
17
|
+
# [--github-output <path>] [--root <dir>]
|
|
18
|
+
#
|
|
19
|
+
# Exit codes: 0 (clean), 1 (violations; reported on stderr, one per line,
|
|
20
|
+
# each naming the specific thing that is wrong), 2 (usage).
|
|
21
|
+
|
|
22
|
+
require "json"
|
|
23
|
+
|
|
24
|
+
require_relative "lib/release_guard"
|
|
25
|
+
|
|
26
|
+
NPM_FLOOR = [11, 5, 1].freeze
|
|
27
|
+
KNOWN_FLAGS = %w[--tag --npm-version --github-output --root].freeze
|
|
28
|
+
|
|
29
|
+
def usage
|
|
30
|
+
warn "usage: release-check --tag <tag> --npm-version <version> [--github-output <path>] [--root <dir>]"
|
|
31
|
+
exit 2
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_args(argv)
|
|
35
|
+
opts = {}
|
|
36
|
+
i = 0
|
|
37
|
+
while i < argv.length
|
|
38
|
+
flag = argv[i]
|
|
39
|
+
usage unless KNOWN_FLAGS.include?(flag)
|
|
40
|
+
|
|
41
|
+
value = argv[i + 1]
|
|
42
|
+
usage if value.nil?
|
|
43
|
+
|
|
44
|
+
opts[flag] = value
|
|
45
|
+
i += 2
|
|
46
|
+
end
|
|
47
|
+
opts
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def read_version(path)
|
|
51
|
+
JSON.parse(File.read(path))["version"]
|
|
52
|
+
rescue Errno::ENOENT, JSON::ParserError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
opts = parse_args(ARGV)
|
|
57
|
+
usage unless opts["--tag"] && opts["--npm-version"]
|
|
58
|
+
|
|
59
|
+
root = opts["--root"] ? File.expand_path(opts["--root"]) : File.expand_path("..", __dir__)
|
|
60
|
+
package_json_path = File.join(root, "package.json")
|
|
61
|
+
plugin_json_path = File.join(root, ".claude-plugin", "plugin.json")
|
|
62
|
+
marketplace_json_path = File.join(root, ".claude-plugin", "marketplace.json")
|
|
63
|
+
|
|
64
|
+
violations = []
|
|
65
|
+
|
|
66
|
+
canonical_version = read_version(package_json_path)
|
|
67
|
+
violations << "could not read a version from #{package_json_path}" if canonical_version.nil?
|
|
68
|
+
|
|
69
|
+
tag = opts["--tag"].sub(%r{\Arefs/tags/}, "")
|
|
70
|
+
dist_tag = nil
|
|
71
|
+
|
|
72
|
+
if canonical_version
|
|
73
|
+
expected_tag = "v#{canonical_version}"
|
|
74
|
+
if tag != expected_tag
|
|
75
|
+
violations << "tag #{tag} does not match package.json version #{canonical_version} (expected #{expected_tag})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
dist_tag = ReleaseGuard.dist_tag(canonical_version)
|
|
79
|
+
stable = dist_tag == "latest"
|
|
80
|
+
|
|
81
|
+
result = ReleaseGuard.check(
|
|
82
|
+
package_json: package_json_path,
|
|
83
|
+
plugin_json: plugin_json_path,
|
|
84
|
+
marketplace_json: marketplace_json_path,
|
|
85
|
+
stable: stable
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
unless result.ok?
|
|
89
|
+
result.mismatches.each do |file|
|
|
90
|
+
violations << "version file #{file} does not agree with package.json (#{canonical_version})"
|
|
91
|
+
end
|
|
92
|
+
if stable && result.prerelease_suffix
|
|
93
|
+
violations << "stable cut #{canonical_version} carries the pre-release suffix -#{result.prerelease_suffix}"
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
npm_version = opts["--npm-version"].to_s.strip
|
|
99
|
+
if npm_version.empty? || !npm_version.match?(/\A\d+(\.\d+)*\z/)
|
|
100
|
+
violations << "npm --version reported #{opts["--npm-version"].inspect}, which is empty or not numeric"
|
|
101
|
+
elsif (npm_version.split(".").map(&:to_i) <=> NPM_FLOOR) < 0
|
|
102
|
+
violations << "npm #{npm_version} is below the 11.5.1 floor OIDC trusted publishing requires"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
if violations.empty?
|
|
106
|
+
if opts["--github-output"]
|
|
107
|
+
File.open(opts["--github-output"], "a") do |f|
|
|
108
|
+
f.puts "version=#{canonical_version}"
|
|
109
|
+
f.puts "dist_tag=#{dist_tag}"
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
puts "OK: tag #{tag}, version #{canonical_version}, dist-tag #{dist_tag}"
|
|
113
|
+
exit 0
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
warn "VIOLATIONS:"
|
|
117
|
+
violations.each { |v| warn "- #{v}" }
|
|
118
|
+
exit 1
|