@zalom/plastic 2.0.0-alpha.2 → 2.0.0-alpha.20

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 (117) hide show
  1. package/agents/plastic-enforcer.md +3 -1
  2. package/agents/plastic-executor.md +9 -3
  3. package/hooks/hooks.json +12 -0
  4. package/hooks/message-display +134 -0
  5. package/hooks/statusline +4 -27
  6. package/package.json +2 -2
  7. package/scripts/agent-report +8 -2
  8. package/scripts/dashboard.rb +297 -18
  9. package/scripts/doctor.rb +652 -5
  10. package/scripts/end-intent +219 -0
  11. package/scripts/hook-capture +25 -113
  12. package/scripts/hook-message-display +81 -0
  13. package/scripts/hook-record +12 -4
  14. package/scripts/hook-session-start +23 -1
  15. package/scripts/index-projection +74 -0
  16. package/scripts/intent-screen +77 -0
  17. package/scripts/lib/action_graph_shim.rb +277 -0
  18. package/scripts/lib/arm.rb +26 -1
  19. package/scripts/lib/atomic_write.rb +31 -0
  20. package/scripts/lib/core_integrity.rb +71 -0
  21. package/scripts/lib/dashboard_screen.rb +40 -0
  22. package/scripts/lib/day_summary.rb +7 -2
  23. package/scripts/lib/doctor_core.rb +101 -5
  24. package/scripts/lib/doctor_session_ledger.rb +52 -0
  25. package/scripts/lib/graph_edges.rb +121 -0
  26. package/scripts/lib/graph_file.rb +246 -0
  27. package/scripts/lib/graph_tree.rb +98 -0
  28. package/scripts/lib/guarded_append.rb +155 -0
  29. package/scripts/lib/hook_registry.rb +14 -0
  30. package/scripts/lib/hook_replay.rb +211 -0
  31. package/scripts/lib/index_projection.rb +201 -0
  32. package/scripts/lib/installer_core.rb +131 -4
  33. package/scripts/lib/intent_screen.rb +309 -0
  34. package/scripts/lib/intent_screen_ansi.rb +262 -0
  35. package/scripts/lib/message_display.rb +586 -0
  36. package/scripts/lib/node_file.rb +214 -0
  37. package/scripts/lib/node_ids.rb +99 -0
  38. package/scripts/lib/node_ledger.rb +377 -0
  39. package/scripts/lib/node_packet.rb +886 -0
  40. package/scripts/lib/node_return.rb +199 -0
  41. package/scripts/lib/node_worktree.rb +337 -0
  42. package/scripts/lib/outcome_report.rb +440 -0
  43. package/scripts/lib/packet_wrapper.rb +132 -0
  44. package/scripts/lib/ready_set.rb +462 -0
  45. package/scripts/lib/release_guard.rb +16 -0
  46. package/scripts/lib/report_screen.rb +1967 -0
  47. package/scripts/lib/roadmap_graph.rb +210 -0
  48. package/scripts/lib/roadmap_migration.rb +95 -0
  49. package/scripts/lib/roadmap_queue.rb +155 -5
  50. package/scripts/lib/roadmap_render.rb +150 -0
  51. package/scripts/lib/roadmap_savepoint.rb +62 -12
  52. package/scripts/lib/runner_absorb.rb +620 -0
  53. package/scripts/lib/runner_answer.rb +206 -0
  54. package/scripts/lib/runner_core.rb +194 -0
  55. package/scripts/lib/runner_dispatch.rb +482 -0
  56. package/scripts/lib/runner_policy.rb +142 -0
  57. package/scripts/lib/runner_proposals.rb +254 -0
  58. package/scripts/lib/runner_rewind.rb +201 -0
  59. package/scripts/lib/runner_sweep.rb +231 -0
  60. package/scripts/lib/savepoint.rb +149 -12
  61. package/scripts/lib/screen_paint.rb +555 -0
  62. package/scripts/lib/screens/dashboard.rb +20 -0
  63. package/scripts/lib/screens/plan.rb +18 -0
  64. package/scripts/lib/screens/roadmap.rb +15 -0
  65. package/scripts/lib/session_git.rb +49 -18
  66. package/scripts/lib/session_ledger.rb +128 -0
  67. package/scripts/lib/verify_intent.rb +33 -0
  68. package/scripts/lib/work_graph_validator.rb +201 -0
  69. package/scripts/node-packet +92 -0
  70. package/scripts/node-transition +291 -0
  71. package/scripts/outcome-report +74 -0
  72. package/scripts/plastic-lock +8 -1
  73. package/scripts/ready-set +126 -0
  74. package/scripts/release-check +118 -0
  75. package/scripts/report-screen +281 -0
  76. package/scripts/roadmap-graph +119 -0
  77. package/scripts/roadmap-savepoint +7 -0
  78. package/scripts/runner +392 -0
  79. package/scripts/savepoint-note +69 -0
  80. package/scripts/spawn-preamble +9 -2
  81. package/scripts/validate-work-graph +39 -0
  82. package/scripts/verify-intent +2 -1
  83. package/skills/auto/SKILL.md +24 -17
  84. package/skills/auto/references/human-report-contract.md +136 -54
  85. package/skills/conventions/references/locks-and-worktrees.md +12 -0
  86. package/skills/dashboard/SKILL.md +13 -2
  87. package/skills/dashboard/templates/dashboard-global.md +1 -1
  88. package/skills/dashboard/templates/dashboard-project.md +2 -2
  89. package/skills/doctor/SKILL.md +10 -4
  90. package/skills/intent-continuing/SKILL.md +51 -41
  91. package/skills/intent-continuing/references/board-fill.md +9 -0
  92. package/skills/intent-continuing/references/boarding-matrix.md +6 -5
  93. package/skills/intent-continuing/references/context-management.md +1 -1
  94. package/skills/intent-ending/SKILL.md +36 -16
  95. package/skills/intent-executing/SKILL.md +17 -5
  96. package/skills/intent-executing/implementer-prompt.md +6 -1
  97. package/skills/intent-speccing/SKILL.md +7 -4
  98. package/skills/releasing/SKILL.md +39 -0
  99. package/skills/releasing/references/promotion-and-tagging.md +10 -6
  100. package/skills/releasing/references/release-lines.md +1 -1
  101. package/skills/roadmap/SKILL.md +26 -0
  102. package/skills/roadmap/references/file-format.md +10 -0
  103. package/templates/dashboard-screen.md +22 -0
  104. package/templates/display-fixture.md +21 -0
  105. package/templates/graph.md +16 -0
  106. package/templates/intent-screen.md +17 -0
  107. package/templates/node-decision.md +11 -0
  108. package/templates/node-research.md +11 -0
  109. package/templates/node-verify.md +13 -0
  110. package/templates/node-work.md +22 -0
  111. package/templates/outcome.md +19 -1
  112. package/templates/report-plan.md +15 -0
  113. package/templates/report-roadmap-delivered.md +10 -0
  114. package/templates/report-roadmap-plan.md +9 -0
  115. package/templates/report-roadmap-state.md +9 -0
  116. package/templates/report-state.md +11 -0
  117. package/templates/roadmap.md +13 -0
