@zalom/plastic 1.12.0 → 1.14.0

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/scripts/doctor.rb CHANGED
@@ -546,10 +546,16 @@ class Doctor
546
546
  # :operational_gap when "savepoint_operational" is in that set; the :gap bucket (signals_complete,
547
547
  # the outcome.md check) never consults it, which is what keeps the exclusion key (intent_id,
548
548
  # rule) rather than just intent_id (see test/doctor_done_signals_test.rb case 12).
549
+ #
550
+ # `:excluded_rules_fired` (intent 280) names the rules that actually suppressed a finding for
551
+ # this dir - not merely the rules registered for it. The caller uses this to build the `consumed`
552
+ # set `DoctorExclusions.dead_rows` needs: a registered rule that never fires here (nothing to
553
+ # suppress) is exactly what makes the row dead.
549
554
  def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:, excluded_rules: [])
550
555
  outcome = File.join(dir, "outcome.md")
551
556
  outcome_real = Bridge.stage_file_present?(outcome)
552
- findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], excluded: [], stalled: nil }
557
+ findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], excluded: [],
558
+ excluded_rules_fired: [], stalled: nil }
553
559
 
554
560
  # HARD conflict: the deliverable exists but INDEX still says Active. This
555
561
  # is the one true INDEX-wins disagreement, so it stays a fail.
@@ -586,7 +592,8 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
586
592
  # reconstructible via maintenance-run --tool rebuild-savepoint, so this is repairable and
587
593
  # reported as a fixable warn (savepoint_operational).
588
594
  savepoint = File.join(dir, "savepoint.md")
589
- bucket = excluded_rules.include?("savepoint_operational") ? findings[:excluded] : findings[:operational_gap]
595
+ suppressed = excluded_rules.include?("savepoint_operational")
596
+ bucket = suppressed ? findings[:excluded] : findings[:operational_gap]
590
597
  if !File.exist?(savepoint)
591
598
  bucket << "#{label}: terminal in INDEX but savepoint.md is missing " \
592
599
  "entirely (operational - reconstructible)"
@@ -594,6 +601,7 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
594
601
  bucket << "#{label}: terminal in INDEX but savepoint.md has no " \
595
602
  "`Done delivered|abandoned` line (operational - reconstructible)"
596
603
  end
604
+ findings[:excluded_rules_fired] << "savepoint_operational" if suppressed && findings[:excluded].any?
597
605
 
598
606
  # Stalled completion: unchanged, never consulted amnesty.
599
607
  if File.exist?(Lock.path(dir))
@@ -614,6 +622,8 @@ def check_done_signals(scopes: nil)
614
622
  exclusion_errors = [] # malformed doctor-exclusions lines, scope-tagged
615
623
  exclusion_error_paths = []
616
624
  exclusion_paths = [] # files that actually contributed a live exclusion
625
+ dead_rows = [] # exclusion rows that suppressed nothing this run (intent 280)
626
+ dead_row_paths = []
617
627
  stalled = []
618
628
  phantoms = []
619
629
 
@@ -624,6 +634,25 @@ def check_done_signals(scopes: nil)
624
634
  exclusion_error_paths << exclusions[:path]
625
635
  end
626
636
 
637
+ consumed = { "savepoint_operational" => [] }
638
+ # `known_ids` (post-review fix): every intent id with a REAL DIRECTORY in this store, scanned
639
+ # directly from disk - independent of INDEX.md. An id can have a directory on disk without
640
+ # being listed in INDEX (a de-indexed "ghost"), and the walk below alone would never visit it;
641
+ # deriving known_ids from walk membership misclassified that ghost as :no_intent (deleted)
642
+ # even though the directory plainly still exists.
643
+ # Shares `store_intent_dirs` (159, intent 189's store-discovery helper) rather than
644
+ # reimplementing the same directory scan (review fix): one predicate for "what is an intent
645
+ # directory in this store", never two that could drift apart.
646
+ known_ids = if File.directory?(store[:store_dir])
647
+ store_intent_dirs(store[:store_dir]).map { |e| e.split("--", 2).first }
648
+ else
649
+ []
650
+ end
651
+ # `evaluated_ids`: the narrower set the walk below actually judges (INDEX-listed and on
652
+ # disk). An id with a real directory that this run never evaluated (on disk, unindexed)
653
+ # carries no evidence either way and must never be called dead - dead_rows leaves it out.
654
+ evaluated_ids = []
655
+
627
656
  index_sections_by_dir(store[:index]).each do |dirname, in_sections|
