@zalom/plastic 2.0.1 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/PLASTIC.md +2 -1
  2. package/README.md +40 -33
  3. package/agents/plastic-enforcer.md +13 -11
  4. package/agents/plastic-executor.md +2 -1
  5. package/agents/plastic-primary-advisor.md +2 -2
  6. package/agents/plastic-secondary-advisor.md +2 -2
  7. package/bin/plastic +2 -0
  8. package/docs/help/agent-architecture.md +9 -8
  9. package/docs/help/agent-report-contract.md +6 -4
  10. package/docs/help/completion-and-done.md +20 -4
  11. package/docs/help/human-report-contract.md +23 -20
  12. package/docs/help/knowledge-graph.md +2 -2
  13. package/docs/help/lifecycle-and-savepoints.md +9 -5
  14. package/docs/help/locks-and-worktrees.md +4 -3
  15. package/docs/help/maintenance-and-revisions.md +2 -2
  16. package/docs/help/roadmaps.md +16 -11
  17. package/docs/help/track-1-guided.md +48 -30
  18. package/docs/help/track-2-auto.md +35 -10
  19. package/docs/help/track-3-projects-and-roadmaps.md +10 -7
  20. package/docs/help/tutorial.md +421 -0
  21. package/package.json +1 -1
  22. package/scripts/end-intent +312 -69
  23. package/scripts/lib/arm.rb +28 -10
  24. package/scripts/lib/cli/commands/auto_lock.rb +17 -5
  25. package/scripts/lib/cli/commands/auto_take.rb +38 -1
  26. package/scripts/lib/cli/commands/doctor.rb +2 -2
  27. package/scripts/lib/cli/commands/intent_command.rb +6 -1
  28. package/scripts/lib/cli/commands/intent_end.rb +15 -2
  29. package/scripts/lib/cli/commands/intent_step.rb +8 -0
  30. package/scripts/lib/cli/commands/project_links.rb +5 -1
  31. package/scripts/lib/cli/commands/project_new.rb +24 -1
  32. package/scripts/lib/cli/commands/render.rb +3 -1
  33. package/scripts/lib/cli/commands/roadmap_next.rb +10 -3
  34. package/scripts/lib/cli/commands/roadmap_show.rb +21 -1
  35. package/scripts/lib/cli/commands/session_commit.rb +1 -1
  36. package/scripts/lib/cli/commands/sync.rb +10 -1
  37. package/scripts/lib/hook_replay.rb +51 -45
  38. package/scripts/lib/installer_core.rb +12 -0
  39. package/scripts/lib/node_input.rb +13 -5
  40. package/scripts/lib/revisions_writer.rb +1 -2
  41. package/scripts/lib/roadmap_queue.rb +12 -4
  42. package/scripts/lib/runner_dispatch.rb +8 -7
  43. package/scripts/lib/session_git.rb +42 -1
  44. package/scripts/lib/untouched_scaffold.rb +51 -0
  45. package/scripts/plastic-lock +24 -28
  46. package/scripts/project-links +6 -21
  47. package/scripts/roadmap-graph +10 -4
  48. package/scripts/rollback.rb +5 -1
  49. package/scripts/update.rb +7 -1
  50. package/skills/_decision-tables.md +3 -3
@@ -34,13 +34,17 @@
34
34
  # doctor's per-intent structure check and OutcomeGuard as a self-check
35
35
  # that reports on stderr and proceeds. Then stamp the intent file's
36
36
  # `## Outcome` section with --outcome-summary if given.
