@zalom/plastic 2.0.0-alpha.22 → 2.0.0-alpha.23
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 +6 -3
- package/agents/plastic-executor.md +4 -0
- package/agents/plastic-node-research.md +28 -0
- package/agents/plastic-node-verify.md +27 -0
- package/agents/plastic-node-work.md +32 -0
- package/bin/lib/context_budget.rb +1 -1
- package/hooks/hooks.json +12 -0
- package/hooks/statusline +28 -0
- package/hooks/stop +5 -0
- package/package.json +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +3 -3
- package/scripts/graph-measure +249 -0
- package/scripts/hook-capture +1 -0
- package/scripts/hook-savepoint +24 -2
- package/scripts/hook-session-start +40 -0
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +61 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/graph_edges.rb +4 -4
- package/scripts/lib/graph_file.rb +4 -4
- package/scripts/lib/graph_measure.rb +645 -0
- package/scripts/lib/graph_measure_budget.rb +408 -0
- package/scripts/lib/graph_measure_cohorts.rb +487 -0
- package/scripts/lib/graph_measure_models.rb +411 -0
- package/scripts/lib/graph_measure_report.rb +532 -0
- package/scripts/lib/graph_tree.rb +2 -2
- package/scripts/lib/handoff.rb +36 -5
- package/scripts/lib/harness_adapter.rb +184 -0
- package/scripts/lib/hook_registry.rb +13 -1
- package/scripts/lib/hook_replay.rb +23 -5
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/installer_core.rb +109 -3
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/node_ledger.rb +8 -1
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +1 -1
- package/scripts/lib/report_screen.rb +10 -6
- package/scripts/lib/roadmap_graph.rb +1 -1
- package/scripts/lib/roadmap_queue.rb +1 -1
- package/scripts/lib/roadmap_render.rb +1 -1
- package/scripts/lib/runner_absorb.rb +31 -5
- package/scripts/lib/runner_dispatch.rb +26 -11
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +3 -3
- package/scripts/lib/session_git.rb +2 -2
- package/scripts/lib/stop_gate.rb +95 -0
- package/scripts/lib/verify_intent.rb +2 -2
- package/scripts/lib/work_graph_validator.rb +6 -6
- package/scripts/new-intent +1 -1
- package/scripts/node-run +224 -0
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +1 -1
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/doctor/SKILL.md +3 -3
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +6 -0
- package/skills/tutorial/references/track-2-auto.md +1 -1
|
@@ -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
|