628
657
  dir = File.join(store[:store_dir], dirname)
629
658
  next unless File.directory?(dir)
@@ -632,6 +661,7 @@ def check_done_signals(scopes: nil)
632
661
  active = in_sections.include?("Active") && !terminal
633
662
  label = "#{store[:scope]} store/#{dirname}"
634
663
  intent_id = dirname.split("--", 2).first
664
+ evaluated_ids << intent_id
635
665
  excluded_rules = DoctorExclusions.rules_for(exclusions, intent_id)
636
666
 
637
667
  findings = done_signal_findings_for_dir(
@@ -646,11 +676,29 @@ def check_done_signals(scopes: nil)
646
676
  excluded.concat(findings[:excluded])
647
677
  exclusion_paths << exclusions[:path]
648
678
  end
679
+ findings[:excluded_rules_fired].each { |fired| (consumed[fired] ||= []) << intent_id }
649
680
  stalled << findings[:stalled] if findings[:stalled]
650
681
  end
682
+
683
+ # Drift in the governance record itself (intent 280): rows naming a pair that produced no
684
+ # finding this run. Computed by set subtraction against the walk above, never re-derived from
685
+ # the exclusion file (208; the intent 200 self-diff). `:no_intent` below only ever fires when
686
+ # `known_ids` (a real directory scan) truly has no entry for the id - never merely because the
687
+ # walk did not visit it.
688
+ DoctorExclusions.dead_rows(exclusions, consumed: consumed, known_ids: known_ids,
689
+ evaluated_ids: evaluated_ids).each do |row|
690
+ reason = if row[:reason] == :no_intent
691
+ "names no live intent directory (a typo, or the intent was deleted)"
692
+ else
693
+ "names an intent with no current #{row[:rule]} finding"
694
+ end
695
+ dead_rows << "#{store[:scope]}: #{exclusions[:path]}: #{row[:rule]} #{row[:id]} - #{reason}"
696
+ dead_row_paths << exclusions[:path]
697
+ end
651
698
  end
652
699
  exclusion_paths.uniq!
653
700
  exclusion_error_paths.uniq!
701
+ dead_row_paths.uniq!
654
702
 
655
703
  checks = []
656
704
 
@@ -697,7 +745,15 @@ def check_done_signals(scopes: nil)
697
745
  # a malformed exclusion file can never report pass (loud), a clean remaining gap set reports
698
746
  # pass with the exclusion count folded in, and a real remaining gap set stays warn, same as
699
747
  # before intent 274, with the same count folded in when exclusions applied.
748
+ #
749
+ # `dead_suffix` (intent 280) folds in a second, independent drift notice: exclusion rows that
750
+ # suppressed nothing this run. It is purely informational, exactly like `exclusion_suffix` - it
751
+ # never changes status on any of the three branches below, because a stale governance-record row
752
+ # is bookkeeping drift, not a store regression (219 D6 is untouched: no disposition is invented).
700
753
  exclusion_suffix = excluded.empty? ? "" : " (#{excluded.size} excluded via #{exclusion_paths.join(", ")})"
754
+ dead_suffix = dead_rows.empty? ? "" : " (#{dead_rows.size} dead row#{dead_rows.size == 1 ? "" : "s"} " \
755
+ "in #{dead_row_paths.join(", ")}, suppressing nothing - prune with " \
756
+ "`maintenance-run --tool register-exclusions --prune`)"
701
757
 
702
758
  if exclusion_errors.any?
703
759
  checks << check(
@@ -705,8 +761,8 @@ def check_done_signals(scopes: nil)
705
761
  message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
706
762
  "missing an operational savepoint.md or its Done echo, and " \
707
763
  "#{exclusion_errors.size} doctor-exclusions error#{exclusion_errors.size == 1 ? "" : "s"} " \
708
- "(a malformed exclusion file never suppresses a finding)#{exclusion_suffix}",
709
- details: operational_gaps + exclusion_errors, fixable: true,
764
+ "(a malformed exclusion file never suppresses a finding)#{exclusion_suffix}#{dead_suffix}",
765
+ details: operational_gaps + exclusion_errors + dead_rows, fixable: true,
710
766
  fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}) - " \
711
767
  "format `rule_name id id id`, blank lines and # comments ignored - then reconstruct " \