37
- # 2. Move the intent's INDEX.md line from `## Active` into `## Completed`
38
- # (delivered) or `## Abandoned` (abandoned), dated today, appending
39
- # --index-note (if given). Accepts a real em dash OR a plain hyphen as
40
- # the id/title separator on READ (shared matcher, IndexEntry.match);
41
- # always EMITS the real em dash on write. An id that resolves to neither
42
- # `## Active` nor the terminal section is a loud failure (exit 1); an id
43
- # already correctly in the terminal section is a quiet, idempotent success.
37
+ # 2. Move the intent's INDEX.md line from `## Active` or `## Future` into
38
+ # `## Completed` (delivered) or `## Abandoned` (abandoned), dated today,
39
+ # appending --index-note (if given). Accepts a real em dash OR a plain
40
+ # hyphen as the id/title separator on READ (shared matcher,
41
+ # IndexEntry.match); always EMITS the real em dash on write. The move is
42
+ # resolved before step 1 writes anything (intent 385): an id that
43
+ # resolves to neither an open section nor the terminal section is a loud
44
+ # failure (exit 1) that writes nothing, in the dry run too; an id already
45
+ # correctly in the terminal section is a quiet, idempotent success. A
46
+ # missing INDEX.md is not a refusal: the move is skipped with a warning,
47
+ # and the dry run says so.
44
48
  # 3. Append the savepoint `Done` bookend (Savepoint.append_terminal_savepoint).
45
49
  # 4. Commit the store repo, unless --no-commit.
46
50
  # 5. Disarm (intent 188, D2/D16; intent 307): check the code worktree (derived
@@ -50,10 +54,15 @@
50
54
  # the durable lock file is actually gone afterward, and release it directly
51
55
  # when a foreign-keyed disarm left it.
52
56
  #
57
+ # A delivered close never merges code. Its code branch must already be merged
58
+ # into the repo checkout's current branch, or the close refuses before any
59
+ # write (exit 9).
60
+ #
53
61
  # Exit codes:
54
62
  # 0 ok - the intent is closed and no delivery.lock remains.
55
- # 1 usage/resolution failure, OR an id that resolves to neither ## Active
56
- # nor the terminal section (D11). Deliberately double duty; see plan.md.
63
+ # 1 usage/resolution failure, OR an id that resolves to neither ## Active,
64
+ # ## Future, nor the terminal section (D11). Deliberately double duty;
65
+ # see plan.md. Refused before any write.
57
66
  # 2 retired in 2.0 (intent 308): the outcome.md guard reports and proceeds.
58
67
  # 3 steps 1-4 already committed, but the delivery lock genuinely could not
59
68
  # be cleared (see step 5 above).
@@ -67,13 +76,27 @@
67
76
  # 6 retired in 2.0 (intent 308): the structure check reports and proceeds.
68
77
  # 7 hollow delivered report refused (317a D7) - intent 308's one deliberate
69
78
  # exception to report-and-proceed; --allow-hollow-report overrides.
79
+ # 8 delivered close of an untouched scaffold refused (acceptance N3):
80
+ # nothing was worked, so there is nothing to deliver. Close it as
81
+ # abandoned, or do the work first. Authors nothing.
82
+ # 9 delivered close refused because its code branch is not merged into the
83
+ # repo checkout's current branch. Refused before any write: nothing is
84
+ # merged, written, or released. Merge the branch, then run the close again.
85
+ #
86
+ # --dry-run runs the same refusals as the real close (exits 1, 8, 9, 7 and 5 with
87
+ # a "would refuse" line) and writes nothing: the hollow-report gate is judged
88
+ # on a scratch copy that went through the same outcome generation and
89
+ # backfill.
70
90
 
71
91
  require_relative "lib/store_layout"
72
92
  require "fileutils"
73
93
  require "date"
74
94
  require "open3"
95
+ require "stringio"
96
+ require "tmpdir"
75
97
  require "pathname"
76
98
  require_relative "lib/arm"
99
+ require_relative "lib/exec_worktree"
77
100
  require_relative "lib/index_entry"
78
101
  require_relative "lib/savepoint"
79
102
  require_relative "lib/lock"
@@ -82,6 +105,8 @@ require_relative "lib/outcome_guard"
82
105
  require_relative "lib/report_screen"
83
106
  require_relative "lib/backfill_intent"
84
107
  require_relative "lib/outcome_report"
