@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.
- 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/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,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
|
-
|
|
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
|
|
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
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|