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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
@@ -0,0 +1,462 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_file"
5
+ require_relative "graph_edges"
6
+ require_relative "node_file"
7
+ require_relative "node_ledger"
8
+
9
+ # ReadySet (intent 336, G3): the one function that says what may run next.
10
+ # A node is ready when four conditions all hold: its own state is eligible,
11
+ # every need is done under an attributed non-torn line, no file-overlapping
12
+ # sibling is currently running, and its dispatch attempts are under its
13
+ # kind's cap. Every failed condition contributes a named blocker. Batches are
14
+ # the topological layers of the edge set; the critical path is every
15
+ # maximal-length chain, counted in nodes.
16
+ #
17
+ # Pure and dependency-injected: #ready? takes already-parsed values (content,
18
+ # graph, nodes) and touches no filesystem at all, because the authoritative
19
+ # call runs inside GuardedAppend's lock hold against the exact bytes the
20
+ # guard read - a second, unguarded re-read there would buy nothing. #analyze
21
+ # is the disk-reading counterpart every other caller uses. This module reads
22
+ # no environment variable and knows nothing about the cross-intent knowledge
23
+ # graph an intent's own frontmatter carries; that field is deliberately never
24
+ # named here (327 D40).
25
+ module ReadySet
26
+ module_function
27
+
28
+ # D3: retry caps by kind, counted as dispatch attempts (running lines since
29
+ # the subject's last terminal line), not failures. Injectable via `caps:`
30
+ # so a caller overrides the policy without editing this module.
31
+ DEFAULT_CAPS = { "work" => 3, "verify" => 3, "research" => 2, "decision" => 2 }.freeze
32
+
33
+ # D9: a fan-in graph has exponentially many maximal-length paths; this
34
+ # bounds how many are ever materialized, with the true total reported
35
+ # beside them via a separate polynomial count.
36
+ DEFAULT_MAX_PATHS = 16
37
+
38
+ # A written state's resolved status is eligible for "running" (D1's
39
+ # condition 1) only for these two: `reclaimed` already resolves to
40
+ # `planned` through NodeLedger::RESOLUTION, and `failed_verification` is
41
+ # deliberately eligible too, so the retry C3 requires is representable.
42
+ READY_PRIOR_STATES = %w[planned failed_verification].freeze
43
+
44
+ TERMINAL_STATES = %w[done superseded abandoned].freeze
45
+
46
+ # The fixed key list a ranker row ever carries (D11/C16): every key is
47
+ # state- or edge-derived, never a telemetry field (tokens, wall, model,
48
+ # suite, packet). Pinned by test/ready_set_ranker_test.rb.
49
+ ROW_KEYS = %i[id kind batch retry downstream_hops on_critical_path].freeze
50
+
51
+ # --- the readiness decision (D1) ---------------------------------------------
52
+
53
+ # ready?(content:, subject:, graph:, nodes:, caps:) -> {ready:, blockers:}.
54
+ # `graph` carries {edges: {id => [needs...]}}; `nodes` carries
55
+ # {id => {kind:, files:}}. Touches no filesystem; every fact it needs is a
56
+ # value already passed in, so it can be called again after the intent
57
+ # directory backing those values has been deleted.
58
+ def ready?(content:, subject:, graph:, nodes:, caps: DEFAULT_CAPS, now: Time.now)
59
+ blockers = []
60
+ status_map = NodeLedger.status_from_content(content)
61
+ entries = NodeLedger.entries_from_content(content)
62
+
63
+ own_status = status_map.fetch(subject.to_s, "planned")
64
+ unless READY_PRIOR_STATES.include?(own_status)
65
+ blockers << "#{subject} is #{own_status}, not eligible to enter running"
66
+ end
67
+
68
+ own_decl = nodes[subject.to_s] || {}
69
+ # Finding 1b: a node graph.md declares with no readable nodes/ file
70
+ # (missing, or a malformed envelope) carries this from load_graph and is
71
+ # never ready, regardless of what the other three conditions say.
72
+ blockers << own_decl[:file_error] if own_decl[:file_error]
73
+
74
+ edges = (graph || {})[:edges] || {}
75
+ needs = edges[subject.to_s] || []
76
+ needs.each do |target|
77
+ last = entries.select { |e| !e[:torn] && e[:subject] == target }.last
78
+ unless last && last[:state] == "done" && NodeLedger.attributed?(last)
79
+ blockers << "needs target #{target} has no attributed, well-formed done line"
80
+ end
81
+ end
82
+
83
+ if dead_end?(subject.to_s, edges, status_map)
84
+ blockers << "#{subject} is a dead end: a needed node is superseded or abandoned"
85
+ end
86
+
87
+ own_files = normalize_files(own_decl[:files])
88
+ if own_files.any?
89
+ overlapping = (nodes.keys - [subject.to_s]).select do |other|
90
+ other_files = normalize_files((nodes[other] || {})[:files])
91
+ next false if other_files.empty?
92
+ next false unless status_map.fetch(other, "planned") == "running"
93
+
94
+ (own_files & other_files).any?
95
+ end
96
+ if overlapping.any?
97
+ blockers << "file overlap with running sibling(s): #{overlapping.sort.join(', ')}"
98
+ end
99
+ end
100
+
101
+ # Finding 1a: an unknown or nil kind (a node the graph never declared,
102
+ # or one whose file could not be read) must never fall through to no
103
+ # cap at all - it gets the work cap, the widest of the four, rather
104
+ # than being skipped or refused outright. Preserves 335's shipped
105
+ # legacy path: no graph.md at all means an empty nodes map and a nil
106
+ # kind, which must keep dispatching, bounded by this fallback cap.
107
+ kind = own_decl[:kind]
108
+ caps_table = caps || DEFAULT_CAPS
109
+ cap = caps_table.fetch(kind) { caps_table["work"] }
110
+ if cap
111
+ attempts = attempts_count(entries, subject.to_s)
112
+ blockers << "#{subject} is at its dispatch cap (#{attempts}/#{cap})" if attempts >= cap
113
+ end
114
+
115
+ { ready: blockers.empty?, blockers: blockers }
116
+ end
117
+
118
+ def normalize_file(f)
119
+ f.to_s.sub(%r{\A\./}, "").sub(%r{/\z}, "")
120
+ end
121
+
122
+ def normalize_files(files)
123
+ (files || []).map { |f| normalize_file(f) }
124
+ end
125
+
126
+ # D3: the `running` lines for `subject` since its last `done`, `superseded`
127
+ # or `abandoned` line, or from the start of the ledger when it has none.
128
+ def attempts_count(entries, subject)
129
+ own = entries.select { |e| !e[:torn] && e[:subject] == subject }
130
+ last_terminal = own.rindex { |e| TERMINAL_STATES.include?(e[:state]) }
131
+ after = last_terminal ? own[(last_terminal + 1)..-1] : own
132
+ after.count { |e| e[:state] == "running" }
133
+ end
134
+
135
+ # D5: every non-torn failed_verification line for subject in the whole
136
+ # ledger. Never resets, and never drives the cap (D3's attempt count does).
137
+ def failed_verification_count(entries, subject)
138
+ entries.count { |e| !e[:torn] && e[:subject] == subject && e[:state] == "failed_verification" }
139
+ end
140
+
141
+ # D7: subject is a dead end when any of its needs targets, directly or
142
+ # transitively, resolves to superseded or abandoned.
143
+ def dead_end?(subject, edges, status_map, memo = {})
144
+ return memo[subject] if memo.key?(subject)
145
+
146
+ memo[subject] = false
147
+ needs = edges[subject] || []
148
+ result = needs.any? do |target|
149
+ %w[superseded abandoned].include?(status_map.fetch(target, "planned")) ||
150
+ dead_end?(target, edges, status_map, memo)
151
+ end
152
+ memo[subject] = result
153
+ end
154
+
155
+ # D8: a done node is stale when, at the end of the ledger, one of its needs
156
+ # resolves to superseded or abandoned AND the line that put it there comes
157
+ # later in file order than the node's own done line.
158
+ def stale?(entries, subject, edges, status_map)
159
+ return false unless status_map[subject] == "done"
160
+
161
+ own_done_idx = entries.each_index.select do |i|
162
+ !entries[i][:torn] && entries[i][:subject] == subject && entries[i][:state] == "done"
163
+ end.last
164
+ return false unless own_done_idx
165
+
166
+ needs = edges[subject] || []
167
+ needs.any? do |target|
168
+ target_idx = entries.each_index.select { |i| !entries[i][:torn] && entries[i][:subject] == target }.last
169
+ next false unless target_idx
170
+
171
+ %w[superseded abandoned].include?(entries[target_idx][:state]) && target_idx > own_done_idx
172
+ end
173
+ end
174
+
175
+ # --- the disk-reading counterpart --------------------------------------------
176
+
177
+ # load_graph(intent_dir) -> {ok:, edges:, nodes:, errors:}. The parse-once
178
+ # step every caller (analyze, node-transition, the roadmap frontier, the
179
+ # doctor rule) shares: graph.md through GraphFile (the one parser), each
180
+ # declared node's kind and files through NodeFile. Never raises: a missing
181
+ # or invalid graph.md, a cyclic graph, a malformed node file, or a node the
182
+ # graph declares with no file all become `ok: false` or an `errors` entry,
183
+ # never an exception across the boundary.
184
+ def load_graph(intent_dir)
185
+ graph_path = File.join(intent_dir, "graph.md")
186
+ parsed = GraphFile.parse(graph_path)
187
+ if parsed[:graph].nil?
188
+ errors = parsed[:errors].empty? ? ["missing or invalid graph.md at #{graph_path}"] : parsed[:errors]
189
+ return { ok: false, edges: {}, nodes: {}, errors: errors }
190
+ end
191
+
192
+ edges = parsed[:graph][:edges]
193
+ errors = parsed[:graph][:errors].dup
194
+
195
+ cyc = GraphEdges.cycle(edges)
196
+ if cyc
197
+ return { ok: false, edges: edges, nodes: {},
198
+ errors: errors + ["cyclic graph, cannot compute readiness: #{cyc.join(' > ')}"] }
199
+ end
200
+
201
+ nodes = {}
202
+ parsed[:graph][:nodes].each do |id|
203
+ path = find_node_path(intent_dir, id)
204
+ if path.nil?
205
+ msg = "node #{id.inspect} is declared in graph.md but has no nodes/ file"
206
+ errors << msg
207
+ nodes[id] = { kind: nil, files: [], file_error: msg }
208
+ next
209
+ end
210
+
211
+ nf = NodeFile.parse(path)
212
+ if nf[:ok]
213
+ nodes[id] = { kind: nf[:kind], files: nf[:files] || [] }
214
+ else
215
+ msg = "node #{id.inspect}'s file is malformed: #{nf[:errors].join('; ')}"
216
+ errors << msg
217
+ nodes[id] = { kind: nil, files: [], file_error: msg }
218
+ end
219
+ end
220
+
221
+ { ok: true, edges: edges, nodes: nodes, errors: errors }
222
+ end
223
+
224
+ # analyze(intent_dir, now:, caps:, max_paths:, ranker:) -> a Result hash
225
+ # over the whole graph: per-node view, batches, critical path(s), and the
226
+ # ranked ready order. Never raises across the boundary: everything
227
+ # #load_graph reports becomes an entry in `errors`, and analysis continues
228
+ # for every node it still can.
229
+ def analyze(intent_dir, now: Time.now, caps: DEFAULT_CAPS, max_paths: DEFAULT_MAX_PATHS, ranker: nil)
230
+ ranker ||= FinishFirstRanker.new
231
+ loaded = load_graph(intent_dir)
232
+ return error_result(loaded[:errors]) unless loaded[:ok]
233
+
234
+ edges = loaded[:edges]
235
+ nodes = loaded[:nodes]
236
+ errors = loaded[:errors]
237
+
238
+ savepoint_path = File.join(intent_dir, "savepoint.md")
239
+ content = File.exist?(savepoint_path) ? File.read(savepoint_path) : ""
240
+
241
+ views = build_node_views(content: content, edges: edges, nodes: nodes, caps: caps)
242
+
243
+ batch_result = batches(edges)
244
+ path_result = critical_paths(edges, max_paths: max_paths)
245
+
246
+ rows = ready_rows(views, batch_result, path_result)
247
+ ranked = ranker.rank(rows)
248
+
249
+ {
250
+ ok: true,
251
+ errors: errors,
252
+ nodes: views,
253
+ batches: batch_result[:ok] ? batch_result[:batches] : [],
254
+ batches_error: batch_result[:ok] ? nil : batch_result[:error],
255
+ critical_path: path_result[:ok] ? path_result[:critical_path] : nil,
256
+ paths: path_result[:ok] ? path_result[:paths] : [],
257
+ hops: path_result[:ok] ? path_result[:hops] : 0,
258
+ max_paths_total: path_result[:ok] ? path_result[:total] : 0,
259
+ ranked_ready: ranked,
260
+ ranker_name: ranker.name,
261
+ }
262
+ end
263
+
264
+ def error_result(errors)
265
+ {
266
+ ok: false, errors: errors, nodes: {}, batches: [], batches_error: nil, critical_path: nil,
267
+ paths: [], hops: 0, max_paths_total: 0, ranked_ready: [], ranker_name: nil,
268
+ }
269
+ end
270
+
271
+ def find_node_path(intent_dir, id)
272
+ Dir.glob(File.join(intent_dir, "nodes", "#{id}*.md")).sort.find do |f|
273
+ NodeFile.filename_matches_id?(File.basename(f, ".md"), id)
274
+ end
275
+ end
276
+
277
+ def build_node_views(content:, edges:, nodes:, caps:)
278
+ status_map = NodeLedger.status_from_content(content)
279
+ entries = NodeLedger.entries_from_content(content)
280
+ dead_memo = {}
281
+
282
+ nodes.each_with_object({}) do |(id, decl), views|
283
+ state = status_map.fetch(id, "planned")
284
+ r = ready?(content: content, subject: id, graph: { edges: edges }, nodes: nodes, caps: caps)
285
+ views[id] = {
286
+ kind: decl[:kind],
287
+ files: decl[:files] || [],
288
+ state: state,
289
+ attempts: attempts_count(entries, id),
290
+ cap: (caps || DEFAULT_CAPS)[decl[:kind]],
291
+ failed_verification_count: failed_verification_count(entries, id),
292
+ ready: r[:ready],
293
+ blockers: r[:blockers],
294
+ dead_end: dead_end?(id, edges, status_map, dead_memo),
295
+ stale: stale?(entries, id, edges, status_map),
296
+ }
297
+ end
298
+ end
299
+
300
+ # --- n3: batches and the critical path ---------------------------------------
301
+
302
+ def all_nodes(edges)
303
+ nodes = edges.keys.dup
304
+ edges.each_value { |targets| (targets || []).each { |t| nodes << t unless nodes.include?(t) } }
305
+ nodes
306
+ end
307
+
308
+ # Topological layers of `edges`: layer one is every node needing nothing,
309
+ # layer k is every node all of whose needs sit in layers below k. Structural
310
+ # only, computed from edges alone, never from ledger state (D-something in
311
+ # the spec: "a batch that shrinks as nodes finish is a ready set, not a
312
+ # batch").
313
+ def batches(edges)
314
+ cyc = GraphEdges.cycle(edges)
315
+ return { ok: false, batches: [], error: "cyclic graph, cannot batch: #{cyc.join(' > ')}" } if cyc
316
+
317
+ layer = {}
318
+ all_nodes(edges).each { |n| assign_layer(n, edges, layer) }
319
+
320
+ grouped = Hash.new { |h, k| h[k] = [] }
321
+ layer.each { |n, l| grouped[l] << n }
322
+ ordered = grouped.keys.sort.map { |l| grouped[l].sort }
323
+ { ok: true, batches: ordered, error: nil }
324
+ end
325
+
326
+ def assign_layer(node, edges, layer)
327
+ return layer[node] if layer.key?(node)
328
+
329
+ needs = edges[node] || []
330
+ layer[node] = needs.empty? ? 1 : 1 + needs.map { |t| assign_layer(t, edges, layer) }.max
331
+ end
332
+
333
+ # Every maximal-length chain (the longest path by node count) in `edges`,
334
+ # up to `max_paths`, plus the true total count (computed by a polynomial
335
+ # dynamic program, never by enumerating every path and truncating - a
336
+ # fan-in graph has exponentially many). `critical_path` is the first under
337
+ # a deterministic tie-break: the lexicographically smallest id sequence,
338
+ # guaranteed by exploring successors in ascending id order depth-first.
339
+ def critical_paths(edges, max_paths: DEFAULT_MAX_PATHS)
340
+ cyc = GraphEdges.cycle(edges)
341
+ if cyc
342
+ return { ok: false, paths: [], critical_path: nil, hops: 0, total: 0, downstream_hops: {},
343
+ error: "cyclic graph, cannot compute a critical path: #{cyc.join(' > ')}" }
344
+ end
345
+
346
+ nodes = all_nodes(edges)
347
+ successors = Hash.new { |h, k| h[k] = [] }
348
+ edges.each { |id, targets| (targets || []).each { |t| successors[t] << id } }
349
+
350
+ longest = {}
351
+ nodes.each { |n| compute_longest(n, successors, longest) }
352
+
353
+ roots = nodes.select { |n| (edges[n] || []).empty? }
354
+ return { ok: true, paths: [], critical_path: nil, hops: 0, total: 0, downstream_hops: longest, error: nil } if roots.empty?
355
+
356
+ max_len = roots.map { |r| longest[r] }.max
357
+ count_memo = {}
358
+ total = roots.select { |r| longest[r] == max_len }.sum { |r| count_of_longest(r, successors, longest, count_memo) }
359
+
360
+ paths = []
361
+ roots.sort.each do |r|
362
+ break if paths.length >= max_paths
363
+ next unless longest[r] == max_len
364
+
365
+ enumerate_longest(r, successors, longest, [r], paths, max_paths)
366
+ end
367
+
368
+ { ok: true, paths: paths, critical_path: paths.first, hops: max_len, total: total,
369
+ downstream_hops: longest, error: nil }
370
+ end
371
+
372
+ # The longest remaining chain (in nodes) starting at each node, downstream
373
+ # toward a leaf. Used by CriticalPathRanker to order off-path nodes.
374
+ def downstream_hops(edges)
375
+ critical_paths(edges)[:downstream_hops]
376
+ end
377
+
378
+ def compute_longest(node, successors, memo)
379
+ return memo[node] if memo.key?(node)
380
+
381
+ succs = successors[node] || []
382
+ memo[node] = succs.empty? ? 1 : 1 + succs.map { |s| compute_longest(s, successors, memo) }.max
383
+ end
384
+
385
+ def count_of_longest(node, successors, longest, memo)
386
+ return memo[node] if memo.key?(node)
387
+
388
+ succs = (successors[node] || []).select { |s| longest[s] == longest[node] - 1 }
389
+ memo[node] = succs.empty? ? 1 : succs.sum { |s| count_of_longest(s, successors, longest, memo) }
390
+ end
391
+
392
+ def enumerate_longest(node, successors, longest, path, results, max_paths)
393
+ return if results.length >= max_paths
394
+
395
+ succs = (successors[node] || []).select { |s| longest[s] == longest[node] - 1 }.sort
396
+ if succs.empty?
397
+ results << path.dup
398
+ return
399
+ end
400
+
401
+ succs.each do |s|
402
+ return if results.length >= max_paths
403
+
404
+ enumerate_longest(s, successors, longest, path + [s], results, max_paths)
405
+ end
406
+ end
407
+
408
+ # --- n4: the ranker seam ------------------------------------------------------
409
+
410
+ def build_row(id:, kind:, batch:, retry_flag:, downstream_hops:, on_critical_path:)
411
+ {
412
+ id: id, kind: kind, batch: batch, retry: retry_flag,
413
+ downstream_hops: downstream_hops, on_critical_path: on_critical_path,
414
+ }.freeze
415
+ end
416
+
417
+ def ready_rows(views, batch_result, path_result)
418
+ batches_list = batch_result[:ok] ? batch_result[:batches] : []
419
+ batch_index = {}
420
+ batches_list.each_with_index { |grp, i| grp.each { |id| batch_index[id] = i + 1 } }
421
+ downstream = path_result[:ok] ? path_result[:downstream_hops] : {}
422
+ crit_set = (path_result[:ok] ? path_result[:critical_path] : nil) || []
423
+
424
+ views.each_with_object([]) do |(id, view), rows|
425
+ next unless view[:ready]
426
+
427
+ rows << build_row(
428
+ id: id,
429
+ kind: view[:kind],
430
+ batch: batch_index[id] || 0,
431
+ retry_flag: view[:state] == "failed_verification" || view[:attempts].to_i.positive?,
432
+ downstream_hops: downstream[id] || 0,
433
+ on_critical_path: crit_set.include?(id)
434
+ )
435
+ end
436
+ end
437
+
438
+ # FinishFirstRanker (D10, default): prefers a retry over a fresh node, then
439
+ # the deeper batch (closer to finishing a chain already underway) over the
440
+ # shallower one, tie-broken on id.
441
+ class FinishFirstRanker
442
+ def rank(rows)
443
+ rows.sort_by { |r| [r[:retry] ? 0 : 1, -r[:batch].to_i, r[:id].to_s] }
444
+ end
445
+
446
+ def name
447
+ "finish-first"
448
+ end
449
+ end
450
+
451
+ # CriticalPathRanker (D10): prefers a node on a critical path, then orders
452
+ # the rest by their own longest remaining chain, tie-broken on id.
453
+ class CriticalPathRanker
454
+ def rank(rows)
455
+ rows.sort_by { |r| [r[:on_critical_path] ? 0 : 1, -r[:downstream_hops].to_i, r[:id].to_s] }
456
+ end
457
+
458
+ def name
459
+ "critical-path"
460
+ end
461
+ end
462
+ end
@@ -45,6 +45,22 @@ module ReleaseGuard
45
45
  )
46
46
  end
47
47
 
48
+ # Derives the npm dist-tag from a version string: "alpha" when it carries
49
+ # an -alpha pre-release suffix, "beta" for -beta, "latest" for no
50
+ # pre-release suffix at all. Any other suffix returns the suffix itself,
51
+ # never "latest" - an unrecognized shape (a "-rc.1", say) must not resolve
52
+ # to the stable channel. Pure function, no ENV reads, shared by
53
+ # scripts/release-check and the test suite so the channel rule has exactly
54
+ # one implementation (intent 347).
55
+ def self.dist_tag(version)
56
+ suffix = version[/-(.+)\z/, 1]
57
+ return "latest" if suffix.nil?
58
+ return "alpha" if version.include?("-alpha")
59
+ return "beta" if version.include?("-beta")
60
+
61
+ suffix
62
+ end
63
+
48
64
  def self.plastic_plugin_version(data)
49
65
  plugins = Array(data["plugins"])
50
66
  plugin = plugins.find { |p| p["name"] == "plastic" } || plugins.first