108
+ require_relative "lib/untouched_scaffold"
109
+ require_relative "lib/scaffold_intent"
85
110
 
86
111
  DISPOSITIONS = %w[delivered abandoned].freeze
87
112
 
@@ -93,10 +118,15 @@ DISPOSITIONS = %w[delivered abandoned].freeze
93
118
  # (intent 188, D9/D12): see move_index_to_terminal below.
94
119
  EM_DASH = "\u2014"
95
120
 
96
- # Raised internally when the INDEX id resolves to neither `## Active` nor the
97
- # terminal section (D11): the caller (main) rescues this and exits 1.
121
+ # Raised internally when the INDEX id resolves to neither an open section nor
122
+ # the terminal section (D11): the caller (main) rescues this and exits 1.
98
123
  class UnresolvedIndexEntry < StandardError; end
99
124
 
125
+ # The INDEX sections an intent can still be closed from, searched in order
126
+ # (intent 385): a Future intent is retired through the same path as an
127
+ # Active one.
128
+ OPEN_HEADINGS = ["## Active", "## Future"].freeze
129
+
100
130
  # --- Explicit flag parsing (no eval, no global injection) -------------------
101
131
 
102
132
  def blank?(value)
@@ -447,19 +477,32 @@ def section_contains_id?(lines, heading_idx, id)
447
477
  (heading_idx + 1...stop).any? { |i| active_line_id(lines[i]) == id }
448
478
  end
449
479
 
450
- # Remove the entry at entry_idx from the `## Active` section. When no real
480
+ # The first open section holding the id's entry, as [heading, heading_idx,
481
+ # entry_idx], or nil when no open section holds it.
482
+ def find_open_entry(lines, id)
483
+ OPEN_HEADINGS.each do |heading|
484
+ start = lines.index { |l| l.rstrip == heading }
485
+ next unless start
486
+
487
+ entry_idx = (start + 1...section_stop(lines, start)).find { |i| active_line_id(lines[i]) == id }
488
+ return [heading, start, entry_idx] if entry_idx
489
+ end
490
+ nil
491
+ end
492
+
493
+ # Remove the entry at entry_idx from its open section. When no real
451
494
  # entries remain, install the canonical "_(none)_" placeholder (the same
452
495
  # empty-state convention INDEX.md already uses).
453
- def remove_active_entry(lines, active_start, entry_idx)
496
+ def remove_open_entry(lines, section_start, entry_idx)
454
497
  new_lines = lines.dup
455
498
  new_lines.delete_at(entry_idx)
456
- stop = section_stop(new_lines, active_start)
457
- remaining = (active_start + 1...stop).reject { |i| new_lines[i].strip.empty? }
499
+ stop = section_stop(new_lines, section_start)
500
+ remaining = (section_start + 1...stop).reject { |i| new_lines[i].strip.empty? }
458
501
  if remaining.empty?
459
502
  # Keep the blank-line separator before the next heading (if any); only
460
503
  # collapse the section body to the placeholder, never the spacing.
461
504
  placeholder = (stop < new_lines.length) ? ["_(none)_\n", "\n"] : ["_(none)_\n"]
462
- new_lines[(active_start + 1)...stop] = placeholder
505
+ new_lines[(section_start + 1)...stop] = placeholder
463
506
  end
464
507
  new_lines
465
508
  end
@@ -483,34 +526,30 @@ def insert_terminal_entry(lines, target_start, new_entry)
483
526
  new_lines
484
527
  end
485
528
 
