@zalom/plastic 2.0.0-alpha.17 → 2.0.0-alpha.19

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 (46) hide show
  1. package/package.json +2 -2
  2. package/scripts/dashboard.rb +20 -0
  3. package/scripts/doctor.rb +120 -2
  4. package/scripts/end-intent +134 -8
  5. package/scripts/hook-capture +4 -105
  6. package/scripts/lib/action_graph_shim.rb +277 -0
  7. package/scripts/lib/atomic_write.rb +31 -0
  8. package/scripts/lib/graph_edges.rb +121 -0
  9. package/scripts/lib/graph_file.rb +246 -0
  10. package/scripts/lib/guarded_append.rb +155 -0
  11. package/scripts/lib/installer_core.rb +32 -0
  12. package/scripts/lib/node_file.rb +214 -0
  13. package/scripts/lib/node_ids.rb +99 -0
  14. package/scripts/lib/node_ledger.rb +377 -0
  15. package/scripts/lib/node_packet.rb +873 -0
  16. package/scripts/lib/outcome_report.rb +440 -0
  17. package/scripts/lib/packet_wrapper.rb +132 -0
  18. package/scripts/lib/ready_set.rb +462 -0
  19. package/scripts/lib/release_guard.rb +16 -0
  20. package/scripts/lib/report_screen.rb +122 -12
  21. package/scripts/lib/roadmap_queue.rb +161 -3
  22. package/scripts/lib/roadmap_savepoint.rb +26 -5
  23. package/scripts/lib/savepoint.rb +123 -12
  24. package/scripts/lib/work_graph_validator.rb +201 -0
  25. package/scripts/node-packet +92 -0
  26. package/scripts/node-transition +291 -0
  27. package/scripts/outcome-report +74 -0
  28. package/scripts/ready-set +126 -0
  29. package/scripts/release-check +118 -0
  30. package/scripts/report-screen +8 -1
  31. package/scripts/roadmap-savepoint +7 -0
  32. package/scripts/validate-work-graph +39 -0
  33. package/skills/auto/SKILL.md +2 -3
  34. package/skills/auto/references/human-report-contract.md +3 -2
  35. package/skills/intent-continuing/references/boarding-matrix.md +1 -0
  36. package/skills/intent-ending/SKILL.md +30 -19
  37. package/skills/intent-executing/SKILL.md +1 -1
  38. package/skills/releasing/SKILL.md +39 -0
  39. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  40. package/skills/releasing/references/release-lines.md +1 -1
  41. package/templates/graph.md +16 -0
  42. package/templates/node-decision.md +11 -0
  43. package/templates/node-research.md +11 -0
  44. package/templates/node-verify.md +13 -0
  45. package/templates/node-work.md +22 -0
  46. package/templates/outcome.md +8 -6
