@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24
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/PLASTIC.md +6 -5
- package/agents/plastic-enforcer.md +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +409 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +40 -13
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_file"
|
|
5
|
+
require_relative "graph_edges"
|
|
6
|
+
require_relative "node_ledger"
|
|
7
|
+
require_relative "intent_screen"
|
|
8
|
+
|
|
9
|
+
# NodeProgress (intent 337a, n1): the graph-era progress reader every report
|
|
10
|
+
# screen's node count calls instead of counting checklist.md items. A
|
|
11
|
+
# graph-era intent (graph.md AND nodes/ both present, D4) counts declared
|
|
12
|
+
# nodes - GraphFile.parse's ## Graph section, via GraphEdges - against
|
|
13
|
+
# NodeLedger.status's last-non-torn-line-per-subject state; a checklist-era
|
|
14
|
+
# intent gets nil, so its screen keeps counting checklist.md exactly as
|
|
15
|
+
# before. No second parser: every number here comes from GraphFile,
|
|
16
|
+
# GraphEdges or NodeLedger, never a hand-rolled scan.
|
|
17
|
+
#
|
|
18
|
+
# Pure: an explicit intent_dir and store_root: in, a fields hash or nil out;
|
|
19
|
+
# no ENV, no Dir.pwd, no raise across the boundary (a malformed record
|
|
20
|
+
# answers nil or zeroed fields, matrix row 1.16).
|
|
21
|
+
module NodeProgress
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# Same keys IntentScreen.progress_fields returns, plus "progress.unit".
|
|
25
|
+
# nil for a pre-graph intent (D4), so the caller keeps counting
|
|
26
|
+
# checklist.md.
|
|
27
|
+
def fields(intent_dir, store_root: nil)
|
|
28
|
+
return nil unless graph_era?(intent_dir)
|
|
29
|
+
|
|
30
|
+
nodes = declared_nodes(File.join(intent_dir, "graph.md"))
|
|
31
|
+
return nil if nodes.nil? || nodes.empty?
|
|
32
|
+
|
|
33
|
+
status = NodeLedger.status(File.join(intent_dir, "savepoint.md"))
|
|
34
|
+
superseded = nodes.count { |n| status[n] == "superseded" }
|
|
35
|
+
abandoned = nodes.count { |n| status[n] == "abandoned" }
|
|
36
|
+
remaining = nodes.reject { |n| status[n] == "superseded" || status[n] == "abandoned" }
|
|
37
|
+
done = remaining.count { |n| status[n] == "done" }
|
|
38
|
+
running = remaining.count { |n| status[n] == "running" }
|
|
39
|
+
total = remaining.length
|
|
40
|
+
|
|
41
|
+
inferred = done.zero? && total.positive? && delivered?(intent_dir, store_root)
|
|
42
|
+
done = total if inferred
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
"progress.bar" => bar_for(done, total),
|
|
46
|
+
"progress.done" => done.to_s,
|
|
47
|
+
"progress.total" => total.to_s,
|
|
48
|
+
"progress.note" => note_for(done, total, running, inferred, superseded, abandoned),
|
|
49
|
+
"progress.unit" => "nodes",
|
|
50
|
+
}
|
|
51
|
+
rescue StandardError
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# D4: graph-era means graph.md AND nodes/ both exist.
|
|
56
|
+
def graph_era?(intent_dir)
|
|
57
|
+
File.exist?(File.join(intent_dir, "graph.md")) && Dir.exist?(File.join(intent_dir, "nodes"))
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Declared node ids from the ## Graph section (GraphFile/GraphEdges), never
|
|
61
|
+
# from Dir over nodes/. nil for a missing ## Graph section or one that
|
|
62
|
+
# declares no nodes (matrix row 1.16).
|
|
63
|
+
def declared_nodes(graph_md_path)
|
|
64
|
+
parsed = GraphFile.parse(graph_md_path)
|
|
65
|
+
graph = parsed && parsed[:graph]
|
|
66
|
+
return nil unless graph
|
|
67
|
+
|
|
68
|
+
ids = graph[:nodes]
|
|
69
|
+
ids.nil? || ids.empty? ? nil : ids
|
|
70
|
+
end
|
|
71
|
+
private_class_method :declared_nodes
|
|
72
|
+
|
|
73
|
+
def bar_for(done, total)
|
|
74
|
+
width = IntentScreen::BAR_WIDTH
|
|
75
|
+
on = total.zero? ? 0 : (done * width) / total
|
|
76
|
+
(IntentScreen::ON * on) + (IntentScreen::OFF * (width - on))
|
|
77
|
+
end
|
|
78
|
+
private_class_method :bar_for
|
|
79
|
+
|
|
80
|
+
# D8's four exact note strings, plus a superseded/abandoned suffix (n6, B1):
|
|
81
|
+
# a node whose resolved state is superseded or abandoned leaves the total
|
|
82
|
+
# and the numerator, and the note names it, superseded first, appended as
|
|
83
|
+
# ", K superseded" and/or ", J abandoned". Unchanged when there are none of
|
|
84
|
+
# either. The inferred case never carries a suffix: it counts the already-
|
|
85
|
+
# reduced total, so the note stays exactly "inferred: delivered before the
|
|
86
|
+
# node ledger".
|
|
87
|
+
def note_for(done, total, running, inferred, superseded, abandoned)
|
|
88
|
+
return "inferred: delivered before the node ledger" if inferred
|
|
89
|
+
|
|
90
|
+
base =
|
|
91
|
+
if total.zero?
|
|
92
|
+
"no nodes counted"
|
|
93
|
+
else
|
|
94
|
+
open = total - done
|
|
95
|
+
if open.zero?
|
|
96
|
+
"all nodes done"
|
|
97
|
+
elsif running.positive?
|
|
98
|
+
"#{open} nodes open, #{running} running"
|
|
99
|
+
else
|
|
100
|
+
"#{open} nodes open"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
suffix = []
|
|
105
|
+
suffix << "#{superseded} superseded" if superseded.positive?
|
|
106
|
+
suffix << "#{abandoned} abandoned" if abandoned.positive?
|
|
107
|
+
suffix.empty? ? base : "#{base}, #{suffix.join(', ')}"
|
|
108
|
+
end
|
|
109
|
+
private_class_method :note_for
|
|
110
|
+
|
|
111
|
+
# Delivered, proven twice (the inferred fallback's precondition):
|
|
112
|
+
# outcome.md on disk plus either an INDEX ## Completed line (store_root:
|
|
113
|
+
# given) or a Done savepoint line (store_root: nil, the roadmap-entries
|
|
114
|
+
# path that pays for no INDEX read per entry).
|
|
115
|
+
def delivered?(intent_dir, store_root)
|
|
116
|
+
return false unless File.exist?(File.join(intent_dir, "outcome.md"))
|
|
117
|
+
|
|
118
|
+
store_root ? index_completed?(store_root, intent_id(intent_dir)) : done_savepoint_line?(intent_dir)
|
|
119
|
+
end
|
|
120
|
+
private_class_method :delivered?
|
|
121
|
+
|
|
122
|
+
def intent_id(intent_dir)
|
|
123
|
+
File.basename(intent_dir).split("--", 2).first
|
|
124
|
+
end
|
|
125
|
+
private_class_method :intent_id
|
|
126
|
+
|
|
127
|
+
def index_completed?(store_root, id)
|
|
128
|
+
path = File.join(store_root, "INDEX.md")
|
|
129
|
+
return false unless File.exist?(path)
|
|
130
|
+
|
|
131
|
+
content = File.read(path).scrub
|
|
132
|
+
section = content[/^## Completed\n(.*?)(?=^## |\z)/m, 1]
|
|
133
|
+
return false unless section
|
|
134
|
+
|
|
135
|
+
section.each_line.any? { |line| line.strip.match?(/\A-\s*\[#{Regexp.escape(id)}(?=[\s\]])/) }
|
|
136
|
+
end
|
|
137
|
+
private_class_method :index_completed?
|
|
138
|
+
|
|
139
|
+
# Only a Done line whose text begins with "delivered" proves delivered (n6,
|
|
140
|
+
# B3): end-intent writes "Done delivered" or "Done abandoned"
|
|
141
|
+
# (scripts/end-intent, Savepoint.append_terminal_savepoint), and only the
|
|
142
|
+
# first disposition is delivery.
|
|
143
|
+
def done_savepoint_line?(intent_dir)
|
|
144
|
+
path = File.join(intent_dir, "savepoint.md")
|
|
145
|
+
return false unless File.exist?(path)
|
|
146
|
+
|
|
147
|
+
File.read(path).scrub.each_line.any? do |line|
|
|
148
|
+
m = line.strip.match(IntentScreen::SAVEPOINT_RE)
|
|
149
|
+
m && m[2] == "Done" && m[3].to_s.start_with?("delivered")
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
private_class_method :done_savepoint_line?
|
|
153
|
+
end
|
|
@@ -120,7 +120,7 @@ module OutcomeReport
|
|
|
120
120
|
# --- n2: the generator -------------------------------------------------------
|
|
121
121
|
|
|
122
122
|
PLACEHOLDER_SUMMARY = "(what was delivered)"
|
|
123
|
-
NON_EVIDENCE_FIELD_KEYS = %w[holder expires
|
|
123
|
+
NON_EVIDENCE_FIELD_KEYS = %w[holder expires input model gates commit verdict reason question by expired suite].freeze
|
|
124
124
|
|
|
125
125
|
# D14: a pipe in any generated table cell (or bullet line) is replaced with a
|
|
126
126
|
# forward slash, never escaped - ReportScreen.table_rows splits on the bare
|
|
@@ -377,7 +377,7 @@ module OutcomeReport
|
|
|
377
377
|
# --- n4: findings, read and capped ------------------------------------------
|
|
378
378
|
|
|
379
379
|
# Two lines at the screens' own 115-column limit (spec D15). A named
|
|
380
|
-
# constant so the number is never
|
|
380
|
+
# constant so the number is never embedded into a regex.
|
|
381
381
|
FINDING_CAP = 200
|
|
382
382
|
|
|
383
383
|
# `### Findings` under the intent record's own `## Insights` section (spec
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
# ProjectConfig - reads a project's project.yml merged over the shipped defaults
|
|
7
|
+
# (moved off the retired shared-helpers module by intent 344, D2). A missing or
|
|
8
|
+
# malformed project.yml never raises: read returns the defaults with a warning
|
|
9
|
+
# on stderr instead.
|
|
10
|
+
module ProjectConfig
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
DEFAULTS = {
|
|
14
|
+
"governing_docs" => ["AGENTS.md"],
|
|
15
|
+
"release" => {
|
|
16
|
+
"on_complete" => "commit",
|
|
17
|
+
},
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def read(slug)
|
|
21
|
+
path = File.join(Dir.home, ".plastic", "projects", slug, "project.yml")
|
|
22
|
+
config = if File.exist?(path)
|
|
23
|
+
YAML.safe_load(File.read(path)) || {}
|
|
24
|
+
else
|
|
25
|
+
{}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
deep_merge(DEFAULTS, config)
|
|
29
|
+
rescue => e
|
|
30
|
+
$stderr.puts "Warning: failed to read project config for #{slug}: #{e.message}"
|
|
31
|
+
DEFAULTS.dup
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def deep_merge(base, overlay)
|
|
35
|
+
result = base.dup
|
|
36
|
+
overlay.each do |key, value|
|
|
37
|
+
if value.is_a?(Hash) && result[key].is_a?(Hash)
|
|
38
|
+
result[key] = deep_merge(result[key], value)
|
|
39
|
+
else
|
|
40
|
+
result[key] = value
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
end
|
package/scripts/lib/ready_set.rb
CHANGED
|
@@ -45,7 +45,7 @@ module ReadySet
|
|
|
45
45
|
|
|
46
46
|
# The fixed key list a ranker row ever carries (D11/C16): every key is
|
|
47
47
|
# state- or edge-derived, never a telemetry field (tokens, wall, model,
|
|
48
|
-
# suite,
|
|
48
|
+
# suite, input). Pinned by test/ready_set_ranker_test.rb.
|
|
49
49
|
ROW_KEYS = %i[id kind batch retry downstream_hops on_critical_path].freeze
|
|
50
50
|
|
|
51
51
|
# --- the readiness decision (D1) ---------------------------------------------
|
|
@@ -21,6 +21,7 @@ require_relative "graph_tree"
|
|
|
21
21
|
require_relative "screen_paint"
|
|
22
22
|
require_relative "outcome_report"
|
|
23
23
|
require_relative "node_file"
|
|
24
|
+
require_relative "node_progress"
|
|
24
25
|
|
|
25
26
|
module ReportScreen
|
|
26
27
|
NOT_RECORDED = "not recorded"
|
|
@@ -920,7 +921,7 @@ def self.matching_action_heading(intent_dir, label)
|
|
|
920
921
|
f["status.note"] = status == "unlisted" ? "no INDEX.md line names this id" : "listed under ## #{status} in INDEX.md"
|
|
921
922
|
f.merge!(IntentScreen.savepoint_fields(intent_dir, text.to_s))
|
|
922
923
|
items = IntentScreen.checklist_items(intent_dir)
|
|
923
|
-
f.merge!(IntentScreen.progress_fields(items))
|
|
924
|
+
f.merge!(NodeProgress.fields(intent_dir, store_root: store_root) || IntentScreen.progress_fields(items))
|
|
924
925
|
f.merge!(IntentScreen.next_fields(items, status, checklist_present: IntentScreen.items_present?(intent_dir)))
|
|
925
926
|
f.merge!(IntentScreen.insight_fields(text.to_s))
|
|
926
927
|
|
|
@@ -931,7 +932,7 @@ def self.matching_action_heading(intent_dir, label)
|
|
|
931
932
|
["Status", f["status"], f["status.note"]],
|
|
932
933
|
["Stage", f["stage"], f["stage.note"]],
|
|
933
934
|
["Savepoint", f["savepoint"], f["savepoint.note"]],
|
|
934
|
-
["Progress", "#{f['progress.bar']} #{f['progress.done']} / #{f['progress.total']}", f["progress.note"]],
|
|
935
|
+
["Progress", "#{f['progress.bar']} #{f['progress.done']} / #{f['progress.total']}#{f['progress.unit'] ? " #{f['progress.unit']}" : ''}", f["progress.note"]],
|
|
935
936
|
["Next", f["next"], f["next.note"]],
|
|
936
937
|
["Insight", f["insight"], f["insight.note"]],
|
|
937
938
|
["Changed", changed_value, CHANGED_NOTE],
|
|
@@ -1101,9 +1102,10 @@ def self.matching_action_heading(intent_dir, label)
|
|
|
1101
1102
|
text = intent_text(e[:dir]).to_s
|
|
1102
1103
|
savepoint = IntentScreen.savepoint_fields(e[:dir], text)
|
|
1103
1104
|
items = IntentScreen.checklist_items(e[:dir])
|
|
1104
|
-
progress = IntentScreen.progress_fields(items)
|
|
1105
|
+
progress = NodeProgress.fields(e[:dir], store_root: store_root) || IntentScreen.progress_fields(items)
|
|
1106
|
+
unit = progress["progress.unit"] ? " #{progress['progress.unit']}" : ""
|
|
1105
1107
|
ch = state_fields(intent_dir: e[:dir], store_root: store_root, changed: changed)[:rows].find { |l, _, _| l == "Changed" }[1]
|
|
1106
|
-
table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']} | #{escape(ch)} | #{lead(e[:dir], now: now)} |"
|
|
1108
|
+
table << "| #{e[:id]} | #{savepoint['stage']} | #{progress['progress.bar']} #{progress['progress.done']} / #{progress['progress.total']}#{unit} | #{escape(ch)} | #{lead(e[:dir], now: now)} |"
|
|
1107
1109
|
end
|
|
1108
1110
|
blocks = entries.map { |e| render_collapsed_block(e[:dir], store_root, changed: changed) }
|
|
1109
1111
|
head_and_table = ([header, ""] + table).join("\n")
|
|
@@ -1721,8 +1723,10 @@ def self.matching_action_heading(intent_dir, label)
|
|
|
1721
1723
|
def self.roadmap_entry_progress(dir)
|
|
1722
1724
|
return NOT_RECORDED unless dir
|
|
1723
1725
|
items = IntentScreen.checklist_items(dir)
|
|
1724
|
-
fields = IntentScreen.progress_fields(items)
|
|
1725
|
-
|
|
1726
|
+
fields = NodeProgress.fields(dir) || IntentScreen.progress_fields(items)
|
|
1727
|
+
unit = fields["progress.unit"] ? " #{fields['progress.unit']}" : ""
|
|
1728
|
+
inferred = fields["progress.note"].to_s.start_with?("inferred") ? " inferred" : ""
|
|
1729
|
+
"#{fields['progress.bar']} #{fields['progress.done']} / #{fields['progress.total']}#{unit}#{inferred}"
|
|
1726
1730
|
end
|
|
1727
1731
|
|
|
1728
1732
|
def self.roadmap_progress_bar(done, total)
|
|
@@ -51,7 +51,7 @@ module RoadmapGraph
|
|
|
51
51
|
entry_ids = order.dup
|
|
52
52
|
|
|
53
53
|
# 1.6: an entry the graph never named (not declared, not targeted) is
|
|
54
|
-
#
|
|
54
|
+
# included as a root, so a partly migrated roadmap never silently
|
|
55
55
|
# drops real work from the batches.
|
|
56
56
|
edges = parsed_edges[:edges].dup
|
|
57
57
|
entry_ids.each do |id|
|
|
@@ -306,7 +306,7 @@ class RoadmapQueue
|
|
|
306
306
|
# and never an invented dispatchable entry with no title. An id a batch
|
|
307
307
|
# lists that the graph does not name is never dropped either: it keeps
|
|
308
308
|
# the pre-change wave behavior (the G4 partial-migration path) by being
|
|
309
|
-
#
|
|
309
|
+
# included as needing nothing (never called on a cyclic graph: #analyze
|
|
310
310
|
# checks that first and reports "error" instead).
|
|
311
311
|
def graph_frontier_for(candidate)
|
|
312
312
|
edges = candidate[:graph_edges][:edges].dup
|
|
@@ -14,7 +14,7 @@ require_relative "atomic_write"
|
|
|
14
14
|
# byte of the file alone.
|
|
15
15
|
#
|
|
16
16
|
# The regroup is surgical, never a rebuild from parsed fields (327's own
|
|
17
|
-
# lesson,
|
|
17
|
+
# lesson, merged at the 2026-09-10 plan review, rows 3.2/3.13/3.14): only
|
|
18
18
|
# entry lines RoadmapGraph itself recognized (the canonical checkbox +
|
|
19
19
|
# status grammar) ever move. A line the grammar cannot parse, and any prose
|
|
20
20
|
# a human wrote between batch headings, is untouched OUTPUT text - it is
|
|
@@ -8,7 +8,7 @@ require_relative "guarded_append"
|
|
|
8
8
|
# RoadmapSavepoint - the roadmap's machine counterpart to its human `## Log` (intent 134).
|
|
9
9
|
#
|
|
10
10
|
# Mirrors scripts/lib/qmd_sync.rb's class shape and the cycle-step savepoint ledger in
|
|
11
|
-
# scripts/lib/
|
|
11
|
+
# scripts/lib/savepoint.rb (append semantics: idempotent `(event, detail)` pair dedup, one
|
|
12
12
|
# deterministic append primitive). Constructor-DI, hermetic: no eval, no ENV or global config
|
|
13
13
|
# seam, clock injected through `now:`. Two operations:
|
|
14
14
|
#
|
|
@@ -10,7 +10,7 @@ require_relative "node_return"
|
|
|
10
10
|
require_relative "node_ledger"
|
|
11
11
|
require_relative "node_worktree"
|
|
12
12
|
require_relative "node_file"
|
|
13
|
-
require_relative "
|
|
13
|
+
require_relative "node_input"
|
|
14
14
|
require_relative "ready_set"
|
|
15
15
|
require_relative "core_integrity"
|
|
16
16
|
require_relative "runner_core"
|
|
@@ -86,6 +86,17 @@ module RunnerAbsorb
|
|
|
86
86
|
return refused("node_not_running") unless current_state == "running"
|
|
87
87
|
|
|
88
88
|
holder = holder_for(entries, node)
|
|
89
|
+
# 340b n8: the harness that ran this attempt, read off the node's own
|
|
90
|
+
# `running` line - never the harness of the session doing the absorbing
|
|
91
|
+
# (row 8.12). `extra_fields` carries it into every terminal fields hash
|
|
92
|
+
# below through the same `.merge(extra_fields)` every path already uses,
|
|
93
|
+
# so it reaches `done`, every `failed_verification`/`needs_decision`, and
|
|
94
|
+
# `blocked` alike; a running line with no `harness=` leaves it out of
|
|
95
|
+
# `extra_fields` entirely, so nothing invents a value (row 8.13).
|
|
96
|
+
harness = harness_for(entries, node)
|
|
97
|
+
extra_fields = {}
|
|
98
|
+
extra_fields[:harness] = harness if harness
|
|
99
|
+
|
|
89
100
|
node_decl = ((context.graph || {})[:nodes] || {})[node] || {}
|
|
90
101
|
kind = node_decl[:kind]
|
|
91
102
|
declared_files = normalize_files(node_decl[:files])
|
|
@@ -97,12 +108,11 @@ module RunnerAbsorb
|
|
|
97
108
|
# empty), and land `done` carrying the executor's own self-reported
|
|
98
109
|
# commit on work nothing had verified ever reached the intent branch.
|
|
99
110
|
unless (context.graph || {})[:ok] && !kind.nil?
|
|
100
|
-
fields = { reason: "invalid_graph", holder: holder }
|
|
111
|
+
fields = { reason: "invalid_graph", holder: holder }.merge(extra_fields)
|
|
101
112
|
return write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger)
|
|
102
113
|
end
|
|
103
114
|
|
|
104
115
|
checks_ran = []
|
|
105
|
-
extra_fields = {}
|
|
106
116
|
|
|
107
117
|
# 1. integrity ------------------------------------------------------------
|
|
108
118
|
checks_ran << "integrity"
|
|
@@ -112,7 +122,7 @@ module RunnerAbsorb
|
|
|
112
122
|
extra_fields["allow_core_drift"] = "true"
|
|
113
123
|
else
|
|
114
124
|
fields = { reason: "core_integrity", core_drift: drift_summary(integrity), holder: holder,
|
|
115
|
-
gates: checks_ran.join("+") }
|
|
125
|
+
gates: checks_ran.join("+") }.merge(extra_fields)
|
|
116
126
|
return write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger)
|
|
117
127
|
end
|
|
118
128
|
end
|
|
@@ -122,7 +132,7 @@ module RunnerAbsorb
|
|
|
122
132
|
text = read_return_text(return_path)
|
|
123
133
|
parsed = NodeReturn.parse(text)
|
|
124
134
|
|
|
125
|
-
attempt =
|
|
135
|
+
attempt = NodeInput.compute_attempt_number(intent_dir: intent_dir, node: node, lease_flag_given: false,
|
|
126
136
|
entries: entries)
|
|
127
137
|
preserve_return_file(intent_dir, node, attempt, return_path, text)
|
|
128
138
|
|
|
@@ -301,7 +311,13 @@ module RunnerAbsorb
|
|
|
301
311
|
{ state: "append_failed", written: false, fields: fields, gates: fields[:gates],
|
|
302
312
|
commit: fields[:commit], error: e.message }
|
|
303
313
|
rescue ArgumentError => e
|
|
304
|
-
|
|
314
|
+
# 340b n8, row 8.11: this fallback still merges no caller `extra_fields`
|
|
315
|
+
# (unchanged from before this node), but `fields[:harness]` is read
|
|
316
|
+
# straight off the fields hash the failed write attempted, which already
|
|
317
|
+
# carries `harness` whenever the caller's own `extra_fields` had it -
|
|
318
|
+
# `.compact` drops the key rather than writing it empty when it did not.
|
|
319
|
+
fallback_fields = { reason: "return_unwritable", holder: fields[:holder], gates: fields[:gates],
|
|
320
|
+
harness: fields[:harness] }.compact
|
|
305
321
|
fallback = begin
|
|
306
322
|
ledger.append_transition(savepoint_path, subject: node, state: "blocked", fields: fallback_fields, now: now)
|
|
307
323
|
rescue GuardedAppend::Unavailable
|
|
@@ -352,6 +368,16 @@ module RunnerAbsorb
|
|
|
352
368
|
end
|
|
353
369
|
private_class_method :holder_for
|
|
354
370
|
|
|
371
|
+
# 340b n8/row 8.12: mirrors holder_for exactly - the value on the node's
|
|
372
|
+
# OWN last `running` line, never a fresh resolve of the current session's
|
|
373
|
+
# own harness. nil when that line carried none (row 8.13), which is what
|
|
374
|
+
# keeps `extra_fields` from ever inventing the key.
|
|
375
|
+
def harness_for(entries, node)
|
|
376
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "running" }.last
|
|
377
|
+
last && (last[:fields] || {})["harness"]
|
|
378
|
+
end
|
|
379
|
+
private_class_method :harness_for
|
|
380
|
+
|
|
355
381
|
def drift_summary(integrity)
|
|
356
382
|
parts = []
|
|
357
383
|
parts << "drifted:#{integrity[:drifted].join(',')}" if Array(integrity[:drifted]).any?
|
|
@@ -519,7 +545,7 @@ module RunnerAbsorb
|
|
|
519
545
|
# `content` itself ends in a newline (the file's own trailing newline
|
|
520
546
|
# made visible as an array slot). When Insights is the LAST section (no
|
|
521
547
|
# further "## " heading), `insights_end` walks all the way to
|
|
522
|
-
# `lines.length`,
|
|
548
|
+
# `lines.length`, merging that sentinel INTO the section - the very next
|
|
523
549
|
# insert then landed AFTER it, turning the file's trailing newline into
|
|
524
550
|
# a spurious blank line and leaving the new bullet as the final element
|
|
525
551
|
# with no newline of its own. Excluding the sentinel here restores both:
|
|
@@ -544,11 +570,11 @@ module RunnerAbsorb
|
|
|
544
570
|
end
|
|
545
571
|
private_class_method :read_return_text
|
|
546
572
|
|
|
547
|
-
# Row 4.42: the return is kept beside the
|
|
548
|
-
#
|
|
573
|
+
# Row 4.42: the return is kept beside the node input at
|
|
574
|
+
# attempts/<node>--a<N>.return - copied there when `return_path` names
|
|
549
575
|
# somewhere else, left alone when it already is that file.
|
|
550
576
|
def preserve_return_file(intent_dir, node, attempt, return_path, text)
|
|
551
|
-
target =
|
|
577
|
+
target = NodeInput.input_path(intent_dir: intent_dir, node: node, attempt: attempt).sub(/\.input\z/, ".return")
|
|
552
578
|
return target if File.expand_path(return_path.to_s) == File.expand_path(target)
|
|
553
579
|
|
|
554
580
|
FileUtils.mkdir_p(File.dirname(target))
|