@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.
@@ -21,7 +21,7 @@ deliberately; the auto pipeline never dispatches them.
21
21
 
22
22
  ## Your Responsibilities
23
23
 
24
- 1. **Take the intent** - `plastic-lock arm` acquires the delivery lock, provisions the code
24
+ 1. **Take the intent** - `plastic auto take ID` acquires the delivery lock, provisions the code
25
25
  worktree, and points this session at the intent; work only inside that worktree.
26
26
  2. **Write the Why and How yourself** - there is no intent tier and no stage agent (removed in
27
27
  2.0, intent 304): record the rulings, write `spec.md`, then `plan.md`, at least one real
package/bin/plastic CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env -S ruby --disable-gems
2
2
  # frozen_string_literal: true
3
3
 
4
+ ENV["PLASTIC_PACKAGE_ROOT"] = File.expand_path("..", __dir__)
5
+
4
6
  require_relative "../scripts/lib/cli"
5
7
 
6
8
  exit Plastic::CLI.call(ARGV)
@@ -22,8 +22,19 @@ savepoint line -> commit -> disarm (Worktree.release -> Lock.release) -> QMD rei
22
22
  reindex always LAST. Running the reindex last keeps the index from ever referencing a lock
23
23
  that disarm just removed.
24
24
 
25
+ `scripts/end-intent` never merges code. Before a delivered close writes anything, it checks
26
+ that the intent's code is already merged into the current branch of the repo checkout. It
27
+ checks the commit the code worktree is on, even when that worktree is on a renamed branch or
28
+ a detached HEAD, and it checks the code branch after the worktree is gone. The repo checkout
29
+ must be on the branch you release from, not detached and not on the code branch. If the code
30
+ isn't merged, or Git can't tell, the close exits 9 and changes nothing: INDEX, the savepoint, the
31
+ lock, and the worktree all stay as they were. `--dry-run` refuses the same way. The refusal
32
+ names the merge to run, for example `git -C <repo> merge plastic/<id>--<slug>`. Run that
33
+ ordinary merge, or release the work through your usual process, and then run the close
34
+ again. An abandoned close and an intent with no code repository skip this check.
35
+
25
36
  `scripts/end-intent` performs this order's disarm step (verify the code worktree is clean,
26
- then merge/remove worktrees, then clear the lock) as its own step 5, mechanically, since
37
+ then remove the worktree, then clear the lock) as its own step 5, mechanically, since
27
38
  intent 188: a session no longer needs a separate one-liner for it, and the script's own
28
39
  exit code (0) is the single fact a caller needs that the intent is closed AND its delivery
