@zalom/plastic 2.0.1 → 2.0.2

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.
@@ -9,13 +9,18 @@ require_relative "../../store_provisioning"
9
9
  # `plastic project new` - registers a project in projects.yml, then provisions
10
10
  # and validates its store (`provision-project-store`, `validate-project`, run
11
11
  # through Legacy). The project directory must already exist; this command
12
- # never makes one, and a slug already registered is a Failure.
12
+ # never makes one. A slug is lowercase letters, digits and hyphens, starting
13
+ # with a letter or digit, and never `global`. A slug or a path already
14
+ # registered is a Failure.
13
15
  module Plastic
14
16
  class CLI
15
17
  module Commands
16
18
  class ProjectNew < Command
17
19
  USAGE_LINE = "plastic project new SLUG --path PATH [--parent ID] [--json]"
18
20
 
21
+ SLUG = /\A[a-z0-9][a-z0-9-]*\z/
22
+ RESERVED = "global"
23
+
19
24
  def initialize(argv, runner: nil, **streams)
20
25
  super(argv, **streams)
21
26
  @runner = runner
@@ -24,9 +29,14 @@ module Plastic
24
29
  def call
25
30
  raise Usage, "SLUG is required" if slug.to_s.empty?
26
31
  raise Usage, "--path is required" if options[:path].to_s.empty?
32
+ unless slug.match?(SLUG) && slug != RESERVED
33
+ raise Usage, "SLUG must be lowercase letters, digits and hyphens, start with a letter or digit, and not be global"
34
+ end
27
35
  raise Failure, "#{options[:path]} does not exist" unless Dir.exist?(options[:path])
28
36
  raise Failure, "#{options[:path]} has no AGENTS.md; write one first" unless File.file?(File.join(options[:path], "AGENTS.md"))
29
37
  raise Failure, "#{slug} is already registered" if StoreProvisioning.registered?(slug, scope.plastic_home)
38
+ owner = slug_for_path
39
+ raise Failure, "#{options[:path]} is already registered as #{owner}" if owner
30
40
 
31
41
  register
32
42
  run("provision-project-store", slug, "--home", scope.plastic_home)
@@ -45,6 +55,19 @@ module Plastic
45
55
  parser.on("--parent ID") { |v| @options[:parent] = v }
46
56
  end
47
57
 
58
+ def slug_for_path
59
+ path = File.realpath(options[:path])
60
+ projects.find do |_slug, info|
61
+ registered = info.is_a?(Hash) && info["path"].is_a?(String) && info["path"]
62
+ registered && Dir.exist?(registered) && File.realpath(registered) == path
63
+ end&.first
64
+ end
65
+
66
+ def projects
67
+ data = File.exist?(projects_yml) ? YAML.safe_load_file(projects_yml) : nil
68
+ (data.is_a?(Hash) && data["projects"].is_a?(Hash)) ? data["projects"] : {}
69
+ end
70
+
48
71
  def projects_yml
49
72
  File.join(scope.plastic_home, "projects.yml")
50
73
  end
@@ -8,6 +8,8 @@ module Plastic
8
8
  class Render < Command
9
9
  USAGE_LINE = "plastic render FILE"
10
10
  STYLE = File.expand_path("../../../../templates/render.css", __dir__)
11
+ # A leading YAML block is metadata; RDoc would render it as body text.
12
+ FRONTMATTER = /\A---\r?\n.*?^---[ \t]*(\r?\n|\z)/m
11
13
 
12
14
  def call
13
15
  file = arguments.first or raise Usage, "FILE is the markdown file to render"
@@ -15,7 +17,7 @@ module Plastic
15
17
 
16
18
  require "rubygems"
17
19
  require "rdoc"
18
- body = RDoc::Markdown.parse(File.read(file, encoding: "UTF-8")).accept(html_formatter)
20
+ body = RDoc::Markdown.parse(File.read(file, encoding: "UTF-8").sub(FRONTMATTER, "")).accept(html_formatter)
19
21
  @output.raw("<!doctype html>\n<meta charset=\"utf-8\">\n<title>#{File.basename(file)}</title>\n" \
20
22
  "<style>\n#{File.read(STYLE)}</style>\n#{body}")
