@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.
Files changed (103) hide show
  1. package/PLASTIC.md +6 -5
  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/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. 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 packet model gates commit verdict reason question by expired suite].freeze
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 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
@@ -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
@@ -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, packet). Pinned by test/ready_set_ranker_test.rb.
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
- "#{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
@@ -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/bridge.rb (append semantics: idempotent `(event, detail)` pair dedup, one
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 "node_packet"
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 = NodePacket.compute_attempt_number(intent_dir: intent_dir, node: node, lease_flag_given: false,
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
- 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:
@@ -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 packet at
548
- # packets/<node>--a<N>.return - copied there when `return_path` names
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 = NodePacket.packet_path(intent_dir: intent_dir, node: node, attempt: attempt).sub(/\.packet\z/, ".return")
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))