486
- # Move the intent's `## Active` line into the terminal section. Returns the
487
- # new INDEX.md content on success. Raises UnresolvedIndexEntry (D11) when the
488
- # id resolves to NEITHER `## Active` NOR the terminal section (including the
489
- # degenerate case where one of the two headings is missing from INDEX.md
490
- # entirely); returns the content UNCHANGED (a quiet, idempotent success) when
491
- # the id is already correctly present in the terminal section, whether or not
492
- # a stray duplicate also still sits under `## Active` (that duplicate is
493
- # cleaned up too, when found, matching the pre-188 idempotent-cleanup
494
- # behavior for the ordinary already-moved re-run case).
529
+ # Move the intent's `## Active` or `## Future` line into the terminal
530
+ # section. Returns the new INDEX.md content on success. Raises
531
+ # UnresolvedIndexEntry (D11) when the id resolves to NEITHER an open section
532
+ # NOR the terminal section (including the degenerate case where the headings
533
+ # are missing from INDEX.md entirely); returns the content UNCHANGED (a quiet,
534
+ # idempotent success) when the id is already correctly present in the
535
+ # terminal section, whether or not a stray duplicate also still sits in an
536
+ # open section (that duplicate is cleaned up too, when found, matching the
537
+ # pre-188 idempotent-cleanup behavior for the ordinary already-moved re-run
538
+ # case).
495
539
  def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
496
540
  target_heading = index_target_heading(disposition)
497
541
  lines = content.lines
498
542
 
499
543
  target_start = lines.index { |l| l.rstrip == target_heading }
500
- active_start = lines.index { |l| l.rstrip == "## Active" }
501
544
  already_in_target = target_start && section_contains_id?(lines, target_start, id)
502
545
 
503
- entry_idx = nil
504
- if active_start
505
- active_stop = section_stop(lines, active_start)
506
- entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
507
- end
546
+ _heading, source_start, entry_idx = find_open_entry(lines, id)
508
547
 
509
548
  if entry_idx.nil?
510
549
  return content if already_in_target # true idempotency (D11): quiet success
511
550
  raise UnresolvedIndexEntry,
512
- "intent #{id} could not be resolved: not found under ## Active, and not already " \
513
- "present in #{target_heading} (check INDEX.md for a malformed or missing entry)"
551
+ "intent #{id} could not be resolved: not found under #{OPEN_HEADINGS.join(" or ")}, and not " \
552
+ "already present in #{target_heading} (check INDEX.md for a malformed or missing entry)"
514
553
  end
515
554
 
516
555
  if target_start.nil?
@@ -522,7 +561,7 @@ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
522
561
  note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
523
562
  new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
524
563
 
525
- lines = remove_active_entry(lines, active_start, entry_idx)
564
+ lines = remove_open_entry(lines, source_start, entry_idx)
526
565
  target_start = lines.index { |l| l.rstrip == target_heading }
527
566
  lines = insert_terminal_entry(lines, target_start, new_entry) unless already_in_target
528
567
 
@@ -543,7 +582,27 @@ def warn_if_active_duplicate_remains(content, id)
543
582
 
544
583
  warn "end-intent: intent #{id} still has an entry under ## Active after the move " \
545
584
  "(a duplicate ## Active line for this id); INDEX.md needs manual cleanup. Check " \
546
- "INDEX.md directly, or run /plastic-doctor."
585
+ "INDEX.md directly, or run `plastic doctor`."
586
+ end
587
+
588
+ # The dry-run line for the INDEX move the close will make, resolved on the
589
+ # current INDEX.md before anything is written (intent 385), so the preview
590
+ # and the real close refuse the same unresolvable entry. Exits 1 on
591
+ # UnresolvedIndexEntry, exactly as step 2 would, but before step 1 writes.
592
+ def preflight_index_move(index_path, id, disposition, today:, index_note:)
593
+ target = index_target_heading(disposition)
594
+ return "INDEX.md not found at #{index_path}, would skip the terminal move" unless File.exist?(index_path)
595
+
596
+ content = File.read(index_path)
597
+ move_index_to_terminal(content, id, disposition, today: today, index_note: index_note)
598
+ source, = find_open_entry(content.lines, id)
599
+ return "INDEX.md entry already in #{target}, would leave it (#{index_path})" unless source
600
+
601
+ "would move INDEX.md entry from #{source} to #{target} (#{index_path})"
602
+ rescue UnresolvedIndexEntry => e
603
+ warn "end-intent: #{e.message}"
604
+ warn "end-intent: nothing was written"
605
+ exit 1
547
606
  end
