@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.
- package/PLASTIC.md +6 -5
- 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/dashboard.rb +1 -1
- package/scripts/doctor.rb +80 -6
- package/scripts/end-intent +22 -19
- package/scripts/exec-worktree +5 -5
- package/scripts/graph-measure +249 -0
- package/scripts/hook-call-budget +6 -6
- package/scripts/hook-capture +16 -15
- package/scripts/hook-record +21 -14
- package/scripts/hook-savepoint +26 -3
- package/scripts/hook-session-start +49 -10
- package/scripts/hook-stop +57 -0
- package/scripts/lib/active_delivery.rb +105 -0
- package/scripts/lib/agent_models.rb +10 -1
- package/scripts/lib/arm.rb +41 -102
- package/scripts/lib/codex_adapter.rb +197 -0
- package/scripts/lib/{packet_wrapper.rb → data_boundary.rb} +7 -7
- package/scripts/lib/day_summary.rb +19 -11
- package/scripts/lib/doctor_core.rb +8 -3
- package/scripts/lib/doctor_session_ledger.rb +3 -52
- package/scripts/lib/engine_permissions.rb +88 -0
- package/scripts/lib/exec_worktree.rb +24 -21
- 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 +409 -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 +40 -13
- 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_entry.rb +53 -0
- package/scripts/lib/index_projection.rb +1 -1
- package/scripts/lib/insights.rb +1 -1
- package/scripts/lib/installer_core.rb +228 -9
- package/scripts/lib/intent_screen.rb +1 -1
- package/scripts/lib/intent_validator.rb +2 -2
- package/scripts/lib/lock.rb +8 -9
- package/scripts/lib/meter_watch.rb +15 -9
- package/scripts/lib/node_file.rb +3 -3
- package/scripts/lib/{node_packet.rb → node_input.rb} +49 -49
- package/scripts/lib/node_input_compatibility.rb +62 -0
- package/scripts/lib/node_ledger.rb +11 -2
- package/scripts/lib/node_progress.rb +153 -0
- package/scripts/lib/outcome_report.rb +2 -2
- package/scripts/lib/project_config.rb +45 -0
- package/scripts/lib/ready_set.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/roadmap_savepoint.rb +1 -1
- package/scripts/lib/runner_absorb.rb +36 -10
- package/scripts/lib/runner_dispatch.rb +66 -51
- package/scripts/lib/runner_sweep.rb +4 -4
- package/scripts/lib/runner_until_empty.rb +252 -0
- package/scripts/lib/runner_watch.rb +389 -0
- package/scripts/lib/savepoint.rb +9 -10
- package/scripts/lib/scaffold_intent.rb +6 -3
- package/scripts/lib/session_close.rb +30 -28
- package/scripts/lib/session_git.rb +22 -16
- package/scripts/lib/session_ledger.rb +44 -4
- 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/lib/worktree.rb +24 -24
- package/scripts/lib/worktree_sweep.rb +3 -3
- package/scripts/new-intent +1 -1
- package/scripts/{node-packet → node-input} +15 -15
- package/scripts/node-run +224 -0
- package/scripts/plastic-lock +33 -32
- package/scripts/read-config +6 -0
- package/scripts/runner +203 -19
- package/scripts/verify-intent +1 -1
- package/skills/auto/SKILL.md +8 -8
- package/skills/auto/references/end-tail.md +9 -11
- package/skills/conventions/references/completion-and-done.md +9 -10
- package/skills/conventions/references/knowledge-graph.md +9 -0
- package/skills/conventions/references/locks-and-worktrees.md +10 -12
- package/skills/direct/SKILL.md +2 -2
- package/skills/doctor/SKILL.md +4 -4
- package/skills/intent-creating/evals/evals.json +1 -1
- package/skills/intent-executing/SKILL.md +7 -1
- package/skills/releasing/SKILL.md +2 -2
- package/skills/releasing/references/promotion-and-tagging.md +1 -1
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/tutorial/references/track-2-auto.md +1 -1
- package/templates/index.md +1 -1
- package/templates/project.yml +1 -1
- package/scripts/lib/bridge.rb +0 -116
|
@@ -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,
|
|
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,
|
|
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
|
package/scripts/lib/handoff.rb
CHANGED
|
@@ -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
|
|
@@ -34,15 +38,11 @@ module Handoff
|
|
|
34
38
|
File.join(SessionLedger.day_dir(store, day), "handoff--#{session}.md")
|
|
35
39
|
end
|
|
36
40
|
|
|
37
|
-
# The day this session's hand-off belongs to: the
|
|
38
|
-
#
|
|
39
|
-
#
|
|
41
|
+
# The day this session's hand-off belongs to: the session's day from the
|
|
42
|
+
# day ledger (SessionLedger.session_day, graph.md D7), the same lookup
|
|
43
|
+
# SessionClose uses for the drop at close.
|
|
40
44
|
def day_for(store, session, today:)
|
|
41
|
-
|
|
42
|
-
return today unless File.exist?(path)
|
|
43
|
-
|
|
44
|
-
value = File.read(path).strip
|
|
45
|
-
SessionLedger.valid_day_id?(value) ? value : today
|
|
45
|
+
SessionLedger.session_day(store, session, today: today)
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def clip(summary)
|
|
@@ -74,9 +74,34 @@ module Handoff
|
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# Raw text of the delivering intent's runner-step.last, or nil when there
|
|
78
|
+
# is no intent, the file is absent, or it cannot be read (row 4.41: a torn
|
|
79
|
+
# file must never raise a PreCompact hand-off out of existence).
|
|
80
|
+
def read_runner_last(intent)
|
|
81
|
+
return nil if intent.to_s.strip.empty?
|
|
82
|
+
|
|
83
|
+
path = File.join(intent, "runner-step.last")
|
|
84
|
+
return nil unless File.file?(path)
|
|
85
|
+
|
|
86
|
+
text = File.read(path)
|
|
87
|
+
text.strip.empty? ? nil : text
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Omitted entirely when no intent resolves or the file is absent/empty
|
|
93
|
+
# (row 4.39); capped like every other section otherwise (row 4.40).
|
|
94
|
+
def runner_section(intent)
|
|
95
|
+
text = read_runner_last(intent)
|
|
96
|
+
return nil unless text
|
|
97
|
+
|
|
98
|
+
clipped = text.bytesize > RUNNER_CAP ? "#{text.byteslice(0, RUNNER_CAP)}\n(truncated)" : text
|
|
99
|
+
"## Runner\n#{clipped}\n"
|
|
100
|
+
end
|
|
101
|
+
|
|
77
102
|
# --- rendering, pure -----------------------------------------------------------
|
|
78
103
|
|
|
79
|
-
def render(store:, day:, session:, trigger:, now: Time.now)
|
|
104
|
+
def render(store:, day:, session:, trigger:, now: Time.now, intent: nil)
|
|
80
105
|
raise ArgumentError, "unknown trigger: #{trigger.inspect}" unless TRIGGERS.include?(trigger)
|
|
81
106
|
|
|
82
107
|
items = read_items(store, day)
|
|
@@ -102,8 +127,9 @@ module Handoff
|
|
|
102
127
|
"Written #{now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')} at #{trigger}",
|
|
103
128
|
"",
|
|
104
129
|
]
|
|
130
|
+
runner = runner_section(intent)
|
|
105
131
|
loop do
|
|
106
|
-
text = compose(header, lists, hidden)
|
|
132
|
+
text = compose(header, lists, hidden, runner)
|
|
107
133
|
return text if text.bytesize <= BUDGET
|
|
108
134
|
|
|
109
135
|
key = TRIM_ORDER.find { |k| !lists[k].empty? }
|
|
@@ -123,12 +149,13 @@ module Handoff
|
|
|
123
149
|
lists[key] = lists[key].last(cap)
|
|
124
150
|
end
|
|
125
151
|
|
|
126
|
-
def compose(header, lists, hidden)
|
|
152
|
+
def compose(header, lists, hidden, runner = nil)
|
|
127
153
|
sections = [
|
|
128
154
|
section("Open", lists[:open], hidden[:open]),
|
|
129
155
|
section("Done", lists[:done], hidden[:done]),
|
|
130
156
|
section("Recent", lists[:recent], hidden[:recent]),
|
|
131
157
|
section("Others today", lists[:others], hidden[:others]),
|
|
158
|
+
runner,
|
|
132
159
|
"## Resume\n#{RESUME}\n",
|
|
133
160
|
]
|
|
134
161
|
(header + sections.compact).join("\n")
|
|
@@ -168,13 +195,13 @@ module Handoff
|
|
|
168
195
|
# partial hand-off and two writers for one session (a tick overlapping a
|
|
169
196
|
# PreCompact) never share a temp name. Returns the path. With
|
|
170
197
|
# `templates: nil` the day is not scaffolded, only its directory ensured.
|
|
171
|
-
def write(store:, day:, session:, trigger:, templates:, now: Time.now)
|
|
198
|
+
def write(store:, day:, session:, trigger:, templates:, now: Time.now, intent: nil)
|
|
172
199
|
if templates
|
|
173
200
|
SessionLedger.open_day(store: store, day: day, templates: templates, author: session)
|
|
174
201
|
else
|
|
175
202
|
FileUtils.mkdir_p(SessionLedger.day_dir(store, day))
|
|
176
203
|
end
|
|
177
|
-
text = render(store: store, day: day, session: session, trigger: trigger, now: now)
|
|
204
|
+
text = render(store: store, day: day, session: session, trigger: trigger, now: now, intent: intent)
|
|
178
205
|
target = path_for(store, day, session)
|
|
179
206
|
tmp = File.join(File.dirname(target), ".handoff-#{session}-#{Process.pid}-#{Thread.current.object_id}.tmp")
|
|
180
207
|
File.write(tmp, text)
|