@zalom/plastic 2.0.0-alpha.18 → 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 (45) 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/lib/action_graph_shim.rb +277 -0
  6. package/scripts/lib/atomic_write.rb +31 -0
  7. package/scripts/lib/graph_edges.rb +121 -0
  8. package/scripts/lib/graph_file.rb +246 -0
  9. package/scripts/lib/guarded_append.rb +155 -0
  10. package/scripts/lib/installer_core.rb +32 -0
  11. package/scripts/lib/node_file.rb +214 -0
  12. package/scripts/lib/node_ids.rb +99 -0
  13. package/scripts/lib/node_ledger.rb +377 -0
  14. package/scripts/lib/node_packet.rb +873 -0
  15. package/scripts/lib/outcome_report.rb +440 -0
  16. package/scripts/lib/packet_wrapper.rb +132 -0
  17. package/scripts/lib/ready_set.rb +462 -0
  18. package/scripts/lib/release_guard.rb +16 -0
  19. package/scripts/lib/report_screen.rb +122 -12
  20. package/scripts/lib/roadmap_queue.rb +161 -3
  21. package/scripts/lib/roadmap_savepoint.rb +26 -5
  22. package/scripts/lib/savepoint.rb +123 -12
  23. package/scripts/lib/work_graph_validator.rb +201 -0
  24. package/scripts/node-packet +92 -0
  25. package/scripts/node-transition +291 -0
  26. package/scripts/outcome-report +74 -0
  27. package/scripts/ready-set +126 -0
  28. package/scripts/release-check +118 -0
  29. package/scripts/report-screen +8 -1
  30. package/scripts/roadmap-savepoint +7 -0
  31. package/scripts/validate-work-graph +39 -0
  32. package/skills/auto/SKILL.md +2 -3
  33. package/skills/auto/references/human-report-contract.md +3 -2
  34. package/skills/intent-continuing/references/boarding-matrix.md +1 -0
  35. package/skills/intent-ending/SKILL.md +30 -19
  36. package/skills/intent-executing/SKILL.md +1 -1
  37. package/skills/releasing/SKILL.md +39 -0
  38. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  39. package/skills/releasing/references/release-lines.md +1 -1
  40. package/templates/graph.md +16 -0
  41. package/templates/node-decision.md +11 -0
  42. package/templates/node-research.md +11 -0
  43. package/templates/node-verify.md +13 -0
  44. package/templates/node-work.md +22 -0
  45. package/templates/outcome.md +8 -6
@@ -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
@@ -17,6 +17,8 @@ require_relative "session_ledger"
17
17
  require_relative "roadmap_queue"
18
18
  require_relative "roadmap_savepoint"
19
19
  require_relative "screen_paint"
20
+ require_relative "outcome_report"
21
+ require_relative "node_file"
20
22
 
21
23
  module ReportScreen
22
24
  NOT_RECORDED = "not recorded"
@@ -595,8 +597,18 @@ end
595
597
  # extracted `heading_tokens` are both kept. The token split now comes from the
596
598
  # shared helper so `action_file_for` cannot drift from this walk, while the
597
599
  # `table_rows(body).any?` guard stays the thing that decides the match.
600
+ # Intent 334 (G1, D10r/D15r): the ordered list of files a "how was this
601
+ # proven" reader walks - actions/*.md first (the common path today), then
602
+ # nodes/*.md, lexicographic WITHIN each directory rather than across both, so
603
+ # an intent carrying both (a G9 backfill in progress) resolves the same label
604
+ # to whichever actions/ file already proves it, never to glob order.
605
+ def self.action_and_node_paths(intent_dir)
606
+ Dir.glob(File.join(intent_dir, "actions", "*.md")).sort +
607
+ Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
608
+ end
609
+
598
610
  def self.matching_action_heading(intent_dir, label)
599
- Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
611
+ action_and_node_paths(intent_dir).each do |path|
600
612
  split_by_headings(File.read(path)).each do |heading, body|
601
613
  next unless heading_tokens(heading).include?(label)
602
614
  return [heading, body] if table_rows(body).any?
@@ -611,19 +623,53 @@ def self.matching_action_heading(intent_dir, label)
611
623
  # step list or any other table - so it cannot answer for a record that has
612
624
  # no matrix anywhere (the close-gate defeat the plan review measured).
613
625
  # Emphasis (bold/italic/code) is stripped from the compared cell; the count