712
768
  "any remaining real gap via `maintenance-run --tool rebuild-savepoint --intent <id> " \
@@ -716,14 +772,17 @@ def check_done_signals(scopes: nil)
716
772
  elsif operational_gaps.empty?
717
773
  checks << check(
718
774
  category: "done_signals", name: "savepoint_operational", status: "pass",
719
- message: "No terminal intent is missing an operational savepoint.md or its Done echo#{exclusion_suffix}"
775
+ message: "No terminal intent is missing an operational savepoint.md or its Done echo" \
776
+ "#{exclusion_suffix}#{dead_suffix}",
777
+ details: dead_rows
720
778
  )
721
779
  else
722
780
  checks << check(
723
781
  category: "done_signals", name: "savepoint_operational", status: "warn",
724
782
  message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
725
- "missing an operational savepoint.md or its Done echo (reconstructible)#{exclusion_suffix}",
726
- details: operational_gaps, fixable: true,
783
+ "missing an operational savepoint.md or its Done echo (reconstructible)" \
784
+ "#{exclusion_suffix}#{dead_suffix}",
785
+ details: operational_gaps + dead_rows, fixable: true,
727
786
  fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
728
787
  "`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
729
788
  "receipt-before-write via RevisionsWriter, one intent per invocation, owner-approval-gated)."
@@ -881,12 +940,19 @@ end
881
940
  )]
882
941
  end
883
942
 
943
+ # The owning store's INDEX.md, resolved from `scope` through the memoized store_discovery
944
+ # (same {key:, index:} shape done_signal_stores enumerates). intent_savepoint_truthful_check
945
+ # needs it to reach that store's doctor-exclusions table and to ask INDEX whether this
946
+ # intent is terminal (intent 281 D3/D6). nil when the scope resolves to no known store,
947
+ # which restores the pre-281 behavior exactly.
948
+ index_path = store_discovery[:stores].find { |s| s[:key] == scope }&.fetch(:index, nil)
949
+
884
950
  [
885
951
  intent_structure_check(intent_dir),
886
952
  intent_lifecycle_artifacts_check(intent_dir, disposition),
887
953
  intent_checklist_complete_check(intent_dir),
888
954
  intent_links_projection_check_for(id, scope),
889
- intent_savepoint_truthful_check(intent_dir),
955
+ intent_savepoint_truthful_check(intent_dir, index_path: index_path),
890
956
  ]
891
957
  end
892
958
 
@@ -997,11 +1063,67 @@ end
997
1063
  end
998
1064
  end
999
1065
 
1066
+ # Whether this one intent's missing-savepoint finding is knowingly excluded, for the
1067
+ # per-intent surface (intent 281). Returns {excluded:, errors:, path:}.
1068
+ #
1069
+ # Same rule id as the store-wide sweep, `savepoint_operational` (281 D1): the fact is
1070
+ # identical (a terminal intent with no savepoint.md), so one registration in one
1071
+ # doctor-exclusions file covers both surfaces and the owner never learns a second name for
1072
+ # one gap. RuleCatalog is deliberately NOT extended.
1073
+ #
1074
+ # Terminal-gated (281 D3): done_signal_findings_for_dir only ever produces this finding
1075
+ # inside `if terminal`, so honoring the exclusion for a still-Active intent would suppress a
1076
+ # strictly larger set of facts than the rule id names - and would let a mistyped id silence
1077
+ # the live, repairable warning scripts/end-intent's pre-write gate exists to raise.
1078
+ #
1079
+ # Never raises: DoctorExclusions is fail-open by contract (274 D5) and index_sections_by_dir
1080
+ # returns an empty map for a missing INDEX.
1081
+ def savepoint_exclusion_for(intent_dir, index_path)
1082
+ none = { excluded: false, errors: [], path: nil }
1083
+ return none unless index_path
1084
+
1085
+ dirname = File.basename(intent_dir)
1086
+ return none unless (index_sections_by_dir(index_path)[dirname] & ["Completed", "Abandoned"]).any?
1087
+
1088
+ loaded = DoctorExclusions.load(index_path)
1089
+ rules = DoctorExclusions.rules_for(loaded, dirname.split("--", 2).first)
1090
+ { excluded: rules.include?("savepoint_operational"), errors: loaded[:errors], path: loaded[:path] }
1091
+ end
1092
+
1000
1093
  # WARN-only, per intent 134 (savepoint truthfulness is advisory, never a hard gate). Do not
