@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.
Files changed (67) hide show
  1. package/agents/plastic-enforcer.md +6 -3
  2. package/agents/plastic-executor.md +4 -0
  3. package/agents/plastic-node-research.md +28 -0
  4. package/agents/plastic-node-verify.md +27 -0
  5. package/agents/plastic-node-work.md +32 -0
  6. package/bin/lib/context_budget.rb +1 -1
  7. package/hooks/hooks.json +12 -0
  8. package/hooks/statusline +28 -0
  9. package/hooks/stop +5 -0
  10. package/package.json +1 -1
  11. package/scripts/doctor.rb +80 -6
  12. package/scripts/end-intent +3 -3
  13. package/scripts/graph-measure +249 -0
  14. package/scripts/hook-capture +1 -0
  15. package/scripts/hook-savepoint +24 -2
  16. package/scripts/hook-session-start +40 -0
  17. package/scripts/hook-stop +57 -0
  18. package/scripts/lib/active_delivery.rb +61 -0
  19. package/scripts/lib/agent_models.rb +10 -1
  20. package/scripts/lib/codex_adapter.rb +197 -0
  21. package/scripts/lib/doctor_core.rb +8 -3
  22. package/scripts/lib/engine_permissions.rb +88 -0
  23. package/scripts/lib/graph_edges.rb +4 -4
  24. package/scripts/lib/graph_file.rb +4 -4
  25. package/scripts/lib/graph_measure.rb +645 -0
  26. package/scripts/lib/graph_measure_budget.rb +408 -0
  27. package/scripts/lib/graph_measure_cohorts.rb +487 -0
  28. package/scripts/lib/graph_measure_models.rb +411 -0
  29. package/scripts/lib/graph_measure_report.rb +532 -0
  30. package/scripts/lib/graph_tree.rb +2 -2
  31. package/scripts/lib/handoff.rb +36 -5
  32. package/scripts/lib/harness_adapter.rb +184 -0
  33. package/scripts/lib/hook_registry.rb +13 -1
  34. package/scripts/lib/hook_replay.rb +23 -5
  35. package/scripts/lib/index_projection.rb +1 -1
  36. package/scripts/lib/installer_core.rb +109 -3
  37. package/scripts/lib/intent_screen.rb +1 -1
  38. package/scripts/lib/intent_validator.rb +2 -2
  39. package/scripts/lib/meter_watch.rb +15 -9
  40. package/scripts/lib/node_file.rb +3 -3
  41. package/scripts/lib/node_ledger.rb +8 -1
  42. package/scripts/lib/node_progress.rb +153 -0
  43. package/scripts/lib/outcome_report.rb +1 -1
  44. package/scripts/lib/report_screen.rb +10 -6
  45. package/scripts/lib/roadmap_graph.rb +1 -1
  46. package/scripts/lib/roadmap_queue.rb +1 -1
  47. package/scripts/lib/roadmap_render.rb +1 -1
  48. package/scripts/lib/runner_absorb.rb +31 -5
  49. package/scripts/lib/runner_dispatch.rb +26 -11
  50. package/scripts/lib/runner_until_empty.rb +252 -0
  51. package/scripts/lib/runner_watch.rb +389 -0
  52. package/scripts/lib/savepoint.rb +3 -3
  53. package/scripts/lib/session_git.rb +2 -2
  54. package/scripts/lib/stop_gate.rb +95 -0
  55. package/scripts/lib/verify_intent.rb +2 -2
  56. package/scripts/lib/work_graph_validator.rb +6 -6
  57. package/scripts/new-intent +1 -1
  58. package/scripts/node-run +224 -0
  59. package/scripts/read-config +6 -0
  60. package/scripts/runner +203 -19
  61. package/scripts/verify-intent +1 -1
  62. package/skills/auto/SKILL.md +1 -1
  63. package/skills/conventions/references/knowledge-graph.md +9 -0
  64. package/skills/doctor/SKILL.md +3 -3
  65. package/skills/intent-creating/evals/evals.json +1 -1
  66. package/skills/intent-executing/SKILL.md +6 -0
  67. package/skills/tutorial/references/track-2-auto.md +1 -1
