@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.
- package/PLASTIC.md +2 -1
- package/README.md +40 -33
- package/agents/plastic-enforcer.md +13 -11
- package/agents/plastic-executor.md +2 -1
- package/agents/plastic-primary-advisor.md +2 -2
- package/agents/plastic-secondary-advisor.md +2 -2
- package/bin/plastic +2 -0
- package/docs/help/agent-architecture.md +9 -8
- package/docs/help/agent-report-contract.md +6 -4
- package/docs/help/completion-and-done.md +20 -4
- package/docs/help/human-report-contract.md +23 -20
- package/docs/help/knowledge-graph.md +2 -2
- package/docs/help/lifecycle-and-savepoints.md +9 -5
- package/docs/help/locks-and-worktrees.md +4 -3
- package/docs/help/maintenance-and-revisions.md +2 -2
- package/docs/help/roadmaps.md +16 -11
- package/docs/help/track-1-guided.md +48 -30
- package/docs/help/track-2-auto.md +35 -10
- package/docs/help/track-3-projects-and-roadmaps.md +10 -7
- package/docs/help/tutorial.md +421 -0
- package/package.json +1 -1
- package/scripts/end-intent +312 -69
- 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/revisions_writer.rb +1 -2
- 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
- package/skills/_decision-tables.md +3 -3
package/scripts/end-intent
CHANGED
|
@@ -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`
|
|
38
|
-
# (delivered) or `## Abandoned` (abandoned), dated today,
|
|
39
|
-
# --index-note (if given). Accepts a real em dash OR a plain
|
|
40
|
-
# the id/title separator on READ (shared matcher,
|
|
41
|
-
# always EMITS the real em dash on write.
|
|
42
|
-
#
|
|
43
|
-
#
|
|
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;
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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,
|
|
457
|
-
remaining = (
|
|
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[(
|
|
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
|
|
487
|
-
# new INDEX.md content on success. Raises
|
|
488
|
-
# id resolves to NEITHER
|
|
489
|
-
#
|
|
490
|
-
# entirely); returns the content UNCHANGED (a quiet,
|
|
491
|
-
# the id is already correctly present in the
|
|
492
|
-
# a stray duplicate also still sits
|
|
493
|
-
# cleaned up too, when found, matching the
|
|
494
|
-
# behavior for the ordinary already-moved re-run
|
|
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 =
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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."
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 "
|
|
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
|
|
1077
|
+
"(status: #{takeover_status}); run `plastic doctor` to inspect the lock"
|
|
835
1078
|
exit 4
|
|
836
1079
|
end
|
|
837
1080
|
end
|
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"]
|