@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +886 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +392 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +24 -17
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +17 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- 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
|