@zalom/plastic 2.0.0-alpha.17 → 2.0.0-alpha.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/scripts/dashboard.rb +20 -0
- package/scripts/doctor.rb +120 -2
- package/scripts/end-intent +134 -8
- package/scripts/hook-capture +4 -105
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/installer_core.rb +32 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +873 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +122 -12
- package/scripts/lib/roadmap_queue.rb +161 -3
- package/scripts/lib/roadmap_savepoint.rb +26 -5
- package/scripts/lib/savepoint.rb +123 -12
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +8 -1
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/validate-work-graph +39 -0
- package/skills/auto/SKILL.md +2 -3
- package/skills/auto/references/human-report-contract.md +3 -2
- package/skills/intent-continuing/references/boarding-matrix.md +1 -0
- package/skills/intent-ending/SKILL.md +30 -19
- package/skills/intent-executing/SKILL.md +1 -1
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/templates/graph.md +16 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +8 -6
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "json"
|
|
6
6
|
require_relative "roadmap_savepoint"
|
|
7
|
+
require_relative "graph_file"
|
|
8
|
+
require_relative "graph_edges"
|
|
7
9
|
|
|
8
10
|
# FileOrderRanker - the default value-ordering strategy: today's roadmap file order,
|
|
9
11
|
# unchanged. This is the intent-173 ranking-swap seam (sibling to the 147 DB-swap seam): a
|
|
@@ -98,6 +100,15 @@ class RoadmapQueue
|
|
|
98
100
|
|
|
99
101
|
winner = ranked.first
|
|
100
102
|
is_tie = tied.length > 1
|
|
103
|
+
|
|
104
|
+
cyc = winner[:graph_edges] && GraphEdges.cycle(winner[:graph_edges][:edges])
|
|
105
|
+
if cyc
|
|
106
|
+
return payload(mode: mode, state: "error", roadmap: winner[:slug],
|
|
107
|
+
frontier_wave: "cyclic roadmap graph, cannot compute a frontier: #{cyc.join(' > ')}",
|
|
108
|
+
dispatchable: [], in_flight: [], blocked: blocked_for(winner),
|
|
109
|
+
tie: is_tie, tie_candidates: [])
|
|
110
|
+
end
|
|
111
|
+
|
|
101
112
|
frontier = frontier_for(winner)
|
|
102
113
|
|
|
103
114
|
state =
|
|
@@ -129,7 +140,28 @@ class RoadmapQueue
|
|
|
129
140
|
|
|
130
141
|
def parse_roadmap(path)
|
|
131
142
|
text = File.read(path)
|
|
132
|
-
{
|
|
143
|
+
{
|
|
144
|
+
slug: File.basename(path, ".md"), path: path,
|
|
145
|
+
waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)),
|
|
146
|
+
graph_edges: parse_roadmap_graph(text),
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# D15: an exact "## Graph" heading line only, never a prefix - a live
|
|
151
|
+
# roadmap can carry "## Graph (2026-09-01, superseded by ...)", which
|
|
152
|
+
# GraphFile.section_body already treats as a non-match because it locates
|
|
153
|
+
# a section by an exact stripped-line comparison. A section that yields no
|
|
154
|
+
# real edge lines (GraphEdges.parse finds zero nodes) is treated the same
|
|
155
|
+
# as no section at all: the fallback to wave order, both silent (D15).
|
|
156
|
+
# Fence-aware (D18): a fenced example edge line is never read as real.
|
|
157
|
+
def parse_roadmap_graph(text)
|
|
158
|
+
section = GraphFile.section_body(text, "## Graph")
|
|
159
|
+
return nil if section.nil?
|
|
160
|
+
|
|
161
|
+
parsed = GraphEdges.parse(GraphFile.strip_fenced_blocks(section))
|
|
162
|
+
return nil if parsed[:nodes].empty?
|
|
163
|
+
|
|
164
|
+
parsed
|
|
133
165
|
end
|
|
134
166
|
|
|
135
167
|
def parse_waves(waves_body)
|
|
@@ -241,9 +273,17 @@ class RoadmapQueue
|
|
|
241
273
|
Time.utc(y, mo, d, h, mi, 0)
|
|
242
274
|
end
|
|
243
275
|
|
|
244
|
-
# --- frontier + dispatchable selection (D-b)
|
|
276
|
+
# --- frontier + dispatchable selection (D-b, D15) -------------------------------
|
|
245
277
|
|
|
278
|
+
# D15: a candidate with a real ## Graph section dispatches by its edges
|
|
279
|
+
# (a delivered entry counts as done); one without, or whose graph section
|
|
280
|
+
# yields no edges (parse_roadmap_graph already returns nil for that case),
|
|
281
|
+
# keeps the wave-order behavior unchanged.
|
|
246
282
|
def frontier_for(candidate)
|
|
283
|
+
candidate[:graph_edges] ? graph_frontier_for(candidate) : wave_frontier_for(candidate)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def wave_frontier_for(candidate)
|
|
247
287
|
candidate[:waves].each do |wave|
|
|
248
288
|
statuses = wave[:entries].map { |e| e[:status] }
|
|
249
289
|
next unless statuses.any? { |s| %w[queued delivering].include?(s) }
|
|
@@ -267,12 +307,130 @@ class RoadmapQueue
|
|
|
267
307
|
nil
|
|
268
308
|
end
|
|
269
309
|
|
|
310
|
+
# D15's edge-driven frontier: the topological layers of the graph's edges,
|
|
311
|
+
# walked in order; the first layer holding a dispatchable (all needs
|
|
312
|
+
# delivered) or in-flight entry is the frontier. An id the graph names but
|
|
313
|
+
# no batch lists is reported in `blocked` (finding 2, R-2), never a crash
|
|
314
|
+
# and never an invented dispatchable entry with no title. An id a batch
|
|
315
|
+
# lists that the graph does not name is never dropped either: it keeps
|
|
316
|
+
# the pre-change wave behavior (the G4 partial-migration path) by being
|
|
317
|
+
# folded in as needing nothing (never called on a cyclic graph: #analyze
|
|
318
|
+
# checks that first and reports "error" instead).
|
|
319
|
+
def graph_frontier_for(candidate)
|
|
320
|
+
edges = candidate[:graph_edges][:edges].dup
|
|
321
|
+
id_to_entry = {}
|
|
322
|
+
wave_of_id = {}
|
|
323
|
+
candidate[:waves].each do |wave|
|
|
324
|
+
wave[:entries].each do |e|
|
|
325
|
+
id_to_entry[e[:id]] = e
|
|
326
|
+
wave_of_id[e[:id]] = wave[:heading]
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
(id_to_entry.keys - all_graph_nodes(edges)).each { |id| edges[id] = [] }
|
|
331
|
+
|
|
332
|
+
topological_layers(edges).each do |layer|
|
|
333
|
+
layer_ids = layer.select { |id| id_to_entry.key?(id) }
|
|
334
|
+
next if layer_ids.empty?
|
|
335
|
+
|
|
336
|
+
queued_ready = layer_ids.select do |id|
|
|
337
|
+
entry = id_to_entry[id]
|
|
338
|
+
entry[:status] == "queued" &&
|
|
339
|
+
(edges[id] || []).all? { |t| id_to_entry[t] && id_to_entry[t][:status] == "delivered" }
|
|
340
|
+
end
|
|
341
|
+
delivering_ids = layer_ids.select { |id| id_to_entry[id][:status] == "delivering" }
|
|
342
|
+
next if queued_ready.empty? && delivering_ids.empty?
|
|
343
|
+
|
|
344
|
+
ordered = @ranker.rank(queued_ready.map { |id| id_to_entry[id] })
|
|
345
|
+
dispatchable = ordered.each_with_index.map do |e, i|
|
|
346
|
+
{ "id" => e[:id], "scope" => scope_label, "roadmap" => candidate[:slug],
|
|
347
|
+
"wave" => wave_of_id[e[:id]], "status" => "queued", "rank" => i + 1 }
|
|
348
|
+
end
|
|
349
|
+
in_flight = delivering_ids.map do |id|
|
|
350
|
+
{ "id" => id, "roadmap" => candidate[:slug], "wave" => wave_of_id[id], "status" => "delivering" }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
heading = dispatchable.first ? dispatchable.first["wave"] : in_flight.first["wave"]
|
|
354
|
+
return { heading: heading, dispatchable: dispatchable, in_flight: in_flight }
|
|
355
|
+
end
|
|
356
|
+
nil
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Topological layers of `edges` ({id => [needs...]}): layer one is every
|
|
360
|
+
# id needing nothing, layer k is every id all of whose needs sit in layers
|
|
361
|
+
# below k. Never called on a cyclic graph (the caller checks first), so no
|
|
362
|
+
# cycle guard is needed here.
|
|
363
|
+
def topological_layers(edges)
|
|
364
|
+
nodes = edges.keys.dup
|
|
365
|
+
edges.each_value { |targets| (targets || []).each { |t| nodes << t unless nodes.include?(t) } }
|
|
366
|
+
|
|
367
|
+
layer = {}
|
|
368
|
+
assign = nil
|
|
369
|
+
assign = lambda do |node|
|
|
370
|
+
next layer[node] if layer.key?(node)
|
|
371
|
+
|
|
372
|
+
needs = edges[node] || []
|
|
373
|
+
layer[node] = needs.empty? ? 1 : 1 + needs.map { |t| assign.call(t) }.max
|
|
374
|
+
end
|
|
375
|
+
nodes.each { |n| assign.call(n) }
|
|
376
|
+
|
|
377
|
+
grouped = Hash.new { |h, k| h[k] = [] }
|
|
378
|
+
layer.each { |n, l| grouped[l] << n }
|
|
379
|
+
grouped.keys.sort.map { |l| grouped[l].sort }
|
|
380
|
+
end
|
|
381
|
+
|
|
270
382
|
def blocked_for(candidate)
|
|
271
|
-
candidate[:waves].flat_map do |wave|
|
|
383
|
+
explicit = candidate[:waves].flat_map do |wave|
|
|
272
384
|
wave[:entries].select { |e| e[:status] == "blocked" }.map do |e|
|
|
273
385
|
{ "id" => e[:id], "roadmap" => candidate[:slug], "wave" => wave[:heading], "status" => "blocked" }
|
|
274
386
|
end
|
|
275
387
|
end
|
|
388
|
+
explicit + graph_reporting_blocked(candidate)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# R-2 (finding 2): a graph naming an id no batch lists is reported, not
|
|
392
|
+
# swallowed - both the unnamed id itself, and any batch entry whose own
|
|
393
|
+
# declared need points straight at it, which would otherwise sit queued
|
|
394
|
+
# forever with nothing in the payload explaining why. Keeps the invariant
|
|
395
|
+
# that `state` is never "exhausted" while a queued entry sits unaccounted
|
|
396
|
+
# for: an entry that can never resolve still shows up here.
|
|
397
|
+
def graph_reporting_blocked(candidate)
|
|
398
|
+
return [] unless candidate[:graph_edges]
|
|
399
|
+
|
|
400
|
+
edges = candidate[:graph_edges][:edges]
|
|
401
|
+
id_to_entry = candidate[:waves].flat_map { |w| w[:entries] }.each_with_object({}) { |e, h| h[e[:id]] = e }
|
|
402
|
+
unreported = all_graph_nodes(edges).reject { |id| id_to_entry.key?(id) }
|
|
403
|
+
|
|
404
|
+
reported = unreported.map do |id|
|
|
405
|
+
{ "id" => id, "roadmap" => candidate[:slug], "wave" => nil, "status" => "unreported",
|
|
406
|
+
"reason" => "graph names #{id.inspect}, no batch entry" }
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
stuck = []
|
|
410
|
+
id_to_entry.each do |id, entry|
|
|
411
|
+
next unless entry[:status] == "queued"
|
|
412
|
+
|
|
413
|
+
(edges[id] || []).each do |target|
|
|
414
|
+
next unless unreported.include?(target)
|
|
415
|
+
|
|
416
|
+
stuck << { "id" => id, "roadmap" => candidate[:slug], "wave" => wave_heading_for(candidate, id),
|
|
417
|
+
"status" => "unreported",
|
|
418
|
+
"reason" => "#{id} needs #{target}, which the graph declares but no batch lists" }
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
reported + stuck
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def wave_heading_for(candidate, id)
|
|
426
|
+
candidate[:waves].each { |w| return w[:heading] if w[:entries].any? { |e| e[:id] == id } }
|
|
427
|
+
nil
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def all_graph_nodes(edges)
|
|
431
|
+
nodes = edges.keys.dup
|
|
432
|
+
edges.each_value { |targets| (targets || []).each { |t| nodes << t unless nodes.include?(t) } }
|
|
433
|
+
nodes
|
|
276
434
|
end
|
|
277
435
|
|
|
278
436
|
# --- scope + payload -----------------------------------------------------------
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
require "time"
|
|
5
5
|
require "fileutils"
|
|
6
|
+
require_relative "guarded_append"
|
|
6
7
|
|
|
7
8
|
# RoadmapSavepoint - the roadmap's machine counterpart to its human `## Log` (intent 134).
|
|
8
9
|
#
|
|
@@ -95,17 +96,37 @@ module RoadmapSavepoint
|
|
|
95
96
|
# recorded (no-op). Creates the paired ledger file (and its directory) lazily. Raises
|
|
96
97
|
# ArgumentError when `event` is outside the controlled vocabulary. Returns true when a line
|
|
97
98
|
# was written, false on a dedup no-op.
|
|
98
|
-
|
|
99
|
+
#
|
|
100
|
+
# Intent 335 (spec D14): the dedup check moves INSIDE one GuardedAppend hold, which makes
|
|
101
|
+
# "is this pair already recorded" and "append it" atomic against a second writer without
|
|
102
|
+
# changing what this method returns. `strict: false` keeps this ledger's existing
|
|
103
|
+
# flock-less-filesystem fallback exactly as it was before the guard existed (spec D12a): a
|
|
104
|
+
# single O_APPEND write still lands whole there, so this ledger never refuses on such a
|
|
105
|
+
# filesystem, unlike a strict transition append elsewhere in 335. `guard:` is the sole thing
|
|
106
|
+
# this module takes from 335 (spec D14: "RoadmapSavepoint takes GuardedAppend and nothing else");
|
|
107
|
+
# `flock:`/`sleeper:` pass straight through as GuardedAppend test seams, never read from an
|
|
108
|
+
# environment variable. The directory is created BEFORE the guard is called (spec D12b): the
|
|
109
|
+
# guard's own File.open would raise Errno::ENOENT on a missing parent, which must propagate as
|
|
110
|
+
# itself rather than be mistaken for lock contention.
|
|
111
|
+
def append(roadmap_path, event, detail, now: Time.now, guard: GuardedAppend, **guard_opts)
|
|
99
112
|
unless EVENTS.include?(event)
|
|
100
113
|
raise ArgumentError, "event must be one of #{EVENTS.join(', ')}, got #{event.inspect}"
|
|
101
114
|
end
|
|
102
115
|
|
|
103
116
|
ledger_path = ledger_path_for(roadmap_path)
|
|
104
|
-
return false if recorded_pairs(ledger_path).include?([event, detail])
|
|
105
|
-
|
|
106
117
|
FileUtils.mkdir_p(File.dirname(ledger_path))
|
|
107
|
-
|
|
108
|
-
|
|
118
|
+
|
|
119
|
+
pair = [event, detail]
|
|
120
|
+
written = false
|
|
121
|
+
guard.call(ledger_path, strict: false, **guard_opts) do |content|
|
|
122
|
+
if content.each_line.filter_map { |line| parse_pair(line) }.include?(pair)
|
|
123
|
+
nil
|
|
124
|
+
else
|
|
125
|
+
written = true
|
|
126
|
+
format_line(now, event, detail)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
written
|
|
109
130
|
end
|
|
110
131
|
|
|
111
132
|
def format_line(time, event, detail)
|
package/scripts/lib/savepoint.rb
CHANGED
|
@@ -23,6 +23,36 @@ module Savepoint
|
|
|
23
23
|
# (<id>--<slug>.md) is never sentineled; it is born complete.
|
|
24
24
|
PLACEHOLDER_SENTINEL = "<!-- plastic:placeholder -->"
|
|
25
25
|
|
|
26
|
+
# --- Node-graph transition subject vocabulary (intent 335, spec D17) -------
|
|
27
|
+
#
|
|
28
|
+
# Owned HERE, not on NodeLedger, because test/savepoint_split_test.rb:57 pins
|
|
29
|
+
# savepoint.rb to loading no other project file: NodeLedger requires this
|
|
30
|
+
# file and reuses these three names rather than duplicating them, so the
|
|
31
|
+
# dependency runs one way only. Intent 334 (G1) mints node ids and must
|
|
32
|
+
# agree with NODE_SUBJECT_RE: it is the single seam for the node id shape.
|
|
33
|
+
|
|
34
|
+
# The literal subject token for an intent-scope transition line ("Intent
|
|
35
|
+
# needs_decision question=..."), as opposed to a node-scope line.
|
|
36
|
+
INTENT_SUBJECT = "Intent"
|
|
37
|
+
|
|
38
|
+
# A node id: one or two lowercase letters (the node's kind prefix, e.g. "n"
|
|
39
|
+
# for work, "v" for verify) followed by digits.
|
|
40
|
+
NODE_SUBJECT_RE = /\A[a-z]{1,2}\d+\z/.freeze
|
|
41
|
+
|
|
42
|
+
# True iff a raw savepoint ledger line's subject (field 2, split on
|
|
43
|
+
# /\s{2,}/) is a transition candidate: the literal Intent token or a node
|
|
44
|
+
# id. A stage line ("How checklist.md created") or a Lock takeover audit
|
|
45
|
+
# line never matches, by construction (spec Acceptance Criteria: "no stage
|
|
46
|
+
# token this tree writes collides with Intent or with the node id
|
|
47
|
+
# pattern").
|
|
48
|
+
def self.transition_candidate?(line)
|
|
49
|
+
parts = line.to_s.split(/\s{2,}/)
|
|
50
|
+
return false unless parts.length >= 2
|
|
51
|
+
|
|
52
|
+
subject = parts[1]
|
|
53
|
+
subject == INTENT_SUBJECT || subject.match?(NODE_SUBJECT_RE)
|
|
54
|
+
end
|
|
55
|
+
|
|
26
56
|
def self.intent_file(intent_dir)
|
|
27
57
|
dir_name = File.basename(intent_dir)
|
|
28
58
|
"#{intent_dir}/#{dir_name}.md"
|
|
@@ -57,22 +87,47 @@ module Savepoint
|
|
|
57
87
|
File.exist?(path)
|
|
58
88
|
end
|
|
59
89
|
|
|
60
|
-
# True iff actions/ holds AT LEAST ONE real
|
|
61
|
-
# first line
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# reports it needs a real action file); it never raises.
|
|
66
|
-
def self.has_real_action?(intent_dir)
|
|
67
|
-
Dir.glob("#{intent_dir}/actions/*.md").any? do |f|
|
|
90
|
+
# True iff DIR_NAME (actions/ or nodes/) holds AT LEAST ONE real *.md file: non-empty,
|
|
91
|
+
# first line not the placeholder sentinel. A `.gitkeep` (no .md extension) never counts.
|
|
92
|
+
# Pure and side-effect-free; fail-open (a missing dir globs to nothing, never raises).
|
|
93
|
+
def self.has_real_files_in?(dir_name, intent_dir)
|
|
94
|
+
Dir.glob("#{intent_dir}/#{dir_name}/*.md").any? do |f|
|
|
68
95
|
File.file?(f) && File.size(f) > 0 && stage_file_present?(f)
|
|
69
96
|
end
|
|
70
97
|
rescue StandardError
|
|
71
98
|
false
|
|
72
99
|
end
|
|
73
100
|
|
|
101
|
+
# True iff the intent has at least one real action file, whether delivered as
|
|
102
|
+
# legacy actions/*.md or as a node graph's nodes/*.md (intent 334, G1, D10r):
|
|
103
|
+
# an intent delivered as nodes is exactly as real as one delivered as
|
|
104
|
+
# actions, so doctor and the exec-stage gate never report a backfill gap on
|
|
105
|
+
# a fully delivered node-graph intent. Checks actions/ first (the common
|
|
106
|
+
# path today), falling through to nodes/ only when actions/ has nothing.
|
|
107
|
+
def self.has_real_action?(intent_dir)
|
|
108
|
+
has_real_files_in?("actions", intent_dir) || has_real_files_in?("nodes", intent_dir)
|
|
109
|
+
rescue StandardError
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Intent 336 (G3, D13): a real graph.md is 327 D41's replacement for
|
|
114
|
+
# plan.md/checklist.md on the common path, so a node-shaped intent is
|
|
115
|
+
# judged on graph.md and nodes/ alone, whether or not spec.md is real - the
|
|
116
|
+
# graph IS the spec under D41. Checked using only this file's own
|
|
117
|
+
# primitives (stage_file_present?, has_real_files_in?), because
|
|
118
|
+
# test/savepoint_split_test.rb pins this file to loading no other project
|
|
119
|
+
# file and no YAML: GraphFile/NodeFile/ReadySet are never required here. An
|
|
120
|
+
# intent with no real graph.md (or a sentinel-placeholder one,
|
|
121
|
+
# stage_file_present? already reads that as absent) derives exactly what it
|
|
122
|
+
# derived before this intent.
|
|
74
123
|
def self.derive_stage(intent_dir)
|
|
75
124
|
return "done" if stage_file_present?("#{intent_dir}/outcome.md")
|
|
125
|
+
|
|
126
|
+
if stage_file_present?("#{intent_dir}/graph.md")
|
|
127
|
+
return "exec" if has_real_files_in?("nodes", intent_dir)
|
|
128
|
+
return "how"
|
|
129
|
+
end
|
|
130
|
+
|
|
76
131
|
if stage_file_present?("#{intent_dir}/plan.md") &&
|
|
77
132
|
has_real_action?(intent_dir) &&
|
|
78
133
|
stage_file_present?("#{intent_dir}/checklist.md")
|
|
@@ -87,19 +142,49 @@ module Savepoint
|
|
|
87
142
|
files = []
|
|
88
143
|
ifile = File.basename(intent_file(intent_dir))
|
|
89
144
|
files << ifile if File.exist?("#{intent_dir}/#{ifile}")
|
|
90
|
-
["spec.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
145
|
+
["spec.md", "graph.md", "plan.md", "checklist.md", "outcome.md"].each do |f|
|
|
91
146
|
files << f if stage_file_present?("#{intent_dir}/#{f}")
|
|
92
147
|
end
|
|
93
|
-
|
|
148
|
+
# Name the directory that actually exists (fold B3): a nodes-only intent
|
|
149
|
+
# must never claim the literal "actions/" artifact it does not have.
|
|
150
|
+
# Checks actions/ first, matching D15r's read order.
|
|
151
|
+
if has_real_files_in?("actions", intent_dir)
|
|
152
|
+
files << "actions/"
|
|
153
|
+
elsif has_real_files_in?("nodes", intent_dir)
|
|
154
|
+
files << "nodes/"
|
|
155
|
+
end
|
|
94
156
|
files
|
|
95
157
|
end
|
|
96
158
|
|
|
97
159
|
def self.missing_for_stage(stage, intent_dir = nil)
|
|
98
160
|
ifile = intent_dir ? File.basename(intent_file(intent_dir)) : "intent.md"
|
|
161
|
+
# A How-stage intent that already started a nodes/ directory is named
|
|
162
|
+
# accordingly, so the next-step hint never tells a node-graph intent to
|
|
163
|
+
# go make an actions/ directory it will never use (fold B3). Mirrors
|
|
164
|
+
# has_real_files_in?'s actions-first order and its real-file requirement
|
|
165
|
+
# (post-execution review, non-blocking 4): an intent carrying real files
|
|
166
|
+
# in both directories, or a real actions/ file beside an empty or
|
|
167
|
+
# .gitkeep-only nodes/, is named actions/, never nodes/.
|
|
168
|
+
action_label = if intent_dir && has_real_files_in?("actions", intent_dir)
|
|
169
|
+
"actions/"
|
|
170
|
+
elsif intent_dir && has_real_files_in?("nodes", intent_dir)
|
|
171
|
+
"nodes/"
|
|
172
|
+
else
|
|
173
|
+
"actions/"
|
|
174
|
+
end
|
|
99
175
|
case stage
|
|
100
176
|
when "what" then [ifile]
|
|
101
177
|
when "why" then ["spec.md"]
|
|
102
|
-
when "how"
|
|
178
|
+
when "how"
|
|
179
|
+
# A node-shaped How intent (a real graph.md already) is named after its
|
|
180
|
+
# own two artifacts, never the three D41 removed from its path (fold
|
|
181
|
+
# B3, extended by 336 D13): checked via stage_file_present? alone, the
|
|
182
|
+
# same primitive derive_stage itself uses.
|
|
183
|
+
if intent_dir && stage_file_present?("#{intent_dir}/graph.md")
|
|
184
|
+
["graph.md", "nodes/"]
|
|
185
|
+
else
|
|
186
|
+
["plan.md", action_label, "checklist.md"]
|
|
187
|
+
end
|
|
103
188
|
when "exec" then ["outcome.md"]
|
|
104
189
|
else []
|
|
105
190
|
end
|
|
@@ -258,6 +343,17 @@ module Savepoint
|
|
|
258
343
|
# A Plastic 1.x ledger may carry a `Tier <value>` line after the spec.md
|
|
259
344
|
# milestone (removed in 2.0, intent 304); a rebuild drops it, and the phantom
|
|
260
345
|
# detector ignores it, so a 1.x store reads clean.
|
|
346
|
+
#
|
|
347
|
+
# Every transition line (intent 335, spec D13) is preserved VERBATIM, in its
|
|
348
|
+
# original relative order, after the reconstructed stage skeleton. It is
|
|
349
|
+
# never dropped and never refused: a transition line's evidence fields
|
|
350
|
+
# (`holder=`, `expires=`, `gates=`, ...) have no file-mtime analog to
|
|
351
|
+
# reconstruct from, so refusing instead of preserving would make this method
|
|
352
|
+
# destroy the graph's only status on every intent that carries one. Relative
|
|
353
|
+
# order BETWEEN a stage line and a transition line is not preserved (safe:
|
|
354
|
+
# status is computed per subject, and the two families share no subject);
|
|
355
|
+
# relative order WITHIN the transition lines is preserved, which is what
|
|
356
|
+
# "last line per subject in file order" depends on.
|
|
261
357
|
def self.rebuild_savepoint(intent_dir)
|
|
262
358
|
ordered = [
|
|
263
359
|
File.basename(intent_file(intent_dir)),
|
|
@@ -271,7 +367,18 @@ module Savepoint
|
|
|
271
367
|
stamp = File.mtime(path).utc.iso8601
|
|
272
368
|
["#{stamp} #{stage} #{milestone}\n"]
|
|
273
369
|
end
|
|
274
|
-
|
|
370
|
+
|
|
371
|
+
savepoint_path = File.join(intent_dir, SAVEPOINT_FILE)
|
|
372
|
+
if File.exist?(savepoint_path)
|
|
373
|
+
# #scrub before scanning (post-execution review row 7.8), the same way
|
|
374
|
+
# NodeLedger.entries does (matrix 2.44): a stray non-UTF-8 byte anywhere
|
|
375
|
+
# in the ledger must not raise out of the one repair tool three doctor
|
|
376
|
+
# fix hints and maintenance-run --tool rebuild-savepoint point at.
|
|
377
|
+
transition_lines = File.read(savepoint_path).scrub.each_line.select { |raw| transition_candidate?(raw) }
|
|
378
|
+
lines += transition_lines.map { |raw| raw.end_with?("\n") ? raw : "#{raw}\n" }
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
File.write(savepoint_path, lines.join)
|
|
275
382
|
lines.length
|
|
276
383
|
end
|
|
277
384
|
|
|
@@ -320,6 +427,10 @@ module Savepoint
|
|
|
320
427
|
File.read(path).each_line do |raw|
|
|
321
428
|
line = raw.strip
|
|
322
429
|
next if line.empty?
|
|
430
|
+
# A transition line (intent 335) is never a stage phantom candidate: its
|
|
431
|
+
# own repeated-line semantics (dedup-free by design, spec D11) are
|
|
432
|
+
# NodeLedger's concern, not this detector's.
|
|
433
|
+
next if transition_candidate?(line)
|
|
323
434
|
parts = line.split(/\s{2,}/)
|
|
324
435
|
next if parts.length < 3
|
|
325
436
|
pair = [parts[1], parts[2]]
|
|
@@ -0,0 +1,201 @@
|
|
|
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 "action_graph_shim"
|
|
8
|
+
|
|
9
|
+
# WorkGraphValidator (intent 334, n4): the in-batch reader over one intent's
|
|
10
|
+
# graph.md and nodes/ - 327's rule for budget, files, and the decision and
|
|
11
|
+
# research kinds (fold A17). {ok:, missing:, errors:}, the same Result shape
|
|
12
|
+
# ProjectValidator returns (fold B7). Named apart from
|
|
13
|
+
# IntentValidator#validate_graph, which already exists for the KNOWLEDGE
|
|
14
|
+
# graph and stays unrelated to this one under D40 (fold B9).
|
|
15
|
+
#
|
|
16
|
+
# Built over GraphFile, GraphEdges, and NodeFile only - never IntentValidator,
|
|
17
|
+
# never ReportScreen. Every check accumulates into `errors` rather than
|
|
18
|
+
# short-circuiting on the first one (fold: "the owner fixes one thing per
|
|
19
|
+
# run").
|
|
20
|
+
module WorkGraphValidator
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def validate(intent_dir)
|
|
24
|
+
missing = []
|
|
25
|
+
errors = []
|
|
26
|
+
|
|
27
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
28
|
+
parsed = GraphFile.parse(graph_path)
|
|
29
|
+
errors.concat(parsed[:errors])
|
|
30
|
+
|
|
31
|
+
graph = parsed[:graph]
|
|
32
|
+
if graph.nil?
|
|
33
|
+
# G9 (intent 342, D6/D7/D12): a legacy intent that never got a
|
|
34
|
+
# graph.md still reads as a valid work graph, structurally, through
|
|
35
|
+
# the backward shim - but only when graph.md is genuinely absent.
|
|
36
|
+
# A graph.md that exists but is malformed (no "## Graph" section)
|
|
37
|
+
# must never fall through to the synthetic chain and hide its own
|
|
38
|
+
# error (D12's whole point). The fresh error list here is
|
|
39
|
+
# deliberate: `errors` above already holds the not-found error from
|
|
40
|
+
# `parsed[:errors]`, and reusing it would return ok: false for every
|
|
41
|
+
# legacy intent in the store.
|
|
42
|
+
if !File.exist?(graph_path) && ActionGraphShim.shape(intent_dir) == :actions
|
|
43
|
+
return validate_actions_shape(intent_dir)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
missing << "graph.md ## Graph section"
|
|
47
|
+
return { ok: false, missing: missing, errors: errors }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
nodes = graph[:nodes]
|
|
51
|
+
edges = graph[:edges]
|
|
52
|
+
declared_ids = edges.keys
|
|
53
|
+
|
|
54
|
+
# Intent-scope dangling check: every node must be declared with its own
|
|
55
|
+
# needs-line here (unlike a roadmap, which may have undeclared roots -
|
|
56
|
+
# that reconciliation is G4's, not this validator's).
|
|
57
|
+
(nodes - declared_ids).each { |id| errors << "needs target #{id.inspect} names no declared node" }
|
|
58
|
+
|
|
59
|
+
cyc = GraphEdges.cycle(edges)
|
|
60
|
+
errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
|
|
61
|
+
|
|
62
|
+
node_paths = Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort
|
|
63
|
+
parsed_nodes = {}
|
|
64
|
+
paths_by_id = Hash.new { |h, k| h[k] = [] }
|
|
65
|
+
|
|
66
|
+
node_paths.each do |path|
|
|
67
|
+
nf = NodeFile.parse(path)
|
|
68
|
+
nf[:errors].each { |e| errors << "#{File.basename(path)}: #{e}" }
|
|
69
|
+
id = nf[:node]
|
|
70
|
+
next unless id
|
|
71
|
+
|
|
72
|
+
paths_by_id[id] << path
|
|
73
|
+
parsed_nodes[id] ||= nf
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
paths_by_id.each do |id, paths|
|
|
77
|
+
next unless paths.length > 1
|
|
78
|
+
|
|
79
|
+
errors << "node id #{id.inspect} claimed by more than one file: #{paths.map { |p| File.basename(p) }.join(', ')}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
declared_ids.each do |id|
|
|
83
|
+
missing << "nodes/ file for #{id}" unless parsed_nodes.key?(id)
|
|
84
|
+
errors << "declared node #{id.inspect} has no node file under nodes/" unless parsed_nodes.key?(id)
|
|
85
|
+
end
|
|
86
|
+
parsed_nodes.each_key do |id|
|
|
87
|
+
errors << "node file for #{id.inspect} exists but ## Graph declares no such node" unless declared_ids.include?(id)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
work_nodes = []
|
|
91
|
+
verify_nodes = []
|
|
92
|
+
|
|
93
|
+
declared_ids.each do |id|
|
|
94
|
+
nf = parsed_nodes[id]
|
|
95
|
+
next unless nf
|
|
96
|
+
|
|
97
|
+
case nf[:kind]
|
|
98
|
+
when "verify"
|
|
99
|
+
verify_nodes << id
|
|
100
|
+
errors << "verify node #{id.inspect} has no ## Criteria" unless has_section?(nf[:body], "## Criteria")
|
|
101
|
+
when "decision"
|
|
102
|
+
errors << "decision node #{id.inspect} has no ## Question with a question" unless has_nonblank_section?(nf[:body], "## Question")
|
|
103
|
+
when "research"
|
|
104
|
+
errors << "research node #{id.inspect} has no ## Deposit" unless has_section?(nf[:body], "## Deposit")
|
|
105
|
+
when "work"
|
|
106
|
+
work_nodes << id
|
|
107
|
+
errors << "work node #{id.inspect} has no ## Steps" unless has_section?(nf[:body], "## Steps")
|
|
108
|
+
errors << "work node #{id.inspect} has no ## Proven by" unless has_section?(nf[:body], "## Proven by")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
above_trivial_bar = work_nodes.length >= 2
|
|
113
|
+
if above_trivial_bar
|
|
114
|
+
verify_directive = parsed[:verify]
|
|
115
|
+
directive_ok = verify_directive && !verify_directive[:reason].to_s.empty?
|
|
116
|
+
|
|
117
|
+
unless directive_ok
|
|
118
|
+
attached = verify_nodes.select { |vid| node_touches_graph?(vid, edges) }
|
|
119
|
+
if attached.empty?
|
|
120
|
+
errors << "two or more work nodes require a verify node attached to the graph, or a verify: none reason=<text> directive"
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
work_nodes.each do |id|
|
|
125
|
+
nf = parsed_nodes[id]
|
|
126
|
+
next unless nf
|
|
127
|
+
|
|
128
|
+
errors << "work node #{id.inspect} has no failure-mode matrix under a heading carrying its id" unless has_valid_matrix?(id, nf[:body])
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
verify_nodes.each do |id|
|
|
133
|
+
next if node_touches_graph?(id, edges)
|
|
134
|
+
|
|
135
|
+
errors << "verify node #{id.inspect} exists but no edge reaches it (attach it or drop it)"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
{ ok: errors.empty?, missing: missing, errors: errors }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A node "touches" the graph when some edge involves it on either side:
|
|
142
|
+
# it targets something, or something targets it. A verify node declared
|
|
143
|
+
# with "needs nothing" and targeted by nothing is fully isolated (fold
|
|
144
|
+
# A13).
|
|
145
|
+
def node_touches_graph?(id, edges)
|
|
146
|
+
(edges[id] || []).any? || edges.values.any? { |targets| targets.include?(id) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def has_section?(body, heading_text)
|
|
150
|
+
NodeFile.split_by_headings(body.to_s).any? { |heading, _| heading.strip == heading_text }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def has_nonblank_section?(body, heading_text)
|
|
154
|
+
NodeFile.split_by_headings(body.to_s).any? { |heading, section| heading.strip == heading_text && !section.strip.empty? }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def heading_tokens(heading)
|
|
158
|
+
heading.to_s.sub(/\A#+\s*/, "").split(/[^A-Za-z0-9]+/)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The matrix table must sit under a heading whose tokens include the
|
|
162
|
+
# node's own id, and that heading must own at least one data row (fold
|
|
163
|
+
# A1 - the same table-owning rule report_screen's resolver uses, so a
|
|
164
|
+
# node's own Proven-by cell is never "not recorded" the moment it ships).
|
|
165
|
+
def has_valid_matrix?(id, body)
|
|
166
|
+
NodeFile.split_by_headings(body.to_s).any? do |heading, section|
|
|
167
|
+
heading_tokens(heading).include?(id) && NodeFile.table_rows(section).any?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The structural check the synthetic (actions/-only) shape gets, and
|
|
172
|
+
# nothing more (D6): at least one node, unique ids, every needs target
|
|
173
|
+
# names a declared node, acyclic. Never the kind-section rules, the
|
|
174
|
+
# failure-mode matrix bar, or the verify-attachment bar - a legacy action
|
|
175
|
+
# file labels its headings "S1", or nothing at all, and was never asked
|
|
176
|
+
# to meet a bar written for a graph authored under 327.
|
|
177
|
+
def validate_actions_shape(intent_dir)
|
|
178
|
+
errors = []
|
|
179
|
+
graph = ActionGraphShim.view(intent_dir)[:graph] || { nodes: [], edges: {}, errors: [] }
|
|
180
|
+
nodes = graph[:nodes]
|
|
181
|
+
edges = graph[:edges]
|
|
182
|
+
|
|
183
|
+
errors << "actions/ yields no nodes" if nodes.empty?
|
|
184
|
+
# D16: uniqueness is checked over the node array, not edges.keys - a
|
|
185
|
+
# Hash key set is unique by construction, so checking edges.keys can
|
|
186
|
+
# never fire.
|
|
187
|
+
errors << "duplicate node ids in synthetic chain" if nodes.uniq.length != nodes.length
|
|
188
|
+
|
|
189
|
+
# D16: every needs target is checked against the declared node list,
|
|
190
|
+
# not the node list against its own key set (nodes - edges.keys, which
|
|
191
|
+
# can never differ since the builder mints edges.keys from nodes).
|
|
192
|
+
(edges.values.flatten.uniq - nodes).each do |id|
|
|
193
|
+
errors << "needs target #{id.inspect} names no declared node"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
cyc = GraphEdges.cycle(edges)
|
|
197
|
+
errors << "cyclic graph, cannot validate: #{cyc.join(' > ')}" if cyc
|
|
198
|
+
|
|
199
|
+
{ ok: errors.empty?, missing: [], errors: errors }
|
|
200
|
+
end
|
|
201
|
+
end
|