@zalom/plastic 2.0.0-alpha.19 → 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/package.json +1 -1
- package/scripts/doctor.rb +36 -0
- package/scripts/index-projection +74 -0
- package/scripts/lib/core_integrity.rb +71 -0
- package/scripts/lib/graph_tree.rb +98 -0
- package/scripts/lib/index_projection.rb +201 -0
- package/scripts/lib/installer_core.rb +44 -0
- package/scripts/lib/node_packet.rb +15 -2
- package/scripts/lib/node_return.rb +199 -0
- package/scripts/lib/node_worktree.rb +337 -0
- package/scripts/lib/report_screen.rb +26 -0
- package/scripts/lib/roadmap_graph.rb +210 -0
- package/scripts/lib/roadmap_migration.rb +95 -0
- package/scripts/lib/roadmap_queue.rb +17 -42
- package/scripts/lib/roadmap_render.rb +150 -0
- 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/roadmap-graph +119 -0
- package/scripts/runner +392 -0
- package/skills/auto/SKILL.md +1 -1
- package/skills/roadmap/SKILL.md +17 -0
- package/templates/report-roadmap-plan.md +1 -1
- package/templates/roadmap.md +13 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
# NodeReturn (intent 340, G7, n4): the closed schema an executor's return must
|
|
7
|
+
# satisfy before RunnerAbsorb ever writes a ledger line from it. #parse reads
|
|
8
|
+
# one YAML document and returns a Result struct (ok: true, every field
|
|
9
|
+
# populated) or a named failure (ok: false, errors: [...]) - never raises
|
|
10
|
+
# across its own boundary, and never trusts anything the caller has not
|
|
11
|
+
# already scrubbed to valid UTF-8 (it scrubs again itself, defense in depth).
|
|
12
|
+
#
|
|
13
|
+
# The key set is closed (ALLOWED_KEYS): an unknown key is a refusal, not a
|
|
14
|
+
# silently dropped field, so the contract between an executor and the runner
|
|
15
|
+
# cannot erode without the next return going red. The status vocabulary is
|
|
16
|
+
# closed too (STATUSES), and each status carries its own required field
|
|
17
|
+
# (REQUIRED_FIELDS) - `done` needs `commit`, `needs_decision` needs
|
|
18
|
+
# `question`, `failed_verification` and `blocked` need `reason`.
|
|
19
|
+
#
|
|
20
|
+
# `proposed_nodes` and `proposed_edges` entries are pinned sub-schemas, not
|
|
21
|
+
# left to whatever an executor happens to emit: a node entry carries `kind`,
|
|
22
|
+
# `title` and `needs`, with optional `files` and `budget`; an edge entry
|
|
23
|
+
# carries `from` and `to`. A malformed entry anywhere in either list refuses
|
|
24
|
+
# the whole return, the same as any other schema violation.
|
|
25
|
+
#
|
|
26
|
+
# `findings` is coerced to an array of plain strings and capped, both in
|
|
27
|
+
# count (MAX_FINDINGS) and per-entry length (MAX_FINDING_LENGTH), so a
|
|
28
|
+
# nested structure or a runaway array can never reach the Insight line
|
|
29
|
+
# RunnerAbsorb builds from it.
|
|
30
|
+
module NodeReturn
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
STATUSES = %w[done failed_verification needs_decision blocked].freeze
|
|
34
|
+
|
|
35
|
+
ALLOWED_KEYS = %w[
|
|
36
|
+
node status commit summary findings proposed_nodes proposed_edges question reason
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
REQUIRED_FIELDS = {
|
|
40
|
+
"done" => %w[commit],
|
|
41
|
+
"needs_decision" => %w[question],
|
|
42
|
+
"failed_verification" => %w[reason],
|
|
43
|
+
"blocked" => %w[reason],
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
PROPOSED_NODE_REQUIRED = %w[kind title needs].freeze
|
|
47
|
+
PROPOSED_EDGE_REQUIRED = %w[from to].freeze
|
|
48
|
+
|
|
49
|
+
MAX_FINDINGS = 20
|
|
50
|
+
MAX_FINDING_LENGTH = 500
|
|
51
|
+
|
|
52
|
+
Result = Struct.new(
|
|
53
|
+
:ok, :node, :status, :commit, :summary, :findings, :proposed_nodes, :proposed_edges,
|
|
54
|
+
:question, :reason, :errors,
|
|
55
|
+
keyword_init: true
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# parse(text) -> a Result. `text` is scrubbed to valid UTF-8 before it ever
|
|
59
|
+
# reaches the YAML parser (row 4.12), and loaded with aliases disabled and
|
|
60
|
+
# no permitted classes (row 4.11: an alias or anchor bomb is refused, not
|
|
61
|
+
# expanded). Every failure path returns a Result with ok: false and a
|
|
62
|
+
# human-readable errors: list; nothing here ever raises out to the caller.
|
|
63
|
+
def parse(text)
|
|
64
|
+
scrubbed = text.to_s.dup.force_encoding("UTF-8").scrub
|
|
65
|
+
loaded = safe_load(scrubbed)
|
|
66
|
+
return failure([loaded[:error]]) unless loaded[:ok]
|
|
67
|
+
|
|
68
|
+
doc = loaded[:value]
|
|
69
|
+
return failure(["return must be a YAML mapping, got #{doc.class}"]) unless doc.is_a?(Hash)
|
|
70
|
+
|
|
71
|
+
doc = stringify_keys(doc)
|
|
72
|
+
unknown = doc.keys - ALLOWED_KEYS
|
|
73
|
+
return failure(["unknown key(s): #{unknown.join(', ')}"]) if unknown.any?
|
|
74
|
+
|
|
75
|
+
return failure(["missing node id (node:)"]) unless present?(doc["node"])
|
|
76
|
+
|
|
77
|
+
status = doc["status"].to_s
|
|
78
|
+
return failure(["unknown status: #{doc["status"].inspect}"]) unless STATUSES.include?(status)
|
|
79
|
+
|
|
80
|
+
missing = REQUIRED_FIELDS.fetch(status, []).reject { |key| present?(doc[key]) }
|
|
81
|
+
return failure(["status #{status} requires #{missing.join(', ')}"]) if missing.any?
|
|
82
|
+
|
|
83
|
+
proposed_nodes, node_errors = normalize_proposed_nodes(doc["proposed_nodes"])
|
|
84
|
+
return failure(node_errors) if node_errors.any?
|
|
85
|
+
|
|
86
|
+
proposed_edges, edge_errors = normalize_proposed_edges(doc["proposed_edges"])
|
|
87
|
+
return failure(edge_errors) if edge_errors.any?
|
|
88
|
+
|
|
89
|
+
Result.new(
|
|
90
|
+
ok: true,
|
|
91
|
+
node: doc["node"].to_s,
|
|
92
|
+
status: status,
|
|
93
|
+
commit: doc["commit"],
|
|
94
|
+
summary: doc["summary"],
|
|
95
|
+
findings: normalize_findings(doc["findings"]),
|
|
96
|
+
proposed_nodes: proposed_nodes,
|
|
97
|
+
proposed_edges: proposed_edges,
|
|
98
|
+
question: doc["question"],
|
|
99
|
+
reason: doc["reason"],
|
|
100
|
+
errors: [],
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def safe_load(text)
|
|
105
|
+
{ ok: true, value: YAML.safe_load(text, aliases: false, permitted_classes: []) }
|
|
106
|
+
rescue Psych::Exception, Psych::AliasesNotEnabled, ArgumentError => e
|
|
107
|
+
{ ok: false, error: "return is not valid YAML: #{e.message}" }
|
|
108
|
+
end
|
|
109
|
+
private_class_method :safe_load
|
|
110
|
+
|
|
111
|
+
def failure(errors)
|
|
112
|
+
Result.new(
|
|
113
|
+
ok: false, node: nil, status: nil, commit: nil, summary: nil, findings: [],
|
|
114
|
+
proposed_nodes: [], proposed_edges: [], question: nil, reason: nil,
|
|
115
|
+
errors: Array(errors)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :failure
|
|
119
|
+
|
|
120
|
+
def present?(value)
|
|
121
|
+
!(value.nil? || value.to_s.strip.empty?)
|
|
122
|
+
end
|
|
123
|
+
private_class_method :present?
|
|
124
|
+
|
|
125
|
+
def stringify_keys(hash)
|
|
126
|
+
hash.each_with_object({}) { |(k, v), memo| memo[k.to_s] = v }
|
|
127
|
+
end
|
|
128
|
+
private_class_method :stringify_keys
|
|
129
|
+
|
|
130
|
+
# [entries_or_nil, errors]. `raw` absent is an empty, valid list (no
|
|
131
|
+
# proposals is the common case). Anything present that is not an Array, or
|
|
132
|
+
# any entry that is not a Hash carrying every PROPOSED_NODE_REQUIRED key,
|
|
133
|
+
# refuses the whole return (row 4.37) rather than dropping the one bad
|
|
134
|
+
# entry silently.
|
|
135
|
+
def normalize_proposed_nodes(raw)
|
|
136
|
+
return [[], []] if raw.nil?
|
|
137
|
+
return [nil, ["proposed_nodes: must be a list, got #{raw.class}"]] unless raw.is_a?(Array)
|
|
138
|
+
|
|
139
|
+
errors = []
|
|
140
|
+
entries = raw.map do |entry|
|
|
141
|
+
unless entry.is_a?(Hash)
|
|
142
|
+
errors << "proposed_nodes entry must be a mapping, got #{entry.class}"
|
|
143
|
+
next nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
e = stringify_keys(entry)
|
|
147
|
+
missing = PROPOSED_NODE_REQUIRED.reject { |key| present?(e[key]) }
|
|
148
|
+
if missing.any?
|
|
149
|
+
errors << "proposed_nodes entry missing #{missing.join(', ')}"
|
|
150
|
+
next nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
{ "kind" => e["kind"], "title" => e["title"], "needs" => Array(e["needs"]),
|
|
154
|
+
"files" => e["files"], "budget" => e["budget"] }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
errors.any? ? [nil, errors] : [entries, []]
|
|
158
|
+
end
|
|
159
|
+
private_class_method :normalize_proposed_nodes
|
|
160
|
+
|
|
161
|
+
# Same shape as #normalize_proposed_nodes, for proposed_edges (row 4.38):
|
|
162
|
+
# each entry needs `from` and `to`.
|
|
163
|
+
def normalize_proposed_edges(raw)
|
|
164
|
+
return [[], []] if raw.nil?
|
|
165
|
+
return [nil, ["proposed_edges: must be a list, got #{raw.class}"]] unless raw.is_a?(Array)
|
|
166
|
+
|
|
167
|
+
errors = []
|
|
168
|
+
entries = raw.map do |entry|
|
|
169
|
+
unless entry.is_a?(Hash)
|
|
170
|
+
errors << "proposed_edges entry must be a mapping, got #{entry.class}"
|
|
171
|
+
next nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
e = stringify_keys(entry)
|
|
175
|
+
missing = PROPOSED_EDGE_REQUIRED.reject { |key| present?(e[key]) }
|
|
176
|
+
if missing.any?
|
|
177
|
+
errors << "proposed_edges entry missing #{missing.join(', ')}"
|
|
178
|
+
next nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
{ "from" => e["from"], "to" => e["to"] }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
errors.any? ? [nil, errors] : [entries, []]
|
|
185
|
+
end
|
|
186
|
+
private_class_method :normalize_proposed_edges
|
|
187
|
+
|
|
188
|
+
# Coerce to an array of plain strings, capped at MAX_FINDINGS entries of at
|
|
189
|
+
# most MAX_FINDING_LENGTH characters each (row 4.10) - a nested Hash, an
|
|
190
|
+
# Integer, or any other scalar an executor emits becomes its #to_s rather
|
|
191
|
+
# than reaching the Insight line RunnerAbsorb builds downstream.
|
|
192
|
+
def normalize_findings(raw)
|
|
193
|
+
return [] if raw.nil?
|
|
194
|
+
|
|
195
|
+
list = raw.is_a?(Array) ? raw : [raw]
|
|
196
|
+
list.first(MAX_FINDINGS).map { |item| item.to_s[0, MAX_FINDING_LENGTH] }
|
|
197
|
+
end
|
|
198
|
+
private_class_method :normalize_findings
|
|
199
|
+
end
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "worktree"
|
|
5
|
+
require_relative "node_ledger"
|
|
6
|
+
require_relative "ready_set"
|
|
7
|
+
require_relative "savepoint"
|
|
8
|
+
|
|
9
|
+
# NodeWorktree (intent 340, G7, n3): a work node's own git worktree, cut from
|
|
10
|
+
# the intent branch tip, merged back into the intent branch (never `alpha`),
|
|
11
|
+
# and swept once its node is terminal. Verify, research and decision nodes
|
|
12
|
+
# never get one (327 spec, C4: a verify node writes no code, so it gets
|
|
13
|
+
# nowhere to write a diff).
|
|
14
|
+
#
|
|
15
|
+
# The node worktree's identity is `<repo>/.claude/worktrees/<id>--<slug>--<node>`
|
|
16
|
+
# on branch `plastic/<id>--<slug>--<node>` - one path segment deeper than the
|
|
17
|
+
# intent worktree Worktree already provisions. `repo` is derived from
|
|
18
|
+
# `context.worktree` alone (it is always `<repo>/.claude/worktrees/<id>--<slug>`,
|
|
19
|
+
# the shape Worktree.paths itself constructs), never re-resolved through
|
|
20
|
+
# projects.yml: any git worktree of a repo can run `git worktree add` for a
|
|
21
|
+
# sibling, so the intent worktree is a perfectly good `-C` handle for every
|
|
22
|
+
# git call this module makes.
|
|
23
|
+
#
|
|
24
|
+
# Deliberately does NOT reuse Worktree.merge_branch (it resolves its target
|
|
25
|
+
# as the REPO's own currently checked-out branch, which is `alpha` in the
|
|
26
|
+
# ordinary case - merging node work there would skip the intent branch
|
|
27
|
+
# entirely) or WorktreeSweep (it globs the store-worktree tree intent 178
|
|
28
|
+
# retired and derives a `plastic-store/` branch name; it can never see a node
|
|
29
|
+
# worktree). This module carries its own merge and its own reaper.
|
|
30
|
+
#
|
|
31
|
+
# Pure and dependency-injected: every git call goes through an injected
|
|
32
|
+
# `runner:` (default Worktree::ShellRunner), always `-C <path>`, never cwd.
|
|
33
|
+
module NodeWorktree
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Only a `work` node gets a worktree (matrix 3.4/3.5).
|
|
37
|
+
WORKTREE_KINDS = %w[work].freeze
|
|
38
|
+
|
|
39
|
+
# release(state:) removes the worktree for these terminal states (matrix
|
|
40
|
+
# 3.8, 3.11); every other state (failed_verification, needs_decision, or
|
|
41
|
+
# anything not yet terminal) keeps it (matrix 3.9, 3.10) - the safe-by-
|
|
42
|
+
# default direction, since evidence you cannot see cannot be reviewed.
|
|
43
|
+
REMOVE_ON_STATES = %w[done superseded abandoned].freeze
|
|
44
|
+
|
|
45
|
+
# --- paths -----------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
# {"repo"=>, "path"=>, "branch"=>}, all nil when `context.worktree` is
|
|
48
|
+
# blank (a global-store-only intent, matrix 3.6 - there is no repo to
|
|
49
|
+
# derive a node worktree path under). Pure: no git call, no filesystem read.
|
|
50
|
+
def paths(context, node:)
|
|
51
|
+
repo = repo_root(context)
|
|
52
|
+
return { "repo" => nil, "path" => nil, "branch" => nil } if repo.nil?
|
|
53
|
+
|
|
54
|
+
name = worktree_name(context, node)
|
|
55
|
+
{ "repo" => repo, "path" => File.join(repo, ".claude", "worktrees", name), "branch" => "plastic/#{name}" }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# --- provisioning ------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# provision(context, node:, kind:) -> {ok:, path:, branch:, provisioned:}.
|
|
61
|
+
# A non-`work` kind gets no worktree (matrix 3.4/3.5). An unresolvable or
|
|
62
|
+
# non-git repo fails open (matrix 3.6): ok stays true, provisioned is
|
|
63
|
+
# false, nothing raises. An existing worktree at the target path is reused,
|
|
64
|
+
# never re-erred on (matrix 3.3).
|
|
65
|
+
def provision(context, node:, kind:, runner: Worktree::ShellRunner.new)
|
|
66
|
+
return unprovisioned unless WORKTREE_KINDS.include?(kind.to_s)
|
|
67
|
+
|
|
68
|
+
p = paths(context, node: node)
|
|
69
|
+
repo, path, branch = p["repo"], p["path"], p["branch"]
|
|
70
|
+
return unprovisioned if repo.nil? || !Worktree.git_repo?(runner, repo)
|
|
71
|
+
return { ok: true, path: path, branch: branch, provisioned: true } if Dir.exist?(path)
|
|
72
|
+
|
|
73
|
+
intent_branch = context.worktree_branch
|
|
74
|
+
return unprovisioned if blank?(intent_branch)
|
|
75
|
+
|
|
76
|
+
# Cut FROM the intent branch's own tip (matrix 3.2), never from whatever
|
|
77
|
+
# the repo's main checkout happens to have checked out - the exact bug
|
|
78
|
+
# Worktree.merge_branch has on the merge side, avoided here on the add
|
|
79
|
+
# side by naming the start point explicitly.
|
|
80
|
+
res = runner.run("-C", repo, "worktree", "add", path, "-b", branch, intent_branch)
|
|
81
|
+
return { ok: true, path: path, branch: branch, provisioned: true } if res.success?
|
|
82
|
+
|
|
83
|
+
# The branch may already exist (a prior provision whose worktree was
|
|
84
|
+
# pruned but whose branch survived): retry attaching it.
|
|
85
|
+
res2 = runner.run("-C", repo, "worktree", "add", path, branch)
|
|
86
|
+
if res2.success?
|
|
87
|
+
{ ok: true, path: path, branch: branch, provisioned: true }
|
|
88
|
+
else
|
|
89
|
+
warn "runner: node worktree provision failed for #{node}: #{res2.stderr.to_s.strip}"
|
|
90
|
+
unprovisioned
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# --- diffing -----------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
# changed_paths(context, node:, kind:) -> the file paths a node's return
|
|
97
|
+
# actually touched, or nil when that cannot be measured (matrix 3.13/3.14,
|
|
98
|
+
# B3): a genuinely empty diff is [], never conflated with "the git call
|
|
99
|
+
# itself could not run" or "there was nowhere to measure a diff at all" -
|
|
100
|
+
# RunnerAbsorb treats nil as `failed_verification reason=scope_unmeasurable`,
|
|
101
|
+
# never as a clean pass.
|
|
102
|
+
#
|
|
103
|
+
# A `work` node has its own branch (matrix 3.1-3.3), so its diff is the
|
|
104
|
+
# ordinary two-branch comparison. A verify or research node gets no
|
|
105
|
+
# worktree or branch of its own (D6, D29) - the only place such a node
|
|
106
|
+
# could actually leave a diff is the shared INTENT worktree itself, so its
|
|
107
|
+
# diff is measured there instead (row 9.9), never against a per-node
|
|
108
|
+
# branch that, for these kinds, never exists (the exact fail-open B3
|
|
109
|
+
# named: that lookup always failed and always read as "nothing changed").
|
|
110
|
+
def changed_paths(context, node:, kind: "work", runner: Worktree::ShellRunner.new)
|
|
111
|
+
if WORKTREE_KINDS.include?(kind.to_s)
|
|
112
|
+
node_branch_diff(context, node: node, runner: runner)
|
|
113
|
+
else
|
|
114
|
+
intent_worktree_diff(context, runner: runner)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def node_branch_diff(context, node:, runner:)
|
|
119
|
+
p = paths(context, node: node)
|
|
120
|
+
repo, branch = p["repo"], p["branch"]
|
|
121
|
+
return nil if blank?(repo) || blank?(branch) || blank?(context.worktree_branch)
|
|
122
|
+
|
|
123
|
+
res = runner.run("-C", repo, "diff", "--name-only", "#{context.worktree_branch}...#{branch}")
|
|
124
|
+
return nil unless res.success?
|
|
125
|
+
|
|
126
|
+
res.stdout.to_s.each_line.map(&:strip).reject(&:empty?)
|
|
127
|
+
end
|
|
128
|
+
private_class_method :node_branch_diff
|
|
129
|
+
|
|
130
|
+
# A non-work node's diff, measured in the intent worktree itself (row 9.9)
|
|
131
|
+
# against the last commit the ledger already knows is clean: the most
|
|
132
|
+
# recent `done` transition's own `commit=` (any subject) - the intent
|
|
133
|
+
# branch only ever advances past that point through a work node's own
|
|
134
|
+
# merge (recorded there) or through exactly the kind of out-of-band commit
|
|
135
|
+
# this check exists to catch. With no such transition recorded yet (no
|
|
136
|
+
# work node has landed on this intent branch at all), the fork point with
|
|
137
|
+
# the repo's own currently checked-out branch is the only other honest
|
|
138
|
+
# baseline available; either baseline missing is "cannot measure" (nil),
|
|
139
|
+
# never "clean" (matrix 3.6's fail-open direction reversed: unmeasurable
|
|
140
|
+
# refuses here, it does not pass).
|
|
141
|
+
def intent_worktree_diff(context, runner:)
|
|
142
|
+
intent_worktree = context.worktree
|
|
143
|
+
branch = context.worktree_branch
|
|
144
|
+
return nil if blank?(intent_worktree) || blank?(branch)
|
|
145
|
+
return nil unless Worktree.git_repo?(runner, intent_worktree)
|
|
146
|
+
|
|
147
|
+
baseline = last_done_commit(context) || repo_fork_point(context, runner)
|
|
148
|
+
return nil if blank?(baseline)
|
|
149
|
+
|
|
150
|
+
res = runner.run("-C", intent_worktree, "diff", "--name-only", baseline, branch)
|
|
151
|
+
return nil unless res.success?
|
|
152
|
+
|
|
153
|
+
res.stdout.to_s.each_line.map(&:strip).reject(&:empty?)
|
|
154
|
+
end
|
|
155
|
+
private_class_method :intent_worktree_diff
|
|
156
|
+
|
|
157
|
+
# The most recent `done` transition's own `commit=`, across every subject
|
|
158
|
+
# in the ledger (torn lines excluded) - the last point RunnerAbsorb itself
|
|
159
|
+
# already vouched for for as clean.
|
|
160
|
+
def last_done_commit(context)
|
|
161
|
+
content = savepoint_content(context.intent_dir)
|
|
162
|
+
entries = NodeLedger.entries_from_content(content)
|
|
163
|
+
entry = entries.reverse.find { |e| !e[:torn] && e[:state] == "done" && (e[:fields] || {})["commit"] }
|
|
164
|
+
entry && entry[:fields]["commit"]
|
|
165
|
+
end
|
|
166
|
+
private_class_method :last_done_commit
|
|
167
|
+
|
|
168
|
+
# merge-base(intent_branch, repo's own checked-out branch) - the point the
|
|
169
|
+
# intent branch itself forked from (the repo's own checkout never receives
|
|
170
|
+
# a node merge, so this stays stable across every later delivery), used
|
|
171
|
+
# only when the ledger has no `done` commit yet to anchor on.
|
|
172
|
+
def repo_fork_point(context, runner)
|
|
173
|
+
repo = repo_root(context)
|
|
174
|
+
return nil if repo.nil?
|
|
175
|
+
|
|
176
|
+
current = Worktree.current_branch(runner, repo: repo)
|
|
177
|
+
return nil if blank?(current)
|
|
178
|
+
|
|
179
|
+
res = runner.run("-C", repo, "merge-base", context.worktree_branch, current)
|
|
180
|
+
return nil unless res.success?
|
|
181
|
+
|
|
182
|
+
sha = res.stdout.to_s.strip
|
|
183
|
+
sha.empty? ? nil : sha
|
|
184
|
+
end
|
|
185
|
+
private_class_method :repo_fork_point
|
|
186
|
+
|
|
187
|
+
# --- merging -----------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
# merge(context, node:) -> {ok:, commit:, conflicted:, error:}. Merges the
|
|
190
|
+
# node branch INTO the intent branch, IN the intent worktree (matrix 3.7) -
|
|
191
|
+
# never Worktree.merge_branch, which targets the repo's own current branch.
|
|
192
|
+
# Refuses outright (matrix 3.7a) when the intent worktree is not actually
|
|
193
|
+
# checked out on the intent branch, rather than merging into whatever it
|
|
194
|
+
# happens to have checked out. A conflicted merge is aborted and its
|
|
195
|
+
# conflicted paths (from `--diff-filter=U`) are returned, not a bare
|
|
196
|
+
# boolean (matrix 3.12/3.12a) - `step` needs the paths to route D8's
|
|
197
|
+
# inside-vs-outside-files: decision.
|
|
198
|
+
def merge(context, node:, runner: Worktree::ShellRunner.new)
|
|
199
|
+
p = paths(context, node: node)
|
|
200
|
+
branch = p["branch"]
|
|
201
|
+
intent_worktree = context.worktree
|
|
202
|
+
|
|
203
|
+
if blank?(intent_worktree) || blank?(branch)
|
|
204
|
+
return { ok: false, commit: nil, conflicted: [], error: "no intent worktree or node branch to merge" }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
current = Worktree.current_branch(runner, repo: intent_worktree)
|
|
208
|
+
if current != context.worktree_branch
|
|
209
|
+
return {
|
|
210
|
+
ok: false, commit: nil, conflicted: [],
|
|
211
|
+
error: "intent worktree is checked out on #{current.inspect}, not #{context.worktree_branch.inspect}",
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
res = runner.run("-C", intent_worktree, "merge", "--no-ff", "--no-edit", branch)
|
|
216
|
+
if res.success?
|
|
217
|
+
sha = runner.run("-C", intent_worktree, "rev-parse", "HEAD").stdout.to_s.strip
|
|
218
|
+
return { ok: true, commit: (sha.empty? ? nil : sha), conflicted: [], error: nil }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
conflicted = runner.run("-C", intent_worktree, "diff", "--name-only", "--diff-filter=U")
|
|
222
|
+
.stdout.to_s.each_line.map(&:strip).reject(&:empty?)
|
|
223
|
+
runner.run("-C", intent_worktree, "merge", "--abort")
|
|
224
|
+
{ ok: false, commit: nil, conflicted: conflicted, error: res.stderr.to_s.strip }
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# --- release (post-merge / post-terminal cleanup) -----------------------------
|
|
228
|
+
|
|
229
|
+
# release(context, node:, state:) -> {ok:, removed:}. Removes the worktree
|
|
230
|
+
# only for REMOVE_ON_STATES (matrix 3.8, 3.11); every other state, and a
|
|
231
|
+
# worktree that is already gone, is left alone (matrix 3.9, 3.10).
|
|
232
|
+
def release(context, node:, state:, runner: Worktree::ShellRunner.new)
|
|
233
|
+
p = paths(context, node: node)
|
|
234
|
+
repo, path = p["repo"], p["path"]
|
|
235
|
+
return { ok: true, removed: false } if blank?(path) || !Dir.exist?(path)
|
|
236
|
+
return { ok: true, removed: false } unless REMOVE_ON_STATES.include?(state.to_s)
|
|
237
|
+
|
|
238
|
+
ok = Worktree.remove_worktree(runner, repo: repo, worktree: path)
|
|
239
|
+
Worktree.prune(runner, repo: repo) if ok
|
|
240
|
+
{ ok: ok, removed: ok }
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# --- the reaper ----------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
# reap(context) -> {removed: [{node:, dir:}], spared: [{node:, dir:, reason:}]}.
|
|
246
|
+
# Scoped to THIS intent's own node worktrees only (matrix 3.18): globs
|
|
247
|
+
# `<repo>/.claude/worktrees/<id>--<slug>--*`, which by construction never
|
|
248
|
+
# matches the intent's own worktree (`<id>--<slug>`, no third segment) or
|
|
249
|
+
# any other intent's. A candidate is removed only when its node resolves
|
|
250
|
+
# terminal (matrix 3.17) AND its branch carries no commits the intent
|
|
251
|
+
# branch does not already have (matrix 3.16) - any ambiguity (an
|
|
252
|
+
# unresolvable branch) spares it, never removes it.
|
|
253
|
+
def reap(context, runner: Worktree::ShellRunner.new)
|
|
254
|
+
repo = repo_root(context)
|
|
255
|
+
return { removed: [], spared: [] } if repo.nil?
|
|
256
|
+
|
|
257
|
+
base = File.join(repo, ".claude", "worktrees")
|
|
258
|
+
return { removed: [], spared: [] } unless Dir.exist?(base)
|
|
259
|
+
|
|
260
|
+
prefix = "#{context.intent_id}--#{context.intent_slug}--"
|
|
261
|
+
status_map = NodeLedger.status_from_content(savepoint_content(context.intent_dir))
|
|
262
|
+
|
|
263
|
+
removed = []
|
|
264
|
+
spared = []
|
|
265
|
+
|
|
266
|
+
Dir.glob(File.join(base, "#{prefix}*")).select { |d| File.directory?(d) }.sort.each do |dir|
|
|
267
|
+
node = File.basename(dir).sub(prefix, "")
|
|
268
|
+
next unless node.match?(Savepoint::NODE_SUBJECT_RE)
|
|
269
|
+
|
|
270
|
+
state = status_map.fetch(node, "planned")
|
|
271
|
+
unless ReadySet::TERMINAL_STATES.include?(state)
|
|
272
|
+
spared << { node: node, dir: dir, reason: "not terminal (#{state})" }
|
|
273
|
+
next
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
branch = "plastic/#{prefix}#{node}"
|
|
277
|
+
ahead = branch_ahead(runner, repo, context.worktree_branch, branch)
|
|
278
|
+
if ahead.nil? || ahead.positive?
|
|
279
|
+
spared << { node: node, dir: dir, reason: "branch has unmerged commits, or is unresolvable" }
|
|
280
|
+
next
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if Worktree.remove_worktree(runner, repo: repo, worktree: dir)
|
|
284
|
+
removed << { node: node, dir: dir }
|
|
285
|
+
else
|
|
286
|
+
spared << { node: node, dir: dir, reason: "worktree remove failed" }
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
Worktree.prune(runner, repo: repo) unless removed.empty?
|
|
291
|
+
{ removed: removed, spared: spared }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# --- internals -----------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def blank?(value)
|
|
297
|
+
value.nil? || value.to_s.strip.empty?
|
|
298
|
+
end
|
|
299
|
+
private_class_method :blank?
|
|
300
|
+
|
|
301
|
+
def worktree_name(context, node)
|
|
302
|
+
"#{context.intent_id}--#{context.intent_slug}--#{node}"
|
|
303
|
+
end
|
|
304
|
+
private_class_method :worktree_name
|
|
305
|
+
|
|
306
|
+
# `context.worktree` is always `<repo>/.claude/worktrees/<id>--<slug>`
|
|
307
|
+
# (Worktree.paths' own shape); three levels up is the repo root. nil when
|
|
308
|
+
# `context.worktree` itself is blank.
|
|
309
|
+
def repo_root(context)
|
|
310
|
+
wt = context&.worktree
|
|
311
|
+
return nil if blank?(wt)
|
|
312
|
+
|
|
313
|
+
File.dirname(File.dirname(File.dirname(File.expand_path(wt))))
|
|
314
|
+
end
|
|
315
|
+
private_class_method :repo_root
|
|
316
|
+
|
|
317
|
+
def unprovisioned
|
|
318
|
+
{ ok: true, path: nil, branch: nil, provisioned: false }
|
|
319
|
+
end
|
|
320
|
+
private_class_method :unprovisioned
|
|
321
|
+
|
|
322
|
+
def savepoint_content(intent_dir)
|
|
323
|
+
path = File.join(intent_dir.to_s, "savepoint.md")
|
|
324
|
+
File.exist?(path) ? File.read(path) : ""
|
|
325
|
+
end
|
|
326
|
+
private_class_method :savepoint_content
|
|
327
|
+
|
|
328
|
+
def branch_ahead(runner, repo, target_branch, branch)
|
|
329
|
+
return nil if blank?(target_branch) || blank?(branch)
|
|
330
|
+
|
|
331
|
+
res = runner.run("-C", repo, "rev-list", "--count", "#{target_branch}..#{branch}")
|
|
332
|
+
return nil unless res.success?
|
|
333
|
+
|
|
334
|
+
res.stdout.to_s.strip.to_i
|
|
335
|
+
end
|
|
336
|
+
private_class_method :branch_ahead
|
|
337
|
+
end
|
|
@@ -16,6 +16,8 @@ require_relative "lock"
|
|
|
16
16
|
require_relative "session_ledger"
|
|
17
17
|
require_relative "roadmap_queue"
|
|
18
18
|
require_relative "roadmap_savepoint"
|
|
19
|
+
require_relative "roadmap_graph"
|
|
20
|
+
require_relative "graph_tree"
|
|
19
21
|
require_relative "screen_paint"
|
|
20
22
|
require_relative "outcome_report"
|
|
21
23
|
require_relative "node_file"
|
|
@@ -1752,6 +1754,29 @@ def self.matching_action_heading(intent_dir, label)
|
|
|
1752
1754
|
]
|
|
1753
1755
|
end
|
|
1754
1756
|
|
|
1757
|
+
# Intent 337, n7: the graph tree block for the plan screen, additive only
|
|
1758
|
+
# - a roadmap with no real "## Graph" section (RoadmapGraph.analyze's
|
|
1759
|
+
# `has_graph: false`) or a cyclic one renders no block at all, so a
|
|
1760
|
+
# graphless roadmap's screen stays byte-identical to before this method
|
|
1761
|
+
# existed (row 7.2). Fits the same screen limit `fit_screen` enforces
|
|
1762
|
+
# everywhere else (row 7.3), fenced so a box-drawing line is never
|
|
1763
|
+
# mistaken for a markdown table row.
|
|
1764
|
+
def self.roadmap_tree_block(path, store_root)
|
|
1765
|
+
index_path = File.join(store_root, "INDEX.md")
|
|
1766
|
+
analysis = RoadmapGraph.analyze(path, index_path: index_path)
|
|
1767
|
+
return "" unless analysis[:has_graph] && analysis[:cycle].nil?
|
|
1768
|
+
|
|
1769
|
+
labels = analysis[:entries].each_with_object({}) { |(id, e), h| h[id] = e[:title] }
|
|
1770
|
+
marks = {
|
|
1771
|
+
critical_path: analysis[:critical_paths] ? (analysis[:critical_paths][:critical_path] || []) : [],
|
|
1772
|
+
ready: analysis[:ready] || [],
|
|
1773
|
+
}
|
|
1774
|
+
tree = GraphTree.render(edges: analysis[:edges], labels: labels, marks: marks, width: FIT_SCREEN_DEFAULT_LIMIT)
|
|
1775
|
+
return "" unless tree[:ok]
|
|
1776
|
+
|
|
1777
|
+
"\n\n**Tree**\n\n```\n#{tree[:text]}```\n"
|
|
1778
|
+
end
|
|
1779
|
+
|
|
1755
1780
|
def self.roadmap_plan_entries_table(data)
|
|
1756
1781
|
label = roadmap_batch_label(data)
|
|
1757
1782
|
rows = ["| #{label} | Graph ID | Intent | Status |", "| --- | --- | --- | --- |"]
|
|
@@ -1919,6 +1944,7 @@ def self.matching_action_heading(intent_dir, label)
|
|
|
1919
1944
|
when "plan"
|
|
1920
1945
|
out = out.gsub("{{fields.rows}}", state_rows(roadmap_plan_fields(text, data, events)).join("\n"))
|
|
1921
1946
|
out = out.gsub("{{entries.table}}", roadmap_plan_entries_table(data))
|
|
1947
|
+
out = out.gsub("{{tree}}", roadmap_tree_block(path, store_root))
|
|
1922
1948
|
when "state"
|
|
1923
1949
|
out = out.gsub("{{fields.rows}}", state_rows(roadmap_state_fields(text, data, events, store_root, now)).join("\n"))
|
|
1924
1950
|
out = out.gsub("{{entries.table}}", roadmap_state_entries_table(data, store_root, now))
|