@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.
Files changed (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. 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. Since intent 302 there is no PreToolUse group at
65
- # all: the edit-path gates are gone on both harnesses. Command invokes the
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