@@ -0,0 +1,532 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "json"
5
+
6
+ # GraphMeasureReport (intent 343, G10, n2): turns one `GraphMeasure.read`
7
+ # record into two renderings, text and JSON, that carry exactly the same
8
+ # numbers because both read the one `model(record)` hash and neither
9
+ # recomputes anything (spec D12, matrix row 2.18).
10
+ #
11
+ # Every leaf in `model` is JSON-safe already: a Time becomes an ISO-8601
12
+ # string, a missing or `:unavailable` measure becomes the literal string
13
+ # "unavailable" (spec D3, row 2.12), never blank and never zero. `render_text`
14
+ # only formats what `model` already computed; it never looks at the raw
15
+ # `GraphMeasure` record.
16
+ module GraphMeasureReport
17
+ module_function
18
+
19
+ UNAVAILABLE = "unavailable"
20
+
21
+ # Markdown table cells cap a comment at this many characters (row 2.16): a
22
+ # very long comment is truncated with a trailing ellipsis rather than
23
+ # producing one unwrapped line.
24
+ COMMENT_CAP = 200
25
+
26
+ # --- public entry points -----------------------------------------------------
27
+
28
+ def render_json(record)
29
+ JSON.generate(model(record))
30
+ end
31
+
32
+ def render_text(record)
33
+ m = model(record)
34
+ lines = []
35
+ lines.concat(wall_clock_block(m[:wall_clock]))
36
+ lines << ""
37
+ lines.concat(session_block(m[:sessions]))
38
+ lines << ""
39
+ lines.concat(node_block(m[:nodes]))
40
+ lines << ""
41
+ lines.concat(bucket_block(m[:buckets]))
42
+ lines << ""
43
+ lines.concat(verify_cost_block(m[:verify_cost]))
44
+ lines << ""
45
+ lines.concat(anomaly_block(m[:anomalies]))
46
+ "#{lines.join("\n")}\n"
47
+ end
48
+
49
+ # The one shared model both renderers read (row 2.18). Every value is a
50
+ # String, Numeric, true/false, an Array, or a Hash: never a raw Time, never
51
+ # a bare `:unavailable` symbol, never nil.
52
+ def model(record)
53
+ {
54
+ wall_clock: wall_clock_model(record),
55
+ sessions: sessions_model(record),
56
+ nodes: nodes_model(record),
57
+ buckets: buckets_model(record),
58
+ verify_cost: verify_cost_model(record),
59
+ anomalies: anomalies_model(record),
60
+ }
61
+ end
62
+
63
+ # --- wall clock: spec D5, matrix row 2.7 -------------------------------------
64
+
65
+ def wall_clock_model(record)
66
+ clock = record[:clock]
67
+ scaffold = record[:scaffold]
68
+ {
69
+ "delivery_total_seconds" => av_seconds(clock[:wall_clock_seconds]),
70
+ "active_seconds" => av_seconds(record[:active_seconds]),
71
+ "paused_seconds" => av_seconds(record[:paused_seconds]),
72
+ "scaffold_gap_seconds" => av_seconds(scaffold[:gap_seconds]),
73
+ "what_at" => av_time(scaffold[:what_at]),
74
+ "why_at" => av_time(scaffold[:why_at]),
75
+ "done_at" => av_time(clock[:end]),
76
+ # D20: "why" when the clock anchors on the ledger's own `Why` line,
77
+ # "first_line" when it fell back to the first ledger line because no
78
+ # `Why` line exists at all (row 3.23). Never nil: build_clock always
79
+ # sets one of the two.
80
+ "clock_anchor" => clock[:anchor].to_s,
81
+ }
82
+ end
83
+ private_class_method :wall_clock_model
84
+
85
+ def wall_clock_block(w)
86
+ anchor_label = w["clock_anchor"] == "first_line" ? "first ledger line" : "why line"
87
+ [
88
+ "== Wall clock ==",
89
+ "clock anchor: #{anchor_label}" \
90
+ "#{w['clock_anchor'] == 'first_line' ? ' (no Why line in this ledger, D20 fallback)' : ''}",
91
+ "delivery total: #{fmt_minutes(w['delivery_total_seconds'])} min (#{anchor_label} to done line)",
92
+ "active: #{fmt_minutes(w['active_seconds'])} min",
93
+ "paused: #{fmt_minutes(w['paused_seconds'])} min",
94
+ "scaffold gap (what line to why line, not counted in delivery total): " \
95
+ "#{fmt_minutes(w['scaffold_gap_seconds'])} min",
96
+ ]
97
+ end
98
+ private_class_method :wall_clock_block
99
+
100
+ # --- sessions: spec D5.3, matrix row 2.8 -------------------------------------
101
+
102
+ def sessions_model(record)
103
+ clock = record[:clock]
104
+ pauses = record[:pauses]
105
+ record[:sessions].each_with_index.map do |s, i|
106
+ {
107
+ "index" => i + 1,
108
+ "start" => av_time(s[:start]),
109
+ "end" => av_time(s[:end]),
110
+ "duration_seconds" => av_seconds(s[:end] - s[:start]),
111
+ "start_evidence" => session_start_evidence(s, pauses, clock),
112
+ "end_evidence" => session_end_evidence(s, pauses, clock),
113
+ }
114
+ end
115
+ end
116
+ private_class_method :sessions_model
117
+
118
+ # A session's start is bounded either by the delivery clock's own start
119
+ # (the first `Why` line) or by the pause immediately before it, named by
120
+ # what CLOSED that pause (spec D5.3: `Lock takeover:` or the line after a
121
+ # `PAUSED` report). Matched by timestamp equality against `record[:pauses]`
122
+ # rather than by array index, because `complement` skips a zero-length
123
+ # session at either end (row 2.8).
124
+ def session_start_evidence(session, pauses, clock)
125
+ if clock[:start] && session[:start] == clock[:start]
126
+ return clock[:anchor] == :first_line ? "first ledger line (delivery start, no Why line)" : "why line (delivery start)"
127
+ end
128
+
129
+ closing = pauses.find { |p| p[:end] == session[:start] }
130
+ return "pause closed by #{closing[:closed_by]}" if closing
131
+
132
+ UNAVAILABLE
133
+ end
134
+ private_class_method :session_start_evidence
135
+
136
+ def session_end_evidence(session, pauses, clock)
137
+ return "done line (delivery end)" if clock[:end] && session[:end] == clock[:end]
138
+
139
+ opening = pauses.find { |p| p[:start] == session[:end] }
140
+ return "pause opened by #{opening[:opened_by]}" if opening
141
+
142
+ UNAVAILABLE
143
+ end
144
+ private_class_method :session_end_evidence
145
+
146
+ def session_block(sessions)
147
+ lines = ["== Sessions ==", "| # | Start | End | Duration (min) | Start evidence | End evidence |",
148
+ "| --- | --- | --- | --- | --- | --- |"]
149
+ if sessions.empty?
150
+ lines << "(none)"
151
+ else
152
+ sessions.each do |s|
153
+ lines << "| #{s['index']} | #{s['start']} | #{s['end']} | #{fmt_minutes(s['duration_seconds'])} | " \
154
+ "#{s['start_evidence']} | #{s['end_evidence']} |"
155
+ end
156
+ end
157
+ lines
158
+ end
159
+ private_class_method :session_block
160
+
161
+ # --- nodes: spec D6/D7, matrix rows 2.9-2.12, 2.15, 2.16 ---------------------
162
+
163
+ def nodes_model(record)
164
+ sort_ids(record[:nodes].keys).map do |id|
165
+ node = record[:nodes][id]
166
+ {
167
+ "id" => id,
168
+ "kind" => av(node[:kind]),
169
+ "kind_source" => av(node[:kind_source] && node[:kind_source].to_s),
170
+ "review_fix" => node[:review_fix],
171
+ "status" => av(node[:status]),
172
+ "model" => av(node[:model]),
173
+ "hop" => node[:hop] == false ? false : av(node[:hop]),
174
+ "attempts" => node[:attempts].map { |a| attempt_model(a) },
175
+ }
176
+ end
177
+ end
178
+ private_class_method :nodes_model
179
+
180
+ def attempt_model(a)
181
+ {
182
+ "holder" => av(a[:holder]),
183
+ "terminal_state" => av(a[:terminal_state]),
184
+ "running_open" => a[:running_open],
185
+ "raw_span_seconds" => av_seconds(a[:raw_span_seconds]),
186
+ "active_span_seconds" => av_seconds(a[:active_span_seconds]),
187
+ "suite" => suite_model(a[:suite]),
188
+ "comment" => sanitize_comment(a[:comment]),
189
+ }
190
+ end
191
+ private_class_method :attempt_model
192
+
193
+ def suite_model(suite)
194
+ return UNAVAILABLE unless suite.is_a?(Hash)
195
+
196
+ {
197
+ "runs" => av(suite[:runs]),
198
+ "assertions" => av(suite[:assertions]),
199
+ "failures" => av(suite[:failures]),
200
+ "errors" => av(suite[:errors]),
201
+ }
202
+ end
203
+ private_class_method :suite_model
204
+
205
+ # A pipe in a comment would otherwise split a markdown table row into extra
206
+ # columns (row 2.15); a very long comment is capped rather than left as one
207
+ # unwrapped line (row 2.16).
208
+ def sanitize_comment(comment)
209
+ return UNAVAILABLE unless comment && !comment.to_s.strip.empty?
210
+
211
+ text = comment.to_s.gsub("|", "/")
212
+ return text if text.length <= COMMENT_CAP
213
+
214
+ "#{text[0...(COMMENT_CAP - 3)].rstrip}..."
215
+ end
216
+ private_class_method :sanitize_comment
217
+
218
+ def node_block(nodes)
219
+ lines = ["== Nodes ==", "| Node | Kind | Review fix | Status | Model | Hop |",
220
+ "| --- | --- | --- | --- | --- | --- |"]
221
+ nodes.each do |n|
222
+ lines << "| #{n['id']} | #{n['kind']} | #{n['review_fix']} | #{n['status']} | #{n['model']} | #{n['hop']} |"
223
+ next if n["attempts"].empty?
224
+
225
+ lines << " | Attempt | Holder | Terminal | Raw (min) | Active (min) | Comment |"
226
+ lines << " | --- | --- | --- | --- | --- | --- |"
227
+ n["attempts"].each_with_index do |a, i|
228
+ lines << " | #{i + 1} | #{a['holder']} | #{a['terminal_state']} | " \
229
+ "#{fmt_minutes(a['raw_span_seconds'])} | #{fmt_minutes(a['active_span_seconds'])} | #{a['comment']} |"
230
+ end
231
+ end
232
+ lines
233
+ end
234
+ private_class_method :node_block
235
+
236
+ # --- buckets: spec D17, matrix row 2.13 --------------------------------------
237
+
238
+ # Buckets by node kind (spec's "build, verify, review fix" split): a
239
+ # verify node's attempts go to "verify", a review-fix work node's
240
+ # attempts go to "review_fix", everything else with a span goes to
241
+ # "build". "lead" is the remainder of active time no attempt's
242
+ # intersected span accounts for (spec's "lead gaps between one terminal
243
+ # line and the next running line"), so the four buckets always sum to
244
+ # active time by construction; row 2.13 states that explicitly rather
245
+ # than leaving it for the reader to add up.
246
+ # B3 (v1 review): the three gate buckets seeded at 0.0 and never fell back
247
+ # to UNAVAILABLE, so a ledger where NO attempt in a bucket ever carries a
248
+ # measured span (337, which has no `running` line anywhere) rendered
249
+ # "build: 0.0 / verify: 0.0 / review_fix: 0.0", indistinguishable from
250
+ # "measured and genuinely zero", six lines above `== Verify cost ==`
251
+ # correctly printing "unavailable" for the same absent source. Reproduced
252
+ # by hand: `graph-measure intent test/fixtures/ledgers/337--roadmap-graph`
253
+ # prints exactly that shape. `contributed` tracks whether at least one
254
+ # attempt actually reached a bucket; the running sum (`sums`) still
255
+ # starts at 0.0 for the arithmetic `lead`/`sums_to_active` need, but the
256
+ # RENDERED value for a bucket nothing contributed to is UNAVAILABLE,
257
+ # never the seed.
258
+ #
259
+ # NEW-3 (v2 review): `contributed` could only say "nothing landed here",
260
+ # never distinguish WHY - a bucket with no node of its kind in this intent
261
+ # at all (a real zero: there was never anything to measure) rendered the
262
+ # same UNAVAILABLE as a bucket whose nodes exist but never carried a
263
+ # measured span (unmeasurable, not absent). Reproduced by hand against a
264
+ # hermetic one-node work-only intent: "build: 30.0 / verify: unavailable
265
+ # / review_fix: unavailable" though the intent has no verify node and no
266
+ # review-fix node anywhere, which the report's own node table already
267
+ # proves. `has_kind` now tracks whether ANY node of a bucket's kind
268
+ # exists at all; a bucket with no node of its kind renders 0.0 (a real,
269
+ # checkable zero), and only a bucket whose nodes exist but never
270
+ # contributed a measured span renders UNAVAILABLE.
271
+ def buckets_model(record)
272
+ active = record[:active_seconds]
273
+ unless active.is_a?(Numeric)
274
+ return { "build" => UNAVAILABLE, "verify" => UNAVAILABLE, "review_fix" => UNAVAILABLE, "lead" => UNAVAILABLE,
275
+ "active_seconds" => UNAVAILABLE, "sums_to_active" => UNAVAILABLE }
276
+ end
277
+
278
+ sums = { "build" => 0.0, "verify" => 0.0, "review_fix" => 0.0 }
279
+ has_kind = { "build" => false, "verify" => false, "review_fix" => false }
280
+ contributed = { "build" => false, "verify" => false, "review_fix" => false }
281
+ record[:nodes].each_value do |node|
282
+ key = bucket_key(node)
283
+ has_kind[key] = true
284
+ node[:attempts].each do |a|
285
+ next unless a[:active_span_seconds].is_a?(Numeric)
286
+
287
+ sums[key] += a[:active_span_seconds]
288
+ contributed[key] = true
289
+ end
290
+ end
291
+ accounted = sums.values.sum
292
+ lead = [active - accounted, 0.0].max
293
+
294
+ # D24 (after v3 B1): `has_kind["review_fix"]` cannot tell "no work node
295
+ # reaches a verify node" from "graph.md could not be read, so no node
296
+ # ever reached one" - both walk the record and find nothing. Only the
297
+ # record itself, through the unread_source anomaly GraphMeasure already
298
+ # raised, knows which case this is. When the edge source is unread and a
299
+ # verify node exists, review fix renders UNAVAILABLE regardless of what
300
+ # has_kind/contributed computed from the (silently empty) edges.
301
+ review_fix_unreadable = !record[:anomalies][:unread_source].empty?
302
+
303
+ totals = {}
304
+ %w[build verify review_fix].each do |key|
305
+ totals[key] = if key == "review_fix" && review_fix_unreadable
306
+ UNAVAILABLE
307
+ elsif contributed[key]
308
+ sums[key]
309
+ elsif has_kind[key]
310
+ UNAVAILABLE
311
+ else
312
+ 0.0
313
+ end
314
+ end
315
+ totals["lead"] = lead
316
+ totals["active_seconds"] = active
317
+
318
+ # M3 (v3 review, the second half of v2's NEW-3): the sum-equals-active
319
+ # boolean is only checkable when the reader can actually see all four
320
+ # addends as numbers. Computing it from the internal `sums` hash (which
321
+ # always holds a number, even for a bucket rendered UNAVAILABLE above)
322
+ # vouches for arithmetic the reader cannot verify from what is shown.
323
+ checkable = %w[build verify review_fix].all? { |key| totals[key].is_a?(Numeric) }
324
+ totals["sums_to_active"] = if checkable
325
+ (totals["build"] + totals["verify"] + totals["review_fix"] + lead - active).abs < 0.001
326
+ else
327
+ UNAVAILABLE
328
+ end
329
+ totals
330
+ end
331
+ private_class_method :buckets_model
332
+
333
+ def bucket_key(node)
334
+ return "verify" if node[:kind] == "verify"
335
+ return "review_fix" if node[:review_fix]
336
+
337
+ "build"
338
+ end
339
+ private_class_method :bucket_key
340
+
341
+ def bucket_block(b)
342
+ lines = ["== Buckets (sum to active time) =="]
343
+ if b["active_seconds"] == UNAVAILABLE
344
+ lines << UNAVAILABLE
345
+ else
346
+ lines << "build: #{fmt_minutes(b['build'])} min"
347
+ lines << "verify: #{fmt_minutes(b['verify'])} min"
348
+ lines << "review fix: #{fmt_minutes(b['review_fix'])} min"
349
+ lines << "lead: #{fmt_minutes(b['lead'])} min"
350
+ lines << "sum equals active time (#{fmt_minutes(b['active_seconds'])} min): #{b['sums_to_active']}"
351
+ end
352
+ lines
353
+ end
354
+ private_class_method :bucket_block
355
+
356
+ # --- verify cost: intent 343 (G10, n4), matrix rows 4.12-4.14 ----------------
357
+
358
+ # The verify nodes' total active span and its share of active time: the
359
+ # same per-attempt `active_span_seconds` the buckets above sum, but
360
+ # reported `unavailable` rather than 0.0 when NOT ONE verify-kind node's
361
+ # attempt carries a `running` line at all (row 4.13) - a ledger predating
362
+ # the `running` line convention has no verify spans to sum, and 0.0 would
363
+ # read as "verification is free" rather than "unmeasurable from this
364
+ # ledger" (spec D3).
365
+ #
366
+ # B3 second hole (v1 review): `a[:running_at]` alone is satisfied by an
367
+ # OPEN attempt too (running, not yet terminal), so a verify node still
368
+ # mid-review reported "total active span: 0.0 min" - measured and zero -
369
+ # rather than "unavailable" - not yet measurable. Reproduced with a single
370
+ # verify node whose only attempt is an open `running` line inside an
371
+ # otherwise-closed clock: `has_running` was `true` (the open attempt has a
372
+ # `running_at`) so the guard let a real `sum_active_spans` call through,
373
+ # and that sum is 0.0 over an attempt with no `span_seconds` yet. A span
374
+ # is only measured once an attempt has CLOSED (spec row 1.4-1.9's own
375
+ # running-to-terminal pairing), so the guard checks `terminal_at` too.
376
+ #
377
+ # NEW-1 (v2 review): checking `running_at && terminal_at` alone is still
378
+ # not "a span was measured" - it is only "the attempt itself closed". Once
379
+ # the open-clock fix left EVERY attempt's `active_span_seconds` nil while
380
+ # the delivery clock has no end yet, a closed verify attempt inside that
381
+ # open clock still satisfied this guard, and `sum_active_spans` coerced
382
+ # its nil span to 0.0. Reproduced by hand: a hermetic intent with one
383
+ # verify node, one closed attempt spanning a real hour, and no `Done`
384
+ # stage line printed "active: unavailable" at the top of the clock block
385
+ # and "total active span: 0.0 min" in Verify cost a few lines later. The
386
+ # guard now requires an actual numeric `active_span_seconds` on at least
387
+ # one attempt, never merely a closed one.
388
+ def verify_cost_model(record)
389
+ verify_nodes = record[:nodes].select { |_, n| n[:kind] == "verify" }
390
+ all_attempts = verify_nodes.values.flat_map { |n| n[:attempts] }
391
+ has_measured = all_attempts.any? { |a| measured_span?(a) }
392
+ active = record[:active_seconds]
393
+
394
+ total = has_measured ? sum_active_spans(all_attempts) : nil
395
+ share = (total && active.is_a?(Numeric) && active.positive?) ? total / active : nil
396
+
397
+ {
398
+ "total_active_span_seconds" => av_seconds(total),
399
+ "share_of_active_time" => av_seconds(share),
400
+ "nodes" => sort_ids(verify_nodes.keys).map { |id| verify_node_model(id, verify_nodes[id]) },
401
+ }
402
+ end
403
+ private_class_method :verify_cost_model
404
+
405
+ def verify_node_model(id, node)
406
+ attempts = node[:attempts]
407
+ node_total = attempts.any? { |a| measured_span?(a) } ? sum_active_spans(attempts) : nil
408
+ {
409
+ "id" => id,
410
+ "active_span_seconds" => av_seconds(node_total),
411
+ "verdict" => av(verdict_for(attempts)),
412
+ }
413
+ end
414
+ private_class_method :verify_node_model
415
+
416
+ # An attempt is closed (running_at and terminal_at both present) AND its
417
+ # own active span actually resolved to a number, never merely nil because
418
+ # the delivery clock itself has no end yet (NEW-1).
419
+ def measured_span?(a)
420
+ a[:running_at] && a[:terminal_at] && a[:active_span_seconds].is_a?(Numeric)
421
+ end
422
+ private_class_method :measured_span?
423
+
424
+ def sum_active_spans(attempts)
425
+ attempts.sum { |a| a[:active_span_seconds].is_a?(Numeric) ? a[:active_span_seconds] : 0.0 }
426
+ end
427
+ private_class_method :sum_active_spans
428
+
429
+ # The last (file-order) attempt whose fields carry a `verdict=` (row
430
+ # 4.14): a review's verdict is shown beside its cost even when the cost
431
+ # itself is unavailable, mirroring `GraphMeasure`'s own "most recent field
432
+ # wins" reading of `model=`.
433
+ def verdict_for(attempts)
434
+ attempts.reverse_each do |a|
435
+ verdict = a[:fields]["verdict"]
436
+ return verdict if verdict && !verdict.to_s.strip.empty?
437
+ end
438
+ nil
439
+ end
440
+ private_class_method :verdict_for
441
+
442
+ def verify_cost_block(vc)
443
+ lines = ["== Verify cost =="]
444
+ lines << "total active span: #{fmt_minutes(vc['total_active_span_seconds'])} min"
445
+ lines << "share of active time: #{fmt_percent(vc['share_of_active_time'])}"
446
+ if vc["nodes"].empty?
447
+ lines << "(no verify nodes)"
448
+ else
449
+ lines << "| Verify node | Active (min) | Verdict |"
450
+ lines << "| --- | --- | --- |"
451
+ vc["nodes"].each do |n|
452
+ lines << "| #{n['id']} | #{fmt_minutes(n['active_span_seconds'])} | #{n['verdict']} |"
453
+ end
454
+ end
455
+ lines
456
+ end
457
+ private_class_method :verify_cost_block
458
+
459
+ # --- anomalies: spec D4, matrix row 2.14 -------------------------------------
460
+
461
+ def anomalies_model(record)
462
+ {
463
+ "torn" => record[:anomalies][:torn].map { |a| a[:line] },
464
+ "unattributed" => record[:anomalies][:unattributed].map { |a| a[:line] },
465
+ "unread_source" => record[:anomalies][:unread_source].map { |a| a[:line] },
466
+ }
467
+ end
468
+ private_class_method :anomalies_model
469
+
470
+ def anomaly_block(a)
471
+ lines = ["== Anomalies =="]
472
+ if a["torn"].empty? && a["unattributed"].empty? && a["unread_source"].empty?
473
+ lines << "(none)"
474
+ else
475
+ a["torn"].each { |raw| lines << "torn: #{raw}" }
476
+ a["unattributed"].each { |raw| lines << "unattributed: #{raw}" }
477
+ a["unread_source"].each { |raw| lines << "unread source: #{raw}" }
478
+ end
479
+ lines
480
+ end
481
+ private_class_method :anomaly_block
482
+
483
+ # --- formatting helpers -------------------------------------------------------
484
+
485
+ # A missing or explicitly `:unavailable` measure renders as the literal
486
+ # string "unavailable" (spec D3, row 2.12), never blank and never zero.
487
+ # `false` is a real measured value (hop off) and passes through untouched.
488
+ def av(value)
489
+ return UNAVAILABLE if value.nil? || value == :unavailable
490
+
491
+ value
492
+ end
493
+ private_class_method :av
494
+
495
+ def av_time(value)
496
+ return UNAVAILABLE unless value.is_a?(Time)
497
+
498
+ value.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
499
+ end
500
+ private_class_method :av_time
501
+
502
+ def av_seconds(value)
503
+ return UNAVAILABLE unless value.is_a?(Numeric)
504
+
505
+ value
506
+ end
507
+ private_class_method :av_seconds
508
+
509
+ def fmt_minutes(seconds)
510
+ return UNAVAILABLE unless seconds.is_a?(Numeric)
511
+
512
+ format("%.1f", seconds / 60.0)
513
+ end
514
+ private_class_method :fmt_minutes
515
+
516
+ def fmt_percent(ratio)
517
+ return UNAVAILABLE unless ratio.is_a?(Numeric)
518
+
519
+ format("%.1f%%", ratio * 100)
520
+ end
521
+ private_class_method :fmt_percent
522
+
523
+ # Numeric-aware id sort ("n2" before "n10"), the same shape
524
+ # scripts/lib/outcome_report.rb's sort_ids uses.
525
+ def sort_ids(ids)
526
+ ids.sort_by do |id|
527
+ m = id.to_s.match(/\A([A-Za-z]+)(\d+)\z/)
528
+ m ? [m[1], m[2].to_i] : [id.to_s, 0]
529
+ end
530
+ end
531
+ private_class_method :sort_ids
532
+ end
@@ -4,13 +4,13 @@
4
4
  require_relative "graph_edges"