1001
1094
  # change this to FAIL: it would silently contradict a standing, binding ruling.
1002
- def intent_savepoint_truthful_check(intent_dir)
1095
+ #
1096
+ # `index_path:` (intent 281) is the owning store's INDEX.md, threaded from check_intent_end.
1097
+ # It makes this surface honor the same doctor-exclusions registration check_done_signals
1098
+ # already honors for the same fact, under the same rule id (281 D1). Only the missing-file
1099
+ # branch below is excludable: the phantom-line branch is permanently non-suppressible by id
1100
+ # or scope (intent 211, 281 D2). Omitting index_path restores the pre-281 behavior exactly.
1101
+ def intent_savepoint_truthful_check(intent_dir, index_path: nil)
1003
1102
  savepoint = File.join(intent_dir, "savepoint.md")
1004
1103
  unless File.exist?(savepoint)
1104
+ exclusion = savepoint_exclusion_for(intent_dir, index_path)
1105
+
1106
+ # A loader error never suppresses anything (274 D5: fail milder than the bug), and the
1107
+ # check that consulted the file is where the error is reported.
1108
+ if exclusion[:errors].any?
1109
+ return check(
1110
+ category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
1111
+ message: "savepoint.md is missing, and #{exclusion[:errors].size} doctor-exclusions " \
1112
+ "error#{exclusion[:errors].size == 1 ? "" : "s"} " \
1113
+ "(a malformed exclusion file never suppresses a finding)",
1114
+ details: exclusion[:errors], fixable: true,
1115
+ fix_hint: "Fix the malformed doctor-exclusions file (#{exclusion[:path]}) - format " \
1116
+ "`rule_name id id id`, blank lines and # comments ignored - then re-run."
1117
+ )
1118
+ end
1119
+
1120
+ # Excluded: the fact stays in the message with the honest count and the file that caused
1121
+ # the suppression, and nothing lands in details (274 D4's wording, N is always 1 here).
1122
+ if exclusion[:excluded]
1123
+ return check(category: "intent_end", name: "intent_savepoint_truthful", status: "pass",
1124
+ message: "savepoint.md is missing (1 excluded via #{exclusion[:path]})")
1125
+ end
1126
+
1005
1127
  return check(category: "intent_end", name: "intent_savepoint_truthful", status: "warn",
1006
1128
  message: "savepoint.md is missing")
1007
1129
  end
@@ -37,8 +37,9 @@ module Bridge
37
37
  # `## Active` block); such bridges are purged. An Active intent's bridge is kept
38
38
  # unconditionally, because while the intent is live the bridge is still load-
39
39
  # bearing: it is the continuation signal (a parked or interrupted run resumes
40
- # from it) and the anti-collision lock (it keys the per-session statusline so
41
- # parallel sessions do not overwrite each other). An age window was the wrong
40
+ # from it) and the anti-collision lock for parallel deliveries on one store,
41
+ # keeping each session's gate checks and locks from overwriting another's. An
42
+ # age window was the wrong
42
43
  # axis: it left dead bridges resident for ~2 days AND could reap bridges of
43
44
  # interrupted-but-still-active intents, which are exactly the ones to preserve.
44
45
 
@@ -121,7 +122,7 @@ module Bridge
121
122
  # The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
122
123
  # session id (Claude Code passes session_id on stdin, not via an env var; the
123
124
  # headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
124
- # a derived hash) lets the statusline, which receives that same id on stdin, find
125
+ # a derived hash) lets the gate hooks, which receive that same id on stdin, find
125
126
  # the bridge by direct filename lookup.
126
127
  def self.resolve_session(explicit, intent_id:, store:)
127
128
  return explicit.to_s.strip unless blank?(explicit)