@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "2.0.2",
3
+ "version": "2.0.3",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -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; 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.
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 `## Active` nor the
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
- # 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
471
494
  # entries remain, install the canonical "_(none)_" placeholder (the same
472
495
  # empty-state convention INDEX.md already uses).
473
- def remove_active_entry(lines, active_start, entry_idx)
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, active_start)
477
- 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? }
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[(active_start + 1)...stop] = placeholder
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 section. Returns the
507
- # new INDEX.md content on success. Raises UnresolvedIndexEntry (D11) when the
508
- # id resolves to NEITHER `## Active` NOR the terminal section (including the
509
- # degenerate case where one of the two headings is missing from INDEX.md
510
- # entirely); returns the content UNCHANGED (a quiet, idempotent success) when
511
- # the id is already correctly present in the terminal section, whether or not
512
- # a stray duplicate also still sits under `## Active` (that duplicate is
513
- # cleaned up too, when found, matching the pre-188 idempotent-cleanup
514
- # 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).
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 = nil
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 ## Active, and not already " \
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 = remove_active_entry(lines, active_start, entry_idx)
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 /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
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 /plastic-doctor to check the " \
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 /plastic-doctor to check for an orphaned worktree."
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 /plastic-doctor to check the lock status."
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 /plastic-doctor check the lock status."
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 /plastic-doctor check " \
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 /plastic-doctor check the lock " \
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 " would move INDEX.md entry from ## Active to #{index_target_heading(disposition)} (#{index_path})"
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 /plastic-doctor fix the lock"
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 moved out of Active")
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
- # performs structural maintenance on an intent (project-links, rebuild-graph,
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
- Any stage skill that needs the owner to choose between options or rule on a batch of
5
- open questions follows this procedure instead of improvising its own format.
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
- Read this when a stage skill's own text says to.
7
+ The installer copies this file to `~/.plastic/_decision-tables.md`.
8
8
 
9
9
  ### The procedure
10
10