29
40
  lock is gone. A pre-flight lock guard runs before anything is written (refuses a live
@@ -11,8 +11,9 @@ use `plastic roadmap show`, `next`, `log`, and `check` to read, drive, and audit
11
11
 
12
12
  File location: `roadmaps/{slug}.md`, a sibling of `INDEX.md`, wherever `INDEX.md` lives, never
13
13
  inside `store/` (store holds intent directories, not project artifacts). For a project that is its
14
- root, `~/.plastic/projects/{slug}/roadmaps/`, beside `project.yml`; for the global tier it is
15
- `~/.plastic/roadmaps/`, beside `~/.plastic/INDEX.md`. `roadmaps/` lists only live (open or
14
+ root, `~/.plastic/stores/{slug}/roadmaps/`, beside `project.yml`; for the global store it is
15
+ `~/.plastic/stores/global/roadmaps/`, beside its `INDEX.md`. Legacy homes keep their
16
+ previous paths until `plastic migrate stores` moves them. `roadmaps/` lists only live (open or
16
17
  in-flight) roadmaps: once a roadmap's goal is reached, it moves to `roadmaps/archived/{slug}.md`,
17
18
  a sibling subdirectory scaffolded once with a `.gitkeep`.
18
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,10 @@
50
50
  # the durable lock file is actually gone afterward, and release it directly
51
51
  # when a foreign-keyed disarm left it.
52
52
  #
53
+ # A delivered close never merges code. Its code branch must already be merged
54
+ # into the repo checkout's current branch, or the close refuses before any
55
+ # write (exit 9).
56
+ #
53
57
  # Exit codes:
54
58
  # 0 ok - the intent is closed and no delivery.lock remains.
55
59
  # 1 usage/resolution failure, OR an id that resolves to neither ## Active
@@ -67,13 +71,27 @@
67
71
  # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
68
72
  # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
69
73
  # exception to report-and-proceed; --allow-hollow-report overrides.
74
+ # 8 delivered close of an untouched scaffold refused (acceptance N3):
75
+ # nothing was worked, so there is nothing to deliver. Close it as
76
+ # abandoned, or do the work first. Authors nothing.
77
+ # 9 delivered close refused because its code branch is not merged into the
78
+ # repo checkout's current branch. Refused before any write: nothing is
79
+ # merged, written, or released. Merge the branch, then run the close again.
80
+ #
81
+ # --dry-run runs the same refusals as the real close (exits 8, 9, 7 and 5 with
82
+ # a "would refuse" line) and writes nothing: the hollow-report gate is judged
83
+ # on a scratch copy that went through the same outcome generation and
84
+ # backfill.
70
85
 
71
86
  require_relative "lib/store_layout"
72
87
  require "fileutils"
73
88
  require "date"
74
89
  require "open3"
90
+ require "stringio"
91
+ require "tmpdir"
75
92
  require "pathname"
76
93
  require_relative "lib/arm"
94
+ require_relative "lib/exec_worktree"
77
95
  require_relative "lib/index_entry"
78
96
  require_relative "lib/savepoint"
79
97
  require_relative "lib/lock"
@@ -82,6 +100,8 @@ require_relative "lib/outcome_guard"
82
100
  require_relative "lib/report_screen"
83
101
  require_relative "lib/backfill_intent"
84
102
  require_relative "lib/outcome_report"
103
+ require_relative "lib/untouched_scaffold"
104
+ require_relative "lib/scaffold_intent"
85
105
 
86
106
  DISPOSITIONS = %w[delivered abandoned].freeze
87
107
 
@@ -665,32 +685,10 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
665
685
  block = worktree_reader.call(intent_dir)
666
686
  worktree_code = block.is_a?(Hash) ? block["code"] : nil
667
687
 
668
- if worktree_code && Dir.exist?(worktree_code)
669
- begin
670
- out, err, status = Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
671
- rescue StandardError => e
672
- out, err, status = nil, e.message, nil
673
- end
674
-
675
- # Fail CLOSED (intent 188, post-review hardening). A failed or impossible
676
- # `git status` (corrupt per-worktree index, git missing from PATH, not a
677
- # repo) means we cannot PROVE the worktree is clean, and the removal below
678
- # force-removes on a plain `git worktree remove` failure, which destroys
679
- # uncommitted work. Never force-remove an unproven worktree: the guard
680
- # exists to protect uncommitted code, so an inconclusive check must refuse,
681
- # not silently proceed as if it were clean.
682
- if status.nil? || !status.success?
683
- unless discard_worktree_changes
684
- warn "end-intent: could not inspect the code worktree at #{worktree_code} " \
685
- "(git status failed: #{err.to_s.strip}); refusing to remove it, because " \
686
- "removal would force-discard any uncommitted work. Pass " \
687
- "--discard-worktree-changes to remove it anyway, or repair the worktree."
688
- return :dirty
689
- end
690
- elsif !out.strip.empty? && !discard_worktree_changes
691
- warn "end-intent: code worktree #{worktree_code} has uncommitted changes; refusing " \
692
- "to remove it. Pass --discard-worktree-changes to force the close deliberately, " \
693
- "or commit/stash the changes and re-run."
688
+ unless discard_worktree_changes
689
+ refusal = worktree_refusal(worktree_code)
690
+ if refusal
691
+ warn refusal
694
692
  return :dirty
695
693
  end
696
694
  end
@@ -758,6 +756,173 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
758
756
  :lock_remains
759
757
  end
760
758
 
759
+ # The disarm step's dirty-worktree guard, as a message (nil when the worktree
760
+ # is clean or absent). Shared by run_disarm and --dry-run so the preview
761
+ # refuses exactly what the close refuses.
762
+ #
763
+ # Fail CLOSED (intent 188, post-review hardening). A failed or impossible
764
+ # `git status` (corrupt per-worktree index, git missing from PATH, not a
765
+ # repo) means we cannot PROVE the worktree is clean, and the removal
766
+ # force-removes on a plain `git worktree remove` failure, which destroys
767
+ # uncommitted work. Never force-remove an unproven worktree: the guard
768
+ # exists to protect uncommitted code, so an inconclusive check must refuse,
769
+ # not silently proceed as if it were clean.
770
+ def worktree_refusal(worktree_code)
771
+ return nil unless worktree_code && Dir.exist?(worktree_code)
772
+
773
+ begin
774
+ out, err, status = Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
775
+ rescue StandardError => e
776
+ out, err, status = nil, e.message, nil
777
+ end
778
+
779
+ if status.nil? || !status.success?
780
+ "end-intent: could not inspect the code worktree at #{worktree_code} " \
781
+ "(git status failed: #{err.to_s.strip}); refusing to remove it, because " \
782
+ "removal would force-discard any uncommitted work. Pass " \
783
+ "--discard-worktree-changes to remove it anyway, or repair the worktree."
784
+ elsif !out.strip.empty?
785
+ "end-intent: code worktree #{worktree_code} has uncommitted changes; refusing " \
786
+ "to remove it. Pass --discard-worktree-changes to force the close deliberately, " \
787
+ "or commit/stash the changes and re-run."
788
+ end
789
+ end
790
+
791
+ # True when the intent's code worktree carries any work: uncommitted changes
792
+ # or commits past its base branch. Anything that cannot be proven reads as
793
+ # changed, so an unreadable worktree never turns a close into a refusal.
794
+ def worktree_changed?(intent_dir)
795
+ block = Arm.worktree_block(intent_dir: intent_dir)
796
+ code = block.is_a?(Hash) ? block["code"] : nil
797
+ return false unless code && Dir.exist?(code)
798
+
799
+ out, status = Open3.capture2e("git", "-C", code, "status", "--porcelain")
800
+ return true unless status.success? && out.strip.empty?
801
+
802
+ base = ScaffoldIntent.detect_base_branch(code)
803
+ return true if base.nil?
804
+
805
+ ahead, status = Open3.capture2e("git", "-C", code, "rev-list", "--count", "#{base}..HEAD")
806
+ !status.success? || ahead.strip != "0"
807
+ rescue StandardError
808
+ true
809
+ end
810
+
811
+ # A delivered close whose code is not on the repo checkout's current branch,
812
+ # as a refusal message (nil when it is, or for a store-only project).
813
+ # end-intent never merges: the owner merges or releases the work, then runs
814
+ # the close again. A real code worktree must prove that its HEAD commit is
815
+ # merged, so a detached or renamed branch is still checked. The code branch
816
+ # must be merged too, even after its worktree is gone. A git failure refuses.
817
+ # A directory that is not a working git worktree, with no code branch, is left
818
+ # to the dirty-worktree guard, which refuses what it cannot inspect.
819
+ def unmerged_refusal(intent_dir, runner: Worktree::ShellRunner.new)
820
+ paths = Arm.code_paths(intent_dir: intent_dir)
821
+ code = paths["code"]
822
+ return nil if code.nil?
823
+
824
+ repo = ExecWorktree.repo_from_worktree_code(code)
825
+ unless runner.run("-C", repo, "rev-parse", "--git-dir").success?
826
+ # No .git at all is a project without code; a .git that Git cannot read is not.
827
+ return nil unless File.exist?(File.join(repo, ".git"))
828
+
829
+ return "could not inspect the Git repository #{repo}, so the code cannot be shown to be merged. " \
830
+ "Check the repository, then run the close again"
831
+ end
832
+
833
+ unmerged_worktree_head(runner, code, repo, paths["code_branch"]) ||
834
+ unmerged_branch(runner, repo, paths["code_branch"])
835
+ end
836
+
837
+ # True when `code` is the top of its own git work tree, not a plain
838
+ # directory inside the repo or a broken checkout.
839
+ def real_worktree?(runner, code)
840
+ return false unless File.directory?(code)
841
+
842
+ top = runner.run("-C", code, "rev-parse", "--show-toplevel")
843
+ top.success? && File.realpath(top.stdout.strip) == File.realpath(code)
844
+ rescue SystemCallError
845
+ false
846
+ end
847
+
848
+ def unmerged_worktree_head(runner, code, repo, branch)
849
+ return nil unless real_worktree?(runner, code)
850
+
851
+ head = runner.run("-C", code, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
852
+ unless head.success?
853
+ return "could not read the HEAD commit of the code worktree #{code}, so its work cannot be " \
854
+ "shown to be merged. Commit it, merge it into the current branch of #{repo}, then run the close again"
855
+ end
856
+ sha = head.stdout.strip
857
+ on = runner.run("-C", code, "symbolic-ref", "--quiet", "--short", "HEAD")
858
+ name = on.success? ? on.stdout.strip : nil
859
+ ref = (name == branch) ? branch : sha[0, 12]
860
+ where = name ? "on branch #{name}" : "detached"
861
+ ancestry_refusal(runner, repo, sha, ref, "the code worktree #{code} (#{where} at #{sha[0, 12]})", branch)
862
+ end
863
+
864
+ def unmerged_branch(runner, repo, branch)
865
+ found = runner.run("-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
866
+ return nil if found.status == 1
867
+ unless found.success?
868
+ return "could not look up code branch #{branch} in #{repo}. Check the repository, then run the close again"
869
+ end
870
+ ancestry_refusal(runner, repo, branch, branch, "code branch #{branch}", branch)
871
+ end
872
+
873
+ # nil when `rev` is an ancestor of the branch the repo checkout is on, else a
874
+ # refusal naming `what` and the ordinary merge of `ref` that fixes it.
875
+ def ancestry_refusal(runner, repo, rev, ref, what, branch)
876
+ target = target_refusal(runner, repo, branch)
877
+ return target if target
878
+
879
+ check = runner.run("-C", repo, "merge-base", "--is-ancestor", rev, "HEAD")
880
+ return nil if check.success?
881
+
882
+ verdict = (check.status == 1) ? "is not merged into" : "could not be checked against"
883
+ "#{what} #{verdict} the current branch of #{repo}. " \
884
+ "Merge it there first (git -C #{repo} merge #{ref}), then run the close again"
885
+ end
886
+
887
+ # Integration means merged into a branch other than the code branch, so a
888
+ # detached checkout, or one on the code branch itself, proves nothing.
889
+ def target_refusal(runner, repo, branch)
890
+ on = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD")
891
+ unless on.success?
892
+ return "the repo checkout #{repo} is not on a branch, so no branch holds the merged code. " \
893
+ "Check out the branch you release from, merge #{branch} into it, then run the close again"
894
+ end
895
+ return nil unless on.stdout.strip == branch
896
+
897
+ "the repo checkout #{repo} is on the code branch #{branch} itself, so the code is not merged anywhere. " \
898
+ "Check out the branch you release from, merge #{branch} into it, then run the close again"
899
+ end
900
+
901
+ # The hollow-report verdict the real close would reach, judged on a scratch
902
+ # copy of the intent after the same outcome generation and backfill, so the
903
+ # dry run writes nothing.
904
+ def predicted_hollow_reason(intent_dir, store:, id:, disposition:, summary:)
905
+ return nil unless disposition == "delivered"
906
+
907
+ Dir.mktmpdir("end-intent-dry-run") do |scratch|
908
+ copy = File.join(scratch, File.basename(intent_dir))
909
+ FileUtils.cp_r(intent_dir, copy)
910
+ quietly do
911
+ run_generate_outcome(copy, disposition: disposition)
912
+ run_backfill(copy, store: store, id: id, disposition: disposition, summary: summary)
913
+ end
914
+ hollow_report_reason(copy, disposition)
915
+ end
916
+ end
917
+
918
+ def quietly
919
+ saved = $stderr
920
+ $stderr = StringIO.new
921
+ yield
922
+ ensure
923
+ $stderr = saved
924
+ end
925
+
761
926
  # --- main --------------------------------------------------------------------
762
927
 
763
928
  def main(argv)
@@ -810,7 +975,41 @@ def main(argv)
810
975
  exit 4
811
976
  end
812
977
 
978
+ # Nothing worked, nothing to deliver (acceptance N3). Before the dry run and
979
+ # before any write, so the preview and the close refuse together.
980
+ if disposition == "delivered"
981
+ untouched = UntouchedScaffold.reason(intent_dir, templates: BackfillIntent.load_templates,
982
+ worktree_changed: method(:worktree_changed?))
983
+ if untouched
984
+ warn "end-intent: refusing to deliver an untouched scaffold: #{untouched}"
985
+ warn "end-intent: do the work first, or close it with --disposition abandoned"
986
+ exit 8
987
+ end
988
+
989
+ unmerged = unmerged_refusal(intent_dir)
990
+ if unmerged
991
+ warn "end-intent: #{opts[:dry_run] ? "dry run: would refuse" : "refusing"} a delivered close: #{unmerged}"
992
+ warn "end-intent: nothing was merged, written, or released"
993
+ exit 9
994
+ end
995
+ end
996
+
813
997
  if opts[:dry_run]
998
+ hollow = predicted_hollow_reason(intent_dir, store: store, id: id, disposition: disposition,
999
+ summary: opts[:outcome_summary])
1000
+ if hollow && !opts[:allow_hollow_report]
1001
+ warn "end-intent: dry run: would refuse a hollow delivered close: #{hollow}"
1002
+ exit 7
1003
+ end
1004
+ unless blank?(session) || opts[:discard_worktree_changes]
1005
+ block = Arm.worktree_block(intent_dir: intent_dir)
1006
+ refusal = worktree_refusal(block.is_a?(Hash) ? block["code"] : nil)
1007
+ if refusal
1008
+ warn "end-intent: dry run: would refuse at disarm (exit 5), after the store commit: " \
1009
+ "#{refusal.delete_prefix("end-intent: ")}"
1010
+ exit 5
1011
+ end
1012
+ end
814
1013
  puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
815
1014
  targets = BackfillIntent.targets_for(intent_dir)
816
1015
  puts " would backfill: #{targets.empty? ? "(nothing)" : targets.join(", ")}"
@@ -81,12 +81,7 @@ module Arm
81
81
  # `{code, code_branch, provisioned}` derived from projects.yml and the
82
82
  # intent id: the same path provision creates, `provisioned` iff it exists.
83
83
  def worktree_block(intent_dir:, home: Dir.home)
84
- dir = File.expand_path(intent_dir)
85
- store = store_for(dir)
86
- h = home_for(dir, home: home)
87
- slug = Worktree.slug_for_store(store, home: h)
88
- p = Worktree.paths(slug: slug, intent_id: intent_id_for(dir),
89
- intent_slug: Worktree.slug_from_dir(dir), home: h)
84
+ p = code_paths(intent_dir: intent_dir, home: home)
90
85
  code = p["code"]
91
86
  provisioned = !blank?(code) && Dir.exist?(code)
92
87
  {
@@ -96,6 +91,16 @@ module Arm
96
91
  }
97
92
  end
98
93
 
94
+ # The code worktree path and branch this intent would have, whether or not
95
+ # the worktree exists on disk (blank code for a store-only project).
96
+ def code_paths(intent_dir:, home: Dir.home)
97
+ dir = File.expand_path(intent_dir)
98
+ h = home_for(dir, home: home)
99
+ slug = Worktree.slug_for_store(store_for(dir), home: h)
100
+ Worktree.paths(slug: slug, intent_id: intent_id_for(dir),
101
+ intent_slug: Worktree.slug_from_dir(dir), home: h)
102
+ end
103
+
99
104
  # Owner rule 2026-08-31: has this session already started a conversation?
100
105
  # A conversation session's tmp directory is made at boot under the global
101
106
  # store's `.tmp/`; a dispatched or headless session (a derived key) never
@@ -182,13 +187,19 @@ module Arm
182
187
 
183
188
  # --- repair ------------------------------------------------------------------
184
189
 
190
+ # A stale foreign lock is reclaimed only by an audited owner step, never by
191
+ # the session that found it.
192
+ def stale_hint(intent_id)
193
+ "reclaiming a stale lock is the owner's audited step; plastic auto lock status #{intent_id} shows it"
194
+ end
195
+
185
196
  # One idempotent repair (the lock half of the repair path):
186
197
  # remove a corrupt lock, back off from a fresh foreign lock (`held`), report
187
198
  # a stale foreign lock (`stale`) for the explicit reclaim verb, keep and
188
199
  # enrich an own lock, heartbeat a delegated one, acquire when none, and
189
200
  # provision the worktree so the repaired intent has its checkout.
190
201
  def repair(intent_dir:, session:, home: Dir.home, now: Time.now, harness: nil,
191
- agent: nil, model: nil, thread: nil, run_mode: nil, hint_harness: nil,
202
+ agent: nil, model: nil, thread: nil, run_mode: nil,
192
203
  runner: Worktree::ShellRunner.new)
193
204
  dir = File.expand_path(intent_dir)
194
205
  h = home_for(dir, home: home)
@@ -208,11 +219,18 @@ module Arm
208
219
  end
209
220
  return { "status" => "stale", "owner" => lock["owner_session"],
210
221
  "actions" => actions, "session" => key,
211
- "hint" => "run #{Lock.skill_ref('plastic-doctor', harness: hint_harness || harness)} " \
212
- "reclaim the lock to take over with an audit" }
222
+ "hint" => stale_hint(intent_id_for(dir)) }
213
223
  end
214
224
 
215
- mode = blank?(run_mode) ? (lock && lock["run_mode"]) : run_mode.to_s
225
+ # A lock rebuilt from nothing (none, or a corrupt one removed) is an auto
226
+ # delivery's lock again; a kept lock keeps whatever mode it had.
227
+ mode = if !blank?(run_mode)
228
+ run_mode.to_s
229
+ elsif lock
230
+ lock["run_mode"]
231
+ else
232
+ "auto"
233
+ end
216
234
  if lock
217
235
  if lock["owner_session"].to_s == key.to_s
218
236
  lock_data = lock.dup
@@ -9,8 +9,9 @@ require_relative "../legacy"
9
9
  # Legacy). The verb comes first, then the id, since the verb is what the
10
10
  # table's two-token match consumes as part of the command name.
11
11
  #
12
- # `status` speaks JSON, so this command renders it as a screen and leaves the
13
- # document to `--json`. `fix` and `release` already report in prose.
12
+ # `status` and `fix` speak JSON, so this command renders them as a screen and
13
+ # leaves the document to `--json`. `release` already reports in prose. A lock
14
+ # another session owns exits 3, which travels as a refusal.
14
15
  module Plastic
15
16
  class CLI
16
17
  module Commands
@@ -32,7 +33,11 @@ module Plastic
32
33
  def call
33
34
  raise Usage, "VERB must be one of #{VERBS.join(", ")}" unless VERBS.include?(verb)
34
35
 
35
- (verb == "status") ? screen(report) : run_verb
36
+ case verb
37
+ when "status" then screen(report("status"))
38
+ when "fix" then options[:json] ? run_verb : fix_screen(report("fix"))
39
+ else run_verb
40
+ end
36
41
  @output.next_step(AFTER.fetch(verb).sub("ID", id.to_s), because: BECAUSE.fetch(verb))
37
42
  end
38
43
 
@@ -43,8 +48,8 @@ module Plastic
43
48
  raise Failure, "plastic-lock exited #{status}" unless status.zero?
44
49
  end
45
50
 
46
- def report
47
- text, status = legacy.capture("plastic-lock", "status", "--intent-dir", intent_dir)
51
+ def report(name)
52
+ text, status = legacy.capture("plastic-lock", name, "--intent-dir", intent_dir)
48
53
  raise Failure, "plastic-lock exited #{status}" unless status.zero?
49
54
 
50
55
  JSON.parse(text)
@@ -60,6 +65,13 @@ module Plastic
60
65
  @output.row("claims", Array(report["claims"]).map(&:to_s))
61
66
  end
62
67
 
68
+ def fix_screen(report)
69
+ lock = report["lock"] || {}
70
+ @output.row("lock", "#{report["status"]} by #{lock["owner_session"] || report["session"]}, " \
71
+ "#{lock["run_mode"]} mode")
72
+ @output.row("actions", Array(report["actions"]).map(&:to_s))
73
+ end
74
+
63
75
  def lock_line(report)
64
76
  lock = report["lock"]
65
77
  return "corrupt, plastic auto lock fix #{id} rewrites it" if report["lock_corrupt"]
@@ -1,30 +1,67 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "intent_command"
4
5
 
5
6
  # `plastic auto take` - arms the intent's delivery lock for this session
6
7
  # (`plastic-lock arm --intent-dir DIR --mode auto`, run through Legacy).
8
+ # The lock records who took it: --harness, --agent, --model and --thread pass
9
+ # through, and a Claude Code session names its harness when none is given.
10
+ # plastic-lock answers in JSON, so this command renders it as a screen and
11
+ # leaves the document to `--json`. A lock another session owns exits 3.
7
12
  module Plastic
8
13
  class CLI
9
14
  module Commands
10
15
  class AutoTake < IntentCommand
11
- USAGE_LINE = "plastic auto take ID [--allow-inline] [--json]"
16
+ USAGE_LINE = "plastic auto take ID [--allow-inline] [--harness NAME] [--agent NAME] " \
17
+ "[--model MODEL] [--thread ID] [--json]"
12
18
 
13
19
  SCRIPT = "plastic-lock"
14
20
  AFTER = "plastic auto brief ID"
15
21
  BECAUSE = "the preamble is the live state a taken intent is read from next"
22
+ PROVENANCE = %i[harness agent model thread].freeze
23
+
24
+ def call
25
+ return super if options[:json]
26
+
27
+ text, status = legacy.capture(SCRIPT, *script_arguments)
28
+ raise Failure, "#{SCRIPT} exited #{status}" unless status.zero?
29
+
30
+ screen(JSON.parse(text))
31
+ command, reason = after_run
32
+ @output.next_step(command, because: reason)
33
+ rescue JSON::ParserError
34
+ raise Failure, "#{SCRIPT} did not print a report"
35
+ end
16
36
 
17
37
  private
18
38
 
19
39
  def switches(parser)
20
40
  parser.on("--allow-inline") { @options[:allow_inline] = true }
41
+ PROVENANCE.each do |name|
42
+ parser.on("--#{name} VALUE") { |value| @options[name] = value }
43
+ end
21
44
  end
22
45
 
23
46
  def script_arguments
24
47
  args = ["arm", "--intent-dir", intent_dir, "--mode", "auto"]
48
+ provenance.each { |name, value| args.push("--#{name}", value) }
25
49
  args << "--allow-inline" if options[:allow_inline]
26
50
  args
27
51
  end
52
+
53
+ def provenance
54
+ given = PROVENANCE.to_h { |name| [name, options[name]] }
55
+ given[:harness] ||= "claude" if @env["CLAUDE_CODE_SESSION_ID"].to_s.strip != ""
56
+ given.compact
57
+ end
58
+
59
+ def screen(report)
60
+ worktree = report["worktree"] || {}
61
+ @output.row("intent", File.basename(report["intent_dir"].to_s))
62
+ @output.row("lock", "#{report["status"]} by #{report["session"]}, #{report["run_mode"]} mode")
63
+ @output.row("worktree", worktree["provisioned"] ? worktree["code"].to_s : "none")
64
+ end
28
65
  end
29
66
  end
30
67
  end
@@ -15,8 +15,8 @@ module Plastic
15
15
  class CLI
16
16
  module Commands
17
17
  class Doctor < InstallerVerb
18
- USAGE_LINE = "plastic doctor [--core] [--store WHICH] [--json]"
19
- FLAGS = %w[--core --store].freeze
18
+ USAGE_LINE = "plastic doctor [--core] [--agent claude|codex|hermes] [--store WHICH] [--json]"
19
+ FLAGS = %w[--core --agent --store].freeze
20
20
 
21
21
  SCRIPT = "doctor.rb"
22
22
  AFTER = "none"
@@ -22,11 +22,16 @@ module Plastic
22
22
  status = legacy.run(self.class::SCRIPT, *script_arguments)
23
23
  raise Failure, "#{self.class::SCRIPT} exited #{status}" unless status.zero?
24
24
 
25
- @output.next_step(self.class::AFTER.sub("ID", id.to_s), because: self.class::BECAUSE)
25
+ command, reason = after_run
26
+ @output.next_step(command, because: reason)
26
27
  end
27
28
 
28
29
  private
29
30
 
31
+ def after_run
32
+ [self.class::AFTER.sub("ID", id.to_s), self.class::BECAUSE]
33
+ end
34
+
30
35
  def id
31
36
  arguments.first
32
37
  end
@@ -15,6 +15,15 @@ module Plastic
15
15
 
16
16
  REFUSAL_STATUS = 4
17
17
 
18
+ # end-intent's own refusals the agent can act on: the reason is theirs to
19
+ # fix, so they stay failures (exit 1), but each is named rather than
20
+ # surfaced as a bare exit code.
21
+ REFUSED = {
22
+ 7 => "end-intent refused a hollow delivered close: write outcome.md's ## Delivered rows to match the action headings",
23
+ 8 => "end-intent refused to deliver an untouched scaffold: do the work first, or close it with --abandoned",
24
+ 9 => "end-intent refused a delivered close whose code is not merged: run the git merge it names in the repo, then run the close again"
25
+ }.freeze
26
+
18
27
  SUMMARY_GUIDANCE = [
19
28
  "a good summary is written for the reader deciding whether to merge, release, or accept",
20
29
  "delivered: what shipped, impact and risk first, in plain language, never the checklist",
@@ -28,9 +37,13 @@ module Plastic
28
37
 
29
38
  status = legacy.run("end-intent", *end_intent_arguments)
30
39
  raise Refusal, "end-intent needs the owner: the delivery lock is held" if status == REFUSAL_STATUS
31
- raise Failure, "end-intent exited #{status}" unless status.zero?
40
+ raise Failure, REFUSED.fetch(status) { "end-intent exited #{status}" } unless status.zero?
32
41
 
33
- @output.next_step("plastic status", because: "the intent moved out of Active")
42
+ if options[:dry_run]
43
+ @output.next_step("none", because: "the dry run wrote nothing and found nothing that would refuse the close")
44
+ else
45
+ @output.next_step("plastic status", because: "the intent moved out of Active")
46
+ end
34
47
  end
35
48
 
36
49
  private
@@ -16,6 +16,7 @@ module Plastic
16
16
  SCRIPT = "runner"
17
17
  AFTER = "plastic intent step ID"
18
18
  BECAUSE = "call step again after each dispatched node returns"
19
+ COMPLETE = "every node in the graph has finished"
19
20
 
20
21
  def call
21
22
  intent_dir
@@ -49,11 +50,18 @@ module Plastic
49
50
  env_session: @env["CLAUDE_CODE_SESSION_ID"], store: scope.store, intent_id: id)
50
51
  return true if session
51
52
  raise Refusal, "intent #{id} is held by another session; inspect with plastic auto lock status #{id} --project #{scope.slug}" if Lock.read(intent_dir)
53
+ raise Refusal, "return not accepted: take intent #{id} with plastic auto take #{id} --project #{scope.slug} before submitting it" unless Array(options[:returns]).empty?
52
54
 
53
55
  @output.next_step("plastic auto take #{id}", because: "graph execution requires this session to take the delivery lock")
54
56
  false
55
57
  end
56
58
 
59
+ def after_run
60
+ return super unless RunnerCore.complete?(RunnerCore.context(intent_dir: intent_dir, home: File.dirname(scope.plastic_home)))
61
+
62
+ ["plastic intent verify #{id}", COMPLETE]
63
+ end
64
+
57
65
  def graph?
58
66
  File.exist?(File.join(intent_dir, "graph.md"))
59
67
  end
@@ -20,7 +20,11 @@ module Plastic
20
20
  status = legacy.run("project-links", "--plastic-home", scope.plastic_home, *dry_run_flag)
21
21
  raise Failure, "project-links exited #{status}" unless status.zero?
22
22
 
23
- @output.next_step("plastic status", because: "the Links sections just rebuilt across every store")
23
+ if options[:dry_run]
24
+ @output.next_step("none", because: "the preview wrote no files")
25
+ else
26
+ @output.next_step("plastic status", because: "the Links sections just rebuilt across every store")
27
+ end
24
28
  end
25
29
 
26
30
  private