5
5
  require_relative "ready_set"
6
6
 
7
- # GraphTree (intent 337, n2, folds 327a): draws any edge map ({id => [needs...]})
7
+ # GraphTree (intent 337, n2, incorporates 327a): draws any edge map ({id => [needs...]})
8
8
  # as an indented tree with box-drawing branches. A node may have any number
9
9
  # of children; a node several branches need (fan-out) is drawn once, at the
10
10
  # point where those branches join. A node that itself needs several things
11
11
  # (fan-in) can only occupy one position in the tree: it hangs under its
12
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
13
+ # as converging references at the join (row 2.14, merged at the 2026-09-10
14
14
  # plan review). Pure, plain text, no roadmap knowledge, so the same
15
15
  # renderer serves the roadmap file, the roadmap screens, and later the node
16
16
  # scope. Batch numbers (for placement) and the topological sort come from
@@ -19,6 +19,10 @@ module Handoff
19
19
  DONE_CAP = 10
20
20
  RECENT_CAP = 10
21
21
  OTHERS_CAP = 10
22
+ # Intent 340b (G7c, n4, D11/D12): runner-step.last is raw stdout+stderr,
23
+ # not a bounded list of items like every other section, so it gets a byte
24
+ # cap of its own rather than an item count.
25
+ RUNNER_CAP = 2048
22
26
  # A ledger summary may run to 200 characters; a hand-off line shows the