@@ -0,0 +1,206 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require_relative "node_ledger"
6
+ require_relative "ready_set"
7
+ require_relative "graph_file"
8
+ require_relative "atomic_write"
9
+ require_relative "node_file"
10
+ require_relative "node_ids"
11
+ require_relative "runner_core"
12
+ require_relative "node_worktree"
13
+ require_relative "worktree"
14
+
15
+ # RunnerAnswer (intent 340, G7, n6): closes a decision node into graph.md's
16
+ # ## Decisions (C26), or unparks a work node the runner parked at
17
+ # `needs_decision` (327 D22). Both share one precondition - the target must
18
+ # currently BE at `needs_decision` - and one shape of input: a non-empty
19
+ # owner answer.
20
+ #
21
+ # A decision node writes its text into ## Decisions through
22
+ # GraphFile.append_decision (temp-plus-rename, matrix row 6.7) and closes
23
+ # with `done verdict=answered`. Any other kind unparks instead:
24
+ # `READY_PRIOR_STATES` (ready_set.rb) accepts `planned` and
25
+ # `failed_verification` back into dispatch, so below ReadySet::DEFAULT_CAPS'
26
+ # HARD attempt backstop, writing `planned` is enough. At or above it, a
27
+ # plain `planned` line changes nothing about `failed_verification_count`
28
+ # (counted per subject over the WHOLE ledger, never reset) - only a brand
29
+ # new subject id starts clean, so the node is superseded and a successor is
30
+ # minted carrying its body, files and needs across.
31
+ #
32
+ # Pure and dependency-injected down to the clock and the rename call; every
33
+ # test here drives a real tmpdir intent directory, never a real ~/.plastic
34
+ # install.
35
+ module RunnerAnswer
36
+ module_function
37
+
38
+ # v2 NEW-6: `blocked` is neither in `ReadySet::READY_PRIOR_STATES` nor in
39
+ # `ReadySet::TERMINAL_STATES` - a node the runner itself blocks (a broken
40
+ # `project.yml`, a core-integrity drift, an unresolvable graph) was
41
+ # unrecoverable forever, since `answer` refused everything but
42
+ # `needs_decision`. Both park an executor's own dispatch pending an owner
43
+ # decision; only the reason differs.
44
+ PARKED_STATES = %w[needs_decision blocked].freeze
45
+
46
+ def answer(context, node:, text:, now: Time.now, ledger: NodeLedger,
47
+ caps: ReadySet::DEFAULT_CAPS, renamer: File.method(:rename),
48
+ worktree: NodeWorktree, runner: Worktree::ShellRunner.new)
49
+ node = node.to_s
50
+ intent_dir = context.intent_dir
51
+ graph_path = File.join(intent_dir, "graph.md")
52
+
53
+ return refusal("empty_answer") if text.to_s.strip.empty?
54
+
55
+ # M9: a second, unguarded re-parse of graph.md - RunnerCore.context's own
56
+ # first read is already safe, but this fresh read is not.
57
+ loaded = RunnerCore.safe_load_graph(intent_dir)
58
+ return refusal("invalid_graph", errors: loaded[:errors]) unless loaded[:ok]
59
+
60
+ nodes_decl = loaded[:nodes]
61
+ edges = loaded[:edges]
62
+ before_content = read_savepoint(intent_dir)
63
+ entries = NodeLedger.entries_from_content(before_content)
64
+ current_state = NodeLedger.status_for_content(before_content, node)
65
+
66
+ # v2 NEW-6/row 11.10/11.11: parked is `needs_decision` OR `blocked` -
67
+ # anything else (planned, running, done, ...) is a healthy node with no
68
+ # evidence to release it on, and must stay refused exactly as before.
69
+ return refusal("not_parked") unless PARKED_STATES.include?(current_state)
70
+
71
+ kind = (nodes_decl[node] || {})[:kind]
72
+ respun_to = nil
73
+
74
+ if kind.to_s == "decision" && current_state == "needs_decision"
75
+ gf = GraphFile.append_decision(graph_path, text.to_s.strip, renamer: renamer)
76
+ return refusal("graph_write_failed", errors: gf[:errors]) unless gf[:ok]
77
+
78
+ # A `done` line's attribution (a non-blank `holder=`) is what
79
+ # ReadySet.ready? requires of a needs target before a successor may
80
+ # become ready (NodeLedger.attributed?): an unattributed done line
81
+ # closes the decision node's own status but never releases anything
82
+ # downstream, so the holder is the session driving this very answer.
83
+ fields = { gates: "answer", verdict: "answered" }
84
+ fields[:holder] = context.session unless blank?(context.session)
85
+ result = ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "done",
86
+ fields: fields, now: now)
87
+ return refusal("append_failed") unless result == :written
88
+ else
89
+ cap = caps.fetch(kind.to_s) { caps["work"] }
90
+ attempts = ReadySet.attempts_count(entries, node)
91
+
92
+ if cap && attempts >= cap
93
+ respun_to = respin(intent_dir, node, nodes_decl, edges, ledger: ledger, now: now, renamer: renamer,
94
+ holder: context.session, context: context, worktree: worktree, runner: runner)
95
+ else
96
+ fields = {}
97
+ fields[:holder] = context.session unless blank?(context.session)
98
+ result = ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "planned",
99
+ fields: fields, now: now)
100
+ return refusal("append_failed") unless result == :written
101
+ end
102
+ end
103
+
104
+ RunnerCore.render_status(context)
105
+
106
+ {
107
+ ok: true, respun_to: respun_to, errors: [],
108
+ newly_ready: newly_ready(intent_dir, before_content: before_content, before_nodes: nodes_decl, before_edges: edges),
109
+ }
110
+ end
111
+
112
+ # --- the hard-cap respin (327 D22) ------------------------------------------
113
+
114
+ def respin(intent_dir, node, nodes_decl, edges, ledger:, now:, renamer:, holder: nil,
115
+ context:, worktree: NodeWorktree, runner: Worktree::ShellRunner.new)
116
+ decl = nodes_decl[node] || {}
117
+ kind = decl[:kind]
118
+ node_path = ReadySet.find_node_path(intent_dir, node)
119
+ parsed = NodeFile.parse(node_path)
120
+
121
+ mint_pool = NodeIds.taken(intent_dir).dup
122
+ succ_id = NodeFile.mint_id(kind, mint_pool)
123
+
124
+ raw = File.read(node_path)
125
+ parts = raw.split("---", 3)
126
+ body = substitute_id(parts[2].to_s, node, succ_id)
127
+ files = parsed[:files] || decl[:files] || []
128
+ budget = parsed[:budget]
129
+
130
+ frontmatter = "---\nnode: #{succ_id}\nkind: #{kind}\nfiles: [#{Array(files).join(', ')}]\nbudget: #{budget}\n---"
131
+ succ_path = File.join(intent_dir, "nodes", "#{succ_id}.md")
132
+ FileUtils.mkdir_p(File.dirname(succ_path))
133
+ File.write(succ_path, "#{frontmatter}#{body}")
134
+
135
+ append_graph_line(File.join(intent_dir, "graph.md"), succ_id, edges[node] || [], renamer: renamer)
136
+
137
+ fields = { by: succ_id }
138
+ fields[:holder] = holder unless blank?(holder)
139
+ ledger.append_transition(savepoint_path(intent_dir), subject: node, state: "superseded",
140
+ fields: fields, now: now)
141
+
142
+ # M5/D7: a superseded node's evidence no longer needs to survive - its
143
+ # own worktree releases here, the same as `done`'s does in RunnerAbsorb.
144
+ worktree.release(context, node: node, state: "superseded", runner: runner)
145
+
146
+ succ_id
147
+ end
148
+ private_class_method :respin
149
+
150
+ def append_graph_line(graph_path, id, needs, renamer:)
151
+ content = File.read(graph_path)
152
+ body = GraphFile.section_body(content, "## Graph").to_s
153
+ lines = body.split("\n")
154
+ rendered = needs.empty? ? "nothing" : needs.join(" ")
155
+ lines << "- #{id} needs #{rendered}"
156
+ new_body = lines.join("\n")
157
+ new_content = GraphFile.replace_or_append_section(content, "## Graph", new_body)
158
+ AtomicWrite.write(graph_path, new_content, renamer: renamer)
159
+ end
160
+ private_class_method :append_graph_line
161
+
162
+ def substitute_id(text, old_id, new_id)
163
+ text.to_s.gsub(/(?<![A-Za-z0-9_-])#{Regexp.escape(old_id.to_s)}(?![A-Za-z0-9_-])/, new_id.to_s)
164
+ end
165
+ private_class_method :substitute_id
166
+
167
+ # --- what became ready (C26) ------------------------------------------------
168
+
169
+ def newly_ready(intent_dir, before_content:, before_nodes:, before_edges:)
170
+ after = RunnerCore.safe_load_graph(intent_dir)
171
+ return [] unless after[:ok]
172
+
173
+ after_content = read_savepoint(intent_dir)
174
+
175
+ after[:nodes].keys.select do |id|
176
+ was = before_nodes.key?(id) &&
177
+ ReadySet.ready?(content: before_content, subject: id, graph: { edges: before_edges },
178
+ nodes: before_nodes)[:ready]
179
+ now_ready = ReadySet.ready?(content: after_content, subject: id, graph: { edges: after[:edges] },
180
+ nodes: after[:nodes])[:ready]
181
+ now_ready && !was
182
+ end.sort
183
+ end
184
+ private_class_method :newly_ready
185
+
186
+ def refusal(reason, errors: [])
187
+ { ok: false, reason: reason, errors: errors, respun_to: nil, newly_ready: [] }
188
+ end
189
+ private_class_method :refusal
190
+
191
+ def blank?(value)
192
+ value.nil? || value.to_s.strip.empty?
193
+ end
194
+ private_class_method :blank?
195
+
196
+ def read_savepoint(intent_dir)
197
+ path = savepoint_path(intent_dir)
198
+ File.exist?(path) ? File.read(path) : ""
199
+ end
200
+ private_class_method :read_savepoint
201
+
202
+ def savepoint_path(intent_dir)
203
+ File.join(intent_dir.to_s, "savepoint.md")
204
+ end
205
+ private_class_method :savepoint_path
206
+ end
@@ -0,0 +1,194 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "arm"
5
+ require_relative "lock"
6
+ require_relative "worktree"
7
+ require_relative "ready_set"
8
+ require_relative "node_ledger"
9
+ require_relative "graph_file"
10
+
11
+ # RunnerCore (intent 340, G7, n1): the shared context every scripts/runner
12
+ # verb needs, resolved once, plus the two read-only queries (status,
13
+ # complete?) and the one write (render_status) this node ships. Session
14
+ # resolution mirrors NodeTransition.resolve_owning_session
15
+ # (scripts/node-transition) exactly, so `running`'s ownership rule and the
16
+ # runner's own idea of "who holds this intent" never drift apart.
17
+ #
18
+ # Pure and dependency-injected: #context takes intent_dir:, home:, and
19
+ # optional session:/env: overrides (the CLI passes the real
20
+ # ENV["CLAUDE_CODE_SESSION_ID"] at its own boundary, the same place
21
+ # node-transition's main reads it, never inside this library). It never
22
+ # raises across its boundary: every field is resolved independently, so a
23
+ # missing or malformed graph.md lands in `errors` and every other field
24
+ # still resolves. #status and #complete? read the filesystem but write
25
+ # nothing; #render_status is the one write, and it goes through
26
+ # GraphFile.write_status, the existing atomic writer.
27
+ module RunnerCore
28
+ module_function
29
+
30
+ Context = Struct.new(
31
+ :intent_dir, :intent_id, :intent_slug, :store, :plastic_home,
32
+ :session, :worktree, :worktree_branch, :graph, :errors,
33
+ keyword_init: true
34
+ )
35
+
36
+ def context(intent_dir:, home: Dir.home, session: nil, env: ENV["CLAUDE_CODE_SESSION_ID"])
37
+ dir = File.expand_path(intent_dir.to_s)
38
+ errors = []
39
+
40
+ intent_id = safe(errors, "intent id") { Arm.intent_id_for(dir) }
41
+ intent_slug = safe(errors, "intent slug") { Worktree.slug_from_dir(dir) }
42
+ store = safe(errors, "store") { Arm.store_for(dir) }
43
+
44
+ # Arm.home_for's own docstring says it returns the PARENT of `.plastic`
45
+ # (the OS home, honestly named `home_root` here) - everywhere else in the
46
+ # tree, including CoreIntegrity.check and RunnerProposals' templates_dir,
47
+ # `plastic_home` means the `.plastic` directory itself. Row 9.5: append
48
+ # it once, here, so every consumer of `context.plastic_home` sees the
49
+ # same directory `manifest.json` and `templates/` actually live in.
50
+ home_root = safe(errors, "plastic home") { Arm.home_for(dir, home: home) } || home
51
+ plastic_home = File.join(home_root, ".plastic")
52
+
53
+ resolved_session = safe(errors, "session") do
54
+ resolve_owning_session(dir, explicit: session, env_session: env, store: store, intent_id: intent_id)
55
+ end
56
+
57
+ # Arm.worktree_block's own `home:` parameter is the OS-home fallback
58
+ # `Arm.home_for` takes (it re-derives home_for internally), not the
59
+ # `.plastic` directory - pass it `home_root`, never `plastic_home`.
60
+ worktree_block = safe(errors, "worktree") { Arm.worktree_block(intent_dir: dir, home: home_root) } || {}
61
+
62
+ graph = safe(errors, "graph") { ReadySet.load_graph(dir) } ||
63
+ { ok: false, edges: {}, nodes: {}, errors: ["graph could not be resolved"] }
64
+ errors.concat(graph[:errors] || []) unless graph[:ok]
65
+
66
+ Context.new(
67
+ intent_dir: dir,
68
+ intent_id: intent_id,
69
+ intent_slug: intent_slug,
70
+ store: store,
71
+ plastic_home: plastic_home,
72
+ session: resolved_session,
73
+ worktree: worktree_block["code"],
74
+ worktree_branch: worktree_block["code_branch"],
75
+ graph: graph,
76
+ errors: errors
77
+ )
78
+ end
79
+
80
+ # Mirrors NodeTransition.resolve_owning_session (scripts/node-transition,
81
+ # intent 335 G2): an explicit session is authoritative and never falls
82
+ # through (an explicit non-owner is refused outright, never silently
83
+ # granted ownership through a fallback); without it, try env_session, then
84
+ # the derived `auto-` key, keeping the first candidate that actually HOLDS
85
+ # the lock.
86
+ def resolve_owning_session(intent_dir, explicit:, env_session:, store:, intent_id:)
87
+ if explicit && !explicit.to_s.strip.empty?
88
+ return Lock.holds?(intent_dir, session: explicit) ? explicit : nil
89
+ end
90
+
91
+ candidates = [env_session, Arm.derive_key(store, intent_id)].reject { |c| c.to_s.strip.empty? }
92
+ candidates.find { |c| Lock.holds?(intent_dir, session: c) }
93
+ end
94
+
95
+ # status(context) -> {id => {kind:, state:, ready:, blockers:,
96
+ # last_transition:}} for every node graph.md DECLARES, whether or not it
97
+ # has a ledger line yet (a node with no line reads as "planned", the
98
+ # ledger's own implicit starting state). Writes NOTHING: savepoint.md's
99
+ # content is read once and every node's readiness is evaluated against
100
+ # that one read, mirroring the way node-transition's own precondition
101
+ # never re-reads the file per node.
102
+ def status(context)
103
+ edges = (context.graph || {})[:edges] || {}
104
+ nodes = (context.graph || {})[:nodes] || {}
105
+ content = savepoint_content(context.intent_dir)
106
+ entries = NodeLedger.entries_from_content(content)
107
+
108
+ nodes.each_with_object({}) do |(id, decl), rows|
109
+ readiness = ReadySet.ready?(content: content, subject: id, graph: { edges: edges }, nodes: nodes)
110
+ last = entries.select { |e| !e[:torn] && e[:subject] == id }.last
111
+
112
+ rows[id] = {
113
+ kind: decl[:kind],
114
+ state: NodeLedger.status_for_content(content, id),
115
+ ready: readiness[:ready],
116
+ blockers: readiness[:blockers],
117
+ last_transition: last,
118
+ }
119
+ end
120
+ end
121
+
122
+ # complete?(context) -> true only when EVERY declared node resolves to a
123
+ # terminal state (done, superseded, abandoned). An empty declared-node set,
124
+ # or any node that is merely not-ready (blocked, needs_decision, deferred,
125
+ # or simply not yet attempted), is stalled, not complete - never shortcut
126
+ # to "the ready set is empty".
127
+ def complete?(context)
128
+ rows = status(context)
129
+ return false if rows.empty?
130
+
131
+ rows.values.all? { |v| ReadySet::TERMINAL_STATES.include?(v[:state]) }
132
+ end
133
+
134
+ # render_status(context) -> GraphFile.write_status's own {ok:, errors:}.
135
+ # One row per node graph.md declares, including a node with no ledger line
136
+ # (which renders "planned"); a graph.md with no ## Status section gets one
137
+ # created, never a raise - both guarantees come from GraphFile.write_status
138
+ # itself, this method only ever supplies the rows.
139
+ #
140
+ # M8: Detail carries the readiness BLOCKERS only for a node still in play;
141
+ # a TERMINAL node (done, superseded, abandoned) instead renders its own
142
+ # last transition's fields, so a `done` node reads its commit rather than
143
+ # the nonsense "n1 is done, not eligible to enter running" every later
144
+ # transition used to rewrite it to.
145
+ def render_status(context)
146
+ rows = status(context)
147
+ graph_rows = rows.map do |id, view|
148
+ { node: id, state: view[:state], detail: detail_for(view) }
149
+ end
150
+ GraphFile.write_status(File.join(context.intent_dir.to_s, "graph.md"), graph_rows)
151
+ end
152
+
153
+ def detail_for(view)
154
+ if ReadySet::TERMINAL_STATES.include?(view[:state])
155
+ transition_detail(view[:last_transition])
156
+ else
157
+ Array(view[:blockers]).join("; ")
158
+ end
159
+ end
160
+
161
+ def transition_detail(last_transition)
162
+ return "" unless last_transition
163
+
164
+ fields = last_transition[:fields] || {}
165
+ fields.map { |k, v| "#{k}=#{v}" }.join(" ")
166
+ end
167
+
168
+ def savepoint_content(intent_dir)
169
+ path = File.join(intent_dir.to_s, "savepoint.md")
170
+ File.exist?(path) ? File.read(path) : ""
171
+ end
172
+
173
+ def safe(errors, label)
174
+ yield
175
+ rescue StandardError => e
176
+ errors << "#{label}: #{e.message}"
177
+ nil
178
+ end
179
+
180
+ # safe_load_graph(intent_dir) -> ReadySet.load_graph's own result, or the
181
+ # same {ok: false, edges: {}, nodes: {}, errors: [...]} shape when the
182
+ # graph is not the well-formed UTF-8 ReadySet assumes (post-execution
183
+ # review M9). `step`, `answer` and `rewind` all re-read and re-parse
184
+ # graph.md fresh, after RunnerCore.context's own first, already-guarded
185
+ # read (`safe` above) - a raise from THAT second read crossed the
186
+ # runner's boundary as a raw stack trace. The one safe loader every such
187
+ # call site uses now, so a graph that goes bad between two reads refuses
188
+ # cleanly everywhere, not just here.
189
+ def safe_load_graph(intent_dir)
190
+ ReadySet.load_graph(intent_dir)
191
+ rescue StandardError => e
192
+ { ok: false, edges: {}, nodes: {}, errors: ["graph.md could not be read: #{e.message}"] }
193
+ end
194
+ end