@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
@@ -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
- { slug: File.basename(path, ".md"), path: path, waves: parse_waves(RoadmapSavepoint.grouping_section_body(text, path: path)) }
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
- def append(roadmap_path, event, detail, now: Time.now)
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
- File.open(ledger_path, "a") { |io| io.write(format_line(now, event, detail)) }
108
- true
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)
@@ -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 action file: a non-empty *.md whose
61
- # first line is not the placeholder sentinel. A `.gitkeep` (no .md extension)
62
- # never counts, an empty *.md never counts, and a sentinel-only *.md never
63
- # counts. Pure and side-effect-free so the gate stays unit-testable. Fail-open:
64
- # a missing actions/ dir globs to nothing and returns false (the gate then
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
- files << "actions/" if has_real_action?(intent_dir)
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" then ["plan.md", "actions/", "checklist.md"]
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
- File.write(File.join(intent_dir, SAVEPOINT_FILE), lines.join)
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