23
27
  # first 80, so the caps above are reachable inside the byte budget.
24
28
  SUMMARY_MAX = 80
@@ -74,9 +78,34 @@ module Handoff
74
78
  end
75
79
  end
76
80
 
81
+ # Raw text of the delivering intent's runner-step.last, or nil when there
82
+ # is no intent, the file is absent, or it cannot be read (row 4.41: a torn
83
+ # file must never raise a PreCompact hand-off out of existence).
84
+ def read_runner_last(intent)
85
+ return nil if intent.to_s.strip.empty?
86
+
87
+ path = File.join(intent, "runner-step.last")
88
+ return nil unless File.file?(path)
89
+
90
+ text = File.read(path)
91
+ text.strip.empty? ? nil : text
92
+ rescue StandardError
93
+ nil
94
+ end
95
+
96
+ # Omitted entirely when no intent resolves or the file is absent/empty
97
+ # (row 4.39); capped like every other section otherwise (row 4.40).
98
+ def runner_section(intent)
99
+ text = read_runner_last(intent)
100
+ return nil unless text
101
+
102
+ clipped = text.bytesize > RUNNER_CAP ? "#{text.byteslice(0, RUNNER_CAP)}\n(truncated)" : text
103
+ "## Runner\n#{clipped}\n"
104
+ end
105
+
77
106
  # --- rendering, pure -----------------------------------------------------------
