@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.
Files changed (127) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/bin/test +24 -4
  4. package/hooks/call-budget +4 -0
  5. package/hooks/hooks.json +24 -0
  6. package/hooks/message-display +134 -0
  7. package/hooks/statusline +4 -27
  8. package/package.json +2 -2
  9. package/scripts/agent-report +8 -2
  10. package/scripts/dashboard.rb +297 -18
  11. package/scripts/doctor.rb +652 -5
  12. package/scripts/end-intent +219 -0
  13. package/scripts/hook-call-budget +222 -0
  14. package/scripts/hook-capture +25 -113
  15. package/scripts/hook-message-display +81 -0
  16. package/scripts/hook-record +12 -4
  17. package/scripts/hook-session-start +45 -7
  18. package/scripts/index-projection +74 -0
  19. package/scripts/intent-screen +77 -0
  20. package/scripts/lib/action_graph_shim.rb +277 -0
  21. package/scripts/lib/arm.rb +26 -1
  22. package/scripts/lib/atomic_write.rb +31 -0
  23. package/scripts/lib/compact_instructions.rb +5 -5
  24. package/scripts/lib/core_integrity.rb +71 -0
  25. package/scripts/lib/dashboard_screen.rb +40 -0
  26. package/scripts/lib/day_summary.rb +7 -2
  27. package/scripts/lib/doctor_core.rb +102 -5
  28. package/scripts/lib/doctor_session_ledger.rb +52 -0
  29. package/scripts/lib/graph_edges.rb +137 -0
  30. package/scripts/lib/graph_file.rb +246 -0
  31. package/scripts/lib/graph_tree.rb +98 -0
  32. package/scripts/lib/guarded_append.rb +155 -0
  33. package/scripts/lib/hook_registry.rb +28 -2
  34. package/scripts/lib/hook_replay.rb +211 -0
  35. package/scripts/lib/index_projection.rb +201 -0
  36. package/scripts/lib/installer_core.rb +141 -6
  37. package/scripts/lib/intent_screen.rb +309 -0
  38. package/scripts/lib/intent_screen_ansi.rb +262 -0
  39. package/scripts/lib/message_display.rb +586 -0
  40. package/scripts/lib/meter_watch.rb +179 -0
  41. package/scripts/lib/node_file.rb +214 -0
  42. package/scripts/lib/node_ids.rb +99 -0
  43. package/scripts/lib/node_ledger.rb +377 -0
  44. package/scripts/lib/node_packet.rb +908 -0
  45. package/scripts/lib/node_return.rb +199 -0
  46. package/scripts/lib/node_worktree.rb +337 -0
  47. package/scripts/lib/outcome_report.rb +440 -0
  48. package/scripts/lib/packet_wrapper.rb +132 -0
  49. package/scripts/lib/ready_set.rb +462 -0
  50. package/scripts/lib/release_guard.rb +16 -0
  51. package/scripts/lib/report_screen.rb +1967 -0
  52. package/scripts/lib/roadmap_graph.rb +210 -0
  53. package/scripts/lib/roadmap_migration.rb +95 -0
  54. package/scripts/lib/roadmap_queue.rb +155 -5
  55. package/scripts/lib/roadmap_render.rb +150 -0
  56. package/scripts/lib/roadmap_savepoint.rb +62 -12
  57. package/scripts/lib/runner_absorb.rb +620 -0
  58. package/scripts/lib/runner_answer.rb +206 -0
  59. package/scripts/lib/runner_core.rb +194 -0
  60. package/scripts/lib/runner_dispatch.rb +506 -0
  61. package/scripts/lib/runner_policy.rb +173 -0
  62. package/scripts/lib/runner_proposals.rb +275 -0
  63. package/scripts/lib/runner_rewind.rb +201 -0
  64. package/scripts/lib/runner_sweep.rb +231 -0
  65. package/scripts/lib/savepoint.rb +149 -12
  66. package/scripts/lib/screen_paint.rb +555 -0
  67. package/scripts/lib/screens/dashboard.rb +20 -0
  68. package/scripts/lib/screens/plan.rb +18 -0
  69. package/scripts/lib/screens/roadmap.rb +15 -0
  70. package/scripts/lib/session_git.rb +49 -18
  71. package/scripts/lib/session_ledger.rb +128 -0
  72. package/scripts/lib/session_usage.rb +190 -0
  73. package/scripts/lib/verify_intent.rb +33 -0
  74. package/scripts/lib/work_graph_validator.rb +201 -0
  75. package/scripts/meter-watch +57 -0
  76. package/scripts/node-packet +92 -0
  77. package/scripts/node-transition +291 -0
  78. package/scripts/outcome-report +74 -0
  79. package/scripts/plastic-lock +8 -1
  80. package/scripts/read-config +3 -3
  81. package/scripts/ready-set +126 -0
  82. package/scripts/release-check +118 -0
  83. package/scripts/report-screen +281 -0
  84. package/scripts/roadmap-graph +119 -0
  85. package/scripts/roadmap-savepoint +7 -0
  86. package/scripts/runner +397 -0
  87. package/scripts/savepoint-note +69 -0
  88. package/scripts/session-usage +56 -0
  89. package/scripts/spawn-preamble +9 -2
  90. package/scripts/validate-work-graph +39 -0
  91. package/scripts/verify-intent +2 -1
  92. package/skills/auto/SKILL.md +41 -34
  93. package/skills/auto/references/human-report-contract.md +136 -54
  94. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  95. package/skills/dashboard/SKILL.md +13 -2
  96. package/skills/dashboard/templates/dashboard-global.md +1 -1
  97. package/skills/dashboard/templates/dashboard-project.md +2 -2
  98. package/skills/doctor/SKILL.md +10 -4
  99. package/skills/intent-continuing/SKILL.md +51 -41
  100. package/skills/intent-continuing/references/board-fill.md +9 -0
  101. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  102. package/skills/intent-continuing/references/context-management.md +1 -1
  103. package/skills/intent-ending/SKILL.md +36 -16
  104. package/skills/intent-executing/SKILL.md +21 -5
  105. package/skills/intent-executing/implementer-prompt.md +6 -1
  106. package/skills/intent-speccing/SKILL.md +7 -4
  107. package/skills/releasing/SKILL.md +39 -0
  108. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  109. package/skills/releasing/references/release-lines.md +1 -1
  110. package/skills/roadmap/SKILL.md +26 -0
  111. package/skills/roadmap/references/file-format.md +10 -0
  112. package/templates/config.yml +3 -3
  113. package/templates/dashboard-screen.md +22 -0
  114. package/templates/display-fixture.md +21 -0
  115. package/templates/graph.md +16 -0
  116. package/templates/intent-screen.md +17 -0
  117. package/templates/node-decision.md +11 -0
  118. package/templates/node-research.md +11 -0
  119. package/templates/node-verify.md +13 -0
  120. package/templates/node-work.md +22 -0
  121. package/templates/outcome.md +19 -1
  122. package/templates/report-plan.md +15 -0
  123. package/templates/report-roadmap-delivered.md +10 -0
  124. package/templates/report-roadmap-plan.md +9 -0
  125. package/templates/report-roadmap-state.md +9 -0
  126. package/templates/report-state.md +11 -0
  127. 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