@zalom/plastic 2.0.0-alpha.21 → 2.0.0-alpha.23

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 (86) hide show
  1. package/PLASTIC.md +20 -18
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/doctor.rb +159 -10
  13. package/scripts/end-intent +3 -3
  14. package/scripts/graph-measure +249 -0
  15. package/scripts/hook-capture +1 -0
  16. package/scripts/hook-savepoint +24 -2
  17. package/scripts/hook-session-start +333 -321
  18. package/scripts/hook-stop +57 -0
  19. package/scripts/insight-append +18 -4
  20. package/scripts/lib/active_delivery.rb +61 -0
  21. package/scripts/lib/agent_models.rb +10 -1
  22. package/scripts/lib/codex_adapter.rb +197 -0
  23. package/scripts/lib/doctor_core.rb +8 -3
  24. package/scripts/lib/engine_permissions.rb +88 -0
  25. package/scripts/lib/graph_edges.rb +4 -4
  26. package/scripts/lib/graph_file.rb +4 -4
  27. package/scripts/lib/graph_measure.rb +645 -0
  28. package/scripts/lib/graph_measure_budget.rb +408 -0
  29. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  30. package/scripts/lib/graph_measure_models.rb +411 -0
  31. package/scripts/lib/graph_measure_report.rb +532 -0
  32. package/scripts/lib/graph_tree.rb +2 -2
  33. package/scripts/lib/handoff.rb +36 -5
  34. package/scripts/lib/harness_adapter.rb +184 -0
  35. package/scripts/lib/hook_registry.rb +13 -1
  36. package/scripts/lib/hook_replay.rb +23 -5
  37. package/scripts/lib/index_projection.rb +1 -1
  38. package/scripts/lib/installer_core.rb +112 -6
  39. package/scripts/lib/intent_screen.rb +1 -1
  40. package/scripts/lib/intent_validator.rb +2 -2
  41. package/scripts/lib/meter_watch.rb +15 -9
  42. package/scripts/lib/node_file.rb +3 -3
  43. package/scripts/lib/node_ledger.rb +8 -1
  44. package/scripts/lib/node_progress.rb +153 -0
  45. package/scripts/lib/outcome_report.rb +1 -1
  46. package/scripts/lib/report_screen.rb +10 -6
  47. package/scripts/lib/roadmap_graph.rb +1 -1
  48. package/scripts/lib/roadmap_queue.rb +1 -1
  49. package/scripts/lib/roadmap_render.rb +1 -1
  50. package/scripts/lib/runner_absorb.rb +31 -5
  51. package/scripts/lib/runner_dispatch.rb +26 -11
  52. package/scripts/lib/runner_until_empty.rb +252 -0
  53. package/scripts/lib/runner_watch.rb +389 -0
  54. package/scripts/lib/savepoint.rb +3 -3
  55. package/scripts/lib/session_git.rb +2 -2
  56. package/scripts/lib/stop_gate.rb +95 -0
  57. package/scripts/lib/verify_intent.rb +2 -2
  58. package/scripts/lib/work_graph_validator.rb +6 -6
  59. package/scripts/new-intent +1 -1
  60. package/scripts/node-run +224 -0
  61. package/scripts/read-config +6 -0
  62. package/scripts/runner +203 -19
  63. package/scripts/skill-lint +115 -6
  64. package/scripts/verify-intent +1 -1
  65. package/skills/auto/SKILL.md +54 -56
  66. package/skills/auto/references/agent-architecture.md +10 -8
  67. package/skills/auto/references/human-report-contract.md +1 -1
  68. package/skills/conventions/references/completion-and-done.md +7 -7
  69. package/skills/conventions/references/knowledge-graph.md +9 -0
  70. package/skills/conventions/references/locks-and-worktrees.md +3 -3
  71. package/skills/conventions/references/maintenance-and-revisions.md +1 -1
  72. package/skills/doctor/SKILL.md +3 -3
  73. package/skills/doctor/report.md +1 -1
  74. package/skills/intent-continuing/references/boarding-matrix.md +2 -2
  75. package/skills/intent-creating/SKILL.md +58 -133
  76. package/skills/intent-creating/evals/evals.json +1 -1
  77. package/skills/intent-ending/SKILL.md +48 -56
  78. package/skills/intent-ending/evals/evals.json +1 -1
  79. package/skills/intent-executing/SKILL.md +43 -136
  80. package/skills/intent-speccing/SKILL.md +3 -0
  81. package/skills/releasing/SKILL.md +1 -1
  82. package/skills/releasing/references/release-lines.md +1 -1
  83. package/skills/tutorial/SKILL.md +2 -1
  84. package/skills/tutorial/references/track-1-guided.md +21 -40
  85. package/skills/tutorial/references/track-2-auto.md +3 -3
  86. package/templates/agents.md +2 -2
