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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.0-alpha.19",
3
+ "version": "2.0.0-alpha.20",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/doctor.rb CHANGED
@@ -29,6 +29,7 @@ require_relative "lib/lock"
29
29
  require_relative "lib/savepoint"
30
30
  require_relative "lib/node_ledger"
31
31
  require_relative "lib/ready_set"
32
+ require_relative "lib/index_projection"
32
33
  require_relative "lib/agent_models"
33
34
  require_relative "lib/outcome_guard"
34
35
  require_relative "lib/skill_lint"
@@ -837,6 +838,9 @@ def check_done_signals(scopes: nil)
837
838
  unbackfilled = [] # spec/plan/actions gaps on terminal intents (intent 308) - repairable
838
839
  excluded_backfill = [] # backfill gaps knowingly exempted via doctor-exclusions (intent 308)
839
840
  dead_rows_by_rule = Hash.new { |h, k| h[k] = [] }
841
+ index_drift = [] # intent 337 (G4): INDEX vs. the intent ledgers, scoped to what
842
+ # savepoint_operational does not already report (row 6.7 - a silent or
843
+ # absent ledger is already IndexProjection's own exclusion, row 5.13)
840
844
 
841
845
  done_signal_stores(scopes).each do |store|
842
846
  exclusions = DoctorExclusions.load(store[:index])
@@ -845,6 +849,14 @@ def check_done_signals(scopes: nil)
845
849
  exclusion_error_paths << exclusions[:path]
846
850
  end
847
851
 
852
+ if File.exist?(store[:index])
853
+ projection = IndexProjection.analyze(store[:store_dir], index_path: store[:index])
854
+ projection[:drift].each do |row|
855
+ index_drift << "#{store[:scope]}: #{row[:id]} - INDEX says #{row[:index_status]}, " \
856
+ "the ledger's last Done line says #{row[:ledger_status]}"
857
+ end
858
+ end
859
+
848
860
  consumed = { "savepoint_operational" => [], "backfilled_complete" => [] }
849
861
  # `known_ids` (post-review fix): every intent id with a REAL DIRECTORY in this store, scanned
850
862
  # directly from disk - independent of INDEX.md. An id can have a directory on disk without
@@ -1089,6 +1101,30 @@ def check_done_signals(scopes: nil)
1089
1101
  )
1090
1102
  end
1091
1103
 
1104
+ # index_ledger_drift (intent 337, G4): a REAL terminal ledger line (Done
1105
+ # delivered or abandoned) that disagrees with the INDEX section an intent
1106
+ # currently sits in. Never fires for a silent or absent ledger - that gap
1107
+ # is savepoint_operational's own concern, and IndexProjection's own drift
1108
+ # computation already excludes it (row 5.13), so no id is ever double
1109
+ # reported across the two checks (row 6.7). Warn+fixable: `index-projection
1110
+ # <store_root> --write` is the repair, never invented here.
1111
+ if index_drift.empty?
1112
+ checks << check(
1113
+ category: "done_signals", name: "index_ledger_drift", status: "pass",
1114
+ message: "No INDEX section disagrees with an intent's own terminal ledger line"
1115
+ )
1116
+ else
1117
+ checks << check(
1118
+ category: "done_signals", name: "index_ledger_drift", status: "warn",
1119
+ message: "#{index_drift.size} intent#{index_drift.size == 1 ? "" : "s"} whose terminal " \
1120
+ "ledger line disagrees with the INDEX section it currently sits in",
1121
+ details: index_drift, fixable: true,
1122
+ fix_hint: "Reconcile via `index-projection <store_root> --write` (the ledger wins; only the " \
1123
+ "conflicting entries move, every other byte of INDEX.md, including ## Clusters and " \
1124
+ "## Relocated, is left untouched)."
1125
+ )
1126
+ end
1127
+
1092
1128
  checks
1093
1129
  end
