@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,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