@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 +1 -1
- package/scripts/doctor.rb +36 -0
- package/scripts/index-projection +74 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +44 -0
- package/scripts/lib/node_packet.rb +15 -2
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/report_screen.rb +26 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +17 -42
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/runner +392 -0
- package/skills/auto/SKILL.md +1 -1
- package/skills/roadmap/SKILL.md +17 -0
- package/templates/report-roadmap-plan.md +1 -1
- package/templates/roadmap.md +13 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
|