@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.
- package/agents/plastic-enforcer.md +3 -1
- package/agents/plastic-executor.md +9 -3
- package/hooks/hooks.json +12 -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-capture +25 -113
- package/scripts/hook-message-display +81 -0
- package/scripts/hook-record +12 -4
- package/scripts/hook-session-start +23 -1
- 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/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 +101 -5
- package/scripts/lib/doctor_session_ledger.rb +52 -0
- package/scripts/lib/graph_edges.rb +121 -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 +14 -0
- package/scripts/lib/hook_replay.rb +211 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +131 -4
- 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/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 +886 -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 +482 -0
- package/scripts/lib/runner_policy.rb +142 -0
- package/scripts/lib/runner_proposals.rb +254 -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/verify_intent.rb +33 -0
- package/scripts/lib/work_graph_validator.rb +201 -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/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 +392 -0
- package/scripts/savepoint-note +69 -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 +24 -17
- 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 +17 -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/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,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
|