614
- # sums matching rows across every matrix heading, in every action file.
626
+ # sums matching rows within one directory, then stops at the first
627
+ # directory that yields a non-zero count (post-execution review,
628
+ # non-blocking 6) - actions/ before nodes/, mirroring the heading walk's
629
+ # first-hit rule, so an intent whose nodes/ files restate ACTION_1's own
630
+ # matrix under the same label is never double-counted.
615
631
  def self.matching_matrix_rows(intent_dir, label)
616
- count = 0
617
- Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
618
- split_by_headings(File.read(path)).each do |heading, body|
619
- next unless heading.to_s.match?(/matrix/i)
620
- table_rows(body).each do |cells|
621
- cell = cells[0].to_s.gsub(/[*_`]/, "").strip
622
- count += 1 if cell == label
632
+ [Dir.glob(File.join(intent_dir, "actions", "*.md")).sort,
633
+ Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort].each do |paths|
634
+ count = 0
635
+ paths.each do |path|
636
+ split_by_headings(File.read(path)).each do |heading, body|
637
+ next unless heading.to_s.match?(/matrix/i)
638
+ table_rows(body).each do |cells|
639
+ cell = cells[0].to_s.gsub(/[*_`]/, "").strip
640
+ count += 1 if cell == label
641
+ end
623
642
  end
624
643
  end
644
+ return count if count.positive?
625
645
  end