@@ -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
@@ -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 folded into a regex.
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
@@ -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
- "#{fields['progress.bar']} #{fields['progress.done']} / #{fields['progress.total']}"
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
- # folded in as a root, so a partly migrated roadmap never silently
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
- # folded in as needing nothing (never called on a cyclic graph: #analyze
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, folded at the 2026-09-10 plan review, rows 3.2/3.13/3.14): only
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
@@ -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
@@ -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
- fallback_fields = { reason: "return_unwritable", holder: fields[:holder], gates: fields[:gates] }.compact
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`, folding that sentinel INTO the section - the very next
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:
@@ -14,6 +14,7 @@ require_relative "runner_policy"
14
14
  require_relative "worktree"
15
15
  require_relative "savepoint"
16
16
  require_relative "guarded_append"
17
+ require_relative "harness_adapter"
17
18
 
18
19
  # RunnerDispatch (intent 340, G7, n5): validates the graph, computes the
19
20
  # ready set, applies RunnerPolicy, mints leases, builds packets, writes
@@ -65,16 +66,25 @@ module RunnerDispatch
65
66
  # :errors, :rearm_command, :dispatched, :stop, :parked, :status, :blockers,
66
67
  # :plan - fields that do not apply to a given outcome stay nil/empty rather
67
68
  # than being omitted, so a caller never has to guard with `dig`.
68
- def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, caps: ReadySet::DEFAULT_CAPS,
69
+ def dispatch(context, limit: DEFAULT_LIMIT, now: Time.now, config: {}, harness: nil, caps: ReadySet::DEFAULT_CAPS,
69
70
  validator: WorkGraphValidator.method(:validate),
70
71
  ready_analyzer: ReadySet.method(:analyze),
71
72
  packet_builder: NodePacket.method(:build),
72
73
  worktree: NodeWorktree,
73
74
  ledger: NodeLedger,
74
- runner: Worktree::ShellRunner.new)
75
+ runner: Worktree::ShellRunner.new,
76
+ harness_adapter: HarnessAdapter)
75
77
  intent_dir = context.intent_dir
76
78
  savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
77
79
 
80
+ # Intent 340b, G7c, n1, rows 1.19/1.20/D21: resolved ONCE for the whole
81
+ # step through HarnessAdapter, never a literal - `harness:` (this call's
82
+ # `--harness` override, or nil) wins over `config`'s own `agent.type`.
83
+ # Every node this call dispatches carries the SAME value, and the
84
+ # caller (scripts/runner) reads it back off the report to pick which
85
+ # harness's block to render.
86
+ harness_key = harness_adapter.resolve_key(config: config, override: harness)
87
+
78
88
  # Row 5.31/5.32: this is RunnerDispatch's OWN lock check, never a shelled
79
89
  # `node-transition` call - append_transition below is used in-process
80
90
  # (RunnerAbsorb's own pattern), so nothing here inherits node-transition's
@@ -161,8 +171,8 @@ module RunnerDispatch
161
171
  next
162
172
  end
163
173
 
164
- result = dispatch_one(context, node: node, kind: kind, now: now, config: config, caps: caps,
165
- edges: edges, nodes_decl: nodes_decl, packet_builder: packet_builder,
174
+ result = dispatch_one(context, node: node, kind: kind, now: now, config: config, harness: harness_key,
175
+ caps: caps, edges: edges, nodes_decl: nodes_decl, packet_builder: packet_builder,
166
176
  worktree: worktree, ledger: ledger, runner: runner)
167
177
  if result[:packet_build_failed]
168
178
  packet_failures << result
@@ -182,7 +192,8 @@ module RunnerDispatch
182
192
  RunnerCore.render_status(context) if dispatched.any? || stop || parked.any?
183
193
 
184
194
  build_report(context: context, dispatched: dispatched, stop: stop, parked: parked,
185
- ceiling_blocked: ceiling_blocked, packet_failures: packet_failures, running_count: running_count)
195
+ ceiling_blocked: ceiling_blocked, packet_failures: packet_failures, running_count: running_count,
196
+ harness: harness_key)
186
197
  end
187
198
 
188
199
  # --- guarded re-entries into graph.md (M9) ----------------------------------
@@ -203,8 +214,8 @@ module RunnerDispatch
203
214
 
204
215
  # --- one node's whole dispatch (packet, lease, `running`) -------------------
205
216
 
206
- def dispatch_one(context, node:, kind:, now:, config:, caps:, edges:, nodes_decl:, packet_builder:, worktree:,
207
- ledger:, runner:)
217
+ def dispatch_one(context, node:, kind:, now:, config:, harness:, caps:, edges:, nodes_decl:, packet_builder:,
218
+ worktree:, ledger:, runner:)
208
219
  intent_dir = context.intent_dir
209
220
  savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
210
221
 
@@ -253,7 +264,11 @@ module RunnerDispatch
253
264
  precondition = lambda do |c|
254
265
  ReadySet.ready?(content: c, subject: node, graph: { edges: edges }, nodes: nodes_decl, caps: caps)[:ready]
255
266
  end
256
- fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model, calls: calls_cap }
267
+ # Row 1.19/1.20/D21: harness= rides alongside model= on every `running`
268
+ # line, resolved once by the caller through HarnessAdapter and threaded
269
+ # straight through here - never re-resolved, never a literal.
270
+ fields = { holder: holder, expires: expires, packet: build_result[:sha], model: model, harness: harness,
271
+ calls: calls_cap }
257
272
 
258
273
  result = begin
259
274
  ledger.append_transition(savepoint_path, subject: node, state: "running", fields: fields, now: now,
@@ -400,7 +415,7 @@ module RunnerDispatch
400
415
 
401
416
  def empty_result
402
417
  { ok: true, reason: nil, errors: [], rearm_command: nil, dispatched: [], stop: nil, parked: [],
403
- status: nil, blockers: [], plan: nil }
418
+ status: nil, blockers: [], plan: nil, harness: nil }
404
419
  end
405
420
  private_class_method :empty_result
406
421
 
@@ -415,9 +430,9 @@ module RunnerDispatch
415
430
  private_class_method :invalid_graph_result
416
431
 
417
432
  def build_report(context:, dispatched:, stop:, parked:, ceiling_blocked: false, packet_failures: [],
418
- running_count: 0)
433
+ running_count: 0, harness: nil)
419
434
  base = empty_result.merge(dispatched: dispatched, stop: stop, parked: parked,
420
- plan: render_plan(dispatched))
435
+ plan: render_plan(dispatched), harness: harness)
421
436
 
422
437
  if dispatched.any?
423
438
  base.merge(status: "dispatched")