@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.
Files changed (46) hide show
  1. package/package.json +2 -2
  2. package/scripts/dashboard.rb +20 -0
  3. package/scripts/doctor.rb +120 -2
  4. package/scripts/end-intent +134 -8
  5. package/scripts/hook-capture +4 -105
  6. package/scripts/lib/action_graph_shim.rb +277 -0
  7. package/scripts/lib/atomic_write.rb +31 -0
  8. package/scripts/lib/graph_edges.rb +121 -0
  9. package/scripts/lib/graph_file.rb +246 -0
  10. package/scripts/lib/guarded_append.rb +155 -0
  11. package/scripts/lib/installer_core.rb +32 -0
  12. package/scripts/lib/node_file.rb +214 -0
  13. package/scripts/lib/node_ids.rb +99 -0
  14. package/scripts/lib/node_ledger.rb +377 -0
  15. package/scripts/lib/node_packet.rb +873 -0
  16. package/scripts/lib/outcome_report.rb +440 -0
  17. package/scripts/lib/packet_wrapper.rb +132 -0
  18. package/scripts/lib/ready_set.rb +462 -0
  19. package/scripts/lib/release_guard.rb +16 -0
  20. package/scripts/lib/report_screen.rb +122 -12
  21. package/scripts/lib/roadmap_queue.rb +161 -3
  22. package/scripts/lib/roadmap_savepoint.rb +26 -5
  23. package/scripts/lib/savepoint.rb +123 -12
  24. package/scripts/lib/work_graph_validator.rb +201 -0
  25. package/scripts/node-packet +92 -0
  26. package/scripts/node-transition +291 -0
  27. package/scripts/outcome-report +74 -0
  28. package/scripts/ready-set +126 -0
  29. package/scripts/release-check +118 -0
  30. package/scripts/report-screen +8 -1
  31. package/scripts/roadmap-savepoint +7 -0
  32. package/scripts/validate-work-graph +39 -0
  33. package/skills/auto/SKILL.md +2 -3
  34. package/skills/auto/references/human-report-contract.md +3 -2
  35. package/skills/intent-continuing/references/boarding-matrix.md +1 -0
  36. package/skills/intent-ending/SKILL.md +30 -19
  37. package/skills/intent-executing/SKILL.md +1 -1
  38. package/skills/releasing/SKILL.md +39 -0
  39. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/templates/graph.md +16 -0
  42. package/templates/node-decision.md +11 -0
  43. package/templates/node-research.md +11 -0
  44. package/templates/node-verify.md +13 -0
  45. package/templates/node-work.md +22 -0
  46. 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