78
107
 
79
- def render(store:, day:, session:, trigger:, now: Time.now)
108
+ def render(store:, day:, session:, trigger:, now: Time.now, intent: nil)
80
109
  raise ArgumentError, "unknown trigger: #{trigger.inspect}" unless TRIGGERS.include?(trigger)
81
110
 
82
111
  items = read_items(store, day)
@@ -102,8 +131,9 @@ module Handoff
102
131
  "Written #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')} at #{trigger}",
103
132
  "",
104
133
  ]
134
+ runner = runner_section(intent)
105
135
  loop do
106
- text = compose(header, lists, hidden)
136
+ text = compose(header, lists, hidden, runner)
107
137
  return text if text.bytesize <= BUDGET
108
138
 
109
139
  key = TRIM_ORDER.find { |k| !lists[k].empty? }
@@ -123,12 +153,13 @@ module Handoff
123
153
  lists[key] = lists[key].last(cap)
124
154
  end
125
155
 
126
- def compose(header, lists, hidden)
156
+ def compose(header, lists, hidden, runner = nil)
127
157
  sections = [
128
158
  section("Open", lists[:open], hidden[:open]),
129
159
  section("Done", lists[:done], hidden[:done]),
130
160
  section("Recent", lists[:recent], hidden[:recent]),
131
161
  section("Others today", lists[:others], hidden[:others]),
162
+ runner,
132
163
  "## Resume\n#{RESUME}\n",
133
164
  ]
134
165
  (header + sections.compact).join("\n")
@@ -168,13 +199,13 @@ module Handoff
168
199
  # partial hand-off and two writers for one session (a tick overlapping a
169
200
  # PreCompact) never share a temp name. Returns the path. With
170
201
  # `templates: nil` the day is not scaffolded, only its directory ensured.
171
- def write(store:, day:, session:, trigger:, templates:, now: Time.now)
202
+ def write(store:, day:, session:, trigger:, templates:, now: Time.now, intent: nil)
172
203
  if templates
173
204
  SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
174
205
  else
175
206
  FileUtils.mkdir_p(SessionLedger.day_dir(store, day))
176
207
  end
177
- text = render(store: store, day: day, session: session, trigger: trigger, now: now)
208
+ text = render(store: store, day: day, session: session, trigger: trigger, now: now, intent: intent)
178
209
  target = path_for(store, day, session)
179
210
  tmp = File.join(File.dirname(target), ".handoff-#{session}-#{Process.pid}-#{Thread.current.object_id}.tmp")
180
211
  File.write(tmp, text)