@@ -0,0 +1,377 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "strscan"
5
+ require_relative "savepoint"
6
+ require_relative "guarded_append"
7
+
8
+ # NodeLedger - the node and intent transition line (intent 335, G2). Owns the
9
+ # line's byte-exact format, the closed state vocabulary, the required fields per
10
+ # state, the parser, torn-line and unattributed-line detection, status as the
11
+ # last line per subject in file order, and the guarded transition append that
12
+ # skips dedup entirely (spec D11).
13
+ #
14
+ # Per spec D17 the subject vocabulary (`Savepoint::INTENT_SUBJECT`,
15
+ # `Savepoint::NODE_SUBJECT_RE`, `Savepoint.transition_candidate?`) lives on
16
+ # Savepoint, not here: this file requires savepoint.rb and reuses them, the
17
+ # dependency running one way only (test/savepoint_split_test.rb:57 pins
18
+ # savepoint.rb to loading no other project file).
19
+ #
20
+ # Pure and dependency-injected: every path and clock is an argument, `guard:` is
21
+ # an injectable seam for the transition append, and this module reads no
22
+ # environment variable.
23
+ module NodeLedger
24
+ module_function
25
+
26
+ # Ten states, closed (spec D1). "ready" is computed and never written.
27
+ STATES = %w[
28
+ planned running done failed_verification needs_decision blocked deferred
29
+ superseded abandoned reclaimed
30
+ ].freeze
31
+
32
+ # A written state's RESOLVED state for the ready function 336 builds on top of
33
+ # this ledger (spec D2): every state resolves to itself except `reclaimed`,
34
+ # which resolves to `planned` ("a reclaimed node is planned again").
35
+ RESOLUTION = { "reclaimed" => "planned" }.freeze
36
+
37
+ # Required fields per state (spec D4), refused by #append_transition and read
38
+ # as torn by #torn? when absent. `done`'s evidence requirement (gates= plus at
39
+ # least one of commit=/verdict=) is asymmetric, so it is not representable as
40
+ # a flat list; DONE_EVIDENCE_FIELDS below carries the "at least one of" half.
41
+ REQUIRED_FIELDS = {
42
+ "planned" => [],
43
+ "running" => %w[holder expires packet model],
44
+ "done" => %w[gates],
45
+ "failed_verification" => %w[gates reason],
46
+ "needs_decision" => %w[question],
47
+ "blocked" => %w[reason],
48
+ "deferred" => %w[reason],
49
+ "superseded" => %w[by],
50
+ "abandoned" => %w[reason],
51
+ "reclaimed" => %w[holder expired],
52
+ }.freeze
53
+
54
+ # `done` requires gates= plus at least one of these (spec D7). `model=` is
55
+ # deliberately absent from every required-fields list except `running` (spec
56
+ # D6) and is accepted, never required, everywhere else - no separate table
57
+ # entry is needed for that: an unlisted field is never required.
58
+ DONE_EVIDENCE_FIELDS = %w[commit verdict].freeze
59
+
60
+ # Canonical field render order (spec D8's "stable declared order", matrix
61
+ # 2.6): every key named in REQUIRED_FIELDS or DONE_EVIDENCE_FIELDS, plus
62
+ # `model` (accepted on any state). A field outside this list still renders,
63
+ # sorted after these, so an unrecognized key is never silently dropped.
64
+ FIELD_ORDER = %w[holder expires packet model gates commit verdict reason question by expired].freeze
65
+
66
+ SEPARATOR = " "
67
+
68
+ # The field key character class, shared by the emitter (#render_fields, below) and
69
+ # the parser (FIELD_TOKEN_RE, further down): a key outside this class is a key the
70
+ # parser can never read back as a pair, so it must never be emitted (post-execution
71
+ # review row 7.7). One source of truth for the char class keeps the two families
72
+ # from drifting the way they did before this row's fix.
73
+ FIELD_KEY_CHARS = "[a-z_]+"
74
+ FIELD_KEY_RE = /\A#{FIELD_KEY_CHARS}\z/.freeze
75
+
76
+ # timestamp<SEP>subject<SEP>state field=value... (comment). Two-space
77
+ # separators keep the line a three-field line under both `split(/\s{2,}/)`
78
+ # and every reader's own `SAVEPOINT_RE` (spec "Line shape"). Raises
79
+ # ArgumentError, writing nothing, for an unknown state or a missing required
80
+ # field (D4/D9): the caller of #append_transition relies on this check
81
+ # running BEFORE the file is ever touched.
82
+ def transition_line(subject:, state:, fields: {}, comment: nil, now: Time.now)
83
+ state = state.to_s
84
+ raise ArgumentError, "unknown transition state: #{state.inspect}" unless STATES.include?(state)
85
+
86
+ missing = missing_fields(state, fields)
87
+ raise ArgumentError, "state #{state} requires #{missing.join(', ')}" if missing.any?
88
+
89
+ timestamp = now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
90
+ rest = ([state] + render_fields(fields)).join(" ")
91
+ rest = "#{rest} (#{normalize_value(comment)})" if comment && !comment.to_s.strip.empty?
92
+ "#{timestamp}#{SEPARATOR}#{subject}#{SEPARATOR}#{rest}\n"
93
+ end
94
+
95
+ # Collapse every run of two-or-more WHITESPACE characters in `text` to one
96
+ # (spec "Line shape": "collapses every whitespace run inside a field value
97
+ # to a single space"), so a caller-supplied value or comment can never
98
+ # reintroduce a two-or-more run the line's own separators rely on being
99
+ # unique to field boundaries. A tab or a newline is refused outright (raises
100
+ # ArgumentError) rather than collapsed: silently eating either would hide a
101
+ # value that could otherwise tear a ledger line. Post-execution review row
102
+ # 7.6: a bare `/ {2,}/` collapsed only literal spaces, so a carriage return,
103
+ # vertical tab or form feed beside a space still reached field 3 as a
104
+ # two-whitespace run that `split(/\s{2,}/)` reads as four fields while
105
+ # SAVEPOINT_RE reads as three - `\s{2,}` is what the acceptance criterion
106
+ # ("no run of two or more whitespace characters ever reaches field 3")
107
+ # actually requires.
108
+ def normalize_value(text)
109
+ value = text.to_s
110
+ raise ArgumentError, "value must not contain a tab or a newline: #{value.inspect}" if value =~ /[\t\n]/
111
+
112
+ value.gsub(/\s{2,}/, " ")
113
+ end
114
+
115
+ # A required-field D4 check independent of vocabulary validity: given a state
116
+ # (assumed valid) and a fields hash, the list of required field names still
117
+ # missing. `done`'s "at least one of commit=/verdict=" half renders as the
118
+ # literal string "commit or verdict" when both are absent.
119
+ def missing_fields(state, fields)
120
+ normalized = stringify_keys(fields)
121
+ required = REQUIRED_FIELDS.fetch(state.to_s, [])
122
+ missing = required.reject { |key| present?(normalized[key]) }
123
+ if state.to_s == "done" && DONE_EVIDENCE_FIELDS.none? { |key| present?(normalized[key]) }
124
+ missing += ["commit or verdict"]
125
+ end
126
+ missing
127
+ end
128
+
129
+ def present?(value)
130
+ !(value.nil? || value.to_s.strip.empty?)
131
+ end
132
+ private_class_method :present?
133
+
134
+ def stringify_keys(fields)
135
+ (fields || {}).each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
136
+ end
137
+ private_class_method :stringify_keys
138
+
139
+ def render_fields(fields)
140
+ normalized = stringify_keys(fields)
141
+ unreadable = normalized.keys.reject { |key| key.match?(FIELD_KEY_RE) }
142
+ if unreadable.any?
143
+ raise ArgumentError, "field key(s) the parser cannot read back: #{unreadable.join(', ')}"
144
+ end
145
+
146
+ known = FIELD_ORDER.select { |key| normalized.key?(key) }
147
+ extra = (normalized.keys - FIELD_ORDER).sort
148
+ (known + extra).map { |key| "#{key}=#{render_value(normalized[key])}" }
149
+ end
150
+ private_class_method :render_fields
151
+
152
+ def render_value(value)
153
+ text = normalize_value(value)
154
+ return text if bare_safe?(text)
155
+
156
+ escaped = text.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }
157
+ "\"#{escaped}\""
158
+ end
159
+ private_class_method :render_value
160
+
161
+ def bare_safe?(text)
162
+ !text.empty? && !text.match?(/[\s"\\]/)
163
+ end
164
+ private_class_method :bare_safe?
165
+
166
+ # --- Parsing ---------------------------------------------------------------
167
+
168
+ # timestamp<2sp>subject<2sp>rest, mirroring intent_screen.rb's SAVEPOINT_RE
169
+ # (the same three-field contract every reader in the tree already assumes).
170
+ TRANSITION_LINE_RE = /\A(\S+)#{SEPARATOR}(\S+)#{SEPARATOR}(.+?)\s*\z/.freeze
171
+ private_constant :TRANSITION_LINE_RE
172
+
173
+ FIELD_TOKEN_RE = /(#{FIELD_KEY_CHARS})=("(?:[^"\\]|\\.)*"|[^\s"]+)/.freeze
174
+ private_constant :FIELD_TOKEN_RE
175
+
176
+ # Parse one raw ledger line into {timestamp:, subject:, state:, fields:,
177
+ # comment:, raw:}, or nil when it does not even split into the three fields
178
+ # every transition line has. `#scrub`s the line first (mirrors
179
+ # SessionLedger.parse_checklist_line) so a stray non-UTF-8 byte anywhere in
180
+ # the ledger never raises out of the parser and takes down every reader
181
+ # (matrix 2.44); it only ever affects that one line's parsed text.
182
+ def parse_transition_line(line)
183
+ raw = line.to_s.chomp.scrub
184
+ m = TRANSITION_LINE_RE.match(raw)
185
+ return nil unless m
186
+
187
+ timestamp, subject, rest = m.captures
188
+ state, remainder = rest.split(/\s+/, 2)
189
+ fields, comment = scan_fields(remainder.to_s)
190
+ { timestamp: timestamp, subject: subject, state: state, fields: fields, comment: comment, raw: raw }
191
+ end
192
+
193
+ # Scan `rest` (everything after the state token) for `key=value` pairs, quote-
194
+ # and-escape aware, greedily from the start; the first token that is not a
195
+ # pair, and everything after it, is the trailing comment (spec D8), with one
196
+ # layer of wrapping parens stripped when present (the shape #transition_line
197
+ # itself always emits). Returns [fields_hash, comment_or_nil].
198
+ def scan_fields(rest)
199
+ scanner = StringScanner.new(rest)
200
+ fields = {}
201
+ loop do
202
+ scanner.skip(/\s+/)
203
+ break if scanner.eos?
204
+
205
+ start_pos = scanner.pos
206
+ if scanner.scan(FIELD_TOKEN_RE)
207
+ fields[scanner[1]] = unquote(scanner[2])
208
+ else
209
+ scanner.pos = start_pos
210
+ break
211
+ end
212
+ end
213
+ tail = scanner.rest.to_s.strip
214
+ comment = tail.empty? ? nil : tail.sub(/\A\((.*)\)\z/m, '\1')
215
+ [fields, comment]
216
+ end
217
+ private_class_method :scan_fields
218
+
219
+ def unquote(raw)
220
+ return raw unless raw.start_with?('"')
221
+
222
+ raw[1..-2].gsub(/\\(.)/) { Regexp.last_match(1) }
223
+ end
224
+ private_class_method :unquote
225
+
226
+ # A transition line is torn (spec D9) when it does not parse into the three
227
+ # fields at all, when its state token is outside STATES, or when its state is
228
+ # in STATES but a field REQUIRED_FIELDS demands is missing. The second half
229
+ # matters more than the first: a crash-truncated write usually keeps a valid
230
+ # state token and loses the tail (`n1 running holder=auto-ce5`), which is
231
+ # the realistic torn line, not a truncated state token.
232
+ def torn?(line)
233
+ parsed = parse_transition_line(line)
234
+ return true unless parsed
235
+ return true unless parsed[:state] && STATES.include?(parsed[:state])
236
+
237
+ missing_fields(parsed[:state], parsed[:fields]).any?
238
+ end
239
+
240
+ def torn_reason(parsed)
241
+ return "does not parse as timestamp subject state ..." unless parsed && parsed[:state]
242
+ return "unknown state #{parsed[:state].inspect}" unless STATES.include?(parsed[:state])
243
+
244
+ "missing required field(s): #{missing_fields(parsed[:state], parsed[:fields]).join(', ')}"
245
+ end
246
+ private_class_method :torn_reason
247
+
248
+ # A transition line is attributed (spec D10) iff it carries a non-blank
249
+ # `holder=` field. Unattributed and torn are independent: a line can be
250
+ # unattributed and otherwise well formed at the same time.
251
+ def attributed?(parsed)
252
+ return false unless parsed
253
+
254
+ present?((parsed[:fields] || {})["holder"])
255
+ end
256
+
257
+ def resolved_state(state)
258
+ RESOLUTION.fetch(state.to_s, state.to_s)
259
+ end
260
+
261
+ # --- Reading the ledger ------------------------------------------------------
262
+
263
+ # Every transition candidate line in `path`, in file order, each annotated
264
+ # with its parse, torn-ness, and attribution. Non-candidate lines (a stage
265
+ # line, a Lock takeover line) are excluded entirely: they are never a
266
+ # transition and are never reported as torn by this module (spec Acceptance
267
+ # Criteria; Savepoint's own phantom detector covers the stage family).
268
+ # `#scrub`s the whole file up front (matrix 2.44) so one bad byte anywhere
269
+ # never raises out of this reader.
270
+ def entries(path)
271
+ return [] unless path && File.exist?(path)
272
+
273
+ entries_from_content(File.read(path))
274
+ end
275
+
276
+ # Same as #entries, but over an in-memory string rather than a path (post-
277
+ # execution review row 7.1/7.2): the readiness decision `node-transition`
278
+ # makes for `running` must be evaluated against the exact content
279
+ # GuardedAppend read under its lock hold, never a re-read of the path (which
280
+ # could observe a different file than the one the guard is holding closed
281
+ # against other writers, and reintroduces the check-then-append gap this row
282
+ # exists to close).
283
+ def entries_from_content(content)
284
+ content.to_s.scrub.each_line.filter_map do |raw|
285
+ line = raw.chomp
286
+ next nil if line.strip.empty?
287
+ next nil unless Savepoint.transition_candidate?(line)
288
+
289
+ parsed = parse_transition_line(line)
290
+ torn = torn?(line)
291
+ {
292
+ raw: line,
293
+ timestamp: parsed && parsed[:timestamp],
294
+ subject: parsed ? parsed[:subject] : line.split(/\s{2,}/)[1],
295
+ state: parsed && parsed[:state],
296
+ fields: parsed ? parsed[:fields] : {},
297
+ comment: parsed && parsed[:comment],
298
+ torn: torn,
299
+ attributed: parsed ? attributed?(parsed) : false,
300
+ }
301
+ end
302
+ end
303
+
304
+ # Status per subject: the last NON-TORN line for that subject in FILE order
305
+ # (never by timestamp, spec Acceptance Criteria), resolved (spec D2). An
306
+ # unattributed-but-well-formed line still counts for status (D10: status
307
+ # shows it; only the readiness check ignores it).
308
+ def status(path)
309
+ status_from_content(path && File.exist?(path) ? File.read(path) : "")
310
+ end
311
+
312
+ # Content-based counterpart to #status (see #entries_from_content).
313
+ def status_from_content(content)
314
+ entries_from_content(content).each_with_object({}) do |entry, memo|
315
+ next if entry[:torn]
316
+
317
+ memo[entry[:subject]] = resolved_state(entry[:state])
318
+ end
319
+ end
320
+
321
+ # A subject with no line at all reads as "planned" (spec D1's implicit
322
+ # starting state), never raises.
323
+ def status_for(path, subject)
324
+ status(path).fetch(subject.to_s, "planned")
325
+ end
326
+
327
+ # Content-based counterpart to #status_for (see #entries_from_content).
328
+ def status_for_content(content, subject)
329
+ status_from_content(content).fetch(subject.to_s, "planned")
330
+ end
331
+
332
+ # The last (file-order) non-torn `running` entry for `subject`, or nil. Used
333
+ # by the reclaim precondition (S3) to find the `expires=` this subject's live
334
+ # dispatch carries.
335
+ def last_running(path, subject)
336
+ entries(path).select { |e| !e[:torn] && e[:subject] == subject.to_s && e[:state] == "running" }.last
337
+ end
338
+
339
+ # Torn and unattributed lines, each paired with a reason (spec C2/C5's
340
+ # reader): [{line:, reason:}, ...]. A clean or absent ledger returns []. A
341
+ # torn line is reported once, as torn; a well-formed unattributed line is
342
+ # reported once, as unattributed - the two categories never conflate.
343
+ def anomalies(path)
344
+ entries(path).filter_map do |entry|
345
+ if entry[:torn]
346
+ { line: entry[:raw], reason: "torn: #{torn_reason(parse_transition_line(entry[:raw]))}" }
347
+ elsif !entry[:attributed]
348
+ { line: entry[:raw], reason: "unattributed (no holder=)" }
349
+ end
350
+ end
351
+ end
352
+
353
+ # --- Writing -----------------------------------------------------------------
354
+
355
+ # Refuse an unknown state or a missing required field BEFORE touching the
356
+ # file (spec C20), then append through `guard` (default GuardedAppend,
357
+ # strict), skipping dedup entirely (spec D11: transition lines never consult
358
+ # savepoint_recorded_pairs). Returns whatever `guard.call` returns
359
+ # (:written or :refused); propagates GuardedAppend::Unavailable rather than
360
+ # swallowing it (a caller must never believe a line landed when it did not).
361
+ #
362
+ # `precondition:` (post-execution review rows 7.1-7.3) is an optional
363
+ # callable evaluated INSIDE the guard's lock hold, against the exact
364
+ # `content` the guard just read - never a value captured before the call.
365
+ # When it returns falsy, the block returns nil (GuardedAppend's own
366
+ # refusal contract) and nothing is written; the caller sees :refused,
367
+ # distinguishable from :written, and must not believe the line landed. This
368
+ # is what makes "is the subject still ready" and "append running" atomic
369
+ # against a second writer: a check followed by a separate append never is.
370
+ def append_transition(path, subject:, state:, fields: {}, comment: nil, now: Time.now, guard: GuardedAppend,
371
+ precondition: nil)
372
+ line = transition_line(subject: subject, state: state, fields: fields, comment: comment, now: now)
373
+ guard.call(path, strict: true) do |content|
374
+ precondition && !precondition.call(content) ? nil : line
375
+ end
376
+ end
377
+ end