21
23
  end
@@ -15,6 +15,7 @@ module Plastic
15
15
  USAGE_LINE = "plastic roadmap next [--json]"
16
16
  AFTER = "plastic roadmap show SLUG"
17
17
  BECAUSE = "the winner it just named is the slug that command wants"
18
+ TIED = "the roadmaps tie; the first is shown, and any of them may be picked"
18
19
 
19
20
  def initialize(argv, runner: nil, **streams)
20
21
  super(argv, **streams)
@@ -24,7 +25,7 @@ module Plastic
24
25
  def call
25
26
  report = parse(*legacy.capture("roadmap-next", "--roadmaps-dir", scope.roadmaps_dir, "--which"))
26
27
  screen(report)
27
- @output.next_step(after(report), because: BECAUSE)
28
+ @output.next_step(after(report), because: (report["state"] == "tie") ? TIED : BECAUSE)
28
29
  end
29
30
 
30
31
  private
@@ -38,10 +39,16 @@ module Plastic
38
39
  end
39
40
 
40
41
  def after(report)
41
- slug = report["roadmap"].to_s
42
+ slug = (report["roadmap"] || tied(report).first).to_s
42
43
  slug.empty? ? AFTER : AFTER.sub("SLUG", slug)
43
44
  end
44
45
 
46
+ # roadmap-next --which reports a tie as state "tie" with "tie" false and
47
+ # each candidate as an object; older reports carried plain names.
48
+ def tied(report)
49
+ Array(report["tie_candidates"]).map { |candidate| candidate.is_a?(Hash) ? candidate["roadmap"] : candidate }
50
+ end
51
+
45
52
  def screen(report)
46
53
  @output.row("state", report["state"])
47
54
  @output.row("roadmap", report["roadmap"] || "none")
@@ -49,7 +56,7 @@ module Plastic
49
56
  @output.row("next", queue(report["dispatchable_queue"]))
50
57
  @output.row("delivering", entries(report["in_flight"]))
51
58
  @output.row("blocked", entries(report["blocked"]))
52
- @output.row("tie", Array(report["tie_candidates"]).join(", ")) if report["tie"]
59
+ @output.row("tie", tied(report).join(", ")) if report["tie"] || report["state"] == "tie"
53
60
  end
54
61
 
55
62
  def queue(list)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../command"
4
4
  require_relative "../legacy"
5
+ require_relative "../../roadmap_graph"
5
6
 
6
7
  # `plastic roadmap show` - a roadmap's state screen (`report-screen roadmap
7
8
  # FILE state`, run through Legacy). SLUG is required: `plastic roadmap next`
@@ -23,11 +24,30 @@ module Plastic
23
24
  status = legacy.run("report-screen", "roadmap", path, "state")
24
25
  raise Failure, "report-screen exited #{status}" unless status.zero?
25
26
 
26
- @output.next_step("plastic roadmap log #{slug} EVENT \"TEXT\"", because: "a savepoint is how this state screen keeps moving")
27
+ problems = graph_problems
28
+ problems.each { |problem| @output.row("warning", problem) }
29
+ if problems.empty?
30
+ @output.next_step("plastic roadmap log #{slug} EVENT \"TEXT\"", because: "a savepoint is how this state screen keeps moving")
31
+ else
32
+ @output.next_step("plastic roadmap check #{slug}", because: "the graph has problems the state screen cannot show")
33
+ end
27
34
  end
28
35
 
29
36
  private
30
37
 
38
+ # A cycle or a graph id no batch lists (acceptance N8). The state screen
39
+ # cannot show either, so they are named under it.
40
+ def graph_problems
41
+ return [] unless File.file?(path)
42
+
43
+ result = RoadmapGraph.analyze(path)
44
+ problems = []
45
+ problems << "cyclic graph: #{result[:cycle].join(" > ")}" if result[:cycle]
46
+ dangling = Array(result[:dangling])
47
+ problems << "graph names #{dangling.join(", ")}, no batch entry" unless dangling.empty?
48
+ problems
49
+ end
50
+
31
51
  def slug