548
607
 
549
608
  # --- store auto-commit (D2 step 4) ------------------------------------------
@@ -665,32 +724,10 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
665
724
  block = worktree_reader.call(intent_dir)
666
725
  worktree_code = block.is_a?(Hash) ? block["code"] : nil
667
726
 
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."
727
+ unless discard_worktree_changes
728
+ refusal = worktree_refusal(worktree_code)
729
+ if refusal
730
+ warn refusal
694
731
  return :dirty
695
732
  end
696
733
  end
@@ -733,7 +770,7 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
733
770
  # let the caller sort it out.
734
771
  warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} is now held by " \
735
772
  "#{current['owner_session'].inspect}, which acquired it during this close; " \
736
- "refusing to delete another session's lock. Run /plastic-doctor to check the " \
773
+ "refusing to delete another session's lock. Run `plastic doctor` to check the " \
737
774
  "lock status."
738
775
  end
739
776
  # current.nil? here means the lock is corrupt/unparseable; nothing to
@@ -748,16 +785,183 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
748
785
  if block.nil?
749
786
  warn "end-intent: no worktree block could be derived for intent #{id}; " \
750
787
  "the delivery lock was cleared directly from disk, but any worktree this intent " \
751
- "provisioned was NOT removed. Run /plastic-doctor to check for an orphaned worktree."
788
+ "provisioned was NOT removed. Run `plastic doctor` to check for an orphaned worktree."
752
789
  end
753
790
  return :ok
754
791
  end
755
792
 
756
793
  warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} is still present after " \
757
- "disarm and a direct release attempt. Run /plastic-doctor to check the lock status."
794
+ "disarm and a direct release attempt. Run `plastic doctor` to check the lock status."
758
795
  :lock_remains
759
796
  end
760
797
 
