@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.
- package/agents/plastic-enforcer.md +1 -1
- package/bin/plastic +2 -0
- package/docs/help/completion-and-done.md +12 -1
- package/docs/help/roadmaps.md +3 -2
- package/package.json +1 -1
- package/scripts/end-intent +225 -26
- package/scripts/lib/arm.rb +28 -10
- package/scripts/lib/cli/commands/auto_lock.rb +17 -5
- package/scripts/lib/cli/commands/auto_take.rb +38 -1
- package/scripts/lib/cli/commands/doctor.rb +2 -2
- package/scripts/lib/cli/commands/intent_command.rb +6 -1
- package/scripts/lib/cli/commands/intent_end.rb +15 -2
- package/scripts/lib/cli/commands/intent_step.rb +8 -0
- package/scripts/lib/cli/commands/project_links.rb +5 -1
- package/scripts/lib/cli/commands/project_new.rb +24 -1
- package/scripts/lib/cli/commands/render.rb +3 -1
- package/scripts/lib/cli/commands/roadmap_next.rb +10 -3
- package/scripts/lib/cli/commands/roadmap_show.rb +21 -1
- package/scripts/lib/cli/commands/session_commit.rb +1 -1
- package/scripts/lib/cli/commands/sync.rb +10 -1
- package/scripts/lib/hook_replay.rb +51 -45
- package/scripts/lib/installer_core.rb +12 -0
- package/scripts/lib/node_input.rb +13 -5
- package/scripts/lib/roadmap_queue.rb +12 -4
- package/scripts/lib/runner_dispatch.rb +8 -7
- package/scripts/lib/session_git.rb +42 -1
- package/scripts/lib/untouched_scaffold.rb +51 -0
- package/scripts/plastic-lock +24 -28
- package/scripts/project-links +6 -21
- package/scripts/roadmap-graph +10 -4
- package/scripts/rollback.rb +5 -1
- package/scripts/update.rb +7 -1
|
@@ -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
|
|
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
|
@@ -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
|
|
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
|
package/docs/help/roadmaps.md
CHANGED
|
@@ -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/
|
|
15
|
-
`~/.plastic/roadmaps/`, beside
|
|
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
package/scripts/end-intent
CHANGED
|
@@ -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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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(", ")}"
|
package/scripts/lib/arm.rb
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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" =>
|
|
212
|
-
"reclaim the lock to take over with an audit" }
|
|
222
|
+
"hint" => stale_hint(intent_id_for(dir)) }
|
|
213
223
|
end
|
|
214
224
|
|
|
215
|
-
|
|
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`
|
|
13
|
-
# document to `--json`. `
|
|
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
|
-
|
|
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",
|
|
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] [--
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|