@zalom/plastic 2.0.0-alpha.2 → 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.
Files changed (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
@@ -0,0 +1,620 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "yaml"
5
+ require "date"
6
+ require "time"
7
+ require "open3"
8
+ require "fileutils"
9
+ require_relative "node_return"
10
+ require_relative "node_ledger"
11
+ require_relative "node_worktree"
12
+ require_relative "node_file"
13
+ require_relative "node_packet"
14
+ require_relative "ready_set"
15
+ require_relative "core_integrity"
16
+ require_relative "runner_core"
17
+ require_relative "worktree"
18
+ require_relative "insights"
19
+ require_relative "savepoint"
20
+ require_relative "guarded_append"
21
+ require_relative "atomic_write"
22
+ require_relative "runner_proposals"
23
+
24
+ # RunnerAbsorb (intent 340, G7, n4): the gate that turns one executor return
25
+ # into exactly one node ledger transition. It runs six checks in a fixed
26
+ # order - integrity, schema, scope, named_tests, merge, suite - and stops at
27
+ # the first refusal. Only a clean pass through all six writes `done`; every
28
+ # other path writes `failed_verification`, `needs_decision` or `blocked`,
29
+ # carrying the reason the gate stopped at and `gates=`, the checks that
30
+ # actually ran.
31
+ #
32
+ # `done` is written only on code-verified evidence: the runner re-derives
33
+ # the merge commit and the suite result itself, never trusting the
34
+ # executor's own report of either. The return's `commit:` field is a
35
+ # required attestation at the schema layer (row 4.6), not the value that
36
+ # lands on the ledger line - the merge's own commit wins whenever a merge
37
+ # actually ran (a work node); the return's commit is the fallback only when
38
+ # no merge exists to measure (a verify or research node's kind, out of this
39
+ # node's tested scope).
40
+ #
41
+ # Pure and dependency-injected down to the clock: every side effect -
42
+ # core integrity, the worktree module, git itself, the suite command, the
43
+ # project's verify reader - is an injectable keyword argument with a real
44
+ # default, so a test never touches a real repository, a real suite, or a
45
+ # real filesystem outside its own tmpdir.
46
+ module RunnerAbsorb
47
+ module_function
48
+
49
+ CHECKS = %w[integrity schema scope named_tests merge suite].freeze
50
+
51
+ # M1: `default_project_reader`'s own sentinel for "the project record
52
+ # exists but could not be parsed" - distinct from nil ("no verify command
53
+ # declared at all, or no project record"), so absorb can block rather than
54
+ # silently proceeding as if nothing were declared.
55
+ UNREADABLE_VERIFY_COMMAND = :verify_command_unreadable
56
+
57
+ # The one joined-line cap for a node's findings on one return (D16): a
58
+ # nested structure never reaches this far (NodeReturn already coerced it
59
+ # to plain strings), this cap is purely about line length.
60
+ FINDING_CAP = 200
61
+
62
+ PROJECT_LAYOUT_RE = %r{\A(.*)/projects/([^/]+)/store/[^/]+\z}.freeze
63
+
64
+ # absorb(context, node:, return_path:) -> a result hash describing the one
65
+ # transition it wrote (or attempted to write). Never raises across its own
66
+ # boundary except GuardedAppend::Unavailable's own explicit recovery path
67
+ # (row 4.40), which is caught, not propagated.
68
+ def absorb(context, node:, return_path:, now: Time.now, allow_core_drift: false,
69
+ integrity_checker: CoreIntegrity.method(:check),
70
+ worktree: NodeWorktree,
71
+ ledger: NodeLedger,
72
+ runner: Worktree::ShellRunner.new,
73
+ suite_runner: method(:default_suite_runner),
74
+ project_reader: method(:default_project_reader),
75
+ proposals: RunnerProposals)
76
+ node = node.to_s
77
+ intent_dir = context.intent_dir
78
+ savepoint_path = File.join(intent_dir.to_s, "savepoint.md")
79
+ content = File.exist?(savepoint_path) ? File.read(savepoint_path) : ""
80
+ entries = NodeLedger.entries_from_content(content)
81
+
82
+ # Row 4.30: a return for a node that is not currently `running` (already
83
+ # terminal, reclaimed, or never dispatched) is refused outright, nothing
84
+ # is written.
85
+ current_state = NodeLedger.status_for_content(content, node)
86
+ return refused("node_not_running") unless current_state == "running"
87
+
88
+ holder = holder_for(entries, node)
89
+ node_decl = ((context.graph || {})[:nodes] || {})[node] || {}
90
+ kind = node_decl[:kind]
91
+ declared_files = normalize_files(node_decl[:files])
92
+
93
+ # v2 NEW-2: a `graph.md` that failed to parse, or a node whose kind
94
+ # cannot be resolved from it, refuses right here - before ANY check
95
+ # runs. Falling through with `kind` nil used to skip the whole work-node
96
+ # branch (no merge ever ran), pass scope vacuously (`declared_files` was
97
+ # empty), and land `done` carrying the executor's own self-reported
98
+ # commit on work nothing had verified ever reached the intent branch.
99
+ unless (context.graph || {})[:ok] && !kind.nil?
100
+ fields = { reason: "invalid_graph", holder: holder }
101
+ return write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger)
102
+ end
103
+
104
+ checks_ran = []
105
+ extra_fields = {}
106
+
107
+ # 1. integrity ------------------------------------------------------------
108
+ checks_ran << "integrity"
109
+ integrity = integrity_checker.call(plastic_home: context.plastic_home)
110
+ unless integrity[:ok]
111
+ if allow_core_drift
112
+ extra_fields["allow_core_drift"] = "true"
113
+ else
114
+ fields = { reason: "core_integrity", core_drift: drift_summary(integrity), holder: holder,
115
+ gates: checks_ran.join("+") }
116
+ return write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger)
117
+ end
118
+ end
119
+
120
+ # 2. schema -----------------------------------------------------------------
121
+ checks_ran << "schema"
122
+ text = read_return_text(return_path)
123
+ parsed = NodeReturn.parse(text)
124
+
125
+ attempt = NodePacket.compute_attempt_number(intent_dir: intent_dir, node: node, lease_flag_given: false,
126
+ entries: entries)
127
+ preserve_return_file(intent_dir, node, attempt, return_path, text)
128
+
129
+ unless parsed.ok
130
+ return fail_check(savepoint_path, context, node, "return_unparsable", checks_ran, holder, extra_fields, now, ledger)
131
+ end
132
+ if parsed.node != node
133
+ return fail_check(savepoint_path, context, node, "node_mismatch", checks_ran, holder, extra_fields, now, ledger)
134
+ end
135
+
136
+ append_findings(intent_dir, node, parsed.findings, now: now)
137
+
138
+ proposal_result = nil
139
+ finish = ->(result) { result.merge(proposal: proposal_result) }
140
+
141
+ # B1: the return's own status governs from here on. The six mechanical
142
+ # checks (3-6) exist to VERIFY a `done` claim, never to overrule an
143
+ # executor's own report of its failure or its own open question - a
144
+ # `needs_decision`, `blocked` or `failed_verification` return writes
145
+ # exactly that state, carrying the field its own schema required
146
+ # (`question` or `reason`), and stops here. Only `done` falls through.
147
+ #
148
+ # v2 NEW-1: the prose riding in `question`/`reason` is the executor's
149
+ # own free text - a YAML block scalar (a two-line question) is the
150
+ # natural way to write it, and `NodeLedger.normalize_value` refuses a
151
+ # tab or a newline outright. Squashed to one line before it ever reaches
152
+ # the ledger, same as every other whitespace run the ledger's own format
153
+ # already collapses.
154
+ unless parsed.status == "done"
155
+ fields = { holder: holder, gates: checks_ran.join("+") }.merge(extra_fields)
156
+ fields[:question] = squash_prose(parsed.question) if parsed.status == "needs_decision"
157
+ fields[:reason] = squash_prose(parsed.reason) if %w[blocked failed_verification].include?(parsed.status)
158
+ return finish.call(write_transition(savepoint_path, context, node, parsed.status, fields, now: now, ledger: ledger))
159
+ end
160
+
161
+ # 340 n10/n11, M4/row 11.14: a proposal is accepted or refused only on a
162
+ # `done` return, right after the schema gate - the only production
163
+ # caller RunnerProposals ever gets. An executor looping on retries must
164
+ # never grow the graph on a failing attempt (v2 minor 11). Every `done`
165
+ # return from here on carries `proposal:` (nil when it proposed
166
+ # nothing), so a refusal is surfaced in the step report rather than left
167
+ # to a savepoint comment nobody reads (row 10.3).
168
+ if Array(parsed.proposed_nodes).any? || Array(parsed.proposed_edges).any?
169
+ proposal_result = proposals.accept(context, proposer: node, proposed_nodes: parsed.proposed_nodes,
170
+ proposed_edges: parsed.proposed_edges, now: now)
171
+ # v2 minor 8/row 11.13: an ACCEPTED proposal's own validator verdict
172
+ # used to be attached only to `result[:proposal]`, which nothing reads
173
+ # - an accepted proposal that invalidates the whole graph bricked
174
+ # every later dispatch with `invalid_graph` and left no record of why.
175
+ # Reported through the same Findings channel the executor's own
176
+ # findings already use, so it survives in the intent record.
177
+ if proposal_result[:ok] && proposal_result[:validator] && !proposal_result[:validator][:ok]
178
+ verdict_errors = Array(proposal_result[:validator][:errors]).join("; ")
179
+ append_findings(intent_dir, node, ["accepted proposal invalidates the graph: #{verdict_errors}"], now: now)
180
+ end
181
+ end
182
+
183
+ # 3. scope --------------------------------------------------------------
184
+ checks_ran << "scope"
185
+ changed = worktree.changed_paths(context, node: node, kind: kind, runner: runner)
186
+ if changed.nil?
187
+ return finish.call(fail_check(savepoint_path, context, node, "scope_unmeasurable", checks_ran, holder, extra_fields, now, ledger))
188
+ end
189
+ scope_reason = scope_violation(kind, changed, declared_files)
190
+ if scope_reason
191
+ return finish.call(fail_check(savepoint_path, context, node, scope_reason, checks_ran, holder, extra_fields, now, ledger))
192
+ end
193
+
194
+ # 4. named_tests ----------------------------------------------------------
195
+ checks_ran << "named_tests"
196
+ if missing_named_tests?(context, node, kind, worktree)
197
+ return finish.call(fail_check(savepoint_path, context, node, "named_test_missing", checks_ran, holder, extra_fields, now, ledger))
198
+ end
199
+
200
+ # 5. merge ------------------------------------------------------------------
201
+ # M2: `merge` lands in gates= only when a merge actually ran - a verify
202
+ # or research return never reaches a merge (only a work node's diff
203
+ # lives on a mergeable branch), so its evidence line must say `merge:none`
204
+ # rather than claiming a check the code skipped for its kind.
205
+ merge_commit = nil
206
+ if kind.to_s == "work"
207
+ checks_ran << "merge"
208
+ merge_result = worktree.merge(context, node: node, runner: runner)
209
+ unless merge_result[:ok]
210
+ conflicted = Array(merge_result[:conflicted])
211
+ if conflicted.empty?
212
+ return finish.call(fail_check(savepoint_path, context, node, "merge_failed", checks_ran, holder, extra_fields, now, ledger))
213
+ end
214
+
215
+ outside = conflicted.reject { |p| path_covered?(p, declared_files) }
216
+ if outside.empty?
217
+ return finish.call(fail_check(savepoint_path, context, node, "merge_conflict", checks_ran, holder, extra_fields, now, ledger))
218
+ end
219
+
220
+ question = "merge conflict touches path(s) outside files: #{outside.sort.join(', ')}"
221
+ fields = { question: question, holder: holder, gates: checks_ran.join("+") }.merge(extra_fields)
222
+ return finish.call(write_transition(savepoint_path, context, node, "needs_decision", fields, now: now, ledger: ledger))
223
+ end
224
+ merge_commit = merge_result[:commit]
225
+ else
226
+ checks_ran << "merge:none"
227
+ end
228
+
229
+ # 6. suite --------------------------------------------------------------
230
+ checks_ran << "suite"
231
+ command = project_reader.call(intent_dir)
232
+ if command == UNREADABLE_VERIFY_COMMAND
233
+ # M1: a project.yml that fails to parse is not the same fact as a
234
+ # project with no verify command at all - the record is broken, so the
235
+ # node blocks rather than proceeding to `done` with `suite:absent`.
236
+ #
237
+ # v2 NEW-3: `gates=` must say `suite:unreadable`, never the bare
238
+ # `suite` `checks_ran` already carries - the suite check itself never
239
+ # ran, so claiming it did is M2's exact defect, reintroduced on the
240
+ # state M1 added.
241
+ gates = (checks_ran[0..-2] + ["suite:unreadable"]).join("+")
242
+ fields = { reason: "verify_command_unreadable", gates: gates, holder: holder }.merge(extra_fields)
243
+ return finish.call(write_transition(savepoint_path, context, node, "blocked", fields, now: now, ledger: ledger))
244
+ end
245
+ if command.nil?
246
+ gates = (checks_ran[0..-2] + ["suite:absent"]).join("+")
247
+ suite_value = "none"
248
+ else
249
+ suite_result = suite_runner.call(dir: context.worktree, command: command)
250
+ unless suite_result[:ok]
251
+ fields = { reason: "suite_red", gates: checks_ran.join("+"), holder: holder,
252
+ suite: format_suite(suite_result) }.merge(extra_fields)
253
+ return finish.call(write_transition(savepoint_path, context, node, "failed_verification", fields, now: now, ledger: ledger))
254
+ end
255
+ gates = checks_ran.join("+")
256
+ suite_value = format_suite(suite_result)
257
+ end
258
+
259
+ commit_value = merge_commit || parsed.commit
260
+ fields = { gates: gates, commit: commit_value, holder: holder, suite: suite_value }.merge(extra_fields)
261
+ result = write_transition(savepoint_path, context, node, "done", fields, now: now, ledger: ledger)
262
+
263
+ worktree.release(context, node: node, state: "done", runner: runner) if result[:written]
264
+
265
+ finish.call(result)
266
+ end
267
+
268
+ # --- transitions -------------------------------------------------------------
269
+
270
+ def fail_check(savepoint_path, context, node, reason, checks_ran, holder, extra_fields, now, ledger)
271
+ fields = { reason: reason, gates: checks_ran.join("+"), holder: holder }.merge(extra_fields)
272
+ write_transition(savepoint_path, context, node, "failed_verification", fields, now: now, ledger: ledger)
273
+ end
274
+ private_class_method :fail_check
275
+
276
+ def refused(reason)
277
+ { state: "refused", written: false, reason: reason, gates: "", fields: {} }
278
+ end
279
+ private_class_method :refused
280
+
281
+ # Appends the one transition line, catching GuardedAppend::Unavailable
282
+ # rather than letting it escape (row 4.40): an append that fails AFTER a
283
+ # merge has already landed on the intent branch must still report that
284
+ # merge commit, so the node stays recoverable rather than looking like
285
+ # nothing happened at all. Re-renders graph.md's ## Status after a
286
+ # successfully written line (row 4.44, D28) - RunnerCore already owns that
287
+ # render, this is its only production caller today.
288
+ #
289
+ # v2 NEW-1/row 11.2: `squash_prose` (below) removes every raw tab and
290
+ # newline before a value ever reaches here, but nothing guarantees that is
291
+ # the ONLY way a return's own prose can make `NodeLedger.transition_line`
292
+ # refuse it (`ArgumentError`, raised before any file is ever touched) - a
293
+ # pathological return must still land a transition, not crash the whole
294
+ # step and leave the node stuck `running` until its lease expires. Falls
295
+ # back to `blocked reason=return_unwritable`, whose own fields are all
296
+ # runner-owned strings, never executor prose, so the fallback write itself
297
+ # can never hit the same wall.
298
+ def write_transition(savepoint_path, context, node, state, fields, now:, ledger:)
299
+ result = ledger.append_transition(savepoint_path, subject: node, state: state, fields: fields, now: now)
300
+ rescue GuardedAppend::Unavailable => e
301
+ { state: "append_failed", written: false, fields: fields, gates: fields[:gates],
302
+ commit: fields[:commit], error: e.message }
303
+ rescue ArgumentError => e
304
+ fallback_fields = { reason: "return_unwritable", holder: fields[:holder], gates: fields[:gates] }.compact
305
+ fallback = begin
306
+ ledger.append_transition(savepoint_path, subject: node, state: "blocked", fields: fallback_fields, now: now)
307
+ rescue GuardedAppend::Unavailable
308
+ :unavailable
309
+ end
310
+ written = fallback == :written
311
+ safe_render_status(context) if written
312
+ { state: "blocked", written: written, fields: fallback_fields, gates: fallback_fields[:gates], commit: nil,
313
+ error: e.message }
314
+ else
315
+ written = result == :written
316
+ safe_render_status(context) if written
317
+ { state: state, written: written, fields: fields, gates: fields[:gates], commit: fields[:commit] }
318
+ end
319
+ private_class_method :write_transition
320
+
321
+ # v2 NEW-5/row 11.9: `RunnerCore.render_status` re-reads and re-parses
322
+ # graph.md from scratch (never trusting `context.graph`, resolved once
323
+ # before this step started) purely to rewrite its own ## Status table - a
324
+ # cosmetic, best-effort render, not evidence. A `graph.md` that turned
325
+ # unreadable mid-step (or was already unreadable, on a node whose kind
326
+ # this call's own NEW-2 guard let through as `blocked`) must never turn a
327
+ # transition ALREADY WRITTEN into a raw stack trace and a half-finished
328
+ # step - the transition already landed in savepoint.md by the time this
329
+ # runs, so a failed render is a missed cosmetic refresh, not a lost write.
330
+ def safe_render_status(context)
331
+ RunnerCore.render_status(context)
332
+ rescue StandardError
333
+ nil
334
+ end
335
+ private_class_method :safe_render_status
336
+
337
+ # Collapse every run of whitespace (space, tab, newline, ...) in an
338
+ # executor's own free text to a single space (v2 NEW-1): the ledger's own
339
+ # field format is a single line, and `NodeLedger.normalize_value` raises
340
+ # ArgumentError outright on a bare tab or newline rather than collapsing
341
+ # it. A YAML block scalar is the natural way to write a two-sentence
342
+ # question, so this runs on every `question:`/`reason:` before either
343
+ # ever reaches a ledger field.
344
+ def squash_prose(text)
345
+ text.to_s.gsub(/\s+/, " ").strip
346
+ end
347
+ private_class_method :squash_prose
348
+
349
+ def holder_for(entries, node)
350
+ last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "running" }.last
351
+ last && (last[:fields] || {})["holder"]
352
+ end
353
+ private_class_method :holder_for
354
+
355
+ def drift_summary(integrity)
356
+ parts = []
357
+ parts << "drifted:#{integrity[:drifted].join(',')}" if Array(integrity[:drifted]).any?
358
+ parts << "missing:#{integrity[:missing].join(',')}" if Array(integrity[:missing]).any?
359
+ parts << (integrity[:reason] || "unknown") if parts.empty?
360
+ parts.join(";")
361
+ end
362
+ private_class_method :drift_summary
363
+
364
+ # --- scope ---------------------------------------------------------------
365
+
366
+ def scope_violation(kind, changed, declared_files)
367
+ case kind.to_s
368
+ when "work"
369
+ outside = Array(changed).reject { |p| path_covered?(p, declared_files) }
370
+ outside.any? ? "diff_outside_files" : nil
371
+ when "verify"
372
+ Array(changed).any? ? "diff_on_verify_node" : nil
373
+ when "research"
374
+ Array(changed).any? ? "diff_on_research_node" : nil
375
+ end
376
+ end
377
+ private_class_method :scope_violation
378
+
379
+ def path_covered?(path, declared_files)
380
+ norm = normalize_scope_path(path)
381
+ declared_files.any? do |f|
382
+ norm == f || norm.start_with?("#{f}/")
383
+ end
384
+ end
385
+ private_class_method :path_covered?
386
+
387
+ def normalize_files(files)
388
+ Array(files).map { |f| normalize_scope_path(f) }
389
+ end
390
+ private_class_method :normalize_files
391
+
392
+ def normalize_scope_path(path)
393
+ path.to_s.sub(%r{\A\./}, "").sub(%r{/\z}, "")
394
+ end
395
+ private_class_method :normalize_scope_path
396
+
397
+ # --- named tests -----------------------------------------------------------
398
+
399
+ # Row 4.19/minor 1: read the node's own failure-mode matrix table, take
400
+ # every `Test` cell's file basename AND method name (before/after the
401
+ # `#`), and check both that `test/<basename>.rb` exists on the node's own
402
+ # worktree - the tree the executor actually wrote to, not the not-yet-
403
+ # merged intent tree - and that it actually DEFINES the named method
404
+ # (post-execution review minor 1: proving only the file exists lets an
405
+ # executor delete the test method while keeping its file and still pass
406
+ # this gate). A node file that cannot be read, or that carries no such
407
+ # section, names no tests and passes this check vacuously (nothing to
408
+ # prove missing).
409
+ def missing_named_tests?(context, node, kind, worktree)
410
+ path = ReadySet.find_node_path(context.intent_dir, node)
411
+ return false unless path
412
+
413
+ nf = NodeFile.parse(path)
414
+ return false unless nf[:ok]
415
+
416
+ section = NodeFile.split_by_headings(nf[:body]).find { |(heading, _)| heading.to_s =~ /failure-mode matrix/i }
417
+ return false unless section
418
+
419
+ tests = NodeFile.table_rows(section[1]).filter_map do |row|
420
+ cell = row[3]
421
+ next nil if cell.to_s.strip.empty?
422
+
423
+ basename, method = cell.gsub("`", "").split("#", 2)
424
+ next nil if basename.to_s.strip.empty?
425
+
426
+ [basename.strip, method.to_s.strip]
427
+ end.uniq
428
+ return false if tests.empty?
429
+
430
+ check_dir = kind.to_s == "work" ? (worktree.paths(context, node: node) || {})["path"] : context.worktree
431
+ return false if check_dir.nil?
432
+
433
+ tests.any? do |basename, method|
434
+ test_path = File.join(check_dir, "test", "#{basename}.rb")
435
+ !File.exist?(test_path) || !method_defined_in_file?(test_path, method)
436
+ end
437
+ end
438
+ private_class_method :missing_named_tests?
439
+
440
+ # true when `method` is blank (a matrix row that names only a file, no
441
+ # `#method`, still passes on file presence alone) or when `path`'s own
442
+ # source text defines it. A source-text check, not a `require` and
443
+ # introspect: loading an untrusted executor-written test file as living
444
+ # Ruby is not a check RunnerAbsorb should ever perform.
445
+ def method_defined_in_file?(path, method)
446
+ return true if method.to_s.empty?
447
+ return false unless File.exist?(path)
448
+
449
+ File.read(path).match?(/^\s*def\s+#{Regexp.escape(method)}\b/)
450
+ rescue StandardError
451
+ false
452
+ end
453
+ private_class_method :method_defined_in_file?
454
+
455
+ # --- findings --------------------------------------------------------------
456
+
457
+ # One capped bullet under `### Findings` (D16), created under `## Insights`
458
+ # when either heading is absent (row 4.35): Insights.append_insight never
459
+ # creates a nested subsection, so this is its own small insertion, not a
460
+ # call into that module.
461
+ #
462
+ # v2 NEW-4: this is a mid-file rewrite (a `## Links` section commonly
463
+ # follows `### Findings`), so it goes through AtomicWrite directly -
464
+ # sibling-temp-plus-rename, the same shape every other writer in this tree
465
+ # uses (D19r) - which is genuine crash safety: a process that dies mid-
466
+ # write leaves the ORIGINAL file untouched, never a half-written one.
467
+ #
468
+ # It is NOT mutual exclusion, and no longer pretends to be one. The
469
+ # previous shape opened this same path under GuardedAppend's flock first
470
+ # and called AtomicWrite.write from inside that hold - but flock guards
471
+ # one open file HANDLE, and AtomicWrite's own rename puts a brand new
472
+ # inode at the path underneath it. A second writer that opened its handle
473
+ # before the rename keeps the OLD, now-unlinked inode; once the first
474
+ # writer unlocks, the second takes a lock that excludes nobody, reads
475
+ # stale content off the orphaned inode, and its own rename overwrites the
476
+ # first writer's already-landed bullet - reproduced with two real
477
+ # processes. `Insights.append_insight` (this module's sibling for the same
478
+ # file) takes no lock at all either, so a bigger lock here would still
479
+ # lose the race against that path. Findings are best-effort (D16): a lost
480
+ # bullet under real concurrency is an accepted gap, not a promise this
481
+ # method makes and breaks.
482
+ def append_findings(intent_dir, node, findings, now:, renamer: File.method(:rename))
483
+ return if Array(findings).empty?
484
+
485
+ joined = Array(findings).join("; ")
486
+ text = joined.length > FINDING_CAP ? "#{joined[0...(FINDING_CAP - 3)].rstrip}..." : joined
487
+ bullet = "- [#{node}] #{text}"
488
+
489
+ path = Savepoint.intent_file(intent_dir)
490
+ content = File.exist?(path) ? File.read(path) : ""
491
+ AtomicWrite.write(path, insert_finding_bullet(content, bullet), renamer: renamer)
492
+ nil
493
+ rescue StandardError
494
+ nil
495
+ end
496
+ private_class_method :append_findings
497
+
498
+ def insert_finding_bullet(content, bullet)
499
+ lines = content.empty? ? [] : content.split("\n", -1)
500
+ insights_idx = lines.index { |l| l.strip == "## Insights" }
501
+
502
+ if insights_idx.nil?
503
+ body = lines.join("\n")
504
+ body = body.sub(/\n+\z/, "") unless body.empty?
505
+ pieces = body.empty? ? [] : [body, ""]
506
+ pieces.concat(["## Insights", "", "### Findings", bullet])
507
+ return "#{pieces.join("\n")}\n"
508
+ end
509
+
510
+ idx = insights_idx + 1
511
+ findings_idx = nil
512
+ while idx < lines.length && !lines[idx].start_with?("## ")
513
+ findings_idx = idx if lines[idx].strip == "### Findings"
514
+ idx += 1
515
+ end
516
+ insights_end = idx
517
+
518
+ # M12: `split("\n", -1)` keeps a trailing "" element exactly when
519
+ # `content` itself ends in a newline (the file's own trailing newline
520
+ # made visible as an array slot). When Insights is the LAST section (no
521
+ # further "## " heading), `insights_end` walks all the way to
522
+ # `lines.length`, folding that sentinel INTO the section - the very next
523
+ # insert then landed AFTER it, turning the file's trailing newline into
524
+ # a spurious blank line and leaving the new bullet as the final element
525
+ # with no newline of its own. Excluding the sentinel here restores both:
526
+ # the bullet lands where content actually ends, one newline intact.
527
+ insights_end -= 1 if insights_end == lines.length && lines.last == ""
528
+
529
+ if findings_idx
530
+ fidx = findings_idx + 1
531
+ fidx += 1 while fidx < insights_end && !lines[fidx].start_with?("#")
532
+ lines.insert(fidx, bullet)
533
+ else
534
+ lines.insert(insights_end, "", "### Findings", bullet)
535
+ end
536
+ lines.join("\n")
537
+ end
538
+ private_class_method :insert_finding_bullet
539
+
540
+ # --- the return file itself -------------------------------------------------
541
+
542
+ def read_return_text(return_path)
543
+ File.exist?(return_path.to_s) ? File.read(return_path.to_s) : ""
544
+ end
545
+ private_class_method :read_return_text
546
+
547
+ # Row 4.42: the return is kept beside the packet at
548
+ # packets/<node>--a<N>.return - copied there when `return_path` names
549
+ # somewhere else, left alone when it already is that file.
550
+ 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")
552
+ return target if File.expand_path(return_path.to_s) == File.expand_path(target)
553
+
554
+ FileUtils.mkdir_p(File.dirname(target))
555
+ File.write(target, text) unless File.exist?(target) && File.read(target) == text
556
+ target
557
+ end
558
+ private_class_method :preserve_return_file
559
+
560
+ # --- the suite ---------------------------------------------------------------
561
+
562
+ # v1 minor 7/row 11.17: reads the LAST summary line, not the first match -
563
+ # a suite command that shells out to more than one sub-process (or prints
564
+ # its own retry) can carry an earlier RED summary line before the one that
565
+ # actually decided its exit status, and `.match` (first match) would
566
+ # record that earlier line's counts on the `done` line instead.
567
+ def default_suite_runner(dir:, command:)
568
+ out, err, status = Open3.capture3(command, chdir: dir.to_s)
569
+ combined = "#{out}\n#{err}"
570
+ matches = combined.scan(/(\d+)\s+runs,\s+(\d+)\s+assertions,\s+(\d+)\s+failures,\s+(\d+)\s+errors/)
571
+ return { ok: false, runs: nil, assertions: nil, failures: nil, errors: nil } if matches.empty?
572
+
573
+ runs, assertions, failures, errors = matches.last.map(&:to_i)
574
+ { ok: status.success? && failures.zero? && errors.zero?, runs: runs, assertions: assertions,
575
+ failures: failures, errors: errors }
576
+ end
577
+
578
+ def format_suite(result)
579
+ "#{result[:runs]}/#{result[:assertions]}/#{result[:failures]}/#{result[:errors]}"
580
+ end
581
+ private_class_method :format_suite
582
+
583
+ # Own, lightweight equivalent of InstallerCore's private project reader
584
+ # (that class needs a full instance to call its own copy): reads
585
+ # project.yml's release.verify for the project this intent belongs to,
586
+ # nil for a global-store-only intent or a project record with none
587
+ # recorded (row 4.39's absent-command case). M1/row 9.14: a project.yml
588
+ # that EXISTS but fails to parse returns UNREADABLE_VERIFY_COMMAND, never
589
+ # nil - that record is broken, not merely silent on a verify command, and
590
+ # the caller must not read the two the same way.
591
+ def default_project_reader(intent_dir)
592
+ m = intent_dir.to_s.match(PROJECT_LAYOUT_RE)
593
+ return nil unless m
594
+
595
+ home, slug = m[1], m[2]
596
+ path = File.join(home, "projects", slug, "project.yml")
597
+ return nil unless File.exist?(path)
598
+
599
+ data = begin
600
+ YAML.safe_load(File.read(path), permitted_classes: [Date, Time])
601
+ rescue StandardError
602
+ return UNREADABLE_VERIFY_COMMAND
603
+ end
604
+ # v2 minor 13/row 11.15: a project.yml that PARSES cleanly but to
605
+ # something other than a mapping (an empty file, a bare string, a list)
606
+ # is the same broken-record fact as one that fails to parse outright -
607
+ # M1's whole point was that a broken record must never read the same as
608
+ # "no verify command declared", and reading a non-Hash as nil silently
609
+ # reopened that exact gap.
610
+ return UNREADABLE_VERIFY_COMMAND unless data.is_a?(Hash)
611
+
612
+ release = data["release"]
613
+ return nil unless release.is_a?(Hash)
614
+
615
+ verify = release["verify"]
616
+ return nil unless verify.is_a?(String) && !verify.strip.empty?
617
+
618
+ verify.split("\n").map(&:strip).reject(&:empty?).join("; ")
619
+ end
620
+ end