798
+ # The disarm step's dirty-worktree guard, as a message (nil when the worktree
799
+ # is clean or absent). Shared by run_disarm and --dry-run so the preview
800
+ # refuses exactly what the close refuses.
801
+ #
802
+ # Fail CLOSED (intent 188, post-review hardening). A failed or impossible
803
+ # `git status` (corrupt per-worktree index, git missing from PATH, not a
804
+ # repo) means we cannot PROVE the worktree is clean, and the removal
805
+ # force-removes on a plain `git worktree remove` failure, which destroys
806
+ # uncommitted work. Never force-remove an unproven worktree: the guard
807
+ # exists to protect uncommitted code, so an inconclusive check must refuse,
808
+ # not silently proceed as if it were clean.
809
+ def worktree_refusal(worktree_code)
810
+ return nil unless worktree_code && Dir.exist?(worktree_code)
811
+
812
+ begin
813
+ out, err, status = Open3.capture3("git", "-C", worktree_code, "status", "--porcelain")
814
+ rescue StandardError => e
815
+ out, err, status = nil, e.message, nil
816
+ end
817
+
818
+ if status.nil? || !status.success?
819
+ "end-intent: could not inspect the code worktree at #{worktree_code} " \
820
+ "(git status failed: #{err.to_s.strip}); refusing to remove it, because " \
821
+ "removal would force-discard any uncommitted work. Pass " \
822
+ "--discard-worktree-changes to remove it anyway, or repair the worktree."
823
+ elsif !out.strip.empty?
824
+ "end-intent: code worktree #{worktree_code} has uncommitted changes; refusing " \
825
+ "to remove it. Pass --discard-worktree-changes to force the close deliberately, " \
826
+ "or commit/stash the changes and re-run."
827
+ end
828
+ end
829
+
830
+ # True when the intent's code worktree carries any work: uncommitted changes
831
+ # or commits past its base branch. Anything that cannot be proven reads as
832
+ # changed, so an unreadable worktree never turns a close into a refusal.
833
+ def worktree_changed?(intent_dir)
834
+ block = Arm.worktree_block(intent_dir: intent_dir)
835
+ code = block.is_a?(Hash) ? block["code"] : nil
836
+ return false unless code && Dir.exist?(code)
837
+
838
+ out, status = Open3.capture2e("git", "-C", code, "status", "--porcelain")
839
+ return true unless status.success? && out.strip.empty?
840
+
841
+ base = ScaffoldIntent.detect_base_branch(code)
842
+ return true if base.nil?
843
+
844
+ ahead, status = Open3.capture2e("git", "-C", code, "rev-list", "--count", "#{base}..HEAD")
845
+ !status.success? || ahead.strip != "0"
846
+ rescue StandardError
847
+ true
848
+ end
849
+
850
+ # A delivered close whose code is not on the repo checkout's current branch,
851
+ # as a refusal message (nil when it is, or for a store-only project).
852
+ # end-intent never merges: the owner merges or releases the work, then runs
853
+ # the close again. A real code worktree must prove that its HEAD commit is
854
+ # merged, so a detached or renamed branch is still checked. The code branch
855
+ # must be merged too, even after its worktree is gone. A git failure refuses.
856
+ # A directory that is not a working git worktree, with no code branch, is left
857
+ # to the dirty-worktree guard, which refuses what it cannot inspect.
858
+ def unmerged_refusal(intent_dir, runner: Worktree::ShellRunner.new)
859
+ paths = Arm.code_paths(intent_dir: intent_dir)
860
+ code = paths["code"]
861
+ return nil if code.nil?
862
+
863
+ repo = ExecWorktree.repo_from_worktree_code(code)
864
+ unless runner.run("-C", repo, "rev-parse", "--git-dir").success?
865
+ # No .git at all is a project without code; a .git that Git cannot read is not.
866
+ return nil unless File.exist?(File.join(repo, ".git"))
867
+
868
+ return "could not inspect the Git repository #{repo}, so the code cannot be shown to be merged. " \
869
+ "Check the repository, then run the close again"
870
+ end
871
+
872
+ unmerged_worktree_head(runner, code, repo, paths["code_branch"]) ||
873
+ unmerged_branch(runner, repo, paths["code_branch"])
874
+ end
875
+
876
+ # True when `code` is the top of its own git work tree, not a plain
877
+ # directory inside the repo or a broken checkout.
878
+ def real_worktree?(runner, code)
879
+ return false unless File.directory?(code)
880
+
881
+ top = runner.run("-C", code, "rev-parse", "--show-toplevel")
882
+ top.success? && File.realpath(top.stdout.strip) == File.realpath(code)
883
+ rescue SystemCallError
884
+ false
885
+ end
886
+
887
+ def unmerged_worktree_head(runner, code, repo, branch)
888
+ return nil unless real_worktree?(runner, code)
889
+
890
+ head = runner.run("-C", code, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
891
+ unless head.success?
892
+ return "could not read the HEAD commit of the code worktree #{code}, so its work cannot be " \
893
+ "shown to be merged. Commit it, merge it into the current branch of #{repo}, then run the close again"
894
+ end
895
+ sha = head.stdout.strip
896
+ on = runner.run("-C", code, "symbolic-ref", "--quiet", "--short", "HEAD")
897
+ name = on.success? ? on.stdout.strip : nil
898
+ ref = (name == branch) ? branch : sha[0, 12]
899
+ where = name ? "on branch #{name}" : "detached"
900
+ ancestry_refusal(runner, repo, sha, ref, "the code worktree #{code} (#{where} at #{sha[0, 12]})", branch)
901
+ end
902
+
903
+ def unmerged_branch(runner, repo, branch)
904
+ found = runner.run("-C", repo, "show-ref", "--verify", "--quiet", "refs/heads/#{branch}")
905
+ return nil if found.status == 1
906
+ unless found.success?
907
+ return "could not look up code branch #{branch} in #{repo}. Check the repository, then run the close again"
908
+ end
909
+ ancestry_refusal(runner, repo, branch, branch, "code branch #{branch}", branch)
910
+ end
911
+
912
+ # nil when `rev` is an ancestor of the branch the repo checkout is on, else a
913
+ # refusal naming `what` and the ordinary merge of `ref` that fixes it.
914
+ def ancestry_refusal(runner, repo, rev, ref, what, branch)
915
+ target = target_refusal(runner, repo, branch)
916
+ return target if target
917
+
918
+ check = runner.run("-C", repo, "merge-base", "--is-ancestor", rev, "HEAD")
919
+ return nil if check.success?
920
+
921
+ verdict = (check.status == 1) ? "is not merged into" : "could not be checked against"
922
+ "#{what} #{verdict} the current branch of #{repo}. " \
923
+ "Merge it there first (git -C #{repo} merge #{ref}), then run the close again"
924
+ end
925
+
926
+ # Integration means merged into a branch other than the code branch, so a
927
+ # detached checkout, or one on the code branch itself, proves nothing.
928
+ def target_refusal(runner, repo, branch)
929
+ on = runner.run("-C", repo, "symbolic-ref", "--quiet", "--short", "HEAD")
930
+ unless on.success?
931
+ return "the repo checkout #{repo} is not on a branch, so no branch holds the merged code. " \
932
+ "Check out the branch you release from, merge #{branch} into it, then run the close again"
933
+ end
934
+ return nil unless on.stdout.strip == branch
935
+
936
+ "the repo checkout #{repo} is on the code branch #{branch} itself, so the code is not merged anywhere. " \
937
+ "Check out the branch you release from, merge #{branch} into it, then run the close again"
938
+ end
939
+
940
+ # The hollow-report verdict the real close would reach, judged on a scratch
941
+ # copy of the intent after the same outcome generation and backfill, so the
942
+ # dry run writes nothing.
943
+ def predicted_hollow_reason(intent_dir, store:, id:, disposition:, summary:)
944
+ return nil unless disposition == "delivered"
945
+
946
+ Dir.mktmpdir("end-intent-dry-run") do |scratch|
947
+ copy = File.join(scratch, File.basename(intent_dir))
948
+ FileUtils.cp_r(intent_dir, copy)
949
+ quietly do
950
+ run_generate_outcome(copy, disposition: disposition)
951
+ run_backfill(copy, store: store, id: id, disposition: disposition, summary: summary)
952
+ end
953
+ hollow_report_reason(copy, disposition)
954
+ end
955
+ end
956
+
957
+ def quietly
958
+ saved = $stderr
959
+ $stderr = StringIO.new
960
+ yield
961
+ ensure
962
+ $stderr = saved
963
+ end
964
+
761
965
  # --- main --------------------------------------------------------------------
762
966
 
763
967
  def main(argv)
@@ -789,7 +993,7 @@ def main(argv)
789
993
  warn "end-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
790
994
  "identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's " \
791
995
  "own recorded owner are all blank); refusing rather than guessing. Pass " \
792
- "--session explicitly, or run /plastic-doctor check the lock status."
996
+ "--session explicitly, or run `plastic doctor` to check the lock status."
793
997
  exit 4
794
998
  end
795
999
 
@@ -797,7 +1001,7 @@ def main(argv)
797
1001
 
798
1002
  if verdict == :refuse
799
1003
  warn "end-intent: delivery lock for #{id} is held by a live session " \
800
- "(#{lock_data['owner_session']}); refusing to close. Run /plastic-doctor check " \
1004
+ "(#{lock_data['owner_session']}); refusing to close. Run `plastic doctor` to check " \
801
1005
  "the lock status"
802
1006
  exit 4
803
1007
  end
@@ -805,17 +1009,56 @@ def main(argv)
805
1009
  if verdict == :refuse_corrupt
806
1010
  warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} exists and is fresh, " \
807
1011
  "but its content will not parse (corrupt); refusing to close since another " \
