@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -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-call-budget +222 -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 +45 -7
- 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/compact_instructions.rb +5 -5
- 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 +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -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 +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- 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/meter_watch.rb +179 -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 +908 -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 +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -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/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -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/read-config +3 -3
- 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 +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -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 +41 -34
- 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 +21 -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/config.yml +3 -3
- 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,246 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_edges"
|
|
5
|
+
require_relative "atomic_write"
|
|
6
|
+
|
|
7
|
+
# GraphFile (intent 334, n2): the four graph.md sections (## Goal,
|
|
8
|
+
# ## Decisions, ## Graph, ## Status), the verify:-none directive, and the two
|
|
9
|
+
# writers - write_status and append_decision. Both writers refuse a cyclic
|
|
10
|
+
# graph and both go through AtomicWrite (fold D19r). Fence-aware everywhere a
|
|
11
|
+
# heading is located, so a fenced example carrying a fake "## " line never
|
|
12
|
+
# splits or ends a real section (fold A8's sibling concern, applied to
|
|
13
|
+
# section boundaries rather than edge lines).
|
|
14
|
+
module GraphFile
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
FENCE_LINE_RE = /\A\s{0,3}(`{3,}|~{3,})/.freeze
|
|
18
|
+
DIRECTIVE_RE = /\A-\s*verify:\s*none(?:\s+reason=(.*))?\z/i.freeze
|
|
19
|
+
|
|
20
|
+
# {ok:, goal:, decisions:, graph:, status:, verify:, errors:}. `graph` is
|
|
21
|
+
# GraphEdges.parse's own Result hash, over the Graph section text with any
|
|
22
|
+
# verify:-none directive line stripped first (fold A8). A missing ## Graph
|
|
23
|
+
# section, or one that declares no nodes, is an error naming which (fold
|
|
24
|
+
# A10).
|
|
25
|
+
def parse(path)
|
|
26
|
+
return failure(["graph file not found: #{path}"]) unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
content = File.read(path)
|
|
29
|
+
goal = section_body(content, "## Goal")
|
|
30
|
+
decisions = section_body(content, "## Decisions")
|
|
31
|
+
graph_section = section_body(content, "## Graph")
|
|
32
|
+
status = section_body(content, "## Status")
|
|
33
|
+
|
|
34
|
+
errors = []
|
|
35
|
+
if graph_section.nil?
|
|
36
|
+
errors << "missing ## Graph section"
|
|
37
|
+
return { ok: false, goal: goal, decisions: decisions, graph: nil, status: status, verify: nil, errors: errors }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
directive, directive_errors, remaining = extract_verify_directive(graph_section)
|
|
41
|
+
errors.concat(directive_errors)
|
|
42
|
+
|
|
43
|
+
parsed_graph = GraphEdges.parse(strip_fenced_blocks(remaining))
|
|
44
|
+
errors.concat(parsed_graph[:errors])
|
|
45
|
+
errors << "## Graph declares no nodes" if parsed_graph[:nodes].empty?
|
|
46
|
+
|
|
47
|
+
{ ok: errors.empty?, goal: goal, decisions: decisions, graph: parsed_graph, status: status, verify: directive, errors: errors }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def failure(errors)
|
|
51
|
+
{ ok: false, goal: nil, decisions: nil, graph: nil, status: nil, verify: nil, errors: errors }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Rows shaped {node:, state:, detail:} parsed back from the ## Status
|
|
55
|
+
# table (D8r's round-trip guarantee).
|
|
56
|
+
def status_rows(path)
|
|
57
|
+
parsed = parse(path)
|
|
58
|
+
rows_from_table(parsed[:status])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Replace the whole ## Status section with rows, creating the section if
|
|
62
|
+
# absent. Refuses a cyclic graph (D8r); goes through AtomicWrite (D19r).
|
|
63
|
+
def write_status(path, rows, renamer: File.method(:rename))
|
|
64
|
+
guard = refuse_if_cyclic(path)
|
|
65
|
+
return guard if guard
|
|
66
|
+
|
|
67
|
+
content = File.read(path)
|
|
68
|
+
new_content = replace_or_append_section(content, "## Status", render_status_table(rows))
|
|
69
|
+
AtomicWrite.write(path, new_content, renamer: renamer)
|
|
70
|
+
{ ok: true, errors: [] }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Append one answered decision as a list item to ## Decisions (C26),
|
|
74
|
+
# leaving every other section byte-identical. Refuses a cyclic graph.
|
|
75
|
+
def append_decision(path, text, renamer: File.method(:rename))
|
|
76
|
+
guard = refuse_if_cyclic(path)
|
|
77
|
+
return guard if guard
|
|
78
|
+
|
|
79
|
+
content = File.read(path)
|
|
80
|
+
bounds = section_bounds(content, "## Decisions")
|
|
81
|
+
return { ok: false, errors: ["missing ## Decisions section"] } unless bounds
|
|
82
|
+
|
|
83
|
+
start_idx, end_idx = bounds
|
|
84
|
+
heading_line_end = line_end(content, start_idx)
|
|
85
|
+
body = content[heading_line_end...end_idx]
|
|
86
|
+
trimmed = body.rstrip
|
|
87
|
+
trailing = body[trimmed.length..]
|
|
88
|
+
new_body = trimmed.empty? ? "- #{text}#{trailing}" : "#{trimmed}\n- #{text}#{trailing}"
|
|
89
|
+
new_content = content[0...heading_line_end] + new_body + content[end_idx..]
|
|
90
|
+
AtomicWrite.write(path, new_content, renamer: renamer)
|
|
91
|
+
{ ok: true, errors: [] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def refuse_if_cyclic(path)
|
|
95
|
+
parsed = parse(path)
|
|
96
|
+
return { ok: false, errors: parsed[:errors] } if parsed[:graph].nil?
|
|
97
|
+
|
|
98
|
+
cyc = GraphEdges.cycle(parsed[:graph][:edges])
|
|
99
|
+
return { ok: false, errors: ["cyclic graph, refusing to write: #{cyc.join(' > ')}"] } if cyc
|
|
100
|
+
|
|
101
|
+
nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# --- verify: none directive --------------------------------------------------
|
|
105
|
+
|
|
106
|
+
def extract_verify_directive(graph_section)
|
|
107
|
+
return [nil, [], graph_section] if graph_section.nil?
|
|
108
|
+
|
|
109
|
+
reason = nil
|
|
110
|
+
present = false
|
|
111
|
+
errors = []
|
|
112
|
+
remaining_lines = []
|
|
113
|
+
|
|
114
|
+
graph_section.each_line do |line|
|
|
115
|
+
stripped = line.strip
|
|
116
|
+
m = stripped.match(DIRECTIVE_RE)
|
|
117
|
+
if m
|
|
118
|
+
present = true
|
|
119
|
+
captured = m[1].to_s.strip
|
|
120
|
+
if captured.empty?
|
|
121
|
+
errors << "verify: none requires reason=<text>: #{stripped.inspect}"
|
|
122
|
+
else
|
|
123
|
+
reason = captured
|
|
124
|
+
end
|
|
125
|
+
else
|
|
126
|
+
remaining_lines << line
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
directive = present ? { reason: reason } : nil
|
|
131
|
+
[directive, errors, remaining_lines.join]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# D18: drop every fenced line from `text`, so a fenced example edge inside a
|
|
135
|
+
# hand-written graph.md or roadmap ## Graph section is never handed to
|
|
136
|
+
# GraphEdges as real edge text. Non-fenced lines pass through unchanged.
|
|
137
|
+
def strip_fenced_blocks(text)
|
|
138
|
+
lines = []
|
|
139
|
+
each_fence_line(text) { |line, fenced| lines << line unless fenced }
|
|
140
|
+
lines.join
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# --- fence-aware section location ---------------------------------------------
|
|
144
|
+
|
|
145
|
+
def each_fence_line(text)
|
|
146
|
+
return enum_for(:each_fence_line, text) unless block_given?
|
|
147
|
+
|
|
148
|
+
marker = nil
|
|
149
|
+
text.to_s.each_line do |line|
|
|
150
|
+
if marker
|
|
151
|
+
yield line, true
|
|
152
|
+
m = line.match(FENCE_LINE_RE)
|
|
153
|
+
next unless m && m[1][0] == marker[0] && m[1].length >= marker[1]
|
|
154
|
+
next unless line.sub(FENCE_LINE_RE, "").strip.empty?
|
|
155
|
+
|
|
156
|
+
marker = nil
|
|
157
|
+
else
|
|
158
|
+
m = line.match(FENCE_LINE_RE)
|
|
159
|
+
if m
|
|
160
|
+
marker = [m[1][0], m[1].length]
|
|
161
|
+
yield line, true
|
|
162
|
+
else
|
|
163
|
+
yield line, false
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# [start_of_heading_line, start_of_next_top_level_heading_or_EOF] byte
|
|
170
|
+
# offsets for the FIRST line, outside any fence, whose stripped text
|
|
171
|
+
# exactly equals heading_text. nil when no such line exists.
|
|
172
|
+
def section_bounds(content, heading_text)
|
|
173
|
+
offset = 0
|
|
174
|
+
heading_start = nil
|
|
175
|
+
|
|
176
|
+
each_fence_line(content) do |line, fenced|
|
|
177
|
+
if !fenced && heading_start.nil? && line.strip == heading_text
|
|
178
|
+
heading_start = offset
|
|
179
|
+
elsif !fenced && heading_start && offset > heading_start && line.match?(/\A##[^#]/)
|
|
180
|
+
return [heading_start, offset]
|
|
181
|
+
end
|
|
182
|
+
offset += line.length
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
heading_start ? [heading_start, offset] : nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def section_body(content, heading_text)
|
|
189
|
+
bounds = section_bounds(content, heading_text)
|
|
190
|
+
return nil unless bounds
|
|
191
|
+
|
|
192
|
+
start_idx, end_idx = bounds
|
|
193
|
+
content[(line_end(content, start_idx))...end_idx].to_s
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def line_end(content, start_idx)
|
|
197
|
+
idx = content.index("\n", start_idx)
|
|
198
|
+
idx ? idx + 1 : content.length
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def replace_or_append_section(content, heading_text, body_text)
|
|
202
|
+
rendered_body = body_text.to_s
|
|
203
|
+
rendered_body += "\n" unless rendered_body.end_with?("\n")
|
|
204
|
+
section = "#{heading_text}\n#{rendered_body}"
|
|
205
|
+
|
|
206
|
+
bounds = section_bounds(content, heading_text)
|
|
207
|
+
if bounds
|
|
208
|
+
start_idx, end_idx = bounds
|
|
209
|
+
tail = content[end_idx..].to_s
|
|
210
|
+
section += "\n" unless tail.empty?
|
|
211
|
+
content[0...start_idx] + section + tail
|
|
212
|
+
else
|
|
213
|
+
head = content.dup
|
|
214
|
+
head += "\n" unless head.end_with?("\n")
|
|
215
|
+
head += "\n" unless head.end_with?("\n\n")
|
|
216
|
+
head + section
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# --- table rendering and reading -----------------------------------------------
|
|
221
|
+
|
|
222
|
+
def render_status_table(rows)
|
|
223
|
+
lines = ["| Node | State | Detail |", "| --- | --- | --- |"]
|
|
224
|
+
rows.each { |r| lines << "| #{r[:node]} | #{r[:state]} | #{r[:detail]} |" }
|
|
225
|
+
"#{lines.join("\n")}\n"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def rows_from_table(text)
|
|
229
|
+
return [] if text.nil?
|
|
230
|
+
|
|
231
|
+
lines = []
|
|
232
|
+
each_fence_line(text) do |line, fenced|
|
|
233
|
+
next if fenced
|
|
234
|
+
|
|
235
|
+
stripped = line.strip
|
|
236
|
+
lines << stripped if stripped.start_with?("|")
|
|
237
|
+
end
|
|
238
|
+
sep_idx = lines.index { |l| l.match?(/\A\|[\s:|-]+\|?\z/) }
|
|
239
|
+
return [] unless sep_idx
|
|
240
|
+
|
|
241
|
+
lines[(sep_idx + 1)..].map do |l|
|
|
242
|
+
cells = l.split("|", -1).map(&:strip)[1..-2].to_a
|
|
243
|
+
{ node: cells[0], state: cells[1], detail: cells[2] }
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "graph_edges"
|
|
5
|
+
require_relative "ready_set"
|
|
6
|
+
|
|
7
|
+
# GraphTree (intent 337, n2, folds 327a): draws any edge map ({id => [needs...]})
|
|
8
|
+
# as an indented tree with box-drawing branches. A node may have any number
|
|
9
|
+
# of children; a node several branches need (fan-out) is drawn once, at the
|
|
10
|
+
# point where those branches join. A node that itself needs several things
|
|
11
|
+
# (fan-in) can only occupy one position in the tree: it hangs under its
|
|
12
|
+
# highest-batch need, ties broken by smallest id, with its other needs shown
|
|
13
|
+
# as converging references at the join (row 2.14, folded at the 2026-09-10
|
|
14
|
+
# plan review). Pure, plain text, no roadmap knowledge, so the same
|
|
15
|
+
# renderer serves the roadmap file, the roadmap screens, and later the node
|
|
16
|
+
# scope. Batch numbers (for placement) and the topological sort come from
|
|
17
|
+
# ReadySet - the ONE sort (327 D1) - never a second local one here.
|
|
18
|
+
module GraphTree
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def render(edges:, labels:, marks:, width:)
|
|
22
|
+
edges = edges || {}
|
|
23
|
+
nodes = ReadySet.all_nodes(edges)
|
|
24
|
+
|
|
25
|
+
cyc = GraphEdges.cycle(edges)
|
|
26
|
+
if cyc
|
|
27
|
+
return { ok: false, text: nil, error: "cyclic graph, cannot render a tree: #{cyc.join(' > ')}", cycle: cyc }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
if nodes.empty?
|
|
31
|
+
return { ok: true, text: "(no dependencies)\n", error: nil, cycle: nil }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
batch_result = ReadySet.batches(edges)
|
|
35
|
+
batch_of = {}
|
|
36
|
+
batch_result[:batches].each_with_index { |layer, i| layer.each { |id| batch_of[id] = i + 1 } }
|
|
37
|
+
|
|
38
|
+
primary_parent = {}
|
|
39
|
+
converging = {}
|
|
40
|
+
nodes.each do |id|
|
|
41
|
+
needs = (edges[id] || []).uniq
|
|
42
|
+
next if needs.empty?
|
|
43
|
+
|
|
44
|
+
primary = needs.min_by { |n| [-(batch_of[n] || 0), n] }
|
|
45
|
+
primary_parent[id] = primary
|
|
46
|
+
others = needs - [primary]
|
|
47
|
+
converging[id] = others.sort unless others.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
children_of = Hash.new { |h, k| h[k] = [] }
|
|
51
|
+
primary_parent.each { |child, parent| children_of[parent] << child }
|
|
52
|
+
children_of.each_value { |list| list.sort! { |a, b| [batch_of[a] || 0, a] <=> [batch_of[b] || 0, b] } }
|
|
53
|
+
|
|
54
|
+
roots = nodes.select { |id| (edges[id] || []).empty? }.sort
|
|
55
|
+
|
|
56
|
+
labels ||= {}
|
|
57
|
+
marks ||= {}
|
|
58
|
+
critical = (marks[:critical_path] || []).to_a
|
|
59
|
+
ready = (marks[:ready] || []).to_a
|
|
60
|
+
|
|
61
|
+
lines = []
|
|
62
|
+
roots.each { |id| append_node(id, "", "", lines, children_of, labels, critical, ready, converging, width) }
|
|
63
|
+
|
|
64
|
+
{ ok: true, text: "#{lines.join("\n")}\n", error: nil, cycle: nil }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# `ancestor_prefix` is the continuation string every ancestor above this
|
|
68
|
+
# node contributes ("│ " when that ancestor still has a later sibling
|
|
69
|
+
# to draw, " " when it was the last child); `connector` is this node's
|
|
70
|
+
# own branch glyph off its parent ("├── " / "└── "), empty for a root.
|
|
71
|
+
def append_node(id, ancestor_prefix, connector, lines, children_of, labels, critical, ready, converging, width)
|
|
72
|
+
lines << render_line(ancestor_prefix + connector, id, labels, critical, ready, converging, width)
|
|
73
|
+
|
|
74
|
+
continuation = connector == "└── " ? " " : (connector.empty? ? "" : "│ ")
|
|
75
|
+
child_ancestor_prefix = ancestor_prefix + continuation
|
|
76
|
+
|
|
77
|
+
children = children_of[id] || []
|
|
78
|
+
children.each_with_index do |child_id, i|
|
|
79
|
+
last = i == children.length - 1
|
|
80
|
+
child_connector = last ? "└── " : "├── "
|
|
81
|
+
append_node(child_id, child_ancestor_prefix, child_connector, lines, children_of, labels, critical, ready, converging, width)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def render_line(connector, id, labels, critical, ready, converging, width)
|
|
86
|
+
label = labels.fetch(id, id).to_s
|
|
87
|
+
text = critical.include?(id) ? "* #{label}" : label
|
|
88
|
+
text += " (also needs #{converging[id].join(', ')})" if converging[id]
|
|
89
|
+
text += " (ready)" if ready.include?(id)
|
|
90
|
+
|
|
91
|
+
available = width - connector.length
|
|
92
|
+
if available.positive? && text.length > available
|
|
93
|
+
text = available > 3 ? "#{text[0, available - 3]}..." : text[0, available]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
"#{connector}#{text}"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# GuardedAppend - the shared fail-closed write guard behind NodeLedger's transition
|
|
5
|
+
# lines and RoadmapSavepoint's roadmap ledger (intent 335, spec "Approach").
|
|
6
|
+
#
|
|
7
|
+
# One module function, #call: opens `path` RDWR|APPEND|CREAT, takes a non-blocking
|
|
8
|
+
# exclusive lock with a bounded number of retries, and under that ONE hold reads the
|
|
9
|
+
# current content, yields it to the caller's block, and appends whatever the block
|
|
10
|
+
# returns. A block returning nil is a refusal: nothing is written. Read, decide, and
|
|
11
|
+
# append happen inside one lock hold on purpose (spec "Approach"): that is what makes
|
|
12
|
+
# "is this subject already running" and "append running" atomic against a second
|
|
13
|
+
# writer, which a check followed by a separate append could never be.
|
|
14
|
+
#
|
|
15
|
+
# Pure and dependency-injected: `flock:` and `sleeper:` are constructor-style test
|
|
16
|
+
# seams (never an environment variable, never a global); this module reads no
|
|
17
|
+
# environment variable and shells out to nothing.
|
|
18
|
+
module GuardedAppend
|
|
19
|
+
# Raised when the lock could not be taken within `retries` attempts (real
|
|
20
|
+
# contention), or when flock itself is unsupported on this filesystem and
|
|
21
|
+
# `strict: true` (spec D12a). Nothing is written either way; the caller is told
|
|
22
|
+
# plainly that nothing landed and must retry.
|
|
23
|
+
class Unavailable < StandardError; end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Five attempts, 20 ms apart: about 100 ms of wall time total (spec D12).
|
|
28
|
+
DEFAULT_RETRIES = 5
|
|
29
|
+
DEFAULT_BACKOFF = 0.02
|
|
30
|
+
|
|
31
|
+
# Default lock and sleep seams: a real flock call, a real sleep. Tests inject
|
|
32
|
+
# replacements to simulate contention, recovery, and a flock-less filesystem
|
|
33
|
+
# hermetically, with no need for a real flock-less mount or a slow test run.
|
|
34
|
+
DEFAULT_FLOCK = ->(handle, mode) { handle.flock(mode) }
|
|
35
|
+
private_constant :DEFAULT_FLOCK
|
|
36
|
+
|
|
37
|
+
DEFAULT_SLEEPER = ->(seconds) { sleep(seconds) }
|
|
38
|
+
private_constant :DEFAULT_SLEEPER
|
|
39
|
+
|
|
40
|
+
# Open `path` (creating it if absent, never truncating it), take an exclusive
|
|
41
|
+
# non-blocking lock with up to `retries` attempts (`backoff` seconds apart), and
|
|
42
|
+
# under that one hold read the file's current content, yield it to the block, and
|
|
43
|
+
# append what the block returns.
|
|
44
|
+
#
|
|
45
|
+
# Returns :written when a line was appended, :refused when the block returned nil
|
|
46
|
+
# (nothing written, spec: a refusal). Raises Unavailable, writing nothing, when the
|
|
47
|
+
# lock could not be taken within `retries` attempts.
|
|
48
|
+
#
|
|
49
|
+
# `strict:` decides what happens when flock itself raises a SystemCallError OTHER
|
|
50
|
+
# than contention (EWOULDBLOCK/EAGAIN) - a filesystem without flock support,
|
|
51
|
+
# distinct from real contention (spec D12a): strict (the default) raises
|
|
52
|
+
# Unavailable; non-strict proceeds unguarded, since a single O_APPEND write still
|
|
53
|
+
# lands whole there. The SystemCallError rescue wraps the flock call only (spec
|
|
54
|
+
# D12b); an Errno::ENOENT from File.open (a missing parent directory) propagates
|
|
55
|
+
# as itself, never read as Unavailable.
|
|
56
|
+
def call(path, retries: DEFAULT_RETRIES, backoff: DEFAULT_BACKOFF, strict: true,
|
|
57
|
+
flock: DEFAULT_FLOCK, sleeper: DEFAULT_SLEEPER, &block)
|
|
58
|
+
created = !File.exist?(path)
|
|
59
|
+
handle = File.open(path, File::RDWR | File::APPEND | File::CREAT, 0o644)
|
|
60
|
+
begin
|
|
61
|
+
status = take_lock(handle, retries: retries, backoff: backoff, flock: flock, sleeper: sleeper)
|
|
62
|
+
|
|
63
|
+
case status
|
|
64
|
+
when :contended
|
|
65
|
+
raise Unavailable, "could not take an exclusive lock on #{path} after #{retries} attempts"
|
|
66
|
+
when :unsupported
|
|
67
|
+
if strict
|
|
68
|
+
raise Unavailable, "flock is unsupported on #{path} and strict: true refuses to proceed unguarded"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
write_line(handle, &block)
|
|
72
|
+
when :locked
|
|
73
|
+
begin
|
|
74
|
+
write_line(handle, &block)
|
|
75
|
+
ensure
|
|
76
|
+
unlock(handle, flock: flock)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
ensure
|
|
80
|
+
handle.close
|
|
81
|
+
end
|
|
82
|
+
rescue Unavailable
|
|
83
|
+
remove_freshly_created_empty_file(path) if created
|
|
84
|
+
raise
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Attempt the lock up to `retries` times. Returns :locked, :unsupported (a
|
|
88
|
+
# non-contention SystemCallError from flock, decided once, never retried), or
|
|
89
|
+
# :contended (every attempt failed with EWOULDBLOCK/EAGAIN or a false return).
|
|
90
|
+
# Sleeps `backoff` seconds after EVERY contended attempt, including the last, so
|
|
91
|
+
# the total backoff budget is exactly `retries` sleeps (spec D12: "about 100 ms of
|
|
92
|
+
# wall time in total" = 5 attempts * 20 ms, not 4).
|
|
93
|
+
def take_lock(handle, retries:, backoff:, flock:, sleeper:)
|
|
94
|
+
status = :contended
|
|
95
|
+
retries.times do
|
|
96
|
+
status = try_flock(handle, flock)
|
|
97
|
+
return status unless status == :contended
|
|
98
|
+
|
|
99
|
+
sleeper.call(backoff)
|
|
100
|
+
end
|
|
101
|
+
status
|
|
102
|
+
end
|
|
103
|
+
private_class_method :take_lock
|
|
104
|
+
|
|
105
|
+
# One attempt at the non-blocking exclusive lock. File#flock RAISES (does not
|
|
106
|
+
# return false) for every errno except EWOULDBLOCK/EAGAIN on most platforms, so
|
|
107
|
+
# both the "returns false" and the "raises EWOULDBLOCK" shapes read as contention;
|
|
108
|
+
# any other SystemCallError means flock is not supported on this filesystem.
|
|
109
|
+
def try_flock(handle, flock)
|
|
110
|
+
result = flock.call(handle, File::LOCK_EX | File::LOCK_NB)
|
|
111
|
+
result == false ? :contended : :locked
|
|
112
|
+
rescue Errno::EWOULDBLOCK, Errno::EAGAIN
|
|
113
|
+
:contended
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
:unsupported
|
|
116
|
+
end
|
|
117
|
+
private_class_method :try_flock
|
|
118
|
+
|
|
119
|
+
# Read the current content, yield it to the block, and write what it returns. A
|
|
120
|
+
# block returning nil writes nothing and reports :refused. Before a real write, if
|
|
121
|
+
# the content is non-empty and does not end in a newline, a newline is written
|
|
122
|
+
# first (spec D9a): a crash that truncated the previous write must not glue the
|
|
123
|
+
# next transition onto its tail. The handle is opened O_APPEND, so every write
|
|
124
|
+
# lands at the current end of file regardless of the read's cursor position.
|
|
125
|
+
def write_line(handle, &block)
|
|
126
|
+
content = handle.read
|
|
127
|
+
line = block.call(content)
|
|
128
|
+
return :refused if line.nil?
|
|
129
|
+
|
|
130
|
+
prefix = !content.empty? && !content.end_with?("\n") ? "\n" : ""
|
|
131
|
+
handle.write("#{prefix}#{line}")
|
|
132
|
+
handle.flush
|
|
133
|
+
:written
|
|
134
|
+
end
|
|
135
|
+
private_class_method :write_line
|
|
136
|
+
|
|
137
|
+
def unlock(handle, flock:)
|
|
138
|
+
flock.call(handle, File::LOCK_UN)
|
|
139
|
+
rescue SystemCallError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
private_class_method :unlock
|
|
143
|
+
|
|
144
|
+
# Row 7.9 (post-execution review): a give-up (Unavailable) must not leave a zero-byte
|
|
145
|
+
# file behind that File::CREAT created for a target that did not exist before this
|
|
146
|
+
# call. Only ever removes a file this same call created (never a pre-existing file,
|
|
147
|
+
# spec matrix 1.11) and only when it is still empty (no write ever reached it on the
|
|
148
|
+
# give-up paths this rescues).
|
|
149
|
+
def remove_freshly_created_empty_file(path)
|
|
150
|
+
File.unlink(path) if File.exist?(path) && File.zero?(path)
|
|
151
|
+
rescue SystemCallError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
private_class_method :remove_freshly_created_empty_file
|
|
155
|
+
end
|
|
@@ -34,6 +34,17 @@ module HookRegistry
|
|
|
34
34
|
{ "name" => "check-update", "status" => "" },
|
|
35
35
|
] },
|
|
36
36
|
],
|
|
37
|
+
# Intent 355, n2: the call budget guard. Not one of the edit-path
|
|
38
|
+
# gates intent 302 removed (those denied a write on content; this
|
|
39
|
+
# denies a call on a per-attempt COUNT, read from the session's own
|
|
40
|
+
# transcript, never from the tool's arguments) - see scripts/hook-
|
|
41
|
+
# call-budget. Claude only: CODEX_LIVE_STATE_EVENTS below does not
|
|
42
|
+
# carry PreToolUse, so codex_hooks_json never projects it.
|
|
43
|
+
"PreToolUse" => [
|
|
44
|
+
{ "matcher" => "", "hooks" => [
|
|
45
|
+
{ "name" => "call-budget", "status" => "" },
|
|
46
|
+
] },
|
|
47
|
+
],
|
|
37
48
|
"PreCompact" => [
|
|
38
49
|
{ "matcher" => "", "hooks" => [
|
|
39
50
|
{ "name" => "savepoint", "status" => "Saving Plastic intent state..." },
|
|
@@ -54,6 +65,20 @@ module HookRegistry
|
|
|
54
65
|
{ "name" => "capture", "status" => "Capturing prompt into the session ledger..." },
|
|
55
66
|
] },
|
|
56
67
|
],
|
|
68
|
+
# This entry belongs to the Claude adapter half of Plastic's
|
|
69
|
+
# harness-agnostic-core / Claude-adapter split (intent 316a1, D3
|
|
70
|
+
# supersedes 316a's D6): MessageDisplay is not one of
|
|
71
|
+
# CODEX_LIVE_STATE_EVENTS, so codex_hooks_json (below) never picks it
|
|
72
|
+
# up; codex_hook_names stays exactly what it was (pinned by
|
|
73
|
+
# test/hook_registry_test.rb:82 and :110-111). Fires on every streamed
|
|
74
|
+
# chunk of every assistant message (D11); the launcher (hooks/message-
|
|
75
|
+
# display) decides with shell builtins and forks nothing on the common
|
|
76
|
+
# case, execing Ruby only for a candidate message.
|
|
77
|
+
"MessageDisplay" => [
|
|
78
|
+
{ "matcher" => "", "hooks" => [
|
|
79
|
+
{ "name" => "message-display", "status" => "" },
|
|
80
|
+
] },
|
|
81
|
+
],
|
|
57
82
|
}
|
|
58
83
|
end
|
|
59
84
|
|
|
@@ -61,8 +86,9 @@ module HookRegistry
|
|
|
61
86
|
# the PostToolUse record hook collapses from Claude's multi-tool matcher onto
|
|
62
87
|
# Codex's single apply_patch tool (181 F4: apply_patch is Codex's sole
|
|
63
88
|
# file-mutation tool; tool_name always reports apply_patch), and the live-state
|
|
64
|
-
# events project through whole.
|
|
65
|
-
#
|
|
89
|
+
# events project through whole. PreToolUse (intent 355, n2's call-budget guard,
|
|
90
|
+
# Claude only) is not one of CODEX_LIVE_STATE_EVENTS below, so Codex still
|
|
91
|
+
# carries no PreToolUse group of its own. Command invokes the
|
|
66
92
|
# codex-hook dispatcher with the hook name. Guide-settled shape [guide Part 3]:
|
|
67
93
|
# top-level {"hooks":{<Event>: [{"matcher","hooks":[{"type":"command","command",
|
|
68
94
|
# "statusMessage"}]}]}}, identical to Claude's shape, string command. Single
|