626
- count
646
+ 0
647
+ end
648
+
649
+ # 339 S9 (D17): a verify node owns `## Criteria`, never a matrix, so its
650
+ # Proven-by is how many criteria its node file names - not the
651
+ # absent-source phrase `matching_action_heading`/`matching_matrix_rows`
652
+ # falls through to for a label with no matrix anywhere. The kind comes from
653
+ # the node file's own envelope, `NodeFile.parse`, never guessed from the
654
+ # label's prefix and never by sniffing a body for a criteria-shaped list
655
+ # (row 9.4): a work node whose body happens to carry a bulleted "##
656
+ # Criteria" section must never borrow this path. Returns nil (not 0) when
657
+ # `label` is not a verify node at all, so `proven_by` can tell "not a
658
+ # verify node" apart from "a verify node with zero criteria".
659
+ def self.verify_node_criteria_count(intent_dir, label)
660
+ path = Dir.glob(File.join(intent_dir, "nodes", "#{label}.md")).first ||
661
+ Dir.glob(File.join(intent_dir, "nodes", "#{label}--*.md")).sort.first
662
+ return nil unless path
663
+
664
+ parsed = NodeFile.parse(path)
665
+ return nil unless parsed[:ok] && parsed[:kind] == "verify"
666
+
667
+ NodeFile.split_by_headings(parsed[:body].to_s).each do |heading, body|
668
+ next unless heading.to_s.sub(/\A#+\s*/, "").strip == "Criteria"
669
+
670
+ return body.each_line.count { |line| line.strip.start_with?("-") }
671
+ end
672
+ nil
627
673
  end
628
674
 
629
675
  # D7: a label with no letter never resolves, on either path - it is a
@@ -633,6 +679,11 @@ def self.matching_action_heading(intent_dir, label)
633
679
  def self.proven_by(intent_dir, label)
634
680
  return NOT_RECORDED unless label.to_s.match?(/[A-Za-z]/)
635
681
 
682
+ criteria_count = verify_node_criteria_count(intent_dir, label)
683
+ unless criteria_count.nil?
684
+ return criteria_count.positive? ? "#{criteria_count} criteri#{criteria_count == 1 ? 'on' : 'a'}" : NOT_RECORDED
685
+ end
686
+
636
687
  _heading, body = matching_action_heading(intent_dir, label)
637
688
  if body
638
689
  n = table_rows(body).length
@@ -950,6 +1001,29 @@ def self.matching_action_heading(intent_dir, label)
950
1001
  lines.last&.first
951
1002
  end
952
1003
 
1004
+ # report-screen archive <store_root> (intent 339, G6, n6, spec D9): a
1005
+ # read-only VIEW of a store's terminal intents - the reading half of intent
1006
+ # 132, declining the other three halves (moving directories, path
1007
+ # resolution, doctor checks). Lists `## Completed` and `## Abandoned` only
1008
+ # (row 6.1, the same set `completed_dirnames` above already gathers - row
1009
+ # v1f.10 deleted the byte-for-byte duplicate that used to live here); each
1010
+ # row's disposition comes from outcome.md's own frontmatter (row 6.2),
1011
+ # never guessed from which INDEX section the dirname was found in, so a
1012
+ # terminal intent with no outcome.md renders the absent-source phrase
1013
+ # instead of a fabricated disposition (row 6.3). Reads only - moves
1014
+ # nothing (row 6.4).
1015
+ def self.render_archive(store_root)
1016
+ index_path = File.join(store_root, "INDEX.md")
1017
+ lines = ["# Archive: #{File.basename(store_root)}", "", "| Intent | Disposition |", "| --- | --- |"]
1018
+ completed_dirnames(index_path).each do |dirname|
1019
+ dir = File.join(store_root, "store", dirname)
1020
+ disposition = outcome_frontmatter(dir)["disposition"]
1021
+ disposition = NOT_RECORDED if disposition.nil? || disposition.to_s.empty?
1022
+ lines << "| #{escape(dirname)} | #{escape(disposition.to_s)} |"
1023
+ end
1024
+ fit_screen("#{lines.join("\n")}\n")
1025
+ end
1026
+
953
1027
  def self.roster(store_root)
954
1028
  index_path = File.join(store_root, "INDEX.md")
955
1029
  entries = active_dirnames(index_path).filter_map do |dirname|
@@ -1106,7 +1180,43 @@ def self.matching_action_heading(intent_dir, label)
1106
1180
  lines << "| --- | --- | --- |"
1107
1181
  needsyou.each { |r| lines << "| #{r[:n]} | #{escape(r[:what])} | #{escape(r[:why])} |" }
1108
1182
  end
1109
- fit_screen("#{lines.join("\n")}\n")
1183
+ out = fit_screen("#{lines.join("\n")}\n")
1184
+
1185
+ # Intent 339 (G6, n5, spec D8): additive, and only for an intent that has
1186
+ # a graph.md - an intent with none renders exactly the bytes it renders
1187
+ # today (row 5.2's frozen golden). Node state and titles come from the
1188
+ # LEDGER via OutcomeReport.model, never from outcome.md (row 5.6): a
1189
+ # stale hand-edited outcome must never be read as truth here.
1190
+ return out unless File.exist?(File.join(intent_dir, "graph.md"))
1191
+
1192
+ out + fit_screen(render_nodes_block(intent_dir))
1193
+ end
1194
+
1195
+ # Row 5.1/5.4/5.5: a "### Nodes" table (id, kind, ledger state, "(stale)"
1196
+ # when C13 applies) plus a "### Findings" bullet block when the record
1197
+ # carries any (D7). Neither heading is the `**Bold**` shape the delivered
1198
+ # screen's own block scan (`/^\*\*(.+?)\*\*/`) reads, so this block can
1199
+ # never widen that pinned header list (row 5.3).
1200
+ def self.render_nodes_block(intent_dir)
1201
+ model = OutcomeReport.model(intent_dir)
1202
+ stale = OutcomeReport.stale_nodes(entries: model[:entries] || [], edges: model[:edges] || {})
1203
+
1204
+ lines = ["", "### Nodes", "| Node | Kind | State |", "| --- | --- | --- |"]
1205
+ OutcomeReport.sort_ids(model[:nodes].keys).each do |id|
1206
+ n = model[:nodes][id]
1207
+ state = n[:state].to_s
1208
+ state = "#{state} (stale)" if stale.include?(id)
1209
+ lines << "| #{id} | #{escape(n[:kind].to_s)} | #{escape(state)} |"
1210
+ end
1211
+
1212
+ findings = OutcomeReport.findings(intent_dir)
1213
+ unless findings.empty?
1214
+ lines << ""
1215
+ lines << "### Findings"
1216
+ findings.each { |f| lines << "- #{escape(f)}" }
1217
+ end
1218
+
1219
+ "#{lines.join("\n")}\n"
1110
1220
  end
1111
1221
 
1112
1222
  # --- S7: the delay verb -----------------------------------------------------------
@@ -1249,7 +1359,7 @@ def self.matching_action_heading(intent_dir, label)
1249
1359
  # heading that resolves but proves nothing is the same hollow-close defect
1250
1360
  # `proven_by` already guards against, so it renders "not recorded" too.
1251
1361
  def self.action_file_for(intent_dir, label)
1252
- Dir.glob(File.join(intent_dir, "actions", "*.md")).sort.each do |path|
1362
+ action_and_node_paths(intent_dir).each do |path|
1253
1363
  split_by_headings(File.read(path)).each do |heading, body|
1254
1364
  next unless heading_tokens(heading).include?(label)
1255
1365
  return File.basename(path, ".md") if table_rows(body).any?