808
- "session may be actively heartbeating it. Run /plastic-doctor check the lock " \
1012
+ "session may be actively heartbeating it. Run `plastic doctor` to check the lock " \
809
1013
  "status, or plastic-lock fix once it is confirmed safe."
810
1014
  exit 4
811
1015
  end
812
1016
 
1017
+ # Nothing worked, nothing to deliver (acceptance N3). Before the dry run and
1018
+ # before any write, so the preview and the close refuse together.
1019
+ if disposition == "delivered"
1020
+ untouched = UntouchedScaffold.reason(intent_dir, templates: BackfillIntent.load_templates,
1021
+ worktree_changed: method(:worktree_changed?))
1022
+ if untouched
1023
+ warn "end-intent: refusing to deliver an untouched scaffold: #{untouched}"
1024
+ warn "end-intent: do the work first, or close it with --disposition abandoned"
1025
+ exit 8
1026
+ end
1027
+
1028
+ unmerged = unmerged_refusal(intent_dir)
1029
+ if unmerged
1030
+ warn "end-intent: #{opts[:dry_run] ? "dry run: would refuse" : "refusing"} a delivered close: #{unmerged}"
1031
+ warn "end-intent: nothing was merged, written, or released"
1032
+ exit 9
1033
+ end
1034
+ end
1035
+
1036
+ # Resolve the INDEX move before any write (intent 385), in the dry run and the
1037
+ # real close alike: an entry the move cannot find refuses here, not after the
1038
+ # backfill has already written into the intent directory.
1039
+ index_move = preflight_index_move(index_path, id, disposition, today: today, index_note: opts[:index_note])
1040
+
813
1041
  if opts[:dry_run]
