@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,254 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "node_file"
|
|
6
|
+
require_relative "node_ids"
|
|
7
|
+
require_relative "graph_edges"
|
|
8
|
+
require_relative "graph_file"
|
|
9
|
+
require_relative "atomic_write"
|
|
10
|
+
require_relative "ready_set"
|
|
11
|
+
require_relative "node_ledger"
|
|
12
|
+
require_relative "savepoint"
|
|
13
|
+
require_relative "work_graph_validator"
|
|
14
|
+
|
|
15
|
+
# RunnerProposals (intent 340, G7, n6): accepts or refuses what an executor
|
|
16
|
+
# proposed in its return (327 D15, D28, D30). The runner mints every node id;
|
|
17
|
+
# an executor never sees or picks one. A proposed node is scaffolded from its
|
|
18
|
+
# kind's template, its id substituted everywhere the template hard-codes its
|
|
19
|
+
# own placeholder (`n1`, `v1`, `d1`, `r1` - the frontmatter key, the H1, and
|
|
20
|
+
# the failure-mode matrix heading), and appended to graph.md's ## Graph
|
|
21
|
+
# section as a fresh, undispatched line - no ledger line at all, which reads
|
|
22
|
+
# as "planned" by construction. A proposed edge is accepted only when both
|
|
23
|
+
# endpoints exist (among the already-declared nodes or a node this very call
|
|
24
|
+
# is minting), its head is not `running`, and the resulting graph stays
|
|
25
|
+
# acyclic (D30); a refusal writes one ledger comment naming which of the
|
|
26
|
+
# three failed.
|
|
27
|
+
#
|
|
28
|
+
# One call, one all-or-nothing write (D15's "nothing is partially written"):
|
|
29
|
+
# every proposed node and edge is validated BEFORE anything touches disk. A
|
|
30
|
+
# single rejected edge refuses the WHOLE call, leaving graph.md and nodes/
|
|
31
|
+
# byte-identical to how #accept found them, and burning no minted id (an id
|
|
32
|
+
# is only ever consumed once a node FILE actually exists; NodeIds.taken never
|
|
33
|
+
# sees one this call abandoned).
|
|
34
|
+
#
|
|
35
|
+
# Pure and dependency-injected: the full validator re-run (327 D17's "a mid-
|
|
36
|
+
# run scaffold is checked, not merely accepted"), the templates directory,
|
|
37
|
+
# the clock and the rename call are all injectable keyword arguments with
|
|
38
|
+
# real defaults, so a test never touches a real ~/.plastic install.
|
|
39
|
+
module RunnerProposals
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
KIND_TEMPLATES = {
|
|
43
|
+
"work" => "node-work.md",
|
|
44
|
+
"verify" => "node-verify.md",
|
|
45
|
+
"decision" => "node-decision.md",
|
|
46
|
+
"research" => "node-research.md",
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# accept(context, proposer:, proposed_nodes:, proposed_edges:, now:,
|
|
50
|
+
# validator:, templates_dir:, renamer:) -> {ok:, minted:, validator:,
|
|
51
|
+
# errors:}. `proposer` names the node whose return carried these proposals
|
|
52
|
+
# - it is used only to attribute the refusal comment; the runner itself
|
|
53
|
+
# decides everything about the proposal's fate.
|
|
54
|
+
def accept(context, proposer:, proposed_nodes: [], proposed_edges: [], now: Time.now,
|
|
55
|
+
validator: WorkGraphValidator.method(:validate), templates_dir: nil,
|
|
56
|
+
renamer: File.method(:rename))
|
|
57
|
+
intent_dir = context.intent_dir
|
|
58
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
59
|
+
templates_root = templates_dir || File.join(context.plastic_home.to_s, "templates")
|
|
60
|
+
|
|
61
|
+
# v2 NEW-5/row 11.8: `ReadySet.load_graph` documents itself as never
|
|
62
|
+
# raising, but that guarantee is only as good as the parser underneath
|
|
63
|
+
# it - a `graph.md` carrying invalid UTF-8 bytes raises `ArgumentError`
|
|
64
|
+
# out of `GraphFile`'s own fence-line scan before `load_graph` ever gets
|
|
65
|
+
# a chance to catch it. M4 made this call reachable straight from
|
|
66
|
+
# `RunnerAbsorb#absorb`, so a `--return` carrying a proposal against a
|
|
67
|
+
# malformed graph used to die with a raw stack trace instead of a plain
|
|
68
|
+
# refusal.
|
|
69
|
+
loaded = safe_load_graph(intent_dir)
|
|
70
|
+
return refuse(intent_dir, proposer, "graph.md is unreadable: #{loaded[:errors].join('; ')}", now) if loaded[:unreadable]
|
|
71
|
+
|
|
72
|
+
declared = loaded[:edges].keys
|
|
73
|
+
status_map = NodeLedger.status_from_content(read_savepoint(intent_dir))
|
|
74
|
+
|
|
75
|
+
mint_pool = NodeIds.taken(intent_dir).dup
|
|
76
|
+
node_specs = []
|
|
77
|
+
|
|
78
|
+
Array(proposed_nodes).each do |raw|
|
|
79
|
+
spec = stringify(raw)
|
|
80
|
+
kind = spec["kind"].to_s
|
|
81
|
+
template_name = KIND_TEMPLATES[kind]
|
|
82
|
+
unless template_name
|
|
83
|
+
return refuse(intent_dir, proposer, "proposed node kind #{kind.inspect} has no template", now)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
template_path = File.join(templates_root, template_name)
|
|
87
|
+
unless File.exist?(template_path)
|
|
88
|
+
return refuse(intent_dir, proposer, "missing template for kind #{kind.inspect} at #{template_path}", now)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
id = NodeFile.mint_id(kind, mint_pool)
|
|
92
|
+
mint_pool << id
|
|
93
|
+
node_specs << {
|
|
94
|
+
id: id, kind: kind, template_path: template_path,
|
|
95
|
+
needs: Array(spec["needs"]).map(&:to_s),
|
|
96
|
+
files: spec["files"].nil? ? nil : Array(spec["files"]),
|
|
97
|
+
budget: spec["budget"],
|
|
98
|
+
}
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
trial_edges = loaded[:edges].dup
|
|
102
|
+
node_specs.each { |s| trial_edges[s[:id]] = s[:needs] }
|
|
103
|
+
known_ids = declared + node_specs.map { |s| s[:id] }
|
|
104
|
+
|
|
105
|
+
edge_specs = []
|
|
106
|
+
Array(proposed_edges).each do |raw|
|
|
107
|
+
e = stringify(raw)
|
|
108
|
+
from = e["from"].to_s
|
|
109
|
+
to = e["to"].to_s
|
|
110
|
+
|
|
111
|
+
unless known_ids.include?(from) && known_ids.include?(to)
|
|
112
|
+
return refuse(intent_dir, proposer,
|
|
113
|
+
"edge #{from}->#{to} refused (endpoint_unknown): both ends must be a declared or proposed node", now)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if status_map.fetch(from, "planned") == "running"
|
|
117
|
+
return refuse(intent_dir, proposer,
|
|
118
|
+
"edge #{from}->#{to} refused (head_running): #{from} is currently running", now)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
trial = trial_edges.dup
|
|
122
|
+
trial[from] = (trial[from] || []) + [to]
|
|
123
|
+
if GraphEdges.cycle(trial)
|
|
124
|
+
return refuse(intent_dir, proposer,
|
|
125
|
+
"edge #{from}->#{to} refused (would_cycle): would make the graph cyclic", now)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
trial_edges = trial
|
|
129
|
+
edge_specs << { from: from, to: to }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
node_specs.each { |s| scaffold_node_file(intent_dir, s) }
|
|
133
|
+
if node_specs.any? || edge_specs.any?
|
|
134
|
+
append_to_graph(graph_path, node_specs: node_specs, edge_specs: edge_specs, renamer: renamer)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# v2 NEW-5/row 11.8: the accept itself already landed on disk by this
|
|
138
|
+
# point (327 D17's "a mid-run scaffold is checked" runs strictly AFTER
|
|
139
|
+
# the write) - a raising validator must report a broken verdict, never
|
|
140
|
+
# turn an already-successful accept into an uncaught exception.
|
|
141
|
+
{ ok: true, minted: node_specs.map { |s| s[:id] }, validator: safe_validate(validator, intent_dir), errors: [] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# --- node scaffolding --------------------------------------------------
|
|
145
|
+
|
|
146
|
+
# A template's own placeholder id ("n1", "v1", ...) is replaced only as a
|
|
147
|
+
# whole token, so minting past 9 (a proposed "n10") never mangles a
|
|
148
|
+
# coincidental substring match.
|
|
149
|
+
def substitute_id(text, old_id, new_id)
|
|
150
|
+
text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def scaffold_node_file(intent_dir, spec)
|
|
154
|
+
parsed_template = NodeFile.parse(spec[:template_path])
|
|
155
|
+
old_id = parsed_template[:node]
|
|
156
|
+
raw = File.read(spec[:template_path])
|
|
157
|
+
parts = raw.split("---", 3)
|
|
158
|
+
body = substitute_id(parts[2].to_s, old_id, spec[:id])
|
|
159
|
+
|
|
160
|
+
files = spec[:files].nil? ? parsed_template[:files] : spec[:files]
|
|
161
|
+
budget = spec[:budget].nil? ? parsed_template[:budget] : spec[:budget]
|
|
162
|
+
|
|
163
|
+
frontmatter = "---\nnode: #{spec[:id]}\nkind: #{spec[:kind]}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
|
|
164
|
+
path = File.join(intent_dir, "nodes", "#{spec[:id]}.md")
|
|
165
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
166
|
+
File.write(path, "#{frontmatter}#{body}")
|
|
167
|
+
end
|
|
168
|
+
private_class_method :scaffold_node_file
|
|
169
|
+
|
|
170
|
+
# --- graph.md editing ----------------------------------------------------
|
|
171
|
+
|
|
172
|
+
# Appends one "- <id> needs <targets>" line per minted node, then applies
|
|
173
|
+
# each accepted edge onto the line (existing or just-appended) whose id
|
|
174
|
+
# matches its `from`, through GraphFile's own public section helpers
|
|
175
|
+
# (section_bounds, section_body, replace_or_append_section - none of them
|
|
176
|
+
# private_class_method'd) so this stays a plain text edit, never a second
|
|
177
|
+
# bespoke parser.
|
|
178
|
+
def append_to_graph(graph_path, node_specs:, edge_specs:, renamer:)
|
|
179
|
+
content = File.read(graph_path)
|
|
180
|
+
bounds = GraphFile.section_bounds(content, "## Graph")
|
|
181
|
+
return unless bounds
|
|
182
|
+
|
|
183
|
+
body = GraphFile.section_body(content, "## Graph").to_s
|
|
184
|
+
lines = body.split("\n")
|
|
185
|
+
|
|
186
|
+
node_specs.each do |s|
|
|
187
|
+
rendered = s[:needs].empty? ? "nothing" : s[:needs].join(" ")
|
|
188
|
+
lines << "- #{s[:id]} needs #{rendered}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
edge_specs.each do |e|
|
|
192
|
+
idx = lines.index { |l| l.strip.match?(/\A-\s*#{Regexp.escape(e[:from])}\s+needs\b/) }
|
|
193
|
+
next unless idx
|
|
194
|
+
|
|
195
|
+
stripped = lines[idx].strip
|
|
196
|
+
m = stripped.match(/\A-\s*(\S+)\s+needs\s+(.*)\z/)
|
|
197
|
+
targets = m[2].to_s.strip.split(/\s+/)
|
|
198
|
+
targets = [] if targets == ["nothing"]
|
|
199
|
+
targets << e[:to] unless targets.include?(e[:to])
|
|
200
|
+
lines[idx] = "- #{e[:from]} needs #{targets.empty? ? 'nothing' : targets.join(' ')}"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
new_body = lines.join("\n")
|
|
204
|
+
new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
|
|
205
|
+
AtomicWrite.write(graph_path, new_content, renamer: renamer)
|
|
206
|
+
end
|
|
207
|
+
private_class_method :append_to_graph
|
|
208
|
+
|
|
209
|
+
# --- refusal -------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
# One ledger comment (a plain milestone-style line through Savepoint's own
|
|
212
|
+
# public append primitive, never a state transition - a refused proposal
|
|
213
|
+
# changes nothing about the proposer's own status) naming which of the
|
|
214
|
+
# three checks failed. Dedup'd by (stage, text) like every other such line,
|
|
215
|
+
# so an identical refusal repeated verbatim across retries writes once.
|
|
216
|
+
def refuse(intent_dir, proposer, reason, now)
|
|
217
|
+
Savepoint.append_savepoint_line(intent_dir, "Proposal", "#{proposer}: #{reason}", now)
|
|
218
|
+
{ ok: false, minted: [], validator: nil, errors: [reason] }
|
|
219
|
+
end
|
|
220
|
+
private_class_method :refuse
|
|
221
|
+
|
|
222
|
+
# --- guarded re-entries into graph.md (v2 NEW-5/row 11.8) -------------------
|
|
223
|
+
|
|
224
|
+
# `unreadable: true` marks the ONE case a raise actually happened -
|
|
225
|
+
# distinct from `ReadySet.load_graph`'s own ordinary `ok: false` (a
|
|
226
|
+
# cyclic graph, a missing node file, ...), which callers here already
|
|
227
|
+
# tolerate and must keep tolerating unchanged.
|
|
228
|
+
def safe_load_graph(intent_dir)
|
|
229
|
+
ReadySet.load_graph(intent_dir)
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
{ ok: false, edges: {}, nodes: {}, errors: ["graph.md could not be read: #{e.message}"], unreadable: true }
|
|
232
|
+
end
|
|
233
|
+
private_class_method :safe_load_graph
|
|
234
|
+
|
|
235
|
+
def safe_validate(validator, intent_dir)
|
|
236
|
+
validator.call(intent_dir)
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
{ ok: false, missing: [], errors: ["graph.md could not be validated: #{e.message}"] }
|
|
239
|
+
end
|
|
240
|
+
private_class_method :safe_validate
|
|
241
|
+
|
|
242
|
+
# --- internals -------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
def stringify(hash)
|
|
245
|
+
(hash || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
246
|
+
end
|
|
247
|
+
private_class_method :stringify
|
|
248
|
+
|
|
249
|
+
def read_savepoint(intent_dir)
|
|
250
|
+
path = File.join(intent_dir.to_s, "savepoint.md")
|
|
251
|
+
File.exist?(path) ? File.read(path) : ""
|
|
252
|
+
end
|
|
253
|
+
private_class_method :read_savepoint
|
|
254
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "node_ledger"
|
|
6
|
+
require_relative "ready_set"
|
|
7
|
+
require_relative "node_file"
|
|
8
|
+
require_relative "node_ids"
|
|
9
|
+
require_relative "graph_file"
|
|
10
|
+
require_relative "atomic_write"
|
|
11
|
+
require_relative "runner_core"
|
|
12
|
+
require_relative "worktree"
|
|
13
|
+
require_relative "node_worktree"
|
|
14
|
+
|
|
15
|
+
# RunnerRewind (intent 340, G7, n6): resets the intent branch to a node's own
|
|
16
|
+
# recorded commit, marks every downstream node superseded (its evidence no
|
|
17
|
+
# longer stands once the code it was built on is gone), and respins the
|
|
18
|
+
# rewound node itself the same way RunnerAnswer's hard-cap path does - never
|
|
19
|
+
# a plain `planned` line, because `failed_verification_count` is counted per
|
|
20
|
+
# subject id over the WHOLE ledger and never resets; only a brand new id
|
|
21
|
+
# starts clean.
|
|
22
|
+
#
|
|
23
|
+
# Internal, confirm-gated (327 D15's rewind clause): `confirm:` must be
|
|
24
|
+
# explicitly true, and the whole intent must be quiescent (no `running` node
|
|
25
|
+
# anywhere) before a branch reset is safe to make - resetting the branch
|
|
26
|
+
# under a live executor's own worktree would move the base it is building on
|
|
27
|
+
# out from under it.
|
|
28
|
+
#
|
|
29
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
30
|
+
# `runner:` (default Worktree::ShellRunner), always `-C <path>`, never cwd;
|
|
31
|
+
# the clock and the rename call are injectable the same way every other
|
|
32
|
+
# runner_* module here already is.
|
|
33
|
+
module RunnerRewind
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def rewind(context, node:, confirm:, now: Time.now, ledger: NodeLedger,
|
|
37
|
+
runner: Worktree::ShellRunner.new, renamer: File.method(:rename),
|
|
38
|
+
worktree: NodeWorktree)
|
|
39
|
+
node = node.to_s
|
|
40
|
+
return refusal("confirm_required") unless confirm
|
|
41
|
+
|
|
42
|
+
intent_dir = context.intent_dir
|
|
43
|
+
# M9: a second, unguarded re-parse of graph.md - RunnerCore.context's
|
|
44
|
+
# own first read is already safe, but this fresh read is not.
|
|
45
|
+
loaded = RunnerCore.safe_load_graph(intent_dir)
|
|
46
|
+
return refusal("invalid_graph", detail: Array(loaded[:errors]).join("; ")) unless loaded[:ok]
|
|
47
|
+
|
|
48
|
+
edges = loaded[:edges]
|
|
49
|
+
nodes_decl = loaded[:nodes]
|
|
50
|
+
before_content = read_savepoint(intent_dir)
|
|
51
|
+
entries = NodeLedger.entries_from_content(before_content)
|
|
52
|
+
status_map = NodeLedger.status_from_content(before_content)
|
|
53
|
+
|
|
54
|
+
return refusal("a_node_is_running") if status_map.value?("running")
|
|
55
|
+
|
|
56
|
+
commit = last_commit(entries, node)
|
|
57
|
+
return refusal("no_recorded_commit") if commit.nil? || commit.to_s.strip.empty?
|
|
58
|
+
|
|
59
|
+
return refusal("no_intent_worktree") if blank?(context.worktree)
|
|
60
|
+
|
|
61
|
+
reset = runner.run("-C", context.worktree, "reset", "--hard", commit)
|
|
62
|
+
return refusal("git_reset_failed", detail: reset.stderr.to_s.strip) unless reset.success?
|
|
63
|
+
|
|
64
|
+
downstream = downstream_of(node, edges)
|
|
65
|
+
downstream.each do |d|
|
|
66
|
+
ledger.append_transition(savepoint_path(intent_dir), subject: d, state: "superseded",
|
|
67
|
+
fields: { by: node }, now: now)
|
|
68
|
+
# M5/D7: a rewind-superseded node's evidence no longer stands once the
|
|
69
|
+
# code it was built on is gone - its worktree releases here too.
|
|
70
|
+
worktree.release(context, node: d, state: "superseded", runner: runner)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
succ_id = respin(intent_dir, node, nodes_decl, edges, ledger: ledger, now: now, renamer: renamer,
|
|
74
|
+
context: context, worktree: worktree, runner: runner)
|
|
75
|
+
|
|
76
|
+
RunnerCore.render_status(context)
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
ok: true, reset_to: commit, superseded: downstream, respun_to: succ_id, errors: [],
|
|
80
|
+
newly_ready: newly_ready(intent_dir, before_content: before_content, before_nodes: nodes_decl,
|
|
81
|
+
before_edges: edges),
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- downstream: everything that, directly or transitively, needs the
|
|
86
|
+
# rewound node - the mirror of ReadySet.critical_paths' own successors map.
|
|
87
|
+
|
|
88
|
+
def downstream_of(node, edges)
|
|
89
|
+
successors = Hash.new { |h, k| h[k] = [] }
|
|
90
|
+
edges.each { |id, targets| (targets || []).each { |t| successors[t] << id } }
|
|
91
|
+
|
|
92
|
+
seen = []
|
|
93
|
+
stack = successors[node].dup
|
|
94
|
+
until stack.empty?
|
|
95
|
+
n = stack.pop
|
|
96
|
+
next if seen.include?(n)
|
|
97
|
+
|
|
98
|
+
seen << n
|
|
99
|
+
stack.concat(successors[n] || [])
|
|
100
|
+
end
|
|
101
|
+
seen.sort
|
|
102
|
+
end
|
|
103
|
+
private_class_method :downstream_of
|
|
104
|
+
|
|
105
|
+
def last_commit(entries, node)
|
|
106
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == node && e[:state] == "done" }.last
|
|
107
|
+
last && (last[:fields] || {})["commit"]
|
|
108
|
+
end
|
|
109
|
+
private_class_method :last_commit
|
|
110
|
+
|
|
111
|
+
# --- the respin (327 D22, same shape as RunnerAnswer's hard-cap path) ------
|
|
112
|
+
|
|
113
|
+
def respin(intent_dir, node, nodes_decl, edges, ledger:, now:, renamer:,
|
|
114
|
+
context:, worktree: NodeWorktree, runner: Worktree::ShellRunner.new)
|
|
115
|
+
decl = nodes_decl[node] || {}
|
|
116
|
+
kind = decl[:kind]
|
|
117
|
+
node_path = ReadySet.find_node_path(intent_dir, node)
|
|
118
|
+
parsed = NodeFile.parse(node_path)
|
|
119
|
+
|
|
120
|
+
mint_pool = NodeIds.taken(intent_dir).dup
|
|
121
|
+
succ_id = NodeFile.mint_id(kind, mint_pool)
|
|
122
|
+
|
|
123
|
+
raw = File.read(node_path)
|
|
124
|
+
parts = raw.split("---", 3)
|
|
125
|
+
body = substitute_id(parts[2].to_s, node, succ_id)
|
|
126
|
+
files = parsed[:files] || decl[:files] || []
|
|
127
|
+
budget = parsed[:budget]
|
|
128
|
+
|
|
129
|
+
frontmatter = "---\nnode: #{succ_id}\nkind: #{kind}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
|
|
130
|
+
succ_path = File.join(intent_dir, "nodes", "#{succ_id}.md")
|
|
131
|
+
FileUtils.mkdir_p(File.dirname(succ_path))
|
|
132
|
+
File.write(succ_path, "#{frontmatter}#{body}")
|
|
133
|
+
|
|
134
|
+
append_graph_line(File.join(intent_dir, "graph.md"), succ_id, edges[node] || [], renamer: renamer)
|
|
135
|
+
|
|
136
|
+
ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "superseded",
|
|
137
|
+
fields: { by: succ_id }, now: now)
|
|
138
|
+
|
|
139
|
+
# M5/D7: the rewound node's own worktree releases too, same as the
|
|
140
|
+
# downstream nodes above.
|
|
141
|
+
worktree.release(context, node: node, state: "superseded", runner: runner)
|
|
142
|
+
|
|
143
|
+
succ_id
|
|
144
|
+
end
|
|
145
|
+
private_class_method :respin
|
|
146
|
+
|
|
147
|
+
def append_graph_line(graph_path, id, needs, renamer:)
|
|
148
|
+
content = File.read(graph_path)
|
|
149
|
+
body = GraphFile.section_body(content, "## Graph").to_s
|
|
150
|
+
lines = body.split("\n")
|
|
151
|
+
rendered = needs.empty? ? "nothing" : needs.join(" ")
|
|
152
|
+
lines << "- #{id} needs #{rendered}"
|
|
153
|
+
new_body = lines.join("\n")
|
|
154
|
+
new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
|
|
155
|
+
AtomicWrite.write(graph_path, new_content, renamer: renamer)
|
|
156
|
+
end
|
|
157
|
+
private_class_method :append_graph_line
|
|
158
|
+
|
|
159
|
+
def substitute_id(text, old_id, new_id)
|
|
160
|
+
text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
|
|
161
|
+
end
|
|
162
|
+
private_class_method :substitute_id
|
|
163
|
+
|
|
164
|
+
def newly_ready(intent_dir, before_content:, before_nodes:, before_edges:)
|
|
165
|
+
after = RunnerCore.safe_load_graph(intent_dir)
|
|
166
|
+
return [] unless after[:ok]
|
|
167
|
+
|
|
168
|
+
after_content = read_savepoint(intent_dir)
|
|
169
|
+
|
|
170
|
+
after[:nodes].keys.select do |id|
|
|
171
|
+
was = before_nodes.key?(id) &&
|
|
172
|
+
ReadySet.ready?(content: before_content, subject: id, graph: { edges: before_edges },
|
|
173
|
+
nodes: before_nodes)[:ready]
|
|
174
|
+
now_ready = ReadySet.ready?(content: after_content, subject: id, graph: { edges: after[:edges] },
|
|
175
|
+
nodes: after[:nodes])[:ready]
|
|
176
|
+
now_ready && !was
|
|
177
|
+
end.sort
|
|
178
|
+
end
|
|
179
|
+
private_class_method :newly_ready
|
|
180
|
+
|
|
181
|
+
def refusal(reason, detail: nil)
|
|
182
|
+
{ ok: false, reason: reason, detail: detail, reset_to: nil, superseded: [], respun_to: nil, newly_ready: [] }
|
|
183
|
+
end
|
|
184
|
+
private_class_method :refusal
|
|
185
|
+
|
|
186
|
+
def blank?(value)
|
|
187
|
+
value.nil? || value.to_s.strip.empty?
|
|
188
|
+
end
|
|
189
|
+
private_class_method :blank?
|
|
190
|
+
|
|
191
|
+
def read_savepoint(intent_dir)
|
|
192
|
+
path = savepoint_path(intent_dir)
|
|
193
|
+
File.exist?(path) ? File.read(path) : ""
|
|
194
|
+
end
|
|
195
|
+
private_class_method :read_savepoint
|
|
196
|
+
|
|
197
|
+
def savepoint_path(intent_dir)
|
|
198
|
+
File.join(intent_dir.to_s, "savepoint.md")
|
|
199
|
+
end
|
|
200
|
+
private_class_method :savepoint_path
|
|
201
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "time"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require_relative "worktree"
|
|
7
|
+
require_relative "lock"
|
|
8
|
+
require_relative "node_ledger"
|
|
9
|
+
require_relative "ready_set"
|
|
10
|
+
require_relative "savepoint"
|
|
11
|
+
|
|
12
|
+
# RunnerSweep (intent 340, G7, n2): the first thing every `step` does. Two
|
|
13
|
+
# separately callable entry points - #abort_if_merging and #reclaim - plus
|
|
14
|
+
# #run, which composes them for a caller that has no absorb step to interleave.
|
|
15
|
+
# `step` (a later node) calls #abort_if_merging, runs absorb, then calls
|
|
16
|
+
# #reclaim itself, so a reclaimed node's just-landed work from absorb is never
|
|
17
|
+
# thrown away by a stale read (matrix row 2.20).
|
|
18
|
+
#
|
|
19
|
+
# #abort_if_merging refuses the whole step when the intent worktree already
|
|
20
|
+
# has a merge in progress (`MERGE_HEAD` resolves): dispatching on top of a
|
|
21
|
+
# half-finished merge would hand the next node a diff full of someone else's
|
|
22
|
+
# conflict markers. Nothing is written when this fires - not the ledger, not
|
|
23
|
+
# graph.md, not even the delivery lease heartbeat (row 2.2, row 2.16: the
|
|
24
|
+
# heartbeat runs strictly after the abort check).
|
|
25
|
+
#
|
|
26
|
+
# #reclaim walks every node whose CURRENT status (the ledger's own resolution,
|
|
27
|
+
# never a raw scan) is `running`, skipping anything named in `skip:` (the
|
|
28
|
+
# nodes this step already absorbed, row 2.19) or already terminal (a `done`
|
|
29
|
+
# node is never touched, row 2.12 - it simply never shows up as `running`).
|
|
30
|
+
# An expired lease with no commits on the node's own branch newer than its
|
|
31
|
+
# expiry is reclaimed outright. An expired lease whose branch DOES carry
|
|
32
|
+
# newer commits is extended instead, up to twice per attempt (row 2.7); the
|
|
33
|
+
# extension is never a ledger transition (`running` cannot re-enter `running`
|
|
34
|
+
# under the transition layer), so it is one line appended to
|
|
35
|
+
# packets/<node>--a<N>.extensions, `N` derived from the ledger's own attempt
|
|
36
|
+
# count (row 2.18), never trusted from the caller. A third expiry reclaims
|
|
37
|
+
# regardless of new commits.
|
|
38
|
+
#
|
|
39
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
40
|
+
# `runner:` (default Worktree::ShellRunner), never cwd; the delivery-lease
|
|
41
|
+
# heartbeat goes through an injected `heartbeat:` (default Lock.heartbeat) so
|
|
42
|
+
# ordering (row 2.16) is provable without a real lock file. No eval, no
|
|
43
|
+
# ENV/global-constant seam.
|
|
44
|
+
module RunnerSweep
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# D-ish: at most two extensions per attempt (row 2.7); the third expiry
|
|
48
|
+
# reclaims regardless of new commits.
|
|
49
|
+
MAX_EXTENSIONS_PER_ATTEMPT = 2
|
|
50
|
+
|
|
51
|
+
# abort_if_merging(context, runner:) -> {ok:, error:, recovery_command:}.
|
|
52
|
+
# `context.worktree` is nil for a global-store-only intent (no git repo to
|
|
53
|
+
# merge into); that case is always ok - there is nothing to abort.
|
|
54
|
+
def abort_if_merging(context, runner: Worktree::ShellRunner.new)
|
|
55
|
+
worktree = context&.worktree
|
|
56
|
+
return { ok: true, error: nil, recovery_command: nil } if blank?(worktree)
|
|
57
|
+
|
|
58
|
+
res = runner.run("-C", worktree, "rev-parse", "-q", "--verify", "MERGE_HEAD")
|
|
59
|
+
return { ok: true, error: nil, recovery_command: nil } unless res.success?
|
|
60
|
+
|
|
61
|
+
recovery_command = "git -C #{worktree} merge --abort"
|
|
62
|
+
warn "runner: a merge is already in progress in #{worktree}; run `#{recovery_command}` " \
|
|
63
|
+
"before the next step can dispatch"
|
|
64
|
+
{ ok: false, error: "a merge is in progress in #{worktree}", recovery_command: recovery_command }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# reclaim(context, runner:, skip:, now:) -> {reclaimed: [{node:, holder:,
|
|
68
|
+
# expired:}], extended: [{node:, head:, time:}]}. Reads the ledger fresh on
|
|
69
|
+
# every call (no cache), which is what makes calling it AFTER absorb (row
|
|
70
|
+
# 2.20) actually see absorb's own just-landed work rather than a stale
|
|
71
|
+
# snapshot taken before it.
|
|
72
|
+
def reclaim(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now)
|
|
73
|
+
intent_dir = context.intent_dir
|
|
74
|
+
content = savepoint_content(intent_dir)
|
|
75
|
+
entries = NodeLedger.entries_from_content(content)
|
|
76
|
+
status_map = NodeLedger.status_from_content(content)
|
|
77
|
+
skip_set = Array(skip).map(&:to_s)
|
|
78
|
+
|
|
79
|
+
reclaimed = []
|
|
80
|
+
extended = []
|
|
81
|
+
|
|
82
|
+
status_map.each do |subject, state|
|
|
83
|
+
next unless state == "running"
|
|
84
|
+
next unless subject.match?(Savepoint::NODE_SUBJECT_RE)
|
|
85
|
+
next if skip_set.include?(subject)
|
|
86
|
+
|
|
87
|
+
last = entries.select { |e| !e[:torn] && e[:subject] == subject && e[:state] == "running" }.last
|
|
88
|
+
next unless last
|
|
89
|
+
|
|
90
|
+
fields = last[:fields] || {}
|
|
91
|
+
expires_raw = fields["expires"]
|
|
92
|
+
expires_at = parse_time(expires_raw)
|
|
93
|
+
next unless expires_at
|
|
94
|
+
next if now < expires_at # row 2.5: an unexpired lease is left alone
|
|
95
|
+
|
|
96
|
+
holder = fields["holder"]
|
|
97
|
+
branch = node_branch(context, subject)
|
|
98
|
+
head_sha, head_time = branch_head(runner, context.worktree, branch)
|
|
99
|
+
has_new_commits = head_time && head_time > expires_at
|
|
100
|
+
|
|
101
|
+
if has_new_commits
|
|
102
|
+
attempt = current_attempt(entries, subject)
|
|
103
|
+
count = extension_count(intent_dir, subject, attempt)
|
|
104
|
+
if count < MAX_EXTENSIONS_PER_ATTEMPT
|
|
105
|
+
record_extension(intent_dir, subject, attempt, head_sha, now)
|
|
106
|
+
extended << { node: subject, head: head_sha, time: now.utc.iso8601 }
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
# row 2.7: the cap is spent - fall through and reclaim anyway.
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
NodeLedger.append_transition(
|
|
113
|
+
savepoint_path(intent_dir),
|
|
114
|
+
subject: subject,
|
|
115
|
+
state: "reclaimed",
|
|
116
|
+
fields: { holder: holder, expired: expires_raw },
|
|
117
|
+
now: now
|
|
118
|
+
)
|
|
119
|
+
reclaimed << { node: subject, holder: holder, expired: expires_raw }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
{ reclaimed: reclaimed, extended: extended }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# run(context, runner:, skip:, now:, heartbeat:) -> the composed report a
|
|
126
|
+
# caller with no absorb step to interleave uses directly. Order is fixed on
|
|
127
|
+
# purpose (row 2.2, row 2.16): abort check, THEN the lease heartbeat, THEN
|
|
128
|
+
# reclaim - never the reverse, and never a write of any kind before the
|
|
129
|
+
# abort check has cleared.
|
|
130
|
+
def run(context, runner: Worktree::ShellRunner.new, skip: [], now: Time.now, heartbeat: Lock.method(:heartbeat))
|
|
131
|
+
abort_result = abort_if_merging(context, runner: runner)
|
|
132
|
+
unless abort_result[:ok]
|
|
133
|
+
return {
|
|
134
|
+
ok: false, aborted: true, error: abort_result[:error],
|
|
135
|
+
recovery_command: abort_result[:recovery_command], reclaimed: [], extended: [],
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
session = context&.session
|
|
140
|
+
heartbeat.call(context.intent_dir, session: session, now: now) unless blank?(session)
|
|
141
|
+
|
|
142
|
+
result = reclaim(context, runner: runner, skip: skip, now: now)
|
|
143
|
+
{
|
|
144
|
+
ok: true, aborted: false, error: nil, recovery_command: nil,
|
|
145
|
+
reclaimed: result[:reclaimed], extended: result[:extended],
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# --- internals ---------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def blank?(value)
|
|
152
|
+
value.nil? || value.to_s.strip.empty?
|
|
153
|
+
end
|
|
154
|
+
private_class_method :blank?
|
|
155
|
+
|
|
156
|
+
def savepoint_path(intent_dir)
|
|
157
|
+
File.join(intent_dir.to_s, "savepoint.md")
|
|
158
|
+
end
|
|
159
|
+
private_class_method :savepoint_path
|
|
160
|
+
|
|
161
|
+
def savepoint_content(intent_dir)
|
|
162
|
+
path = savepoint_path(intent_dir)
|
|
163
|
+
File.exist?(path) ? File.read(path) : ""
|
|
164
|
+
end
|
|
165
|
+
private_class_method :savepoint_content
|
|
166
|
+
|
|
167
|
+
# The node's own worktree branch (n3's naming: `plastic/<id>--<slug>--<node>`),
|
|
168
|
+
# derived from the intent id/slug alone - never through NodeWorktree, which
|
|
169
|
+
# this node does not depend on.
|
|
170
|
+
def node_branch(context, node)
|
|
171
|
+
"plastic/#{context.intent_id}--#{context.intent_slug}--#{node}"
|
|
172
|
+
end
|
|
173
|
+
private_class_method :node_branch
|
|
174
|
+
|
|
175
|
+
# [head_sha, committer_time] for `branch` in `worktree`'s repo, or [nil, nil]
|
|
176
|
+
# when the worktree is gone, the branch does not exist, or anything else
|
|
177
|
+
# about the git call fails (row 2.13: never raise).
|
|
178
|
+
def branch_head(runner, worktree, branch)
|
|
179
|
+
return [nil, nil] if blank?(worktree) || blank?(branch)
|
|
180
|
+
|
|
181
|
+
res = runner.run("-C", worktree, "log", "-1", "--format=%H%x1f%cI", branch)
|
|
182
|
+
return [nil, nil] unless res.success?
|
|
183
|
+
|
|
184
|
+
sha, iso = res.stdout.to_s.strip.split("\x1f")
|
|
185
|
+
[sha, parse_time(iso)]
|
|
186
|
+
rescue StandardError
|
|
187
|
+
[nil, nil]
|
|
188
|
+
end
|
|
189
|
+
private_class_method :branch_head
|
|
190
|
+
|
|
191
|
+
def parse_time(raw)
|
|
192
|
+
return nil if blank?(raw)
|
|
193
|
+
|
|
194
|
+
Time.iso8601(raw.to_s)
|
|
195
|
+
rescue ArgumentError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
private_class_method :parse_time
|
|
199
|
+
|
|
200
|
+
# Row 2.18: the attempt number comes from the ledger's own count of
|
|
201
|
+
# `running` lines since the subject's last terminal line - the exact same
|
|
202
|
+
# arithmetic NodePacket uses to name that attempt's packet file, so the
|
|
203
|
+
# extensions file for a `running` line always matches the packet it extends.
|
|
204
|
+
def current_attempt(entries, subject)
|
|
205
|
+
ReadySet.attempts_count(entries, subject)
|
|
206
|
+
end
|
|
207
|
+
private_class_method :current_attempt
|
|
208
|
+
|
|
209
|
+
def extensions_path(intent_dir, node, attempt)
|
|
210
|
+
File.join(intent_dir.to_s, "packets", "#{node}--a#{attempt}.extensions")
|
|
211
|
+
end
|
|
212
|
+
private_class_method :extensions_path
|
|
213
|
+
|
|
214
|
+
# Row 2.8: counted from the attempt-scoped file alone, so a prior attempt's
|
|
215
|
+
# extensions never count against a fresh dispatch.
|
|
216
|
+
def extension_count(intent_dir, node, attempt)
|
|
217
|
+
path = extensions_path(intent_dir, node, attempt)
|
|
218
|
+
return 0 unless File.exist?(path)
|
|
219
|
+
|
|
220
|
+
File.read(path).each_line.count { |l| !l.strip.empty? }
|
|
221
|
+
end
|
|
222
|
+
private_class_method :extension_count
|
|
223
|
+
|
|
224
|
+
# Row 2.9: the observed head sha and the time, one line, append-only.
|
|
225
|
+
def record_extension(intent_dir, node, attempt, head_sha, now)
|
|
226
|
+
path = extensions_path(intent_dir, node, attempt)
|
|
227
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
228
|
+
File.open(path, "a") { |f| f.write("#{now.utc.iso8601} head=#{head_sha}\n") }
|
|
229
|
+
end
|
|
230
|
+
private_class_method :record_extension
|
|
231
|
+
end
|