@zalom/plastic 2.0.2 → 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 +12 -10
- package/agents/plastic-executor.md +2 -1
- package/agents/plastic-primary-advisor.md +2 -2
- package/agents/plastic-secondary-advisor.md +2 -2
- package/docs/help/agent-architecture.md +9 -8
- package/docs/help/agent-report-contract.md +6 -4
- package/docs/help/completion-and-done.md +10 -5
- 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 +13 -9
- 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 +88 -44
- package/scripts/lib/cli/commands/intent_end.rb +1 -1
- package/scripts/lib/revisions_writer.rb +1 -2
- package/skills/_decision-tables.md +3 -3
package/package.json
CHANGED
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
|
|
@@ -56,8 +60,9 @@
|
|
|
56
60
|
#
|
|
57
61
|
# Exit codes:
|
|
58
62
|
# 0 ok - the intent is closed and no delivery.lock remains.
|
|
59
|
-
# 1 usage/resolution failure, OR an id that resolves to neither ## Active
|
|
60
|
-
# 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.
|
|
61
66
|
# 2 retired in 2.0 (intent 308): the outcome.md guard reports and proceeds.
|
|
62
67
|
# 3 steps 1-4 already committed, but the delivery lock genuinely could not
|
|
63
68
|
# be cleared (see step 5 above).
|
|
@@ -78,7 +83,7 @@
|
|
|
78
83
|
# repo checkout's current branch. Refused before any write: nothing is
|
|
79
84
|
# merged, written, or released. Merge the branch, then run the close again.
|
|
80
85
|
#
|
|
81
|
-
# --dry-run runs the same refusals as the real close (exits 8, 9, 7 and 5 with
|
|
86
|
+
# --dry-run runs the same refusals as the real close (exits 1, 8, 9, 7 and 5 with
|
|
82
87
|
# a "would refuse" line) and writes nothing: the hollow-report gate is judged
|
|
83
88
|
# on a scratch copy that went through the same outcome generation and
|
|
84
89
|
# backfill.
|
|
@@ -113,10 +118,15 @@ DISPOSITIONS = %w[delivered abandoned].freeze
|
|
|
113
118
|
# (intent 188, D9/D12): see move_index_to_terminal below.
|
|
114
119
|
EM_DASH = "\u2014"
|
|
115
120
|
|
|
116
|
-
# Raised internally when the INDEX id resolves to neither
|
|
117
|
-
# 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.
|
|
118
123
|
class UnresolvedIndexEntry < StandardError; end
|
|
119
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
|
+
|
|
120
130
|
# --- Explicit flag parsing (no eval, no global injection) -------------------
|
|
121
131
|
|
|
122
132
|
def blank?(value)
|
|
@@ -467,19 +477,32 @@ def section_contains_id?(lines, heading_idx, id)
|
|
|
467
477
|
(heading_idx + 1...stop).any? { |i| active_line_id(lines[i]) == id }
|
|
468
478
|
end
|
|
469
479
|
|
|
470
|
-
#
|
|
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
|
|
471
494
|
# entries remain, install the canonical "_(none)_" placeholder (the same
|
|
472
495
|
# empty-state convention INDEX.md already uses).
|
|
473
|
-
def
|
|
496
|
+
def remove_open_entry(lines, section_start, entry_idx)
|
|
474
497
|
new_lines = lines.dup
|
|
475
498
|
new_lines.delete_at(entry_idx)
|
|
476
|
-
stop = section_stop(new_lines,
|
|
477
|
-
remaining = (
|
|
499
|
+
stop = section_stop(new_lines, section_start)
|
|
500
|
+
remaining = (section_start + 1...stop).reject { |i| new_lines[i].strip.empty? }
|
|
478
501
|
if remaining.empty?
|
|
479
502
|
# Keep the blank-line separator before the next heading (if any); only
|
|
480
503
|
# collapse the section body to the placeholder, never the spacing.
|
|
481
504
|
placeholder = (stop < new_lines.length) ? ["_(none)_\n", "\n"] : ["_(none)_\n"]
|
|
482
|
-
new_lines[(
|
|
505
|
+
new_lines[(section_start + 1)...stop] = placeholder
|
|
483
506
|
end
|
|
484
507
|
new_lines
|
|
485
508
|
end
|
|
@@ -503,34 +526,30 @@ def insert_terminal_entry(lines, target_start, new_entry)
|
|
|
503
526
|
new_lines
|
|
504
527
|
end
|
|
505
528
|
|
|
506
|
-
# Move the intent's `## Active` line into the terminal
|
|
507
|
-
# new INDEX.md content on success. Raises
|
|
508
|
-
# id resolves to NEITHER
|
|
509
|
-
#
|
|
510
|
-
# entirely); returns the content UNCHANGED (a quiet,
|
|
511
|
-
# the id is already correctly present in the
|
|
512
|
-
# a stray duplicate also still sits
|
|
513
|
-
# cleaned up too, when found, matching the
|
|
514
|
-
# 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).
|
|
515
539
|
def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
|
|
516
540
|
target_heading = index_target_heading(disposition)
|
|
517
541
|
lines = content.lines
|
|
518
542
|
|
|
519
543
|
target_start = lines.index { |l| l.rstrip == target_heading }
|
|
520
|
-
active_start = lines.index { |l| l.rstrip == "## Active" }
|
|
521
544
|
already_in_target = target_start && section_contains_id?(lines, target_start, id)
|
|
522
545
|
|
|
523
|
-
entry_idx =
|
|
524
|
-
if active_start
|
|
525
|
-
active_stop = section_stop(lines, active_start)
|
|
526
|
-
entry_idx = (active_start + 1...active_stop).find { |i| active_line_id(lines[i]) == id }
|
|
527
|
-
end
|
|
546
|
+
_heading, source_start, entry_idx = find_open_entry(lines, id)
|
|
528
547
|
|
|
529
548
|
if entry_idx.nil?
|
|
530
549
|
return content if already_in_target # true idempotency (D11): quiet success
|
|
531
550
|
raise UnresolvedIndexEntry,
|
|
532
|
-
"intent #{id} could not be resolved: not found under
|
|
533
|
-
"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)"
|
|
534
553
|
end
|
|
535
554
|
|
|
536
555
|
if target_start.nil?
|
|
@@ -542,7 +561,7 @@ def move_index_to_terminal(content, id, disposition, today:, index_note: nil)
|
|
|
542
561
|
note_suffix = (index_note && !index_note.to_s.strip.empty?) ? " #{index_note.to_s.strip}" : ""
|
|
543
562
|
new_entry = "- [#{id} #{EM_DASH} #{title}](#{link}) #{EM_DASH} #{today}#{note_suffix}\n"
|
|
544
563
|
|
|
545
|
-
lines =
|
|
564
|
+
lines = remove_open_entry(lines, source_start, entry_idx)
|
|
546
565
|
target_start = lines.index { |l| l.rstrip == target_heading }
|
|
547
566
|
lines = insert_terminal_entry(lines, target_start, new_entry) unless already_in_target
|
|
548
567
|
|
|
@@ -563,7 +582,27 @@ def warn_if_active_duplicate_remains(content, id)
|
|
|
563
582
|
|
|
564
583
|
warn "end-intent: intent #{id} still has an entry under ## Active after the move " \
|
|
565
584
|
"(a duplicate ## Active line for this id); INDEX.md needs manual cleanup. Check " \
|
|
566
|
-
"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
|
|
567
606
|
end
|
|
568
607
|
|
|
569
608
|
# --- store auto-commit (D2 step 4) ------------------------------------------
|
|
@@ -731,7 +770,7 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
|
|
|
731
770
|
# let the caller sort it out.
|
|
732
771
|
warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} is now held by " \
|
|
733
772
|
"#{current['owner_session'].inspect}, which acquired it during this close; " \
|
|
734
|
-
"refusing to delete another session's lock. Run
|
|
773
|
+
"refusing to delete another session's lock. Run `plastic doctor` to check the " \
|
|
735
774
|
"lock status."
|
|
736
775
|
end
|
|
737
776
|
# current.nil? here means the lock is corrupt/unparseable; nothing to
|
|
@@ -746,13 +785,13 @@ def run_disarm(intent_dir, id, session, discard_worktree_changes:,
|
|
|
746
785
|
if block.nil?
|
|
747
786
|
warn "end-intent: no worktree block could be derived for intent #{id}; " \
|
|
748
787
|
"the delivery lock was cleared directly from disk, but any worktree this intent " \
|
|
749
|
-
"provisioned was NOT removed. Run
|
|
788
|
+
"provisioned was NOT removed. Run `plastic doctor` to check for an orphaned worktree."
|
|
750
789
|
end
|
|
751
790
|
return :ok
|
|
752
791
|
end
|
|
753
792
|
|
|
754
793
|
warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} is still present after " \
|
|
755
|
-
"disarm and a direct release attempt. Run
|
|
794
|
+
"disarm and a direct release attempt. Run `plastic doctor` to check the lock status."
|
|
756
795
|
:lock_remains
|
|
757
796
|
end
|
|
758
797
|
|
|
@@ -954,7 +993,7 @@ def main(argv)
|
|
|
954
993
|
warn "end-intent: a delivery lock exists at #{Lock.path(intent_dir)} but no session " \
|
|
955
994
|
"identity could be resolved (--session, CLAUDE_CODE_SESSION_ID, and the lock's " \
|
|
956
995
|
"own recorded owner are all blank); refusing rather than guessing. Pass " \
|
|
957
|
-
"--session explicitly, or run
|
|
996
|
+
"--session explicitly, or run `plastic doctor` to check the lock status."
|
|
958
997
|
exit 4
|
|
959
998
|
end
|
|
960
999
|
|
|
@@ -962,7 +1001,7 @@ def main(argv)
|
|
|
962
1001
|
|
|
963
1002
|
if verdict == :refuse
|
|
964
1003
|
warn "end-intent: delivery lock for #{id} is held by a live session " \
|
|
965
|
-
"(#{lock_data['owner_session']}); refusing to close. Run
|
|
1004
|
+
"(#{lock_data['owner_session']}); refusing to close. Run `plastic doctor` to check " \
|
|
966
1005
|
"the lock status"
|
|
967
1006
|
exit 4
|
|
968
1007
|
end
|
|
@@ -970,7 +1009,7 @@ def main(argv)
|
|
|
970
1009
|
if verdict == :refuse_corrupt
|
|
971
1010
|
warn "end-intent: the delivery lock at #{Lock.path(intent_dir)} exists and is fresh, " \
|
|
972
1011
|
"but its content will not parse (corrupt); refusing to close since another " \
|
|
973
|
-
"session may be actively heartbeating it. Run
|
|
1012
|
+
"session may be actively heartbeating it. Run `plastic doctor` to check the lock " \
|
|
974
1013
|
"status, or plastic-lock fix once it is confirmed safe."
|
|
975
1014
|
exit 4
|
|
976
1015
|
end
|
|
@@ -994,6 +1033,11 @@ def main(argv)
|
|
|
994
1033
|
end
|
|
995
1034
|
end
|
|
996
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
|
+
|
|
997
1041
|
if opts[:dry_run]
|
|
998
1042
|
hollow = predicted_hollow_reason(intent_dir, store: store, id: id, disposition: disposition,
|
|
999
1043
|
summary: opts[:outcome_summary])
|
|
@@ -1014,7 +1058,7 @@ def main(argv)
|
|
|
1014
1058
|
targets = BackfillIntent.targets_for(intent_dir)
|
|
1015
1059
|
puts " would backfill: #{targets.empty? ? "(nothing)" : targets.join(", ")}"
|
|
1016
1060
|
puts " would stamp ## Outcome: #{opts[:outcome_summary].inspect}" if opts[:outcome_summary]
|
|
1017
|
-
puts "
|
|
1061
|
+
puts " #{index_move}"
|
|
1018
1062
|
puts " would append index note: #{opts[:index_note].inspect}" if opts[:index_note]
|
|
1019
1063
|
puts " would append savepoint bookend: Done #{disposition}"
|
|
1020
1064
|
puts(opts[:no_commit] ? " would skip store commit (--no-commit)" : " would commit the store repo")
|
|
@@ -1030,7 +1074,7 @@ def main(argv)
|
|
|
1030
1074
|
takeover_status, = Lock.takeover(intent_dir, session: session)
|
|
1031
1075
|
unless takeover_status == :taken
|
|
1032
1076
|
warn "end-intent: could not take over the stale delivery lock for #{id} " \
|
|
1033
|
-
"(status: #{takeover_status}); run
|
|
1077
|
+
"(status: #{takeover_status}); run `plastic doctor` to inspect the lock"
|
|
1034
1078
|
exit 4
|
|
1035
1079
|
end
|
|
1036
1080
|
end
|
|
@@ -42,7 +42,7 @@ module Plastic
|
|
|
42
42
|
if options[:dry_run]
|
|
43
43
|
@output.next_step("none", because: "the dry run wrote nothing and found nothing that would refuse the close")
|
|
44
44
|
else
|
|
45
|
-
@output.next_step("plastic status", because: "the intent
|
|
45
|
+
@output.next_step("plastic status", because: "the intent is now closed")
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# RevisionsWriter - the shared append-only revisions.md writer (intent 107's convention,
|
|
5
5
|
# generalized from restore_intent_v1.rb's proven pattern, intent 197). Every tool that
|
|
6
|
-
#
|
|
7
|
-
# restore-intent-v1) must record it here: PLASTIC.md's `revisions.md` contract is that a
|
|
6
|
+
# uses this writer (project-links, rebuild-graph, or rebuild-savepoint) records changes here: PLASTIC.md's `revisions.md` contract is that a
|
|
8
7
|
# structural change and its receipt are never separated. This module owns rendering ONE
|
|
9
8
|
# entry's text and appending it correctly; it does no git operations (that is
|
|
10
9
|
# lib/maintenance_git.rb's job) and never overwrites a prior entry.
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
## Numbered Decision Tables
|
|
2
2
|
|
|
3
3
|
The shared procedure for collecting owner rulings during any stage (Why, How, Exec).
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
An agent that needs the owner to choose between options or rule on a batch of open
|
|
5
|
+
questions follows this procedure instead of improvising its own format.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The installer copies this file to `~/.plastic/_decision-tables.md`.
|
|
8
8
|
|
|
9
9
|
### The procedure
|
|
10
10
|
|