1042
+ hollow = predicted_hollow_reason(intent_dir, store: store, id: id, disposition: disposition,
1043
+ summary: opts[:outcome_summary])
1044
+ if hollow && !opts[:allow_hollow_report]
1045
+ warn "end-intent: dry run: would refuse a hollow delivered close: #{hollow}"
1046
+ exit 7
1047
+ end
1048
+ unless blank?(session) || opts[:discard_worktree_changes]
1049
+ block = Arm.worktree_block(intent_dir: intent_dir)
1050
+ refusal = worktree_refusal(block.is_a?(Hash) ? block["code"] : nil)
1051
+ if refusal
1052
+ warn "end-intent: dry run: would refuse at disarm (exit 5), after the store commit: " \
1053
+ "#{refusal.delete_prefix("end-intent: ")}"
1054
+ exit 5
1055
+ end
1056
+ end
814
1057
  puts "end-intent: dry run for #{File.basename(intent_dir)} (#{disposition})"
815
1058
  targets = BackfillIntent.targets_for(intent_dir)
816
1059
  puts " would backfill: #{targets.empty? ? "(nothing)" : targets.join(", ")}"
817
1060
  puts " would stamp ## Outcome: #{opts[:outcome_summary].inspect}" if opts[:outcome_summary]
818
- puts " would move INDEX.md entry from ## Active to #{index_target_heading(disposition)} (#{index_path})"
1061
+ puts " #{index_move}"
819
1062
  puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
820
1063
  puts " would append savepoint bookend: Done #{disposition}"
821
1064
  puts(opts[:no_commit] ? " would skip store commit (--no-commit)" : " would commit the store repo")
@@ -831,7 +1074,7 @@ def main(argv)
831
1074
  takeover_status, = Lock.takeover(intent_dir, session: session)
832
1075
  unless takeover_status == :taken
833
1076
  warn "end-intent: could not take over the stale delivery lock for #{id} " \
834
- "(status: #{takeover_status}); run /plastic-doctor fix the lock"
1077
+ "(status: #{takeover_status}); run `plastic doctor` to inspect the lock"
835
1078
  exit 4
836
1079
  end
837
1080
  end
@@ -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"]