1094
1130
 
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: UTF-8
3
+ # frozen_string_literal: true
4
+
5
+ # index-projection - the CLI over IndexProjection (intent 337, G4): prints
6
+ # the drift between INDEX.md and the intent ledgers for one store root
7
+ # (INDEX.md plus a store/ directory), exits non-zero when any drift exists.
8
+ # --write renders INDEX.md's four status sections from the projection
9
+ # through AtomicWrite; every other byte, including ## Clusters and
10
+ # ## Relocated, is untouched.
11
+ #
12
+ # Usage:
13
+ # index-projection <store_root> [--write]
14
+ #
15
+ # Exit codes:
16
+ # 0 - no drift (or, with --write, the write landed cleanly)
17
+ # 1 - drift exists (a real terminal-ledger conflict, an INDEX entry with
18
+ # no directory, or a directory INDEX does not list)
19
+ # 2 - usage: no argument, or the path is not a store root
20
+
21
+ require_relative "lib/index_projection"
22
+
23
+ module IndexProjectionCli
24
+ module_function
25
+
26
+ def usage
27
+ warn "Usage: index-projection <store_root> [--write]"
28
+ end
29
+
30
+ def main(argv)
31
+ args = argv.dup
32
+ write = !!args.delete("--write")
33
+
34
+ root = args.shift
35
+ unless root && Dir.exist?(root)
36
+ warn "index-projection: #{root.inspect} is not a store root"
37
+ usage
38
+ return 2
39
+ end
40
+
41
+ index_path = File.join(root, "INDEX.md")
42
+ store_dir = File.join(root, "store")
43
+ unless File.exist?(index_path)
44
+ warn "index-projection: no INDEX.md at #{index_path}"
45
+ return 2
46
+ end
47
+
48
+ if write
49
+ result = IndexProjection.write(store_dir, index_path: index_path)
50
+ unless result[:ok]
51
+ warn "index-projection: #{result[:error]}"
52
+ return 2
53
+ end
54
+ puts "index-projection: wrote #{index_path} (#{result[:moved].length} moved)"
55
+ return 0
56
+ end
57
+
58
+ analysis = IndexProjection.analyze(store_dir, index_path: index_path)
59
+ print_report(analysis)
60
+
61
+ (analysis[:drift].empty? && analysis[:index_only].empty? && analysis[:directory_only].empty?) ? 0 : 1
62
+ end
63
+
64
+ def print_report(analysis)
65
+ puts "Drift (#{analysis[:drift].length}):"
66
+ analysis[:drift].each { |r| puts " #{r[:id]}: INDEX=#{r[:index_status]} ledger=#{r[:ledger_status]}" }
67
+ puts "INDEX-only (#{analysis[:index_only].length}):"
68
+ analysis[:index_only].each { |r| puts " #{r[:id]} (#{r[:index_status]})" }
69
+ puts "Directory-only (#{analysis[:directory_only].length}):"
70
+ analysis[:directory_only].each { |r| puts " #{r[:id]}" }
71
+ end
72
+ end
73
+
74
+ exit(IndexProjectionCli.main(ARGV)) if $PROGRAM_NAME == __FILE__
@@ -0,0 +1,71 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+ require "digest"
6
+
7
+ # CoreIntegrity (intent 340, G7, n1): re-hashes the files an installed
8
+ # ~/.plastic/manifest.json lists, so the runner's own "am I running trusted
9
+ # code" check has one implementation. scripts/doctor.rb's
10
+ # Doctor#check_install_integrity does this same arithmetic inline today,
11
+ # per-agent (Codex/Hermes/Claude), against each agent's own manifest; this
12
+ # module is not a refactor of that (out of scope for this node), it exists
13
+ # so the runner - and a later node that points doctor at it - never have to
14
+ # reimplement the same hashing loop a third time.
15
+ #
16
+ # Pure and side-effect-free: `check` takes plastic_home: and never raises
17
+ # across its boundary. A missing, unreadable, or non-JSON manifest.json is a
18
+ # named refusal (`reason:`), never an exception. A manifest-listed file that
19
+ # no longer exists is reported under `missing`, distinct from `drifted` (a
20
+ # file that exists but no longer hashes to the manifest's recorded value).
21
+ module CoreIntegrity
22
+ module_function
23
+
24
+ def check(plastic_home:)
25
+ manifest_path = File.join(plastic_home.to_s, "manifest.json")
26
+ return refusal("manifest.json not found at #{manifest_path}") unless File.exist?(manifest_path)
27
+
28
+ raw = begin
29
+ File.read(manifest_path)
30
+ rescue StandardError => e
31
+ return refusal("manifest.json is unreadable: #{e.message}")
32
+ end
33
+
34
+ data = begin
35
+ JSON.parse(raw)
36
+ rescue JSON::ParserError => e
37
+ return refusal("manifest.json is not valid JSON: #{e.message}")
38
+ end
39
+
40
+ files = data.is_a?(Hash) ? data["files"] : nil
41
+ return refusal("manifest.json's files: is not a mapping") unless files.is_a?(Hash)
42
+
43
+ drifted = []
44
+ missing = []
45
+ files.each do |path, expected_hash|
46
+ unless File.exist?(path)
47
+ missing << path
48
+ next
49
+ end
50
+
51
+ # v1 minor 6/row 11.16: a tracked file that exists but cannot be read
52
+ # (permissions changed under this process) must never raise out of a
53
+ # module whose whole contract is "never raises across its boundary" -
54
+ # unreadable is reported the same way a hash mismatch is: this
55
+ # process cannot confirm the file matches what the manifest expects,
56
+ # which is exactly what `drifted` already means.
57
+ actual_hash = begin
58
+ Digest::SHA256.file(path).hexdigest
59
+ rescue SystemCallError
60
+ nil
61
+ end
62
+ drifted << path if actual_hash.nil? || actual_hash != expected_hash
63
+ end
64
+
65
+ { ok: drifted.empty? && missing.empty?, drifted: drifted, missing: missing, reason: nil }
66
+ end
67
+
68
+ def refusal(reason)
69
+ { ok: false, drifted: [], missing: [], reason: reason }
70
+ end
71
+ end
@@ -0,0 +1,98 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "graph_edges"
5
+ require_relative "ready_set"
6
+
7
+ # GraphTree (intent 337, n2, folds 327a): draws any edge map ({id => [needs...]})
8
+ # as an indented tree with box-drawing branches. A node may have any number
9
+ # of children; a node several branches need (fan-out) is drawn once, at the
10
+ # point where those branches join. A node that itself needs several things
11
+ # (fan-in) can only occupy one position in the tree: it hangs under its
12
+ # highest-batch need, ties broken by smallest id, with its other needs shown
13
+ # as converging references at the join (row 2.14, folded at the 2026-09-10
14
+ # plan review). Pure, plain text, no roadmap knowledge, so the same
15
+ # renderer serves the roadmap file, the roadmap screens, and later the node
16
+ # scope. Batch numbers (for placement) and the topological sort come from
17
+ # ReadySet - the ONE sort (327 D1) - never a second local one here.
18
+ module GraphTree
19
+ module_function
20
+
21
+ def render(edges:, labels:, marks:, width:)
22
+ edges = edges || {}
23
+ nodes = ReadySet.all_nodes(edges)
24
+
25
+ cyc = GraphEdges.cycle(edges)
26
+ if cyc
27
+ return { ok: false, text: nil, error: "cyclic graph, cannot render a tree: #{cyc.join(' > ')}", cycle: cyc }
28
+ end
29
+
30
+ if nodes.empty?
31
+ return { ok: true, text: "(no dependencies)\n", error: nil, cycle: nil }
32
+ end
33
+
34
+ batch_result = ReadySet.batches(edges)
35
+ batch_of = {}
36
+ batch_result[:batches].each_with_index { |layer, i| layer.each { |id| batch_of[id] = i + 1 } }
37
+
38
+ primary_parent = {}
39
+ converging = {}
40
+ nodes.each do |id|
41
+ needs = (edges[id] || []).uniq
42
+ next if needs.empty?
43
+
44
+ primary = needs.min_by { |n| [-(batch_of[n] || 0), n] }
45
+ primary_parent[id] = primary
46
+ others = needs - [primary]
47
+ converging[id] = others.sort unless others.empty?
48
+ end
49
+
50
+ children_of = Hash.new { |h, k| h[k] = [] }
51
+ primary_parent.each { |child, parent| children_of[parent] << child }
52
+ children_of.each_value { |list| list.sort! { |a, b| [batch_of[a] || 0, a] <=> [batch_of[b] || 0, b] } }
53
+
54
+ roots = nodes.select { |id| (edges[id] || []).empty? }.sort
55
+
56
+ labels ||= {}
57
+ marks ||= {}
58
+ critical = (marks[:critical_path] || []).to_a
59
+ ready = (marks[:ready] || []).to_a
60
+
61
+ lines = []
62
+ roots.each { |id| append_node(id, "", "", lines, children_of, labels, critical, ready, converging, width) }
63
+
64
+ { ok: true, text: "#{lines.join("\n")}\n", error: nil, cycle: nil }
65
+ end
66
+
67
+ # `ancestor_prefix` is the continuation string every ancestor above this
68
+ # node contributes ("│ " when that ancestor still has a later sibling
69
+ # to draw, " " when it was the last child); `connector` is this node's
70
+ # own branch glyph off its parent ("├── " / "└── "), empty for a root.
71
+ def append_node(id, ancestor_prefix, connector, lines, children_of, labels, critical, ready, converging, width)
72
+ lines << render_line(ancestor_prefix + connector, id, labels, critical, ready, converging, width)
73
+
74
+ continuation = connector == "└── " ? " " : (connector.empty? ? "" : "│ ")
75
+ child_ancestor_prefix = ancestor_prefix + continuation
76
+
77
+ children = children_of[id] || []
78
+ children.each_with_index do |child_id, i|
79
+ last = i == children.length - 1
80
+ child_connector = last ? "└── " : "├── "
81
+ append_node(child_id, child_ancestor_prefix, child_connector, lines, children_of, labels, critical, ready, converging, width)
82
+ end
83
+ end
84
+
85
+ def render_line(connector, id, labels, critical, ready, converging, width)
86
+ label = labels.fetch(id, id).to_s
87
+ text = critical.include?(id) ? "* #{label}" : label
88
+ text += " (also needs #{converging[id].join(', ')})" if converging[id]
89
+ text += " (ready)" if ready.include?(id)
90
+
91
+ available = width - connector.length
92
+ if available.positive? && text.length > available
93
+ text = available > 3 ? "#{text[0, available - 3]}..." : text[0, available]
94
+ end
95
+
96
+ "#{connector}#{text}"
97
+ end
98
+ end
@@ -0,0 +1,201 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "doctor_exclusions"
5
+ require_relative "graph_file"
6
+ require_relative "atomic_write"
7
+
8
+ # IndexProjection (intent 337, n5): computes every intent's status from its
9
+ # own savepoint.md ledger, reads the status INDEX.md currently claims, and
10
+ # reports the drift between them. The ledger wins WHERE THE LEDGER SPEAKS
11
+ # (row 5.1/5.2): a REAL terminal line (Done delivered/abandoned, or a
12
+ # classifiable Done detail) beats a stale INDEX section. An intent whose
13
+ # ledger is silent (no terminal line) or absent (no savepoint.md at all)
14
+ # keeps the status INDEX already carries (row 5.13, folded at the
15
+ # 2026-09-10 plan review): 63 of 451 intents in the plastic store have no
16
+ # savepoint.md and 59 more never reach a Done line, and a literal reading
17
+ # would demote all of them. This module computes and compares only; it
18
+ # writes nothing (row 5.11) and reads no clock or environment variable.
19
+ module IndexProjection
20
+ module_function
21
+
22
+ INDEX_SECTIONS = %w[Active Future Completed Abandoned].freeze
23
+ TERMINAL_STATUSES = %w[Completed Abandoned].freeze
24
+ EXCLUSION_RULES = %w[savepoint_operational backfilled_complete].freeze
25
+
26
+ # `store_path` holds the intent directories (a project's own `store/`, or
27
+ # the global `~/.plastic/store`). `index_path` defaults to a sibling
28
+ # `INDEX.md` inside `store_path` (matching every fixture in this test
29
+ # file), but the real Plastic layout keeps INDEX.md one level ABOVE
30
+ # `store/` - callers there (doctor.rb, the CLI) pass it explicitly.
31
+ def analyze(store_path, index_path: nil)
32
+ index_path ||= File.join(store_path, "INDEX.md")
33
+ index_map = read_index(index_path)
34
+ dir_ids = store_intent_ids(store_path)
35
+
36
+ excluded = excluded_ids(index_path)
37
+
38
+ drift = index_map.filter_map do |id, index_status|
39
+ next if index_status == "Future" # 5.5: Future has no ledger counterpart
40
+ next if excluded.include?(id) # 5.12
41
+
42
+ ledger_status = ledger_status_for(store_path, id)
43
+ next unless TERMINAL_STATUSES.include?(ledger_status) # 5.3/5.13
44
+ next if ledger_status == index_status
45
+
46
+ { id: id, index_status: index_status, ledger_status: ledger_status }
47
+ end
48
+
49
+ index_only = (index_map.keys - dir_ids).reject { |id| excluded.include?(id) }
50
+ .map { |id| { id: id, index_status: index_map[id] } }
51
+ directory_only = (dir_ids - index_map.keys).reject { |id| excluded.include?(id) }
52
+ .map { |id| { id: id } }
53
+
54
+ { ok: true, drift: drift, index_only: index_only, directory_only: directory_only, errors: [] }
55
+ end
56
+
57
+ # Render the four status sections (## Active, ## Future, ## Completed,
58
+ # ## Abandoned) from the projection and write through AtomicWrite. Only
59
+ # entries `analyze` actually reported as `drift` ever move - an entry
60
+ # whose ledger is silent or absent never appears in `drift` (row 5.13),
61
+ # so --write can never demote it (row 6.14). Every other line, including
62
+ # ## Clusters and ## Relocated (or any other section), is untouched
63
+ # (row 6.13): only the four named headings are ever replaced.
64
+ def write(store_path, index_path:, renamer: File.method(:rename))
65
+ return { ok: false, written: false, error: "no INDEX.md at #{index_path}" } unless File.exist?(index_path)
66
+
67
+ analysis = analyze(store_path, index_path: index_path)
68
+ text = read_utf8(index_path)
69
+
70
+ original_lines = {}
71
+ INDEX_SECTIONS.each do |heading|
72
+ section_body(text, heading).each_line do |line|
73
+ next unless line.strip.start_with?("- [")
74
+
75
+ m = line.strip.match(/\A-\s*\[(\S+)\s/)
76
+ original_lines[m[1]] = line if m
77
+ end
78
+ end
79
+
80
+ moves = analysis[:drift].each_with_object({}) { |r, h| h[r[:id]] = r[:ledger_status] }
81
+
82
+ new_bodies = INDEX_SECTIONS.each_with_object({}) do |heading, h|
83
+ kept = section_body(text, heading).each_line.reject do |line|
84
+ stripped = line.strip
85
+ next false unless stripped.start_with?("- [")
86
+
87
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
88
+ m && moves.key?(m[1])
89
+ end
90
+ arriving = moves.select { |_id, target| target == heading }.keys
91
+ h[heading] = (kept + arriving.filter_map { |id| original_lines[id] }).join
92
+ end
93
+
94
+ content = text
95
+ INDEX_SECTIONS.each do |heading|
96
+ content = GraphFile.replace_or_append_section(content, "## #{heading}", new_bodies[heading].rstrip + "\n")
97
+ end
98
+
99
+ AtomicWrite.write(index_path, content, renamer: renamer)
100
+ { ok: true, written: true, moved: analysis[:drift], error: nil }
101
+ end
102
+
103
+ # --- INDEX -------------------------------------------------------------------
104
+
105
+ def read_index(index_path)
106
+ map = {}
107
+ return map unless File.exist?(index_path)
108
+
109
+ text = read_utf8(index_path)
110
+ INDEX_SECTIONS.each do |heading|
111
+ section_body(text, heading).each_line do |line|
112
+ stripped = line.strip
113
+ next unless stripped.start_with?("- [")
114
+
115
+ m = stripped.match(/\A-\s*\[(\S+)\s/)
116
+ map[m[1]] = heading if m
117
+ end
118
+ end
119
+ map
120
+ end
121
+
122
+ def section_body(text, heading)
123
+ m = text.match(/^##\s+#{Regexp.escape(heading)}\s*$(.*?)(?=^##\s|\z)/m)
124
+ m ? m[1] : ""
125
+ end
126
+
127
+ # --- store directories ---------------------------------------------------------
128
+
129
+ def store_intent_ids(store_path)
130
+ return [] unless Dir.exist?(store_path)
131
+
132
+ Dir.entries(store_path).select { |e| e.include?("--") && File.directory?(File.join(store_path, e)) }
133
+ .map { |e| e.split("--", 2).first }
134
+ end
135
+
136
+ def intent_dir_for(store_path, id)
137
+ Dir.glob(File.join(store_path, "#{id}--*")).find { |p| File.directory?(p) }
138
+ end
139
+
140
+ # --- the ledger (savepoint.md) --------------------------------------------------
141
+
142
+ # "Completed", "Abandoned", "Active" (no terminal line yet), "unknown" (no
143
+ # directory or no savepoint.md), or "indeterminate" (a Done line whose
144
+ # detail is neither delivered/merged nor abandoned, row 5.14).
145
+ def ledger_status_for(store_path, id)
146
+ dir = intent_dir_for(store_path, id)
147
+ return "unknown" unless dir
148
+
149
+ sp_path = File.join(dir, "savepoint.md")
150
+ return "unknown" unless File.exist?(sp_path)
151
+
152
+ parsed = complete_lines(read_utf8(sp_path)).filter_map { |l| parse_line(l) }
153
+ last_done = parsed.reverse.find { |p| p[:kind] == "Done" }
154
+ return "Active" unless last_done
155
+
156
+ classify_done_detail(last_done[:detail]) || "indeterminate"
157
+ end
158
+
159
+ # Drop the trailing torn fragment: a savepoint line that never got its
160
+ # closing newline because the process died mid-append (row 5.6). Works
161
+ # whether the file ends in a newline (the dropped element is the empty
162
+ # string split(-1) always yields after a final "\n") or not (the dropped
163
+ # element is the torn fragment itself).
164
+ def complete_lines(content)
165
+ return [] if content.to_s.empty?
166
+
167
+ lines = content.split("\n", -1)
168
+ lines.pop
169
+ lines.reject { |l| l.strip.empty? }
170
+ end
171
+
172
+ def parse_line(line)
173
+ parts = line.strip.split(/\s{2,}/, 3)
174
+ return nil unless parts.length == 3
175
+
176
+ { time: parts[0], kind: parts[1], detail: parts[2] }
177
+ end
178
+
179
+ def classify_done_detail(detail)
180
+ d = detail.to_s.strip
181
+ return "Abandoned" if d.start_with?("abandoned")
182
+ return "Completed" if d.start_with?("delivered") || d.start_with?("merged")
183
+
184
+ nil
185
+ end
186
+
187
+ # --- doctor exclusions (row 5.12) -----------------------------------------------
188
+
189
+ def excluded_ids(index_path)
190
+ loaded = DoctorExclusions.load(index_path)
191
+ EXCLUSION_RULES.flat_map { |rule| loaded[:rules][rule] || [] }.uniq
192
+ end
193
+
194
+ # --- utf-8 -----------------------------------------------------------------------
195
+
196
+ def read_utf8(path)
197
+ text = File.read(path)
198
+ text.force_encoding(Encoding::UTF_8)
199
+ text.valid_encoding? ? text : text.scrub("")
200
+ end
201
+ end
@@ -498,6 +498,17 @@ class InstallerCore
498
498
  # test/install_sync_test.rb stays green across every intermediate unit.
499
499
  "scripts/lib/ready_set.rb" => "scripts/lib/ready_set.rb",
500
500
  "scripts/ready-set" => "scripts/ready-set",
501
+ # Intent 337 (G4): the roadmap graph model and its tree renderer,
502
+ # registered as they land (test/installer_core_test.rb requires every
503
+ # scripts/lib file to be manifest-covered as soon as it exists, not
504
+ # only once the CLI that ships it arrives at n4).
505
+ "scripts/lib/roadmap_graph.rb" => "scripts/lib/roadmap_graph.rb",
506
+ "scripts/lib/graph_tree.rb" => "scripts/lib/graph_tree.rb",
507
+ "scripts/lib/index_projection.rb" => "scripts/lib/index_projection.rb",
508
+ "scripts/lib/roadmap_render.rb" => "scripts/lib/roadmap_render.rb",
509
+ "scripts/lib/roadmap_migration.rb" => "scripts/lib/roadmap_migration.rb",
510
+ "scripts/roadmap-graph" => "scripts/roadmap-graph",
511
+ "scripts/index-projection" => "scripts/index-projection",
501
512
  # Intent 338 (G5): the node packet command - the trust-boundary wrapper,
502
513
  # the five-block gatherer/assembler, and the CLI 340's runner calls.
503
514
  "scripts/lib/packet_wrapper.rb" => "scripts/lib/packet_wrapper.rb",
@@ -507,6 +518,39 @@ class InstallerCore
507
518
  # node graph for any legacy intent, so WorkGraphValidator can require
508
519
  # it without going red on contact with install_sync_test.
509
520
  "scripts/lib/action_graph_shim.rb" => "scripts/lib/action_graph_shim.rb",
521
+ # Intent 340 (G7, n1): the runner command - the subcommand table over
522
+ # the declared node graph, its shared context (RunnerCore), and the
523
+ # installed-core integrity check (CoreIntegrity) the runner's trust
524
+ # boundary and doctor will both call.
525
+ "scripts/runner" => "scripts/runner",
526
+ "scripts/lib/runner_core.rb" => "scripts/lib/runner_core.rb",
527
+ "scripts/lib/core_integrity.rb" => "scripts/lib/core_integrity.rb",
528
+ # Intent 340 (G7, n2): the merge abort, the reclaim, and the extension -
529
+ # the first thing every `step` does, routed from scripts/runner's `sweep`
530
+ # verb.
531
+ "scripts/lib/runner_sweep.rb" => "scripts/lib/runner_sweep.rb",
532
+ # Intent 340 (G7, n3): a work node's own git worktree, cut from the
533
+ # intent branch tip, merged back into the intent branch, and swept once
534
+ # its node is terminal.
535
+ "scripts/lib/node_worktree.rb" => "scripts/lib/node_worktree.rb",
536
+ # Intent 340 (G7, n4): the return schema (NodeReturn) and the six-check
537
+ # gate (RunnerAbsorb) that turns one executor return into exactly one
538
+ # node ledger transition, required lazily by scripts/runner's `step`.
539
+ "scripts/lib/node_return.rb" => "scripts/lib/node_return.rb",
540
+ "scripts/lib/runner_absorb.rb" => "scripts/lib/runner_absorb.rb",
541
+ # Intent 340 (G7, n5): validation, policy, leases and the dispatch plan
542
+ # - RunnerPolicy (the kind table) and RunnerDispatch, required lazily
543
+ # by scripts/runner's `step`.
544
+ "scripts/lib/runner_policy.rb" => "scripts/lib/runner_policy.rb",
545
+ "scripts/lib/runner_dispatch.rb" => "scripts/lib/runner_dispatch.rb",
546
+ # Intent 340 (G7, n6): answer (closes a decision node or unparks a
547
+ # work node parked at needs_decision), proposals (mints ids for what
548
+ # an executor proposed), and rewind (resets the intent branch to a
549
+ # node's own commit and respins it) - routed from scripts/runner's
550
+ # `answer` and `rewind` verbs.
551
+ "scripts/lib/runner_answer.rb" => "scripts/lib/runner_answer.rb",
552
+ "scripts/lib/runner_proposals.rb" => "scripts/lib/runner_proposals.rb",
553
+ "scripts/lib/runner_rewind.rb" => "scripts/lib/runner_rewind.rb",
510
554
  }
511
555
  end
512
556
 
@@ -740,9 +740,14 @@ module NodePacket
740
740
 
741
741
  # C21: the number of `running` lines already recorded for `node`, plus one
742
742
  # when a lease is being supplied by flag (a NEW dispatch), floored at 1.
743
+ # Minor 5: a torn `running` line (missing holder=/expires=/packet=/model=)
744
+ # is skipped here exactly as ReadySet.attempts_count already skips it -
745
+ # counting it desynchronizes this attempt number from the extensions file
746
+ # a live node's own `packets/<node>--a<N>.extensions` names, since that
747
+ # file is keyed by the attempt sweep computed off the SAME filtered count.
743
748
  def compute_attempt_number(intent_dir:, node:, lease_flag_given:, entries: nil)
744
749
  entries ||= NodeLedger.entries(savepoint_path(intent_dir))
745
- count = entries.count { |e| e[:subject] == node.to_s && e[:state] == "running" }
750
+ count = entries.count { |e| !e[:torn] && e[:subject] == node.to_s && e[:state] == "running" }
746
751
  [count + (lease_flag_given ? 1 : 0), 1].max
747
752
  end
748
753
 
@@ -772,7 +777,13 @@ module NodePacket
772
777
  # usage (unknown node), 3 unreadable/unparsable graph, node file or
773
778
  # record, 4 overflow past the third cut, 5 an existing attempt whose bytes
774
779
  # differ.
775
- def build(intent_dir:, node:, budget_tokens: DEFAULT_BUDGET_TOKENS, hop_tokens: DEFAULT_HOP_TOKENS,
780
+ # M7/row 10.9: `budget_tokens: nil` (rather than DEFAULT_BUDGET_TOKENS)
781
+ # is how a caller says "no override" - the fallback below then reaches for
782
+ # the node block's OWN declared `budget:` before ever touching the
783
+ # shipped default, so a caller that never learned about a node's budget
784
+ # (the CLI, another future caller) still gets it, not just the one path
785
+ # RunnerDispatch explicitly threads it through (row 10.8).
786
+ def build(intent_dir:, node:, budget_tokens: nil, hop_tokens: DEFAULT_HOP_TOKENS,
776
787
  holder: nil, expires: nil, model: nil, attempt: nil, out: nil, force: false,
777
788
  renamer: File.method(:rename), git_runner: DEFAULT_GIT_RUNNER,
778
789
  worktree_reader: Arm.method(:worktree_block), project_reader: method(:default_project_reader))
@@ -783,6 +794,8 @@ module NodePacket
783
794
  return { ok: false, exit_code: nb[:error_kind] == :unknown_node ? 2 : 3, errors: nb[:errors] }
784
795
  end
785
796
 
797
+ budget_tokens = (budget_tokens || nb[:budget] || DEFAULT_BUDGET_TOKENS).to_i
798
+
786
799
  record = record_block(intent_dir: intent_dir, kind: nb[:kind])
787
800
  return { ok: false, exit_code: 3, errors: record[:errors] } unless record[:ok]
788
801