32
52
  arguments.first
33
53
  end
@@ -24,7 +24,7 @@ module Plastic
24
24
  status = legacy.run("session-commit", "--cwd", @directory, "--summary", summary)
25
25
  raise Failure, "session-commit exited #{status}" unless status.zero?
26
26
 
27
- @output.next_step("plastic session handoff", because: "a handoff after a commit keeps the day ledger current")
27
+ @output.next_step("plastic session handoff", because: "the line above says whether a commit landed; a handoff keeps the day ledger current")
28
28
  end
29
29
 
30
30
  private
@@ -10,9 +10,14 @@ module Plastic
10
10
  class Sync < Command
11
11
  USAGE_LINE = "plastic sync [--dry-run] [--json]"
12
12
 
13
+ # Rebuilt on every sync; a missing one is named in the plan, so the
14
+ # preview and the sync report the same work (acceptance N7).
15
+ DERIVED = [WorkGraph, ReferenceArchive].freeze
16
+
13
17
  def call
14
18
  home = scope.plastic_home
15
19
  plan = File.exist?(SearchIndex.path(home)) ? StoreSync.plan(home) : [["build", SearchIndex::NAME]]
20
+ plan += DERIVED.reject { |database| File.exist?(database.path(home)) }.map { |database| ["build", database::NAME] }
16
21
  refuse(home, plan.filter_map { |action, path| path if action == "conflict" })
17
22
  plan.group_by(&:first).each { |action, pairs| @output.row(action, pairs.map(&:last)) }
18
23
  @output.row("result", "nothing to do") if plan.empty?
@@ -36,7 +41,11 @@ module Plastic
36
41
  end
37
42
 
38
43
  def apply(home, plan)
39
- (plan == [["build", SearchIndex::NAME]]) ? SearchIndex.build(home) : StoreSync.apply(home, plan)
44
+ if plan.include?(["build", SearchIndex::NAME])
45
+ SearchIndex.build(home)
46
+ else
47
+ StoreSync.apply(home, plan.reject { |step| step.first == "build" })
48
+ end
40
49
  WorkGraph.build(home)
41
50
  ReferenceArchive.build(home)
42
51
  end
@@ -3,7 +3,6 @@
3
3
 
4
4
  require "json"
5
5
  require "open3"
6
- require "timeout"
7
6
 
8
7
  # HookReplay (intent 331a, T1; promoted to a production lib in 331e) - streams
9
8
  # text through a MessageDisplay launcher the way Claude Code streams an
@@ -31,14 +30,9 @@ module HookReplay
31
30
  # environment. Default `{}` keeps every existing caller's behavior
32
31
  # unchanged: this is an extension, not a fork.
33
32
  #
34
- # `timeout` (intent 331e): when given, bounds EACH chunk's spawn to that
35
- # many seconds. A bare `Timeout.timeout` around `Open3.capture3` does not
36
- # reliably bound a genuinely hanging child: capture3's own wait still
37
- # blocks on Process.waitpid for the child regardless of the raised
38
- # Timeout::Error (the same gotcha scripts/hook-record works around), so a
39
- # timeout here spawns directly and kills the child on expiry instead.
40
- # Default `nil` keeps every existing caller on the original unbounded
41
- # Open3.capture3 path.
33
+ # `timeout` (intent 331e): when given, bounds each chunk's whole exchange to
34
+ # that many seconds (see run_bounded). Default `nil` keeps every existing
35
+ # caller on the original unbounded Open3.capture3 path.
42
36
  def replay(hook_path:, tmp_root:, text:, chunk: 40, session_id: "s-replay", message_id: "replay",
43
37
  env: {}, timeout: nil)
