@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.24

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 (103) hide show
  1. package/PLASTIC.md +6 -5
  2. package/agents/plastic-enforcer.md +6 -3
  3. package/agents/plastic-executor.md +4 -0
  4. package/agents/plastic-node-research.md +28 -0
  5. package/agents/plastic-node-verify.md +27 -0
  6. package/agents/plastic-node-work.md +32 -0
  7. package/bin/lib/context_budget.rb +1 -1
  8. package/hooks/hooks.json +12 -0
  9. package/hooks/statusline +28 -0
  10. package/hooks/stop +5 -0
  11. package/package.json +1 -1
  12. package/scripts/dashboard.rb +1 -1
  13. package/scripts/doctor.rb +80 -6
  14. package/scripts/end-intent +22 -19
  15. package/scripts/exec-worktree +5 -5
  16. package/scripts/graph-measure +249 -0
  17. package/scripts/hook-call-budget +6 -6
  18. package/scripts/hook-capture +16 -15
  19. package/scripts/hook-record +21 -14
  20. package/scripts/hook-savepoint +26 -3
  21. package/scripts/hook-session-start +49 -10
  22. package/scripts/hook-stop +57 -0
  23. package/scripts/lib/active_delivery.rb +105 -0
  24. package/scripts/lib/agent_models.rb +10 -1
  25. package/scripts/lib/arm.rb +41 -102
  26. package/scripts/lib/codex_adapter.rb +197 -0
  27. package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
  28. package/scripts/lib/day_summary.rb +19 -11
  29. package/scripts/lib/doctor_core.rb +8 -3
  30. package/scripts/lib/doctor_session_ledger.rb +3 -52
  31. package/scripts/lib/engine_permissions.rb +88 -0
  32. package/scripts/lib/exec_worktree.rb +24 -21
  33. package/scripts/lib/graph_edges.rb +4 -4
  34. package/scripts/lib/graph_file.rb +4 -4
  35. package/scripts/lib/graph_measure.rb +645 -0
  36. package/scripts/lib/graph_measure_budget.rb +409 -0
  37. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  38. package/scripts/lib/graph_measure_models.rb +411 -0
  39. package/scripts/lib/graph_measure_report.rb +532 -0
  40. package/scripts/lib/graph_tree.rb +2 -2
  41. package/scripts/lib/handoff.rb +40 -13
  42. package/scripts/lib/harness_adapter.rb +184 -0
  43. package/scripts/lib/hook_registry.rb +13 -1
  44. package/scripts/lib/hook_replay.rb +23 -5
  45. package/scripts/lib/index_entry.rb +53 -0
  46. package/scripts/lib/index_projection.rb +1 -1
  47. package/scripts/lib/insights.rb +1 -1
  48. package/scripts/lib/installer_core.rb +228 -9
  49. package/scripts/lib/intent_screen.rb +1 -1
  50. package/scripts/lib/intent_validator.rb +2 -2
  51. package/scripts/lib/lock.rb +8 -9
  52. package/scripts/lib/meter_watch.rb +15 -9
  53. package/scripts/lib/node_file.rb +3 -3
  54. package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
  55. package/scripts/lib/node_input_compatibility.rb +62 -0
  56. package/scripts/lib/node_ledger.rb +11 -2
  57. package/scripts/lib/node_progress.rb +153 -0
  58. package/scripts/lib/outcome_report.rb +2 -2
  59. package/scripts/lib/project_config.rb +45 -0
  60. package/scripts/lib/ready_set.rb +1 -1
  61. package/scripts/lib/report_screen.rb +10 -6
  62. package/scripts/lib/roadmap_graph.rb +1 -1
  63. package/scripts/lib/roadmap_queue.rb +1 -1
  64. package/scripts/lib/roadmap_render.rb +1 -1
  65. package/scripts/lib/roadmap_savepoint.rb +1 -1
  66. package/scripts/lib/runner_absorb.rb +36 -10
  67. package/scripts/lib/runner_dispatch.rb +66 -51
  68. package/scripts/lib/runner_sweep.rb +4 -4
  69. package/scripts/lib/runner_until_empty.rb +252 -0
  70. package/scripts/lib/runner_watch.rb +389 -0
  71. package/scripts/lib/savepoint.rb +9 -10
  72. package/scripts/lib/scaffold_intent.rb +6 -3
  73. package/scripts/lib/session_close.rb +30 -28
  74. package/scripts/lib/session_git.rb +22 -16
  75. package/scripts/lib/session_ledger.rb +44 -4
  76. package/scripts/lib/stop_gate.rb +95 -0
  77. package/scripts/lib/verify_intent.rb +2 -2
  78. package/scripts/lib/work_graph_validator.rb +6 -6
  79. package/scripts/lib/worktree.rb +24 -24
  80. package/scripts/lib/worktree_sweep.rb +3 -3
  81. package/scripts/new-intent +1 -1
  82. package/scripts/{node-packet → node-input} +15 -15
  83. package/scripts/node-run +224 -0
  84. package/scripts/plastic-lock +33 -32
  85. package/scripts/read-config +6 -0
  86. package/scripts/runner +203 -19
  87. package/scripts/verify-intent +1 -1
  88. package/skills/auto/SKILL.md +8 -8
  89. package/skills/auto/references/end-tail.md +9 -11
  90. package/skills/conventions/references/completion-and-done.md +9 -10
  91. package/skills/conventions/references/knowledge-graph.md +9 -0
  92. package/skills/conventions/references/locks-and-worktrees.md +10 -12
  93. package/skills/direct/SKILL.md +2 -2
  94. package/skills/doctor/SKILL.md +4 -4
  95. package/skills/intent-creating/evals/evals.json +1 -1
  96. package/skills/intent-executing/SKILL.md +7 -1
  97. package/skills/releasing/SKILL.md +2 -2
  98. package/skills/releasing/references/promotion-and-tagging.md +1 -1
  99. package/skills/releasing/references/release-lines.md +1 -1
  100. package/skills/tutorial/references/track-2-auto.md +1 -1
  101. package/templates/index.md +1 -1
  102. package/templates/project.yml +1 -1
  103. package/scripts/lib/bridge.rb +0 -116
