@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.21
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 +3 -1
- package/agents/plastic-executor.md +9 -3
- package/bin/test +24 -4
- package/hooks/call-budget +4 -0
- package/hooks/hooks.json +24 -0
- package/hooks/message-display +134 -0
- package/hooks/statusline +4 -27
- package/package.json +2 -2
- package/scripts/agent-report +8 -2
- package/scripts/dashboard.rb +297 -18
- package/scripts/doctor.rb +652 -5
- package/scripts/end-intent +219 -0
- package/scripts/hook-call-budget +222 -0
- package/scripts/hook-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +45 -7
- package/scripts/index-projection +74 -0
- package/scripts/intent-screen +77 -0
- package/scripts/lib/action_graph_shim.rb +277 -0
- package/scripts/lib/arm.rb +26 -1
- package/scripts/lib/atomic_write.rb +31 -0
- package/scripts/lib/compact_instructions.rb +5 -5
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/dashboard_screen.rb +40 -0
- package/scripts/lib/day_summary.rb +7 -2
- package/scripts/lib/doctor_core.rb +102 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +137 -0
- package/scripts/lib/graph_file.rb +246 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/guarded_append.rb +155 -0
- package/scripts/lib/hook_registry.rb +28 -2
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +141 -6
- package/scripts/lib/intent_screen.rb +309 -0
- package/scripts/lib/intent_screen_ansi.rb +262 -0
- package/scripts/lib/message_display.rb +586 -0
- package/scripts/lib/meter_watch.rb +179 -0
- package/scripts/lib/node_file.rb +214 -0
- package/scripts/lib/node_ids.rb +99 -0
- package/scripts/lib/node_ledger.rb +377 -0
- package/scripts/lib/node_packet.rb +908 -0
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/outcome_report.rb +440 -0
- package/scripts/lib/packet_wrapper.rb +132 -0
- package/scripts/lib/ready_set.rb +462 -0
- package/scripts/lib/release_guard.rb +16 -0
- package/scripts/lib/report_screen.rb +1967 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +155 -5
- package/scripts/lib/roadmap_render.rb +150 -0
- package/scripts/lib/roadmap_savepoint.rb +62 -12
- package/scripts/lib/runner_absorb.rb +620 -0
- package/scripts/lib/runner_answer.rb +206 -0
- package/scripts/lib/runner_core.rb +194 -0
- package/scripts/lib/runner_dispatch.rb +506 -0
- package/scripts/lib/runner_policy.rb +173 -0
- package/scripts/lib/runner_proposals.rb +275 -0
- package/scripts/lib/runner_rewind.rb +201 -0
- package/scripts/lib/runner_sweep.rb +231 -0
- package/scripts/lib/savepoint.rb +149 -12
- package/scripts/lib/screen_paint.rb +555 -0
- package/scripts/lib/screens/dashboard.rb +20 -0
- package/scripts/lib/screens/plan.rb +18 -0
- package/scripts/lib/screens/roadmap.rb +15 -0
- package/scripts/lib/session_git.rb +49 -18
- package/scripts/lib/session_ledger.rb +128 -0
- package/scripts/lib/session_usage.rb +190 -0
- package/scripts/lib/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -0
- package/scripts/meter-watch +57 -0
- package/scripts/node-packet +92 -0
- package/scripts/node-transition +291 -0
- package/scripts/outcome-report +74 -0
- package/scripts/plastic-lock +8 -1
- package/scripts/read-config +3 -3
- package/scripts/ready-set +126 -0
- package/scripts/release-check +118 -0
- package/scripts/report-screen +281 -0
- package/scripts/roadmap-graph +119 -0
- package/scripts/roadmap-savepoint +7 -0
- package/scripts/runner +397 -0
- package/scripts/savepoint-note +69 -0
- package/scripts/session-usage +56 -0
- package/scripts/spawn-preamble +9 -2
- package/scripts/validate-work-graph +39 -0
- package/scripts/verify-intent +2 -1
- package/skills/auto/SKILL.md +41 -34
- package/skills/auto/references/human-report-contract.md +136 -54
- package/skills/conventions/references/locks-and-worktrees.md +12 -0
- package/skills/dashboard/SKILL.md +13 -2
- package/skills/dashboard/templates/dashboard-global.md +1 -1
- package/skills/dashboard/templates/dashboard-project.md +2 -2
- package/skills/doctor/SKILL.md +10 -4
- package/skills/intent-continuing/SKILL.md +51 -41
- package/skills/intent-continuing/references/board-fill.md +9 -0
- package/skills/intent-continuing/references/boarding-matrix.md +6 -5
- package/skills/intent-continuing/references/context-management.md +1 -1
- package/skills/intent-ending/SKILL.md +36 -16
- package/skills/intent-executing/SKILL.md +21 -5
- package/skills/intent-executing/implementer-prompt.md +6 -1
- package/skills/intent-speccing/SKILL.md +7 -4
- package/skills/releasing/SKILL.md +39 -0
- package/skills/releasing/references/promotion-and-tagging.md +10 -6
- package/skills/releasing/references/release-lines.md +1 -1
- package/skills/roadmap/SKILL.md +26 -0
- package/skills/roadmap/references/file-format.md +10 -0
- package/templates/config.yml +3 -3
- package/templates/dashboard-screen.md +22 -0
- package/templates/display-fixture.md +21 -0
- package/templates/graph.md +16 -0
- package/templates/intent-screen.md +17 -0
- package/templates/node-decision.md +11 -0
- package/templates/node-research.md +11 -0
- package/templates/node-verify.md +13 -0
- package/templates/node-work.md +22 -0
- package/templates/outcome.md +19 -1
- package/templates/report-plan.md +15 -0
- package/templates/report-roadmap-delivered.md +10 -0
- package/templates/report-roadmap-plan.md +9 -0
- package/templates/report-roadmap-state.md +9 -0
- package/templates/report-state.md +11 -0
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "date"
|
|
6
|
+
require_relative "graph_file"
|
|
7
|
+
require_relative "node_file"
|
|
8
|
+
require_relative "node_ledger"
|
|
9
|
+
require_relative "atomic_write"
|
|
10
|
+
require_relative "report_screen"
|
|
11
|
+
|
|
12
|
+
# OutcomeReport (intent 339, G6): the report model over graph.md, nodes/, and
|
|
13
|
+
# the node ledger (n1), the generator and its command (n2), the plan-versus-
|
|
14
|
+
# delivered diff and the transitive stale walk (n3), and findings, read and
|
|
15
|
+
# capped (n4). Every rendered cell traces to graph.md, a node file, or a
|
|
16
|
+
# ledger line; an absent source renders as a named reason, never a guess
|
|
17
|
+
# (spec D1). Pure: no clock, no environment, every path an explicit argument.
|
|
18
|
+
# Edges come from GraphFile/GraphEdges only - never the older node-ledger
|
|
19
|
+
# reader intent 336 deletes in this same batch (spec D16).
|
|
20
|
+
module OutcomeReport
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# One past NodeFile::KIND_PREFIX ("n" => "work", ...), so a declared node
|
|
24
|
+
# with no node file on disk (matrix 1.8) still gets a kind, inferred from
|
|
25
|
+
# its id's own prefix rather than left nil.
|
|
26
|
+
KIND_BY_PREFIX = NodeFile::KIND_PREFIX.invert.freeze
|
|
27
|
+
|
|
28
|
+
TITLE_LINE_RE = /\A#\s+\S+\s*-\s*(.+)\z/.freeze
|
|
29
|
+
|
|
30
|
+
# {ok:, errors:, goal:, edges:, nodes:, entries:}. `edges` maps a declared
|
|
31
|
+
# node id to the ids it needs (GraphEdges' own shape). `nodes` maps every id
|
|
32
|
+
# this intent has ever mentioned - declared in graph.md, filed under
|
|
33
|
+
# nodes/, or named in the ledger - to {declared:, file_present:, kind:,
|
|
34
|
+
# title:, state:, fields:, retries:}. `entries` is the raw ledger read
|
|
35
|
+
# (NodeLedger.entries' own shape), carried through so #graph_diff and
|
|
36
|
+
# #stale_nodes can walk file order without a second read. Never raises
|
|
37
|
+
# across its boundary (matrix 1.1, 1.6).
|
|
38
|
+
# `node_file_parser:` (row v1f.4, N4) is the injectable seam: tests drive a
|
|
39
|
+
# raising stub through it to prove this method's OWN rescue, rather than
|
|
40
|
+
# pinning one already owned by NodeFile.parse or NodeLedger.entries a layer
|
|
41
|
+
# down.
|
|
42
|
+
def model(intent_dir, node_file_parser: NodeFile.method(:parse))
|
|
43
|
+
graph_path = File.join(intent_dir, "graph.md")
|
|
44
|
+
unless File.exist?(graph_path)
|
|
45
|
+
return { ok: false, errors: ["no graph.md at #{graph_path}"], goal: nil, edges: {}, nodes: {}, entries: [] }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
parsed = GraphFile.parse(graph_path)
|
|
49
|
+
edges = (parsed[:graph] && parsed[:graph][:edges]) || {}
|
|
50
|
+
declared_ids = (parsed[:graph] && parsed[:graph][:nodes]) || []
|
|
51
|
+
|
|
52
|
+
ledger_path = File.join(intent_dir, "savepoint.md")
|
|
53
|
+
entries = NodeLedger.entries(ledger_path)
|
|
54
|
+
ledger_ids = entries.map { |e| e[:subject] }.uniq
|
|
55
|
+
node_files = node_files_by_id(intent_dir, node_file_parser: node_file_parser)
|
|
56
|
+
|
|
57
|
+
all_ids = (declared_ids + ledger_ids + node_files.keys).uniq
|
|
58
|
+
nodes = all_ids.each_with_object({}) do |id, memo|
|
|
59
|
+
memo[id] = node_entry(id, declared_ids, entries, node_files[id])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
{ ok: parsed[:ok], errors: parsed[:errors] || [], goal: parsed[:goal], edges: edges, nodes: nodes,
|
|
63
|
+
entries: entries }
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
{ ok: false, errors: ["outcome report model crashed: #{e.message}"], goal: nil, edges: {}, nodes: {}, entries: [] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Row v1f.12 (N11): every nodes/*.md file is parsed exactly once here,
|
|
69
|
+
# id -> {path:, parsed:}, so `model` is linear in file count rather than
|
|
70
|
+
# quadratic (the old `node_file_path_for` re-globbed and re-parsed every
|
|
71
|
+
# file once per node id).
|
|
72
|
+
def node_files_by_id(intent_dir, node_file_parser: NodeFile.method(:parse))
|
|
73
|
+
Dir.glob(File.join(intent_dir, "nodes", "*.md")).sort.each_with_object({}) do |path, memo|
|
|
74
|
+
parsed = node_file_parser.call(path)
|
|
75
|
+
memo[parsed[:node]] = { path: path, parsed: parsed } if parsed[:node]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def node_entry(id, declared_ids, entries, file_entry)
|
|
80
|
+
file_path = file_entry && file_entry[:path]
|
|
81
|
+
parsed = file_entry && file_entry[:parsed]
|
|
82
|
+
kind = (parsed && parsed[:kind]) || KIND_BY_PREFIX[id.to_s[0]]
|
|
83
|
+
title = (parsed && title_from_body(parsed[:body])) || id.to_s
|
|
84
|
+
|
|
85
|
+
subject_entries = entries.select { |e| e[:subject] == id }
|
|
86
|
+
non_torn = subject_entries.reject { |e| e[:torn] }
|
|
87
|
+
state = non_torn.empty? ? "planned" : NodeLedger.resolved_state(non_torn.last[:state])
|
|
88
|
+
done_entry = non_torn.select { |e| e[:state] == "done" }.last
|
|
89
|
+
fields = done_entry ? done_entry[:fields] : {}
|
|
90
|
+
# Row v1f.14: carried alongside `fields`, never merged into it - a
|
|
91
|
+
# `failed_verification` node has no `done` entry, so `fields` stays {}
|
|
92
|
+
# (row 1.4 must stay true). The failure branch of
|
|
93
|
+
# verification_line_for reads its reason from here instead.
|
|
94
|
+
failure_entry = non_torn.select { |e| e[:state] == "failed_verification" }.last
|
|
95
|
+
failure_fields = failure_entry ? failure_entry[:fields] : {}
|
|
96
|
+
retries = non_torn.count { |e| e[:state] == "failed_verification" }
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
declared: declared_ids.include?(id),
|
|
100
|
+
file_present: !file_path.nil?,
|
|
101
|
+
kind: kind,
|
|
102
|
+
title: title,
|
|
103
|
+
state: state,
|
|
104
|
+
fields: fields,
|
|
105
|
+
failure_fields: failure_fields,
|
|
106
|
+
retries: retries,
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def title_from_body(body)
|
|
111
|
+
return nil unless body
|
|
112
|
+
|
|
113
|
+
line = body.to_s.each_line.find { |l| l.start_with?("#") }
|
|
114
|
+
return nil unless line
|
|
115
|
+
|
|
116
|
+
m = line.strip.match(TITLE_LINE_RE)
|
|
117
|
+
m && m[1].strip
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# --- n2: the generator -------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
PLACEHOLDER_SUMMARY = "(what was delivered)"
|
|
123
|
+
NON_EVIDENCE_FIELD_KEYS = %w[holder expires packet model gates commit verdict reason question by expired suite].freeze
|
|
124
|
+
|
|
125
|
+
# D14: a pipe in any generated table cell (or bullet line) is replaced with a
|
|
126
|
+
# forward slash, never escaped - ReportScreen.table_rows splits on the bare
|
|
127
|
+
# character with no escape awareness.
|
|
128
|
+
def sanitize_cell(text)
|
|
129
|
+
text.to_s.gsub("|", "/")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Numeric-aware id sort ("n2" before "n10"), for deterministic output order
|
|
133
|
+
# regardless of Hash insertion order.
|
|
134
|
+
def sort_ids(ids)
|
|
135
|
+
ids.sort_by do |id|
|
|
136
|
+
m = id.to_s.match(/\A([A-Za-z]+)(\d+)\z/)
|
|
137
|
+
m ? [m[1], m[2].to_i] : [id.to_s, 0]
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def section_body(text, heading)
|
|
142
|
+
return nil unless text
|
|
143
|
+
|
|
144
|
+
text.split(/^#{Regexp.escape(heading)}\s*$/, 2)[1]&.split(/^## /, 2)&.first
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def existing_frontmatter(text)
|
|
148
|
+
return {} unless text && text.start_with?("---")
|
|
149
|
+
|
|
150
|
+
parts = text.split("---", 3)
|
|
151
|
+
return {} if parts.length < 3
|
|
152
|
+
|
|
153
|
+
(YAML.safe_load(parts[1], permitted_classes: [Date, Time]) || {}).each_with_object({}) do |(k, v), memo|
|
|
154
|
+
memo[k.to_s] = v
|
|
155
|
+
end
|
|
156
|
+
rescue StandardError
|
|
157
|
+
{}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# An authored block, trimmed, or nil when the section is absent, blank, or
|
|
161
|
+
# still the literal "None"/placeholder scaffold text (spec D2).
|
|
162
|
+
def preserved_block(existing_text, heading, placeholder: nil)
|
|
163
|
+
body = section_body(existing_text, heading)
|
|
164
|
+
return nil if body.nil?
|
|
165
|
+
|
|
166
|
+
trimmed = body.gsub(/<!--.*?-->/m, "").strip
|
|
167
|
+
return nil if trimmed.empty? || trimmed == "None"
|
|
168
|
+
return nil if placeholder && trimmed == placeholder
|
|
169
|
+
|
|
170
|
+
body.strip
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def default_summary(model)
|
|
174
|
+
work = model[:nodes].select { |_, n| n[:kind] == "work" }
|
|
175
|
+
done = work.select { |_, n| n[:state] == "done" }
|
|
176
|
+
"#{done.length} of #{work.length} work node#{work.length == 1 ? '' : 's'} delivered."
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Row v1f.11 (N10): a whole sentence, not the first LINE of the goal - a
|
|
180
|
+
# goal that wraps across source lines (this intent's own graph.md does)
|
|
181
|
+
# must not be cut mid-sentence at the wrap. Wrapped lines within the first
|
|
182
|
+
# paragraph are joined with spaces, then cut at the first sentence-ending
|
|
183
|
+
# punctuation; a paragraph with none is used whole.
|
|
184
|
+
def title_text(model)
|
|
185
|
+
goal = model[:goal].to_s.strip
|
|
186
|
+
return "generated report" if goal.empty?
|
|
187
|
+
|
|
188
|
+
paragraph = goal.split(/\n[ \t]*\n/, 2).first.to_s
|
|
189
|
+
joined = paragraph.each_line.map(&:strip).join(" ")
|
|
190
|
+
sentence = joined[/\A.*?[.!?](?=\s|\z)/]
|
|
191
|
+
(sentence || joined).strip
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# D19 (S10): a delivered row is labelled by the S-label of the action
|
|
195
|
+
# heading that owns that node's own failure-mode matrix, falling back to
|
|
196
|
+
# the bare node id (D3's grammar) when no heading carries one. This keeps
|
|
197
|
+
# a generated record close-able under an already-installed core that only
|
|
198
|
+
# collects S-labels off action headings - the same core that, before this,
|
|
199
|
+
# found S-labels on the headings and node ids on the rows and refused with
|
|
200
|
+
# no intersection between the two (ruling D19's probe).
|
|
201
|
+
ACTION_S_LABEL_RE = /\AS\d+\z/.freeze
|
|
202
|
+
|
|
203
|
+
# Resolves through the exact same walk `ReportScreen.proven_by` reads
|
|
204
|
+
# against (`matching_action_heading` + `heading_tokens`), so the label this
|
|
205
|
+
# emits can never disagree with what a reader's Proven-by cell resolves.
|
|
206
|
+
# `intent_dir` is nil for an in-memory model with nothing on disk (rows
|
|
207
|
+
# 2.4/2.5) - that, like a node id absent from every heading, falls back to
|
|
208
|
+
# the node id unchanged.
|
|
209
|
+
def delivered_row_label(id, intent_dir)
|
|
210
|
+
return id.to_s unless intent_dir
|
|
211
|
+
|
|
212
|
+
heading, body = ReportScreen.matching_action_heading(intent_dir, id.to_s)
|
|
213
|
+
return id.to_s unless heading && body
|
|
214
|
+
|
|
215
|
+
s_label = ReportScreen.heading_tokens(heading).find { |t| t.match?(ACTION_S_LABEL_RE) }
|
|
216
|
+
s_label || id.to_s
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def render_delivered_section(model, intent_dir: nil)
|
|
220
|
+
rows = model[:nodes].select { |_, n| n[:kind] == "work" && n[:state] == "done" }
|
|
221
|
+
lines = ["## Delivered", "| Row | What |", "| --- | --- |"]
|
|
222
|
+
sort_ids(rows.keys).each do |id|
|
|
223
|
+
lines << "| #{delivered_row_label(id, intent_dir)} | #{sanitize_cell(rows[id][:title])} |"
|
|
224
|
+
end
|
|
225
|
+
"#{lines.join("\n")}\n"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def verification_line_for(id, node)
|
|
229
|
+
fields = node[:fields] || {}
|
|
230
|
+
if node[:state] == "failed_verification"
|
|
231
|
+
reason = (node[:failure_fields] || {})["reason"] || "reason not recorded"
|
|
232
|
+
return "- #{id}: verification failed (#{sanitize_cell(reason)})"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
parts = []
|
|
236
|
+
parts << "tests committed red before `#{sanitize_cell(fields['commit'])}`" if fields["commit"]
|
|
237
|
+
parts << "gates=#{sanitize_cell(fields['gates'])}" if fields["gates"]
|
|
238
|
+
parts << "verdict=#{sanitize_cell(fields['verdict'])}" if fields["verdict"]
|
|
239
|
+
extra = fields.reject { |k, _| NON_EVIDENCE_FIELD_KEYS.include?(k) }
|
|
240
|
+
extra.each { |k, v| parts << "#{k}=#{sanitize_cell(v)}" }
|
|
241
|
+
parts << "retried #{node[:retries]}x" if node[:retries].to_i.positive?
|
|
242
|
+
return nil if parts.empty?
|
|
243
|
+
|
|
244
|
+
"- #{id}: #{parts.join(', ')}"
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def render_verification_section(model)
|
|
248
|
+
candidates = model[:nodes].select { |_, n| %w[work verify].include?(n[:kind]) }
|
|
249
|
+
lines = sort_ids(candidates.keys).filter_map { |id| verification_line_for(id, candidates[id]) }
|
|
250
|
+
|
|
251
|
+
suite = candidates.values.filter_map { |n| n[:fields] && n[:fields]["suite"] }.first
|
|
252
|
+
lines << "- suite: #{suite}" if suite
|
|
253
|
+
|
|
254
|
+
lines = ["- no work or verify node has recorded evidence yet"] if lines.empty?
|
|
255
|
+
"## Verification\n#{lines.join("\n")}\n"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def render_needs_you_default(model)
|
|
259
|
+
needs = model[:nodes].select { |_, n| n[:state] == "needs_decision" }
|
|
260
|
+
return "None" if needs.empty?
|
|
261
|
+
|
|
262
|
+
lines = ["| N | Need | Reason |", "| --- | --- | --- |"]
|
|
263
|
+
sort_ids(needs.keys).each_with_index do |id, i|
|
|
264
|
+
reason = (needs[id][:fields] && needs[id][:fields]["question"]) || "not recorded"
|
|
265
|
+
lines << "| N#{i + 1} | #{sanitize_cell(id)} | #{sanitize_cell(reason)} |"
|
|
266
|
+
end
|
|
267
|
+
lines.join("\n")
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Row v1f.3 (B3): serialized with to_yaml, not `"#{k}: #{v}"`
|
|
271
|
+
# interpolation - a preserved value carrying a colon, a newline, or a
|
|
272
|
+
# leading/trailing space corrupted the whole frontmatter block under the
|
|
273
|
+
# old naive join, taking `disposition` down with it. Key order is
|
|
274
|
+
# Hash#to_yaml's own (insertion order), so the caller's last-writer-wins
|
|
275
|
+
# reassignment of `disposition` still lands at its original position.
|
|
276
|
+
def frontmatter_lines(fm)
|
|
277
|
+
fm.to_yaml.sub(/\A---\n/, "").rstrip.split("\n")
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# {ok:, errors:, goal:, edges:, nodes:} in, the whole `outcome.md` text out.
|
|
281
|
+
# Preserved verbatim when authored (spec D2): every frontmatter key except
|
|
282
|
+
# `disposition`, `## Summary`, `## Needs you`, `## Follow-ups`. Regenerated
|
|
283
|
+
# every time: `## Delivered`, `## Verification` (and, once n3/n4 wire them
|
|
284
|
+
# in, `## Graph diff` and `## Findings`).
|
|
285
|
+
def render(model, disposition:, existing: nil, findings: [], intent_dir: nil)
|
|
286
|
+
fm = existing_frontmatter(existing)
|
|
287
|
+
fm["disposition"] = disposition
|
|
288
|
+
fm_lines = ["---"] + frontmatter_lines(fm) + ["---"]
|
|
289
|
+
|
|
290
|
+
summary = preserved_block(existing, "## Summary", placeholder: PLACEHOLDER_SUMMARY) || default_summary(model)
|
|
291
|
+
needs_you = preserved_block(existing, "## Needs you") || render_needs_you_default(model)
|
|
292
|
+
follow_ups = preserved_block(existing, "## Follow-ups") || "None"
|
|
293
|
+
|
|
294
|
+
lines = fm_lines.dup
|
|
295
|
+
lines << "# Outcome: #{title_text(model)}"
|
|
296
|
+
lines << ""
|
|
297
|
+
lines << "## Summary"
|
|
298
|
+
lines << summary
|
|
299
|
+
lines << ""
|
|
300
|
+
lines << render_delivered_section(model, intent_dir: intent_dir)
|
|
301
|
+
lines << render_verification_section(model)
|
|
302
|
+
lines << graph_diff(model)
|
|
303
|
+
lines << render_findings_section(findings) unless findings.nil? || findings.empty?
|
|
304
|
+
lines << "## Needs you"
|
|
305
|
+
lines << needs_you
|
|
306
|
+
lines << ""
|
|
307
|
+
lines << "## Follow-ups"
|
|
308
|
+
lines << follow_ups
|
|
309
|
+
"#{lines.join("\n")}\n"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# --- n3: plan versus delivered, and stale -----------------------------------
|
|
313
|
+
|
|
314
|
+
# A done node's transitive `needs` closure (spec D5), by ledger LINE
|
|
315
|
+
# POSITION, never timestamp - the ledger's own status rule is last line per
|
|
316
|
+
# subject in file order, so a flag computed from anything else could
|
|
317
|
+
# disagree with the state printed beside it. `stale_fn:` (spec D6) lets
|
|
318
|
+
# intent 336's ready-set module supply its own computation without an edit
|
|
319
|
+
# here. Cycle-guarded: a node already on the current walk's path is never
|
|
320
|
+
# re-entered.
|
|
321
|
+
def stale_nodes(entries:, edges:, stale_fn: nil)
|
|
322
|
+
return stale_fn.call(entries: entries, edges: edges) if stale_fn
|
|
323
|
+
|
|
324
|
+
non_torn = entries.reject { |e| e[:torn] }
|
|
325
|
+
last_state = {}
|
|
326
|
+
last_index = {}
|
|
327
|
+
done_index = {}
|
|
328
|
+
non_torn.each_with_index do |e, i|
|
|
329
|
+
last_state[e[:subject]] = e[:state]
|
|
330
|
+
last_index[e[:subject]] = i
|
|
331
|
+
done_index[e[:subject]] = i if e[:state] == "done"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
edges.keys.select do |id|
|
|
335
|
+
done_index.key?(id) && stale_via_closure?(id, edges, last_state, last_index, done_index[id], [])
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def stale_via_closure?(id, edges, last_state, last_index, own_done_idx, visiting)
|
|
340
|
+
return false if visiting.include?(id)
|
|
341
|
+
|
|
342
|
+
visiting = visiting + [id]
|
|
343
|
+
(edges[id] || []).any? do |dep|
|
|
344
|
+
(last_state[dep] == "superseded" && last_index[dep] && last_index[dep] > own_done_idx) ||
|
|
345
|
+
stale_via_closure?(dep, edges, last_state, last_index, own_done_idx, visiting)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# The plan-versus-delivered divergence: a planned node not done, a node the
|
|
350
|
+
# ledger knows that the graph never declared, a node that took a retry, a
|
|
351
|
+
# stale node (spec D4). One line saying so when nothing diverged.
|
|
352
|
+
def graph_diff(model)
|
|
353
|
+
lines = []
|
|
354
|
+
|
|
355
|
+
declared = model[:nodes].select { |_, n| n[:declared] }
|
|
356
|
+
sort_ids(declared.keys).each do |id|
|
|
357
|
+
lines << "#{id} is #{declared[id][:state]}, not done" unless declared[id][:state] == "done"
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
undeclared = model[:nodes].reject { |_, n| n[:declared] }
|
|
361
|
+
sort_ids(undeclared.keys).each { |id| lines << "#{id} was not declared in graph.md" }
|
|
362
|
+
|
|
363
|
+
retried = model[:nodes].select { |_, n| n[:retries].to_i.positive? }
|
|
364
|
+
sort_ids(retried.keys).each do |id|
|
|
365
|
+
n = retried[id]
|
|
366
|
+
lines << "#{id} took #{n[:retries]} #{n[:retries] == 1 ? 'retry' : 'retries'}"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
stale = stale_nodes(entries: model[:entries] || [], edges: model[:edges] || {})
|
|
370
|
+
sort_ids(stale).each { |id| lines << "#{id} is stale: a dependency was superseded after it finished" }
|
|
371
|
+
|
|
372
|
+
return "## Graph diff\nDelivered matches the plan.\n" if lines.empty?
|
|
373
|
+
|
|
374
|
+
"## Graph diff\n#{lines.map { |l| "- #{l}" }.join("\n")}\n"
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# --- n4: findings, read and capped ------------------------------------------
|
|
378
|
+
|
|
379
|
+
# Two lines at the screens' own 115-column limit (spec D15). A named
|
|
380
|
+
# constant so the number is never folded into a regex.
|
|
381
|
+
FINDING_CAP = 200
|
|
382
|
+
|
|
383
|
+
# `### Findings` under the intent record's own `## Insights` section (spec
|
|
384
|
+
# D7). G7 writes those lines one Insight entry at a time; nothing writes one
|
|
385
|
+
# today, so this reads a fixture shape until then. [] when the record, the
|
|
386
|
+
# `## Insights` section, or the `### Findings` subsection is absent -
|
|
387
|
+
# never raises, never treats the whole Insights section as one finding.
|
|
388
|
+
def findings(intent_dir)
|
|
389
|
+
path = File.join(intent_dir, "#{File.basename(intent_dir)}.md")
|
|
390
|
+
return [] unless File.exist?(path)
|
|
391
|
+
|
|
392
|
+
text = File.read(path)
|
|
393
|
+
insights = text.split(/^## Insights\s*$/, 2)[1].to_s.split(/^## /, 2)[0].to_s
|
|
394
|
+
body = insights.split(/^### Findings\s*$/, 2)[1]
|
|
395
|
+
return [] if body.nil?
|
|
396
|
+
|
|
397
|
+
body = body.split(/^#+\s/, 2)[0]
|
|
398
|
+
finding_bullet_rows(body).map { |f| cap_finding(sanitize_cell(f)) }
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Same continuation-line rule as ReportScreen.bullet_rows: a "- " line plus
|
|
402
|
+
# its wrapped continuation, ending at a blank line or the next heading.
|
|
403
|
+
def finding_bullet_rows(section)
|
|
404
|
+
rows = []
|
|
405
|
+
section.to_s.each_line do |line|
|
|
406
|
+
stripped = line.strip
|
|
407
|
+
if line.lstrip.start_with?("- ")
|
|
408
|
+
rows << line.lstrip.sub(/\A-\s*/, "").strip
|
|
409
|
+
elsif stripped.empty? || line.start_with?("#")
|
|
410
|
+
rows << nil unless rows.empty? || rows.last.nil?
|
|
411
|
+
elsif !rows.empty? && !rows.last.nil?
|
|
412
|
+
rows[rows.length - 1] = "#{rows.last} #{stripped}"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
rows.compact
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def cap_finding(text)
|
|
419
|
+
return text if text.length <= FINDING_CAP
|
|
420
|
+
|
|
421
|
+
"#{text[0...(FINDING_CAP - 3)].rstrip}..."
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def render_findings_section(findings)
|
|
425
|
+
return "" if findings.nil? || findings.empty?
|
|
426
|
+
|
|
427
|
+
lines = ["## Findings", "| Finding |", "| --- |"]
|
|
428
|
+
findings.each { |f| lines << "| #{f} |" }
|
|
429
|
+
"#{lines.join("\n")}\n"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def write(intent_dir, disposition:, renamer: File.method(:rename))
|
|
433
|
+
outcome_path = File.join(intent_dir, "outcome.md")
|
|
434
|
+
existing = File.exist?(outcome_path) ? File.read(outcome_path) : nil
|
|
435
|
+
m = model(intent_dir)
|
|
436
|
+
text = render(m, disposition: disposition, existing: existing, findings: findings(intent_dir), intent_dir: intent_dir)
|
|
437
|
+
AtomicWrite.write(outcome_path, text, renamer: renamer)
|
|
438
|
+
text
|
|
439
|
+
end
|
|
440
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
# PacketWrapper (intent 338, G5, n1): the trust boundary a node packet is
|
|
7
|
+
# built over. A data block carries text other agents and the owner wrote,
|
|
8
|
+
# and is opened by `<<<PLASTIC-DATA:<token> label="..." source="...">>>` and
|
|
9
|
+
# closed by `<<<END-PLASTIC-DATA:<token>>>`. The token is content-derived
|
|
10
|
+
# (spec D4), so the same payloads always produce the same token and the
|
|
11
|
+
# packet stays deterministic; the escaping rule (spec D5) runs on every
|
|
12
|
+
# payload independently of the token, so a payload carrying this packet's
|
|
13
|
+
# own closing marker still cannot close its block. Attribute values
|
|
14
|
+
# (`label`, `source`) are sanitized separately, by a whitelist (spec D5a),
|
|
15
|
+
# because they are not payload text and the payload escaping rule does not
|
|
16
|
+
# cover them.
|
|
17
|
+
#
|
|
18
|
+
# Pure and side-effect-free: no I/O, no clock, nothing raised across the
|
|
19
|
+
# boundary. `estimate_tokens` is the one arithmetic every budget in this
|
|
20
|
+
# delivery is spent in (spec D6): bytes over four, rounded.
|
|
21
|
+
module PacketWrapper
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
MARKER_OPEN = "<<<PLASTIC-DATA:"
|
|
25
|
+
MARKER_CLOSE = "<<<END-PLASTIC-DATA:"
|
|
26
|
+
ATTR_MAX = 200
|
|
27
|
+
|
|
28
|
+
# The attribute whitelist (spec D5a): anything outside this class becomes
|
|
29
|
+
# `_`. A quote, an angle bracket, a backslash, a newline and a carriage
|
|
30
|
+
# return are therefore all impossible in a marker line.
|
|
31
|
+
ATTR_SAFE_CHARS_RE = %r{[^A-Za-z0-9 _.,:#/@+=-]}.freeze
|
|
32
|
+
|
|
33
|
+
OPEN_LINE_RE = /\A#{Regexp.escape(MARKER_OPEN)}([0-9a-f]+) label="([^"]*)" source="([^"]*)">>>\z/.freeze
|
|
34
|
+
CLOSE_LINE_RE = /\A#{Regexp.escape(MARKER_CLOSE)}([0-9a-f]+)>>>\z/.freeze
|
|
35
|
+
|
|
36
|
+
# The first twelve hex characters of the SHA-256 over the payloads joined
|
|
37
|
+
# by a newline (spec D4): fixed per packet, unguessable from any single
|
|
38
|
+
# record file, deterministic across two builds of the same payloads.
|
|
39
|
+
def boundary_token(payloads)
|
|
40
|
+
Digest::SHA256.hexdigest(Array(payloads).map(&:to_s).join("\n"))[0, 12]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One-way marker escaping (spec D5), plus a UTF-8 scrub (matrix 1.10) so
|
|
44
|
+
# one invalid byte anywhere in a record never raises the whole packet
|
|
45
|
+
# build. Runs regardless of what token trails the marker text, because a
|
|
46
|
+
# payload copied from an earlier packet may carry ANY token, not only this
|
|
47
|
+
# one (matrix 1.2's concern applied to escaping rather than to the token
|
|
48
|
+
# itself).
|
|
49
|
+
def escape(payload)
|
|
50
|
+
text = payload.to_s.scrub
|
|
51
|
+
text = text.gsub(MARKER_OPEN) { "<<<\\PLASTIC-DATA:" }
|
|
52
|
+
text.gsub(MARKER_CLOSE) { "<<<\\END-PLASTIC-DATA:" }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Whitelist-sanitize and truncate a marker attribute value (spec D5a).
|
|
56
|
+
# Every character outside the whitelist becomes `_`; the result is
|
|
57
|
+
# truncated to ATTR_MAX characters, so neither a hostile value nor a
|
|
58
|
+
# multi-kilobyte one can escape or bloat a marker line.
|
|
59
|
+
def attr_safe(value)
|
|
60
|
+
value.to_s.scrub.gsub(ATTR_SAFE_CHARS_RE, "_")[0, ATTR_MAX]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# One complete, closed data block ending in a newline. The payload is
|
|
64
|
+
# escaped and scrubbed first; both markers sit alone on their own line
|
|
65
|
+
# (matrix 1.4), even when the payload is empty (matrix 1.9).
|
|
66
|
+
def wrap(payload, label:, source:, token:)
|
|
67
|
+
body = escape(payload)
|
|
68
|
+
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
69
|
+
open_line = "#{MARKER_OPEN}#{token} label=\"#{attr_safe(label)}\" source=\"#{attr_safe(source)}\">>>\n"
|
|
70
|
+
close_line = "#{MARKER_CLOSE}#{token}>>>\n"
|
|
71
|
+
"#{open_line}#{body}#{close_line}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# [{label:, source:, payload:}], in order. Matches markers case-sensitively
|
|
75
|
+
# (matrix 1.16: no `/i`, ever) and closes a block only on a close marker
|
|
76
|
+
# whose token matches the block's own opening token, so a stray marker for
|
|
77
|
+
# a different token found mid-payload (which the escaping rule already
|
|
78
|
+
# makes unreachable from real payload text) is never mistaken for this
|
|
79
|
+
# block's close.
|
|
80
|
+
def unwrap(text)
|
|
81
|
+
lines = text.to_s.each_line.to_a
|
|
82
|
+
blocks = []
|
|
83
|
+
i = 0
|
|
84
|
+
while i < lines.length
|
|
85
|
+
m = lines[i].chomp("\n").match(OPEN_LINE_RE)
|
|
86
|
+
unless m
|
|
87
|
+
i += 1
|
|
88
|
+
next
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
token, label, source = m[1], m[2], m[3]
|
|
92
|
+
payload_lines = []
|
|
93
|
+
i += 1
|
|
94
|
+
while i < lines.length
|
|
95
|
+
cm = lines[i].chomp("\n").match(CLOSE_LINE_RE)
|
|
96
|
+
break if cm && cm[1] == token
|
|
97
|
+
|
|
98
|
+
payload_lines << lines[i]
|
|
99
|
+
i += 1
|
|
100
|
+
end
|
|
101
|
+
blocks << { label: label, source: source, payload: payload_lines.join }
|
|
102
|
+
i += 1
|
|
103
|
+
end
|
|
104
|
+
blocks
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Disarm any line that is, on its own, a complete open or close marker
|
|
108
|
+
# (intent 338 post-execution review, finding A2). Blocks 1 and 5 are
|
|
109
|
+
# instruction, raw-interpolated rather than wrapped, on the grounds that
|
|
110
|
+
# they are orchestrator- and project-record-authored; that trust does not
|
|
111
|
+
# extend to `release.verify` in project.yml, which the owner and other
|
|
112
|
+
# agents write. `escape` already disarms every occurrence of the marker
|
|
113
|
+
# substring wherever it falls, but a marker sitting mid-line (matrix 1.5's
|
|
114
|
+
# convention of quoting a matrix row verbatim inside a node file) is
|
|
115
|
+
# already harmless, because `unwrap` only recognizes a marker that is the
|
|
116
|
+
# WHOLE line. Rewriting a harmless mid-line quote would be needless churn
|
|
117
|
+
# on instruction text an owner reads, so only a line that fully matches
|
|
118
|
+
# `OPEN_LINE_RE` or `CLOSE_LINE_RE` gets `escape`'s backslash treatment.
|
|
119
|
+
def neutralize_marker_lines(text)
|
|
120
|
+
text.to_s.each_line.map do |line|
|
|
121
|
+
chomped = line.chomp("\n")
|
|
122
|
+
chomped.match?(OPEN_LINE_RE) || chomped.match?(CLOSE_LINE_RE) ? escape(line) : line
|
|
123
|
+
end.join
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# (bytes / 4.0).round (spec D6): the estimate G10 measures against. Taken
|
|
127
|
+
# over bytesize, never characters, so multi-byte text is never
|
|
128
|
+
# under-counted.
|
|
129
|
+
def estimate_tokens(text)
|
|
130
|
+
(text.to_s.bytesize / 4.0).round
|
|
131
|
+
end
|
|
132
|
+
end
|