44
38
  chunks = text.scan(/.{1,#{chunk}}/m)
@@ -168,46 +162,58 @@ module HookReplay
168
162
  [out, err, status.exitstatus]
169
163
  end
170
164
 
171
- # Spawn directly (never Open3.capture3) so a timeout can actually kill the
172
- # child, with stdin/stdout/stderr routed through scratch files under the
173
- # caller's own tmp_root, and never pipes, so a stalled or oversized write can
174
- # never deadlock the read side, and never anywhere outside tmp_root, so a
175
- # bounded replay carries the same "writes only under the injected tmp
176
- # root" guarantee as the unbounded path.
177
- def run_bounded(hook_path, payload, full_env, tmp_root, timeout)
178
- token = "#{Process.pid}-#{(Time.now.to_f * 1_000_000).to_i}-#{rand(1_000_000)}"
179
- in_path = File.join(tmp_root, ".hook-replay-in-#{token}")
180
- out_path = File.join(tmp_root, ".hook-replay-out-#{token}")
181
- err_path = File.join(tmp_root, ".hook-replay-err-#{token}")
182
- File.write(in_path, JSON.generate(payload))
183
-
184
- pid = Process.spawn(full_env, hook_path, in: in_path, out: out_path, err: err_path)
185
- exitstatus =
186
- begin
187
- Timeout.timeout(timeout) { Process.wait(pid) }
188
- $?.exitstatus
189
- rescue Timeout::Error
190
- kill_and_reap(pid)
191
- nil # nil exitstatus is the caller's signal that this chunk timed out
192
- end
193
-
194
- out = File.exist?(out_path) ? File.read(out_path) : ""
195
- err = File.exist?(err_path) ? File.read(err_path) : ""
196
- [out, err, exitstatus]
165
+ # Bounded run over pipes (acceptance N9). Scratch files for the child's
166
+ # stdin and stdout broke on Snap Ruby: the launcher's `ruby` could neither
167
+ # read nor write them, so every chunk came back empty with exit 1. Pipes
168
+ # work there, as the unbounded path already shows.
169
+ #
170
+ # One deadline covers the whole exchange: the launcher's exit, feeding
171
+ # stdin, and draining stdout and stderr. A launcher can exit while a child
172
+ # it started keeps the pipes open, so waiting for the launcher alone is not
173
+ # enough. A writer thread feeds stdin and reader threads drain both outputs
174
+ # at the same time, so large input or output cannot block.
175
+ #
176
+ # The launcher leads its own process group. When the deadline passes, the
177
+ # whole group is killed, the pipes are closed, and the result carries the
178
+ # output read so far with a nil exitstatus: the caller's signal that this
179
+ # chunk timed out. Nothing is written under tmp_root.
180
+ def run_bounded(hook_path, payload, full_env, _tmp_root, timeout)
181
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
182
+ stdin, stdout, stderr, wait = Open3.popen3(full_env, hook_path, pgroup: true)
183
+ buffers = [String.new, String.new]
184
+ threads = [Thread.new { feed(stdin, JSON.generate(payload)) }] +
185
+ [stdout, stderr].zip(buffers).map { |io, buffer| Thread.new { drain(io, buffer) } }
186
+ finished = [wait, *threads].all? { |thread| thread.join(remaining(deadline)) }
187
+ kill_group(wait.pid) unless finished
188
+ buffers.map { |buffer| buffer.force_encoding(Encoding.default_external) } +
189
+ [finished ? wait.value.exitstatus : nil]
197
190
  ensure
198
- [in_path, out_path, err_path].each { |p| File.delete(p) if p && File.exist?(p) }
191
+ [stdin, stdout, stderr].compact.each { |io| io.close unless io.closed? }
192
+ threads&.each { |thread| thread.join(1) }
199
193
  end
200
194
 
201
- def kill_and_reap(pid)
202
- Process.kill("KILL", pid)
203
- rescue StandardError
204
- nil
195
+ def remaining(deadline)
196
+ [deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max
197
+ end
198
+
199
+ def feed(io, data)
200
+ io.write(data)
201
+ rescue IOError, SystemCallError
202
+ # the hook exited without reading its input, or the deadline closed the pipe
205
203
  ensure
206
- begin
207
- Process.wait(pid)
208
- rescue StandardError
209
- nil
210
- end
204
+ io.close unless io.closed?
205
+ end
206
+
207
+ def drain(io, buffer)
208
+ loop { buffer << io.readpartial(65_536) }
209
+ rescue IOError, SystemCallError
210
+ # end of output, or the deadline closed the pipe
211
+ end
212
+
213
+ def kill_group(pid)
214
+ Process.kill("KILL", -pid)
215
+ rescue SystemCallError
216
+ nil # the whole group has already exited
211
217
  end
212
218
 
213
219
  # The final chunk's parsed displayContent, or nil when it emitted nothing
@@ -71,6 +71,17 @@ class InstallerCore
71
71
 
72
72
  attr_reader :package_root, :plastic_home, :version, :agents
73
73
 
74
+ def store_layout_compatible?(target)
75
+ !Plastic::StoreLayout.moved?(plastic_home) || semver_compare(target, "2.0.0-alpha.28") >= 0
76
+ end
77
+
78
+ def refuse_incompatible_store_layout(target)
79
+ return if store_layout_compatible?(target)
80
+
81
+ warn "Plastic #{target} cannot read stores/ in #{plastic_home}. Keep a version that supports this layout."
82
+ true
83
+ end
84
+
74
85
  def initialize(package_root:, plastic_home: DEFAULT_PLASTIC_HOME, version: nil, agents: DEFAULT_AGENTS)
75
86
  @package_root = package_root
76
87
  @plastic_home = plastic_home
@@ -405,6 +416,7 @@ class InstallerCore
405
416
  "scripts/lib/index_entry.rb" => "scripts/lib/index_entry.rb",
406
417
  "scripts/lib/project_config.rb" => "scripts/lib/project_config.rb",
407
418
  "scripts/lib/savepoint.rb" => "scripts/lib/savepoint.rb",
419
+ "scripts/lib/untouched_scaffold.rb" => "scripts/lib/untouched_scaffold.rb",
408
420
  "scripts/lib/guarded_append.rb" => "scripts/lib/guarded_append.rb",
409
421
  "scripts/lib/node_ledger.rb" => "scripts/lib/node_ledger.rb",
410
422
  "scripts/lib/node_input_compatibility.rb" => "scripts/lib/node_input_compatibility.rb",
@@ -36,6 +36,10 @@ module NodeInput
36
36
  # reports is not provisioned.
37
37
  STOP_DIRECTIVE = "STOP: no lease is recorded for this node. Do not edit files or run any command until a runner dispatches this node with a holder, an expiry and a model."
38
38
 
39
+ # A work node whose worktree is missing gets its own reason (acceptance
40
+ # N12): the lease may be fine, and saying it is missing contradicts it.
41
+ WORKTREE_STOP_DIRECTIVE = "STOP: no worktree is provisioned for this node. Do not edit files until a runner dispatches it into one."
42
+
39
43
  # Pinned so `input=<sha>` is a function of the repo's history alone
40
44
  # (post-execution review finding B4): unpinned, `git log --stat` varies
41
45
  # with the terminal's COLUMNS (abbreviates paths, narrows the graph
@@ -386,12 +390,17 @@ module NodeInput
386
390
 
387
391
  # --- block 5: where to work (instruction) ---------------------------------
388
392
 
393
+ # A node kind that needs no worktree (research, verify) is marked
394
+ # `read_only` by the runner: it reads the repository and edits nothing, so
395
+ # the absent worktree is expected and carries no stop directive.
389
396
  def worktree_block(intent_dir:, worktree_reader: Arm.method(:worktree_block))
390
397
  info = worktree_reader.call(intent_dir: intent_dir)
391
398
  if info && info["provisioned"]
392
399
  "worktree: #{info['code']} (branch #{info['code_branch']})"
400
+ elsif info && info["read_only"]
401
+ "worktree: none; this node reads the repository and edits nothing"
393
402
  else
394
- "worktree: none provisioned\n#{STOP_DIRECTIVE}"
403
+ "worktree: none provisioned\n#{WORKTREE_STOP_DIRECTIVE}"
395
404
  end
396
405
  end
397
406
 
@@ -445,9 +454,8 @@ module NodeInput
445
454
 
446
455
  # `lease_missing` (post-execution review finding A1) hoists C7's stop
447
456
  # directive here, block 5 (instruction, spec D3), whenever the node input
448
- # carries no lease. `worktree_block` already renders its own copy when the
449
- # worktree is unprovisioned; the two conditions often fire together, so a
450
- # directive already present is never repeated.
457
+ # carries no lease. A missing worktree has its own directive, so each stop
458
+ # line names its own reason.
451
459
  #
452
460
  # `call_cap` (intent 355, n2, D2): one sentence naming this attempt's tool
453
461
  # call cap and the return it hits at, so the executor learns the number
@@ -458,7 +466,7 @@ module NodeInput
458
466
  files: [])
459
467
  wt = worktree_block(intent_dir: intent_dir, worktree_reader: worktree_reader)
460
468
  parts = [wt, test_command_block(intent_dir: intent_dir, files: files, project_reader: project_reader)]
461
- parts << STOP_DIRECTIVE if lease_missing && !wt.include?(STOP_DIRECTIVE)
469
+ parts << STOP_DIRECTIVE if lease_missing
462
470
  parts << call_cap_sentence(call_cap) if call_cap
463
471
  parts.join("\n")
464
472
  end
@@ -224,18 +224,26 @@ class RoadmapQueue
224
224
 
225
225
  # --- liveness ranking (ports plastic-intent-continuing's read-time algorithm) -
226
226
 
227
+ # A roadmap whose graph has a cycle cannot compute a frontier, so it ranks
228
+ # after every healthy one (acceptance N8). It still wins when nothing else
229
+ # is left, and the payload then reports the cycle as an error.
227
230
  def rank_candidates(parsed_list)
228
231
  parsed_list.map do |c|
229
232
  entries = c[:waves].flat_map { |w| w[:entries] }
230
233
  live = entries.any? { |e| %w[delivering blocked].include?(e[:status]) }
231
- c.merge(live: live, last_event: last_event_time(c[:path]))
232
- end.sort_by { |c| [c[:live] ? 0 : 1, -c[:last_event].to_i, c[:slug]] }
234
+ cyclic = !!(c[:graph_edges] && GraphEdges.cycle(c[:graph_edges][:edges]))
235
+ c.merge(live: live, cyclic: cyclic, last_event: last_event_time(c[:path]))
236
+ end.sort_by { |c| rank_key(c) + [c[:slug]] }
237
+ end
238
+
239
+ def rank_key(candidate)
240
+ [candidate[:cyclic] ? 1 : 0, candidate[:live] ? 0 : 1, -candidate[:last_event].to_i]
233
241
  end
234
242
 
235
243
  def tied_group(ranked)
236
244
  return [] if ranked.empty?
237
- top_key = [ranked.first[:live], ranked.first[:last_event].to_i]
238
- ranked.select { |c| [c[:live], c[:last_event].to_i] == top_key }
245
+ top_key = rank_key(ranked.first)
246
+ ranked.select { |c| rank_key(c) == top_key }
239
247
  end
240
248
 
241
249
  def last_event_time(path)
@@ -46,9 +46,9 @@ module RunnerDispatch
46
46
 
47
47
  HARD_CAP_RE = /\Ais at its dispatch cap \((\d+)\/(\d+)\)\z/.freeze
48
48
 
49
- # D8 (355, n6): the agent every dispatched, non-decision node names - a
50
- # role, never a harness (matrix 6.5), and never a consultation advisor, which
51
- # stays a deliberate, never-auto-dispatched consultation agent.
49
+ # The spawn block's default agent. A dispatched node names its own kind's
50
+ # node agent instead (HarnessAdapter.agent_type_for_kind), the same agent
51
+ # the dispatch line under it names (acceptance N12).
52
52
  SPAWN_AGENT = "plastic-executor"
53
53
 
54
54
  # matrix 6.1/6.2: one spawn block per dispatched node - agent, the model
@@ -237,7 +237,8 @@ module RunnerDispatch
237
237
  provisioned = RunnerPolicy.worktree?(kind) ? worktree.provision(context, node: node, kind: kind, runner: runner)
238
238
  : unprovisioned
239
239
  node_reader = lambda do |intent_dir:|
240
- { "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned] }
240
+ { "code" => provisioned[:path], "code_branch" => provisioned[:branch], "provisioned" => !!provisioned[:provisioned],
241
+ "read_only" => !RunnerPolicy.worktree?(kind) }
241
242
  end
242
243
 
243
244
  # Row 5.18: build the node input BEFORE writing `running` - a `running` line
@@ -289,7 +290,7 @@ module RunnerDispatch
289
290
 
290
291
  test_command = NodeInput.test_command_block(intent_dir: intent_dir, files: (nodes_decl[node] || {})[:files])
291
292
  spawn = spawn_block(model: model, effort: effort, input: build_result[:path], test_command: test_command,
292
- call_cap: calls_cap)
293
+ call_cap: calls_cap, agent: HarnessAdapter.agent_type_for_kind(kind))
293
294
 
294
295
  {
295
296
  ok: true,
@@ -409,9 +410,9 @@ module RunnerDispatch
409
410
  end
410
411
 
411
412
  # Row 5.32: re-arms delivery.lock for a resumed session with a new id -
412
- # `plastic-lock arm` is the shipped command that takes ownership again.
413
+ # `plastic auto take` is the public command that takes ownership again.
413
414
  def rearm_command(intent_dir)
414
- "plastic-lock arm --intent-dir #{intent_dir}"
415
+ "plastic auto take #{File.basename(intent_dir.to_s).split("--").first}"
415
416
  end
416
417
 
417
418
  # --- refusals and the report -------------------------------------------------
@@ -7,6 +7,7 @@ require_relative "worktree"
7
7
  require_relative "scaffold_intent"
8
8
  require_relative "session_ledger"
9
9
  require_relative "active_delivery"
10
+ require_relative "lock"
10
11
 
11
12
  # SessionGit - the branch model and per-repo flow settings behind
12
13
  # `scripts/session-commit` (intent 300), which is how a checklist item
@@ -306,6 +307,44 @@ module SessionGit
306
307
  parts.each_cons(2).any? { |a, b| a == ".claude" && b == "worktrees" }
307
308
  end
308
309
 
310
+ # Why an agent-owned branch was left alone, named from the real delivery
311
+ # lock (acceptance N5). The branch or worktree name gives the intent id; the
312
+ # repository's slug in projects.yml gives its store. A branch whose intent
313
+ # cannot be found is still left alone, and says so plainly.
314
+ def delivery_state(repo, branch, plastic_home, session)
315
+ intent_dir = delivery_intent_dir(repo, branch, plastic_home)
316
+ return "it is an intent delivery branch" if intent_dir.nil?
317
+
318
+ id = File.basename(intent_dir)[/\A\d+/]
319
+ owner = Lock.read(intent_dir).to_h["owner_session"].to_s
320
+ return "it is intent #{id}'s delivery branch and no delivery lock is held" if owner.empty?
321
+ if !blank?(session) && owner.start_with?(session.to_s)
322
+ return "this session holds intent #{id}'s delivery lock; commit delivery work there with git"
323
+ end
324
+
325
+ "intent #{id}'s delivery lock is held by #{owner}"
326
+ end
327
+
328
+ def delivery_intent_dir(repo, branch, plastic_home)
329
+ parts = File.expand_path(repo.to_s).split(File::SEPARATOR)
330
+ at = parts.each_cons(2).find_index { |a, b| a == ".claude" && b == "worktrees" }
331
+ name = branch.to_s.start_with?("plastic/") ? branch.to_s.delete_prefix("plastic/") : parts.last
332
+ main = at ? parts[0...at].join(File::SEPARATOR) : File.expand_path(repo.to_s)
333
+ id = name[/\A(\d+)--/, 1]
334
+ slug = slug_for_repo(main, plastic_home)
335
+ return nil if id.nil? || slug.nil?
336
+
337
+ Dir.glob(File.join(Plastic::StoreLayout.root(plastic_home, slug), "store", "#{id}--*")).first
338
+ end
339
+
340
+ def slug_for_repo(main, plastic_home)
341
+ projects = YAML.safe_load_file(File.join(plastic_home, "projects.yml")).fetch("projects")
342
+ target = File.realpath(main)
343
+ projects.find { |_slug, info| File.exist?(info["path"].to_s) && File.realpath(info["path"]) == target }&.first
344
+ rescue StandardError
345
+ nil
346
+ end
347
+
309
348
  def stage_and_commit(dir, subject, runner:, body: nil)
310
349
  runner.run("-C", dir, "add", "-A")
311
350
  args = ["-C", dir, "commit", "-m", subject]
@@ -340,7 +379,9 @@ module SessionGit
340
379
 
341
380
  branch_now = current_branch(repo, runner: runner)
342
381
  return note("detached HEAD: no commit") if branch_now == "HEAD"
343
- return note("left branch #{branch_now} untouched: agent lock") if agent_owned?(repo, branch_now)
382
+ if agent_owned?(repo, branch_now)
383
+ return note("left branch #{branch_now} untouched: #{delivery_state(repo, branch_now, plastic_home, session)}")
384
+ end
344
385
  return note("repository has no commits yet: no commit") unless has_commits?(repo, runner: runner)
345
386
 
346
387
  flow, flow_notes = load_flow(cwd: cwd, repo: repo, plastic_home: plastic_home, runner: runner)
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "savepoint"
4
+ require_relative "backfill_intent"
5
+
6
+ # Finds an intent that is still exactly what new-intent created, so
7
+ # end-intent can refuse to close it as delivered.
8
+ #
9
+ # The rule is narrow so older intents still close. An intent is untouched
10
+ # only when all of these hold:
11
+ #
12
+ # - spec.md, plan.md, checklist.md and outcome.md exist and are still
13
+ # unedited placeholders, with no ticked checklist item;
14
+ # - there is no action, node or graph file;
15
+ # - savepoint.md holds only What lines;
16
+ # - the code worktree, if there is one, has no changes.
17
+ #
18
+ # An older intent with missing files is never untouched.
19
+ module UntouchedScaffold
20
+ module_function
21
+
22
+ LIFECYCLE = %w[spec.md plan.md checklist.md outcome.md].freeze
23
+ SCAFFOLD_LINE = /\A\S+\s+What\s/
24
+ TICKED_ITEM = /^\s*- \[[xX]\] /
25
+
26
+ def reason(intent_dir, templates: {}, worktree_changed: ->(_dir) { false })
27
+ return nil unless placeholders_untouched?(intent_dir, templates)
28
+ return nil if Savepoint.has_real_action?(intent_dir)
29
+ return nil if File.exist?(File.join(intent_dir, "graph.md"))
30
+ return nil unless scaffold_savepoint?(File.join(intent_dir, "savepoint.md"))
31
+ return nil if worktree_changed.call(intent_dir)
32
+
33
+ "spec.md, plan.md, checklist.md and outcome.md are still placeholders, " \
34
+ "no action, node or graph was written, the savepoint records no work, " \
35
+ "and the code worktree has no changes"
36
+ end
37
+
38
+ def placeholders_untouched?(intent_dir, templates)
39
+ paths = LIFECYCLE.map { |rel| File.join(intent_dir, rel) }
40
+ return false unless paths.all? { |path| File.exist?(path) && !Savepoint.stage_file_present?(path) }
41
+ return false if File.read(File.join(intent_dir, "checklist.md")).match?(TICKED_ITEM)
42
+
43
+ BackfillIntent.classify(intent_dir, templates: templates)[:edited].empty?
44
+ end
45
+
46
+ def scaffold_savepoint?(path)
47
+ return false unless File.exist?(path)
48
+
49
+ File.readlines(path).reject { |line| line.strip.empty? }.all? { |line| line.match?(SCAFFOLD_LINE) }
50
+ end
51
+ end