@@ -0,0 +1,645 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "time"
5
+ require "tmpdir"
6
+ require_relative "savepoint"
7
+ require_relative "node_ledger"
8
+ require_relative "graph_file"
9
+ require_relative "node_file"
10
+
11
+ # GraphMeasure (intent 343, G10, n1): the ledger reader half of measurement.
12
+ # `GraphMeasure.read(intent_dir)` takes one intent directory and returns one
13
+ # frozen measurement record. Pure and dependency-injected: every path and
14
+ # every clock is a keyword argument with a real default, it reads no
15
+ # environment variable, and it writes nothing to the intent directory (any
16
+ # scratch file this module creates to work around GraphFile/NodeFile's lack
17
+ # of UTF-8 scrubbing lives in its own Dir.mktmpdir, never inside intent_dir).
18
+ #
19
+ # It adds no second parser and no second estimator. `NodeLedger` owns the
20
+ # transition-line format, the closed state vocabulary, torn detection and
21
+ # attribution; `Savepoint` owns the stage-line vocabulary; `GraphFile` owns
22
+ # `## Graph`; `NodeFile` owns the envelope and `KIND_PREFIX`. This module
23
+ # composes them and computes what none of them computes: time.
24
+ module GraphMeasure
25
+ module_function
26
+
27
+ # Spec D5: 45 minutes, a named constant and an injectable argument (row 1.15).
28
+ DEFAULT_GAP_THRESHOLD_MINUTES = 45.0
29
+
30
+ # Every state but the two that can never CLOSE an attempt (spec matrix rows
31
+ # 1.4-1.9): `planned` is never written, `running` OPENS an attempt.
32
+ TERMINAL_STATES = (NodeLedger::STATES - %w[planned running]).freeze
33
+
34
+ SAVEPOINT_FILE = "savepoint.md"
35
+
36
+ # {ok:, scaffold:, clock:, sessions:, pauses:, active_seconds:,
37
+ # paused_seconds:, nodes:, anomalies:, suite_history:, gap_threshold_minutes:}.
38
+ # Never raises: a missing or empty savepoint.md, an unparsable graph.md, an
39
+ # invalid-UTF-8 node file, all resolve to an empty or partial record rather
40
+ # than an exception (spec D3, D4, rows 1.30, 1.31).
41
+ def read(intent_dir, now: Time.now, gap_threshold_minutes: DEFAULT_GAP_THRESHOLD_MINUTES)
42
+ dir = File.expand_path(intent_dir.to_s)
43
+ content = read_content(File.join(dir, SAVEPOINT_FILE))
44
+ events = all_events(content)
45
+
46
+ transition_events = events.select { |e| e[:kind] == :transition }
47
+ entries_by_subject = group_by_subject(transition_events)
48
+
49
+ attempts_by_subject = entries_by_subject.each_with_object({}) do |(subject, subject_entries), memo|
50
+ memo[subject] = build_attempts(subject_entries, now)
51
+ end
52
+
53
+ clock = build_clock(events)
54
+ threshold_seconds = gap_threshold_minutes.to_f * 60
55
+
56
+ active_running_intervals = merge_running_intervals(attempts_by_subject, now)
57
+ pauses = compute_pauses(events, active_running_intervals, threshold_seconds, clock, now)
58
+ sessions = complement(pauses, clock[:start], clock[:end])
59
+ # B2 (v1 review): `complement` returns `[]` whenever the clock has no end
60
+ # (an intent with no `Done` line yet), and summing an empty session list
61
+ # against a real running-to-terminal span silently produced `0.0`
62
+ # instead of `nil` - every attempt in an open intent reported "active
63
+ # 0.0 min" no matter how long it actually ran (reproduced against this
64
+ # very intent's own live savepoint.md while n8 was `running`: n4's
65
+ # closed attempt showed "raw 1094.4 / active 0.0"). An active span is
66
+ # only knowable once the delivery clock itself has both ends.
67
+ clock_known = !!(clock[:start] && clock[:end])
68
+ fill_active_spans!(attempts_by_subject, sessions, clock_known)
69
+
70
+ graph = load_graph(dir)
71
+ node_ids = (graph[:node_ids] + attempts_by_subject.keys.reject { |s| s == Savepoint::INTENT_SUBJECT }).uniq
72
+ kind_of, kind_source_of = resolve_kinds(dir, node_ids)
73
+ hop_ever_seen = transition_events.any? { |e| e[:fields].key?("hop") }
74
+ review_fix_memo = {}
75
+
76
+ # D24 (after v3 B1): a work node can only be PROVEN not a review fix by
77
+ # reading the `needs` edges that would have made it one. When graph.md
78
+ # is missing or unparsable, `edges` above falls back to `{}` in silence,
79
+ # which used to read exactly like "read the edges, found none" - a
80
+ # bucket built on that silently reassigns real minutes to "build" and
81
+ # reports a confident 0.0 for review fixes. The only case where an
82
+ # unread edge source is still harmless is when no verify node exists
83
+ # anywhere in the record at all: then no node can reach one through any
84
+ # edge set, read or not (the hermetic one-node case this preserves).
85
+ verify_node_exists = kind_of.values.include?("verify")
86
+ review_fix_unreadable = !graph[:readable] && verify_node_exists
87
+
88
+ nodes = node_ids.each_with_object({}) do |id, memo|
89
+ attempts = attempts_by_subject[id] || []
90
+ kind = kind_of[id]
91
+ memo[id] = {
92
+ kind: kind,
93
+ kind_source: kind_source_of[id] || :unknown,
94
+ review_fix: kind == "work" ? review_fix?(id, kind_of, graph[:edges], review_fix_memo) : false,
95
+ status: NodeLedger.status_for_content(content, id),
96
+ model: node_model(attempts),
97
+ hop: node_hop(attempts, hop_ever_seen),
98
+ attempts: attempts,
99
+ }
100
+ end
101
+
102
+ anomalies = split_anomalies(NodeLedger.anomalies(File.join(dir, SAVEPOINT_FILE)))
103
+ # D4: an unread classification source is counted and named, never passed
104
+ # over in silence (spec row 1.10's own rule, applied to graph.md rather
105
+ # than a torn ledger line).
106
+ anomalies[:unread_source] = if review_fix_unreadable
107
+ [{ line: "graph.md missing or unparsable; review-fix classification excluded",
108
+ reason: "unread_source" }]
109
+ else
110
+ []
111
+ end
112
+
113
+ record = {
114
+ ok: true,
115
+ gap_threshold_minutes: gap_threshold_minutes.to_f,
116
+ scaffold: clock[:scaffold],
117
+ clock: { start: clock[:start], end: clock[:end], anchor: clock[:anchor],
118
+ wall_clock_seconds: clock[:wall_clock_seconds] },
119
+ sessions: sessions,
120
+ pauses: pauses,
121
+ active_seconds: clock[:start] && clock[:end] ? sessions.sum { |s| s[:end] - s[:start] } : :unavailable,
122
+ paused_seconds: clock[:start] && clock[:end] ? pauses.sum { |p| p[:end] - p[:start] } : :unavailable,
123
+ nodes: nodes,
124
+ anomalies: anomalies,
125
+ suite_history: suite_history(transition_events),
126
+ }
127
+
128
+ deep_freeze(record)
129
+ end
130
+
131
+ # --- reading the raw ledger --------------------------------------------------
132
+
133
+ def read_content(path)
134
+ return "" unless path && File.exist?(path)
135
+
136
+ File.read(path).scrub
137
+ end
138
+ private_class_method :read_content
139
+
140
+ # One combined, file-ordered event list over EVERY line of savepoint.md,
141
+ # transition lines delegated to NodeLedger's own primitives (row 1.1: no
142
+ # second parser), stage/Report/Lock/Commit lines classified locally because
143
+ # NodeLedger.entries deliberately excludes them (Savepoint owns that
144
+ # vocabulary). Never sorted by timestamp (row 1.27): callers walk this list
145
+ # in the order returned.
146
+ def all_events(content)
147
+ content.to_s.each_line.filter_map do |raw|
148
+ line = raw.chomp
149
+ next nil if line.strip.empty?
150
+
151
+ if Savepoint.transition_candidate?(line)
152
+ parsed = NodeLedger.parse_transition_line(line)
153
+ {
154
+ kind: :transition,
155
+ raw: line,
156
+ timestamp: parsed && parsed[:timestamp],
157
+ subject: parsed ? parsed[:subject] : line.split(/\s{2,}/)[1],
158
+ state: parsed && parsed[:state],
159
+ fields: parsed ? parsed[:fields] : {},
160
+ comment: parsed && parsed[:comment],
161
+ torn: NodeLedger.torn?(line),
162
+ }
163
+ else
164
+ parts = line.split(/\s{2,}/, 3)
165
+ next nil if parts.length < 2
166
+
167
+ { kind: :stage, raw: line, timestamp: parts[0], subject: parts[1], rest: parts[2].to_s }
168
+ end
169
+ end
170
+ end
171
+ private_class_method :all_events
172
+
173
+ def group_by_subject(transition_events)
174
+ transition_events.each_with_object(Hash.new { |h, k| h[k] = [] }) do |entry, memo|
175
+ memo[entry[:subject]] << entry unless entry[:torn]
176
+ end
177
+ end
178
+ private_class_method :group_by_subject
179
+
180
+ def parse_time(ts)
181
+ return nil unless ts
182
+
183
+ Time.iso8601(ts.to_s)
184
+ rescue ArgumentError
185
+ nil
186
+ end
187
+ private_class_method :parse_time
188
+
189
+ # --- clock: spec D5.1 --------------------------------------------------------
190
+
191
+ # D20: the clock anchors on the first `Why` line when one exists. An intent
192
+ # whose savepoint.md carries no `Why` line at all (337 is the real example)
193
+ # has nothing for that anchor to start from, so the clock falls back to the
194
+ # FIRST ledger line of any kind (stage or transition) and the record names
195
+ # which anchor it used (row 3.23). The scaffold gap (`What` line to `Why`
196
+ # line) is a distance between two SPECIFIC stage lines; without a `Why` line
197
+ # that distance does not exist, so the fallback reports it `unavailable`
198
+ # rather than inventing a number against the wrong anchor.
199
+ def build_clock(events)
200
+ stage_events = events.select { |e| e[:kind] == :stage }
201
+ what_event = stage_events.find { |e| e[:subject] == "What" }
202
+ why_event = stage_events.find { |e| e[:subject] == "Why" }
203
+ done_event = stage_events.reverse.find { |e| e[:subject] == "Done" }
204
+
205
+ what_at = what_event && parse_time(what_event[:timestamp])
206
+ why_at = why_event && parse_time(why_event[:timestamp])
207
+ done_at = done_event && parse_time(done_event[:timestamp])
208
+
209
+ anchor = :why
210
+ start_at = why_at
211
+ unless start_at
212
+ first_with_time = events.find { |e| parse_time(e[:timestamp]) }
213
+ start_at = first_with_time && parse_time(first_with_time[:timestamp])
214
+ anchor = :first_line
215
+ end
216
+
217
+ {
218
+ start: start_at,
219
+ end: done_at,
220
+ anchor: anchor,
221
+ wall_clock_seconds: (start_at && done_at) ? (done_at - start_at) : nil,
222
+ scaffold: {
223
+ what_at: what_at,
224
+ why_at: why_at,
225
+ gap_seconds: (anchor == :why && what_at && why_at) ? (why_at - what_at) : nil,
226
+ },
227
+ }
228
+ end
229
+ private_class_method :build_clock
230
+
231
+ # --- attempts: spec rows 1.4-1.9, 1.6 ----------------------------------------
232
+
233
+ def build_attempts(subject_entries, now)
234
+ attempts = []
235
+ open_running = nil
236
+ subject_entries.each do |entry|
237
+ case entry[:state]
238
+ when "running"
239
+ attempts << finalize_attempt(open_running, nil, now) if open_running
240
+ open_running = entry
241
+ when *TERMINAL_STATES
242
+ attempts << finalize_attempt(open_running, entry, now)
243
+ open_running = nil
244
+ end
245
+ end
246
+ attempts << finalize_attempt(open_running, nil, now) if open_running
247
+ attempts
248
+ end
249
+ private_class_method :build_attempts
250
+
251
+ # `fields` merges the RUNNING line's fields under the TERMINAL line's (spec
252
+ # rows 1.25, 1.26): most fields (`holder`, `model`) are restated on both, but
253
+ # `hop=` is written once, on the `running` line only (row 3.10's real-ledger
254
+ # shape), and a terminal-only read silently lost it. A key present on both
255
+ # takes the terminal's value, which stays the more authoritative line.
256
+ def finalize_attempt(running, terminal, now)
257
+ running_at = running && parse_time(running[:timestamp])
258
+ terminal_at = terminal && parse_time(terminal[:timestamp])
259
+ running_fields = (running && running[:fields]) || {}
260
+ terminal_fields = (terminal && terminal[:fields]) || {}
261
+ fields = running_fields.merge(terminal_fields)
262
+
263
+ span_note =
264
+ if running_at && terminal_at then nil
265
+ elsif running_at && !terminal_at then :open
266
+ elsif !running_at && terminal_at then :no_running
267
+ end
268
+
269
+ span_seconds = (running_at && terminal_at) ? [terminal_at - running_at, 0].max : nil
270
+ suite_raw = fields["suite"]
271
+ suite = if terminal && terminal[:state] == "done"
272
+ present?(suite_raw) ? parse_suite(suite_raw) : :unavailable
273
+ elsif present?(suite_raw)
274
+ parse_suite(suite_raw)
275
+ end
276
+
277
+ {
278
+ holder: fields["holder"],
279
+ running_at: running_at,
280
+ terminal_at: terminal_at,
281
+ terminal_state: terminal && terminal[:state],
282
+ completing: terminal ? terminal[:state] == "done" : false,
283
+ span_seconds: span_seconds,
284
+ span_note: span_note,
285
+ raw_span_seconds: span_seconds,
286
+ active_span_seconds: nil, # filled by fill_active_spans!
287
+ running_open: running_at && !terminal_at ? true : false,
288
+ suite: suite,
289
+ fields: fields,
290
+ comment: (terminal && terminal[:comment]) || (running && running[:comment]),
291
+ }
292
+ end
293
+ private_class_method :finalize_attempt
294
+
295
+ def fill_active_spans!(attempts_by_subject, sessions, clock_known)
296
+ attempts_by_subject.each_value do |attempts|
297
+ attempts.each do |attempt|
298
+ next unless attempt[:span_seconds]
299
+
300
+ attempt[:active_span_seconds] = clock_known ? intersect_with_sessions(attempt[:running_at], attempt[:terminal_at], sessions) : nil
301
+ end
302
+ end
303
+ end
304
+ private_class_method :fill_active_spans!
305
+
306
+ def intersect_with_sessions(start_t, end_t, sessions)
307
+ return nil unless start_t && end_t
308
+
309
+ total = 0.0
310
+ sessions.each do |s|
311
+ lo = [start_t, s[:start]].max
312
+ hi = [end_t, s[:end]].min
313
+ total += (hi - lo) if hi > lo
314
+ end
315
+ total
316
+ end
317
+ private_class_method :intersect_with_sessions
318
+
319
+ # --- pauses and sessions: spec D5.2, D5.3, rows 1.12-1.17 --------------------
320
+
321
+ def merge_running_intervals(attempts_by_subject, now)
322
+ intervals = attempts_by_subject.values.flatten.filter_map do |a|
323
+ next nil unless a[:running_at]
324
+
325
+ { start: a[:running_at], end: a[:terminal_at] || now }
326
+ end
327
+ merge_time_ranges(intervals)
328
+ end
329
+ private_class_method :merge_running_intervals
330
+
331
+ def compute_pauses(events, active_running_intervals, threshold_seconds, clock, now)
332
+ clock_start, clock_end = clock[:start], clock[:end]
333
+ raw = explicit_pauses(events) + generic_pauses(events, active_running_intervals, threshold_seconds)
334
+ normalized = raw.filter_map do |p|
335
+ s = p[:start]
336
+ e = p[:end] || clock_end || now
337
+ next nil unless s
338
+ next nil if clock_start && s < clock_start
339
+ s = clock_start if clock_start && s < clock_start
340
+ e = clock_end if clock_end && e > clock_end
341
+ next nil if e <= s
342
+
343
+ { start: s, end: e, opened_by: p[:opened_by], closed_by: p[:closed_by] }
344
+ end
345
+ merge_pauses(normalized)
346
+ end
347
+ private_class_method :compute_pauses
348
+
349
+ # A `Report` line whose text begins `PAUSED` opens a pause at its own stamp
350
+ # (spec D5.3); a `Lock takeover:` line closes a pause opened at the last
351
+ # ledger line before it, regardless of that line's own kind.
352
+ def explicit_pauses(events)
353
+ pauses = []
354
+ events.each_with_index do |ev, i|
355
+ if ev[:kind] == :stage && ev[:subject] == "Lock" && ev[:rest].to_s.start_with?("takeover:")
356
+ prev = i.positive? ? events[i - 1] : nil
357
+ next unless prev
358
+
359
+ s = parse_time(prev[:timestamp])
360
+ e = parse_time(ev[:timestamp])
361
+ pauses << { start: s, end: e, opened_by: :previous_line, closed_by: :lock_takeover } if s && e
362
+ elsif ev[:kind] == :stage && ev[:subject] == "Report" && ev[:rest].to_s.strip.start_with?("PAUSED")
363
+ nxt = events[i + 1]
364
+ s = parse_time(ev[:timestamp])
365
+ e = nxt && parse_time(nxt[:timestamp])
366
+ pauses << { start: s, end: e, opened_by: :paused_report, closed_by: nxt ? :next_line : nil } if s
367
+ end
368
+ end
369
+ pauses
370
+ end
371
+ private_class_method :explicit_pauses
372
+
373
+ # A gap between two adjacent ledger lines longer than the threshold is a
374
+ # pause only when no subject holds an open `running` attempt across it
375
+ # (spec D5.2, row 1.12). Timestamps are read in file order and never
376
+ # assumed to ascend (row 1.27): a non-positive gap is simply not a pause,
377
+ # never an error.
378
+ def generic_pauses(events, active_running_intervals, threshold_seconds)
379
+ pauses = []
380
+ times = events.map { |e| parse_time(e[:timestamp]) }
381
+ (events.length - 1).times do |i|
382
+ t1 = times[i]
383
+ t2 = times[i + 1]
384
+ next unless t1 && t2
385
+
386
+ gap = t2 - t1
387
+ next if gap <= threshold_seconds
388
+ next if active_running_intervals.any? { |iv| iv[:start] < t2 && iv[:end] > t1 }
389
+
390
+ pauses << { start: t1, end: t2, opened_by: :gap, closed_by: :gap }
391
+ end
392
+ pauses
393
+ end
394
+ private_class_method :generic_pauses
395
+
396
+ def merge_time_ranges(intervals)
397
+ sorted = intervals.compact.reject { |iv| iv[:start].nil? || iv[:end].nil? }.sort_by { |iv| iv[:start] }
398
+ merged = []
399
+ sorted.each do |iv|
400
+ last = merged.last
401
+ if last && iv[:start] <= last[:end]
402
+ last[:end] = iv[:end] if iv[:end] > last[:end]
403
+ else
404
+ merged << { start: iv[:start], end: iv[:end] }
405
+ end
406
+ end
407
+ merged
408
+ end
409
+ private_class_method :merge_time_ranges
410
+
411
+ def merge_pauses(pauses)
412
+ sorted = pauses.compact.sort_by { |p| p[:start] }
413
+ merged = []
414
+ sorted.each do |p|
415
+ last = merged.last
416
+ if last && p[:start] <= last[:end]
417
+ if p[:end] > last[:end]
418
+ last[:end] = p[:end]
419
+ last[:closed_by] = p[:closed_by]
420
+ end
421
+ else
422
+ merged << p.dup
423
+ end
424
+ end
425
+ merged
426
+ end
427
+ private_class_method :merge_pauses
428
+
429
+ # The complement of `pauses` inside [start, finish]: the active session
430
+ # intervals (spec D5's "the two evidence kinds bound a gap from opposite
431
+ # ends", row 1.17's reconciliation).
432
+ def complement(pauses, start, finish)
433
+ return [] unless start && finish
434
+
435
+ sessions = []
436
+ cursor = start
437
+ pauses.sort_by { |p| p[:start] }.each do |p|
438
+ sessions << { start: cursor, end: p[:start] } if p[:start] > cursor
439
+ cursor = p[:end] if p[:end] > cursor
440
+ end
441
+ sessions << { start: cursor, end: finish } if finish > cursor
442
+ sessions
443
+ end
444
+ private_class_method :complement
445
+
446
+ # --- kind and review-fix classification: spec D6, D7, rows 1.18-1.20 --------
447
+
448
+ def load_graph(dir)
449
+ graph_path = File.join(dir, "graph.md")
450
+ result = with_safe_path(graph_path) { |p| p ? GraphFile.parse(p) : nil }
451
+ readable = !!(result && result[:graph])
452
+ edges = readable ? result[:graph][:edges] : {}
453
+ node_ids = readable ? result[:graph][:nodes] : []
454
+ { edges: edges || {}, node_ids: node_ids || [], readable: readable }
455
+ end
456
+ private_class_method :load_graph
457
+
458
+ def resolve_kinds(dir, node_ids)
459
+ graph_ids = node_ids
460
+ kind_of = {}
461
+ source_of = {}
462
+ graph_ids.each do |id|
463
+ kind, source = resolve_kind(dir, id)
464
+ kind_of[id] = kind
465
+ source_of[id] = source
466
+ end
467
+ [kind_of, source_of]
468
+ end
469
+ private_class_method :resolve_kinds
470
+
471
+ # Kind comes from the node file when one exists (spec D6, row 1.18);
472
+ # otherwise it falls back to `NodeFile::KIND_PREFIX`, reused rather than
473
+ # restated (row 1.19), and the fallback is flagged.
474
+ def resolve_kind(dir, id)
475
+ path = find_node_file(dir, id)
476
+ if path
477
+ parsed = with_safe_path(path) { |p| NodeFile.parse(p) }
478
+ return [parsed[:kind], :node_file] if parsed && parsed[:kind] && !parsed[:kind].to_s.empty?
479
+ end
480
+
481
+ prefix = id.to_s[/\A[a-z]{1,2}/]
482
+ kind = prefix && NodeFile::KIND_PREFIX.invert[prefix]
483
+ [kind, :fallback]
484
+ end
485
+ private_class_method :resolve_kind
486
+
487
+ def find_node_file(dir, id)
488
+ Dir.glob(File.join(dir, "nodes", "*.md")).find do |f|
489
+ NodeFile.filename_matches_id?(File.basename(f, ".md"), id)
490
+ end
491
+ end
492
+ private_class_method :find_node_file
493
+
494
+ # A review-fix node is a work node that reaches a verify node through
495
+ # `needs` without passing through another verify node, transitively (spec
496
+ # D7, D23, row 1.20): traversal stops expanding past the first verify node
497
+ # on any branch, so a work node several `needs` hops from its nearest
498
+ # verify is still classified a review fix.
499
+ def review_fix?(id, kind_of, edges, memo)
500
+ return memo[id] if memo.key?(id)
501
+
502
+ memo[id] = false
503
+ memo[id] = (edges[id] || []).any? do |target|
504
+ kind_of[target] == "verify" || review_fix?(target, kind_of, edges, memo)
505
+ end
506
+ end
507
+ private_class_method :review_fix?
508
+
509
+ # --- model, hop: spec rows 1.25, 1.26 -----------------------------------------
510
+
511
+ def present?(value)
512
+ !(value.nil? || value.to_s.strip.empty?)
513
+ end
514
+ private_class_method :present?
515
+
516
+ def node_model(attempts)
517
+ attempts.reverse_each do |a|
518
+ model = a[:fields]["model"]
519
+ return model if present?(model)
520
+ end
521
+ :unavailable
522
+ end
523
+ private_class_method :node_model
524
+
525
+ # `hop=` unavailable when NO line in the whole ledger ever carries it (a
526
+ # ledger written before the field existed), distinct from a node whose own
527
+ # running line carries no `hop=` in a ledger that does track it elsewhere
528
+ # (hop off, spec row 1.26).
529
+ def node_hop(attempts, hop_ever_seen)
530
+ return :unavailable unless hop_ever_seen
531
+
532
+ attempts.reverse_each do |a|
533
+ next unless a[:running_at]
534
+
535
+ hop = a[:fields]["hop"]
536
+ return hop.to_i if present?(hop)
537
+ return false
538
+ end
539
+ false
540
+ end
541
+ private_class_method :node_hop
542
+
543
+ # --- suite: spec rows 1.21-1.24 -----------------------------------------------
544
+
545
+ def parse_suite(raw)
546
+ parts = raw.to_s.split("/")
547
+ case parts.length
548
+ when 4
549
+ { runs: parts[0].to_i, assertions: parts[1].to_i, failures: parts[2].to_i, errors: parts[3].to_i, form: :four_field }
550
+ when 2
551
+ { runs: parts[0].to_i, assertions: :unavailable, failures: parts[1].to_i, errors: :unavailable, form: :two_field }
552
+ else
553
+ { runs: :unavailable, assertions: :unavailable, failures: :unavailable, errors: :unavailable, form: :unknown, raw: raw }
554
+ end
555
+ end
556
+ private_class_method :parse_suite
557
+
558
+ # Suite growth is computed against the previous `done` line THAT CARRIES
559
+ # `suite=`, in file order, globally across every subject (spec row 1.24).
560
+ def suite_history(transition_events)
561
+ history = []
562
+ previous = nil
563
+ transition_events.each do |e|
564
+ next unless e[:state] == "done" && !e[:torn]
565
+
566
+ suite_raw = e[:fields]["suite"]
567
+ next unless present?(suite_raw)
568
+
569
+ parsed = parse_suite(suite_raw)
570
+ growth = previous ? suite_growth(previous, parsed) : :unavailable
571
+ history << { subject: e[:subject], at: parse_time(e[:timestamp]), suite: parsed, growth: growth }
572
+ previous = parsed
573
+ end
574
+ history
575
+ end
576
+ private_class_method :suite_history
577
+
578
+ def suite_growth(prev, cur)
579
+ # B1 (v1 review): `runs` used to be a bare subtraction while the other
580
+ # three fields went through `numeric_delta`. A `suite=` value that does
581
+ # not split into 2 or 4 slash-separated parts parses to `form: :unknown`
582
+ # with every field (including `runs`) set to `:unavailable`; subtracting
583
+ # two symbols raised NoMethodError and took the whole `cohorts` verb down
584
+ # with it (reproduced against intent 339's own savepoint.md, whose
585
+ # `suite=` values carry the words "runs" and "assertions" rather than the
586
+ # bare four-slash form `parse_suite` expects).
587
+ growth = { runs: numeric_delta(prev[:runs], cur[:runs]) }
588
+ growth[:failures] = numeric_delta(prev[:failures], cur[:failures])
589
+ growth[:assertions] = numeric_delta(prev[:assertions], cur[:assertions])
590
+ growth[:errors] = numeric_delta(prev[:errors], cur[:errors])
591
+ growth
592
+ end
593
+ private_class_method :suite_growth
594
+
595
+ def numeric_delta(prev, cur)
596
+ return :unavailable unless prev.is_a?(Integer) && cur.is_a?(Integer)
597
+
598
+ cur - prev
599
+ end
600
+ private_class_method :numeric_delta
601
+
602
+ # --- anomalies: spec rows 1.10, 1.11 ------------------------------------------
603
+
604
+ def split_anomalies(anomalies)
605
+ torn = anomalies.select { |a| a[:reason].to_s.start_with?("torn:") }
606
+ unattributed = anomalies.select { |a| a[:reason].to_s.start_with?("unattributed") }
607
+ { torn: torn, unattributed: unattributed, unread_source: [] }
608
+ end
609
+ private_class_method :split_anomalies
610
+
611
+ # --- UTF-8 safety for GraphFile and NodeFile: spec row 1.30 -------------------
612
+
613
+ # GraphFile.parse and NodeFile.parse read their own path and do not scrub
614
+ # (NodeLedger already does). A bad byte anywhere in graph.md or a node file
615
+ # would otherwise raise out of a regex match deep in either parser. This
616
+ # never writes into intent_dir (spec D2): a scrubbed copy is written to a
617
+ # throwaway Dir.mktmpdir only when the content actually needed scrubbing.
618
+ def with_safe_path(path)
619
+ return yield(nil) unless path && File.exist?(path)
620
+
621
+ raw = File.read(path)
622
+ scrubbed = raw.scrub
623
+ return yield(path) if scrubbed == raw
624
+
625
+ Dir.mktmpdir("graph-measure-scrub") do |tmp|
626
+ safe_path = File.join(tmp, File.basename(path))
627
+ File.write(safe_path, scrubbed)
628
+ yield(safe_path)
629
+ end
630
+ end
631
+ private_class_method :with_safe_path
632
+
633
+ # --- freezing: spec row 1.28 --------------------------------------------------
634
+
635
+ def deep_freeze(obj)
636
+ case obj
637
+ when Hash
638
+ obj.each { |k, v| deep_freeze(k); deep_freeze(v) }
639
+ when Array
640
+ obj.each { |v| deep_freeze(v) }
641
+ end
642
+ obj.freeze
643
+ end
644
+ private_class_method :deep_freeze
645
+ end