@zalom/plastic 1.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.13.0",
3
+ "version": "1.14.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
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
@@ -120,6 +120,137 @@ class Doctor
120
120
  end
121
121
  end
122
122
 
123
+ # Every (event, command) pair, unfiltered (intent 276).
124
+ def each_hook_command(hooks_hash)
125
+ return unless hooks_hash.is_a?(Hash)
126
+
127
+ hooks_hash.each do |event, groups|
128
+ group_list = groups.is_a?(Hash) ? [groups] : Array(groups)
129
+ group_list.each do |g|
130
+ next unless g.is_a?(Hash)
131
+
132
+ # A bare Hash is one entry, not a skipped group (review finding).
133
+ hooks_list = case g["hooks"]
134
+ when Array then g["hooks"]
135
+ when Hash then [g["hooks"]]
136
+ end
137
+ next unless hooks_list
138
+
139
+ hooks_list.each do |h|
140
+ cmd = h.is_a?(Hash) ? h["command"].to_s : ""
141
+ yield event, cmd unless cmd.empty?
142
+ end
143
+ end
144
+ end
145
+ end
146
+
147
+ # Candidate start positions before end_pos: string start, or right after
148
+ # whitespace or an opening quote (round 3).
149
+ def command_boundaries(raw, end_pos)
150
+ boundaries = [0]
151
+ (0...end_pos).each do |i|
152
+ ch = raw[i]
153
+ boundaries << i + 1 if ch =~ /\s/ || ch == '"' || ch == "'"
154
+ end
155
+ boundaries.uniq.sort
156
+ end
157
+
158
+ # Strip a quote pair only when it wraps the WHOLE string; never delete a
159
+ # quote character inside it (that broke a real apostrophe, round 3).
160
+ def strip_balanced_quotes(str)
161
+ if str.length >= 2 && (str[0] == '"' || str[0] == "'") && str[0] == str[-1]
162
+ str[1..-2]
163
+ else
164
+ str
165
+ end
166
+ end
167
+
168
+ # End position of the leftmost known launcher name in raw, matched whole
169
+ # (optionally + ".rb"); nil if none appears.
170
+ def launcher_name_end_position(raw, known_names)
171
+ positions = known_names.filter_map do |name|
172
+ m = raw.match(/(?<![\w.-])#{Regexp.escape(name)}(\.rb)?(?=["'\s]|\z)/)
173
+ m && [m.begin(0), m.end(0)]
174
+ end
175
+ return nil if positions.empty?
176
+
177
+ positions.min_by(&:first).last
178
+ end
179
+
180
+ # Mode (b): true unless a launcher this command names is missing (round 3
181
+ # candidate design: see command_boundaries/strip_balanced_quotes above).
182
+ def launcher_on_disk?(cmd, known_names)
183
+ raw = cmd.to_s
184
+ end_pos = launcher_name_end_position(raw, known_names)
185
+ return true unless end_pos
186
+
187
+ any_absolute = false
188
+ command_boundaries(raw, end_pos).each do |start|
189
+ candidate = strip_balanced_quotes(raw[start...end_pos])
190
+ next if candidate.empty?
191
+
192
+ candidate = File.expand_path(candidate) if candidate.start_with?("~")
193
+ next unless candidate.start_with?("/")
194
+
195
+ any_absolute = true
196
+ return true if File.file?(candidate)
197
+ end
198
+
199
+ !any_absolute
200
+ end
201
+
202
+ # Mode (a): does the EXECUTABLE's basename carry plastic-, never an
203
+ # argument's? Executable = longest leading candidate (whole command, or
204
+ # with a leading word stripped) that resolves to a real file, else the
205
+ # first token, quote-aware (round 3).
206
+ def unowned_prefixed_command?(cmd)
207
+ raw = cmd.to_s
208
+ return false if raw.strip.empty?
209
+
210
+ executable = executable_candidate(raw)
211
+ return false unless executable
212
+
213
+ File.basename(executable).sub(/\.rb\z/, "").start_with?("plastic-")
214
+ end
215
+
216
+ def executable_candidate(raw)
217
+ return resolved_candidate(raw) || after_leading_word(raw) || quote_aware_first_token(raw)
218
+ end
219
+
220
+ def after_leading_word(raw)
221
+ first_space = raw.index(/\s/)
222
+ return nil unless first_space
223
+
224
+ resolved_candidate(raw[(first_space + 1)..-1].to_s.strip)
225
+ end
226
+
227
+ # Quote-stripped, tilde-expanded str, returned only if it resolves to a
228
+ # real file; nil otherwise so the caller tries its next candidate.
229
+ def resolved_candidate(str)
230
+ candidate = strip_balanced_quotes(str)
231
+ return nil if candidate.empty?
232
+
233
+ candidate = File.expand_path(candidate) if candidate.start_with?("~")
234
+ candidate if File.file?(candidate)
235
+ end
236
+
237
+ def quote_aware_first_token(raw)
238
+ if raw.start_with?('"') || raw.start_with?("'")
239
+ q = raw[0]
240
+ close = raw.index(q, 1)
241
+ return close ? raw[1...close] : raw[1..-1].to_s
242
+ end
243
+
244
+ raw.split(/\s+/).reject(&:empty?).first.to_s
245
+ end
246
+
247
+ # Shared mode-(a) fix_hint text (intent 276), parametrized by the config
248
+ # file the rename gets re-registered in.
249
+ def unowned_hook_rename_hint(config_file)
250
+ "The plastic- prefix is reserved for Plastic's hooks and skills. Rename your hook and " \
251
+ "re-register it in #{config_file}."
252
+ end
253
+
123
254
  def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
124
255
  result = {
125
256
  category: category,
@@ -329,21 +460,27 @@ class Doctor
329
460
  # dead once already.
330
461
  expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
331
462
  diffs = []
463
+ # settings.json is hand-editable: "hooks" or a per-event value can be
464
+ # any JSON shape, not only what HookRegistry emits. Guard both here
465
+ # rather than trust .dig / .select on an assumed Hash/Array.
466
+ hooks_value = settings["hooks"].is_a?(Hash) ? settings["hooks"] : {}
332
467
  expected.each do |event, group|
333
468
  groups = group.is_a?(Array) ? group : [group]
334
- live = settings.dig("hooks", event) || []
469
+ live = hooks_value[event]
470
+ live = [] unless live.is_a?(Array)
335
471
  groups.each do |g|
336
472
  matches = live.select { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
337
473
  wanted = g["hooks"].map { |h| h["command"] }
338
- got = matches.flat_map { |m| Array(m["hooks"]).map { |h| h["command"] } }
474
+ got = matches.flat_map { |m| Array(m["hooks"]).select { |h| h.is_a?(Hash) }.map { |h| h["command"] } }
339
475
  missing = wanted - got
340
476
  diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
341
477
  end
342
478
  end
343
- live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
479
+ live_plastic = hooks_value.flat_map do |event, groups|
344
480
  Array(groups).flat_map do |g|
345
481
  next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
346
- g["hooks"].map { |h| h["command"].to_s }.select { |c| HookRegistry.claude_purge_command?(c) }
482
+ g["hooks"].select { |h| h.is_a?(Hash) }.map { |h| h["command"].to_s }
483
+ .select { |c| HookRegistry.claude_purge_command?(c) }
347
484
  .map { |c| "#{event}: #{c}" }
348
485
  end
349
486
  end
@@ -361,6 +498,9 @@ class Doctor
361
498
  details: diffs, fixable: true,
362
499
  fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
363
500
  end
501
+
502
+ # hooks_entries_owned: unfiltered unowned/missing-launcher scan (intent 276).
503
+ checks << hooks_entries_owned_check(settings)
364
504
  end
365
505
 
366
506
  # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
@@ -368,8 +508,7 @@ class Doctor
368
508
 
369
509
  # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
370
510
  # e.g. an old-name copy after a rename; intent 158a AC15)
371
- stray_check = stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
372
- checks << stray_check if stray_check
511
+ checks << stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
373
512
 
374
513
  # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
375
514
  checks << flat_agents_check(agent_dir, "--claude")
@@ -377,6 +516,59 @@ class Doctor
377
516
  checks
378
517
  end
379
518
 
519
+ # Unfiltered classification (intent 276, spec Approach table): mode (a)
520
+ # unowned warns, mode (b) current-but-missing fails, a retired/non-hook
521
+ # launcher is skipped, a third-party hook stays silent.
522
+ def hooks_entries_owned_check(settings)
523
+ known_launchers = HookRegistry.claude_launcher_names
524
+ unowned = []
525
+ missing_launcher = []
526
+
527
+ each_hook_command(settings["hooks"]) do |event, cmd|
528
+ if HookRegistry.claude_current_command?(cmd)
529
+ missing_launcher << "missing launcher: #{event}: #{cmd} names a file that is not on disk" unless launcher_on_disk?(cmd, known_launchers)
530
+ elsif HookRegistry.claude_purge_command?(cmd)
531
+ next # retired or non-hook launcher; hooks_match_registry owns this case
532
+ elsif unowned_prefixed_command?(cmd)
533
+ unowned << "reserved prefix: #{event}: #{cmd} is not a hook Plastic registers"
534
+ end
535
+ end
536
+
537
+ hooks_entries_owned_result("hooks_entries_owned", unowned, missing_launcher,
538
+ pass_message: "Every settings.json hook entry is Plastic's and installed, or not ours",
539
+ rename_hint: unowned_hook_rename_hint("settings.json"),
540
+ missing_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude."
541
+ )
542
+ end
543
+
544
+ # Fail on mode (b), else warn on mode (a), else pass (spec Decision 3).
545
+ def hooks_entries_owned_result(name, unowned, missing_launcher, pass_message:, rename_hint:, missing_hint:)
546
+ status = if !missing_launcher.empty?
547
+ "fail"
548
+ elsif !unowned.empty?
549
+ "warn"
550
+ else
551
+ "pass"
552
+ end
553
+
554
+ return check(category: "agent_registration", name: name, status: "pass", message: pass_message) if status == "pass"
555
+
556
+ clauses = []
557
+ clauses << "#{unowned.size} #{unowned.size == 1 ? "carries" : "carry"} the reserved plastic- prefix without being Plastic's" unless unowned.empty?
558
+ clauses << "#{missing_launcher.size} #{missing_launcher.size == 1 ? "names" : "name"} a launcher missing from disk" unless missing_launcher.empty?
559
+
560
+ hints = []
561
+ hints << rename_hint unless unowned.empty?
562
+ hints << missing_hint unless missing_launcher.empty?
563
+
564
+ check(
565
+ category: "agent_registration", name: name, status: status,
566
+ message: clauses.join("; "),
567
+ details: unowned + missing_launcher,
568
+ fixable: true, fix_hint: hints.join(" ")
569
+ )
570
+ end
571
+
380
572
  def claude_dispatcher_gate_names(source)
381
573
  case_start = source.index(/^\s*case gate\b/)
382
574
  return nil unless case_start
@@ -460,34 +652,42 @@ class Doctor
460
652
  end
461
653
  end
462
654
 
655
+ # Manifest-diff stray-skill check (intent 158a), extended (intent 276) to
656
+ # never vanish on a missing manifest and to name the reserved-prefix rule.
463
657
  def stray_skills_check(agent_dir, installer_flag, manifest_path)
464
658
  skills_root = File.join(agent_dir, "skills")
465
- manifest = read_json_safe(manifest_path)
466
- files = manifest.is_a?(Hash) ? manifest["files"] : nil
467
- return nil unless files.is_a?(Hash)
468
-
469
659
  installed = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
470
660
  .map { |f| File.basename(File.dirname(f)) }
471
- tracked = files.keys
472
- .select { |p| p.start_with?("#{skills_root}/") }
473
- .map { |p| p.sub("#{skills_root}/", "").split("/").first }
474
- .uniq
475
-
476
- strays = (installed - tracked).sort
661
+ manifest = read_json_safe(manifest_path)
662
+ files = manifest.is_a?(Hash) ? manifest["files"] : nil
477
663
 
478
- if strays.empty?
479
- check(
480
- category: "agent_registration", name: "stray_skills", status: "pass",
481
- message: "No stray plastic-* skill directories in #{tilde(skills_root)}"
482
- )
483
- else
484
- check(
664
+ if !files.is_a?(Hash) && installed.empty?
665
+ return check(category: "agent_registration", name: "stray_skills", status: "pass",
666
+ message: "No plastic-* skills installed in #{tilde(skills_root)}; nothing to verify")
667
+ elsif !files.is_a?(Hash)
668
+ return check(
485
669
  category: "agent_registration", name: "stray_skills", status: "warn",
486
- message: "#{strays.size} installed skill dir(s) not in the current manifest (stray, e.g. a leftover old-name copy)",
487
- details: strays,
488
- fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
670
+ message: "#{installed.size} plastic-* skill dir(s) installed but the manifest at " \
671
+ "#{tilde(manifest_path)} is missing or unusable, so ownership cannot be verified",
672
+ details: installed.sort, fixable: true,
673
+ fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest #{installer_flag}"
489
674
  )
490
675
  end
676
+
677
+ tracked = files.keys.select { |p| p.start_with?("#{skills_root}/") }
678
+ .map { |p| p.sub("#{skills_root}/", "").split("/").first }.uniq
679
+ strays = (installed - tracked).sort
680
+ return check(category: "agent_registration", name: "stray_skills", status: "pass",
681
+ message: "No stray plastic-* skill directories in #{tilde(skills_root)}") if strays.empty?
682
+
683
+ check(
684
+ category: "agent_registration", name: "stray_skills", status: "warn",
685
+ message: "#{strays.size} installed skill dir(s) are not skills Plastic shipped",
686
+ details: strays, fixable: true,
687
+ fix_hint: "The plastic- prefix is reserved for hooks and skills Plastic ships: rename a skill " \
688
+ "of your own that carries it, or re-run the installer (npx @zalom/plastic@latest " \
689
+ "#{installer_flag}) to clear a genuine leftover."
690
+ )
491
691
  end
492
692
 
493
693
  def flat_agents_check(agent_dir, installer_flag)
@@ -516,8 +716,7 @@ class Doctor
516
716
 
517
717
  # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
518
718
  # e.g. an old-name copy after a rename; intent 158a AC15)
519
- stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
520
- checks << stray_check if stray_check
719
+ checks << stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
521
720
 
522
721
  checks
523
722
  end
@@ -567,6 +766,7 @@ class Doctor
567
766
 
568
767
  hooks_check = codex_hooks_registered_check(config)
569
768
  checks << hooks_check
769
+ codex_hooks_entries_owned_check(config).tap { |c| checks << c if c }
570
770
  checks << codex_hooks_implemented_check(config)
571
771
  checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
572
772
  codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
@@ -673,6 +873,31 @@ class Doctor
673
873
  end
674
874
  end
675
875
 
876
+ # Codex sibling of hooks_entries_owned_check: mode (b) is "the dispatcher
877
+ # is registered but scripts/codex-hook is missing" (intent 276). nil when
878
+ # hooks.json is missing: codex_hooks_registered_check owns that state.
879
+ def codex_hooks_entries_owned_check(config)
880
+ data = read_json_safe(File.join(config[:home_dir], "hooks.json"))
881
+ return nil if data.nil?
882
+
883
+ unowned = []
884
+ missing_dispatcher = []
885
+
886
+ each_hook_command(data["hooks"]) do |event, cmd|
887
+ if HookRegistry.codex_purge_command?(cmd)
888
+ missing_dispatcher << "missing launcher: #{event}: #{cmd} names a file that is not on disk" unless launcher_on_disk?(cmd, HookRegistry::CODEX_DISPATCHER_BASENAMES)
889
+ elsif unowned_prefixed_command?(cmd)
890
+ unowned << "reserved prefix: #{event}: #{cmd} is not a hook Plastic registers"
891
+ end
892
+ end
893
+
894
+ hooks_entries_owned_result("codex_hooks_entries_owned", unowned, missing_dispatcher,
895
+ pass_message: "Every hooks.json entry is Plastic's and installed, or not ours",
896
+ rename_hint: unowned_hook_rename_hint("hooks.json"),
897
+ missing_hint: "Re-run the Plastic installer with --codex."
898
+ )
899
+ end
900
+
676
901
  # codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
677
902
  # hooks.json content matches what HookRegistry would emit; both sides of THAT
678
903
  # comparison come from the registry, so a pass only proves the registry agrees
@@ -21,6 +21,10 @@ require_relative "rule_catalog"
21
21
  # excludes anything (fail milder than the bug: a typo must not silently suppress a real
22
22
  # regression) and contributes one error string naming its 1-based line number. An unreadable
23
23
  # file contributes one error and zero exclusions. This module NEVER raises.
24
+ #
25
+ # The file is also the input to a drift check (intent 280): `dead_rows` below reports rows that
26
+ # suppress nothing this run, so a governance record that only ever grows does not silently decay
27
+ # into an unreviewable list.
24
28
  module DoctorExclusions
25
29
  module_function
26
30
 
@@ -90,4 +94,54 @@ module DoctorExclusions
90
94
  def rules_for(loaded, intent_id)
91
95
  loaded[:rules].select { |_rule, ids| ids.include?(intent_id) }.keys
92
96
  end
97
+
98
+ # PURE (intent 280, hardened by post-review fixes). Dead rows: registered (rule, id) pairs that
99
+ # suppressed nothing this run.
100
+ #
101
+ # `consumed` is { rule_name => [intent_id] }, built by the CALLER from findings that actually
102
+ # fired during its own directory walk. `known_ids` is every intent id with a REAL DIRECTORY in
103
+ # the store - resolved by the caller against the store's own directory listing, never against
104
+ # which ids a particular walk happened to visit (review fix: an id can have a real directory on
105
+ # disk without being listed in INDEX.md at all, a de-indexed "ghost"; deriving known_ids from
106
+ # walk membership alone misclassified that ghost as :no_intent - deleted - even though its
107
+ # directory plainly still exists). `evaluated_ids` (defaults to `known_ids` when omitted) is the
108
+ # narrower set of ids this run's walk actually judged one way or the other. An id with a real
109
+ # directory that was never evaluated this run (on disk, but absent from INDEX so the walk never
110
+ # visited it) carries no evidence either way and is left out of the result entirely - never
111
+ # called dead, never called live. None of `loaded`, `consumed`, `known_ids`, or `evaluated_ids`
112
+ # is derived from the exclusion file itself: this function is handed all of them and has no way
113
+ # to reach the file, which is what keeps it from becoming the intent 200 self-diff (a check that
114
+ # only ever proves the file agrees with itself - see 208).
115
+ #
116
+ # Only rules present as a KEY in `consumed` are considered at all: a rule this run's walk never
117
+ # tracked consumption for (e.g. a newly-excludable check no caller has been updated to evaluate
118
+ # yet) is "not evaluated" for the whole rule, and none of its rows are ever reported dead -
119
+ # reporting them would assume evidence the caller never actually gathered.
120
+ #
121
+ # Genuinely pure and non-raising: reads with `fetch`/`Array()` throughout, never indexes a
122
+ # default-proc Hash with `[]` (doing so would silently ADD that key to the CALLER's own hash as
123
+ # a side effect - not pure), and tolerates a nil/empty/malformed `loaded`, a nil `consumed`, and
124
+ # nil `known_ids`/`evaluated_ids`.
125
+ #
126
+ # Returns [{ rule:, id:, reason: }], reason being :no_finding (a real directory, evaluated this
127
+ # run, but the rule fired nothing to suppress) or :no_intent (no real directory at all - a typo,
128
+ # or the intent was deleted). Order is stable: rule name, then id.
129
+ def dead_rows(loaded, consumed: {}, known_ids: [], evaluated_ids: nil)
130
+ rules = (loaded || {})[:rules] || {}
131
+ consumed = consumed || {}
132
+ known = Array(known_ids)
133
+ evaluated = evaluated_ids.nil? ? known : Array(evaluated_ids)
134
+
135
+ rules.keys.sort.select { |rule| consumed.key?(rule) }.flat_map do |rule|
136
+ live = Array(consumed.fetch(rule, []))
137
+ dead_ids = (Array(rules[rule]) - live).sort
138
+
139
+ dead_ids.each_with_object([]) do |id, acc|
140
+ is_known = known.include?(id)
141
+ next if is_known && !evaluated.include?(id) # on disk, never evaluated - no evidence
142
+
143
+ acc << { rule: rule, id: id, reason: is_known ? :no_finding : :no_intent }
144
+ end
145
+ end
146
+ end
93
147
  end
@@ -1217,13 +1217,23 @@ class InstallerCore
1217
1217
  path.sub(Dir.home, "~")
1218
1218
  end
1219
1219
 
1220
- def report_removed_hook_entries(removed, file_label)
1220
+ def report_removed_hook_entries(removed, file_label, qualifier: "stale Plastic")
1221
1221
  return if removed.nil? || removed.empty?
1222
1222
 
1223
- puts " \u{1f9f9} Removed #{removed.size} stale Plastic hook entr#{removed.size == 1 ? "y" : "ies"} from #{file_label}:"
1223
+ puts " \u{1f9f9} Removed #{removed.size} #{qualifier} hook entr#{removed.size == 1 ? "y" : "ies"} from #{file_label}:"
1224
1224
  removed.each { |event, command| puts " - #{event}: #{tilde(command.to_s)}" }
1225
1225
  end
1226
1226
 
1227
+ # The statusline swap-back on uninstall is not an [event, command] pair: it is a
1228
+ # value restored, not an entry deleted. It gets its own line rather than being
1229
+ # forced into the entry list (intent 278).
1230
+ def report_removed_statusline(restored_command, file_label = "settings.json")
1231
+ puts " \u{1f9f9} Removed Plastic's statusLine from #{file_label}."
1232
+ return if restored_command.nil? || restored_command.to_s.empty?
1233
+
1234
+ puts " - restored your original statusLine: #{tilde(restored_command.to_s)}"
1235
+ end
1236
+
1227
1237
  # The other half of intent 275: a hook the purge KEPT because the registry does not
1228
1238
  # know it, but whose name carries Plastic's prefix. Silence here is what let the
1229
1239
  # 1.11.0 update delete the owner's plastic-writing-style hook unnoticed; now the
@@ -1493,15 +1503,24 @@ class InstallerCore
1493
1503
  settings = read_json_safe(settings_path)
1494
1504
  return unless settings && settings["hooks"]
1495
1505
 
1506
+ removed = []
1507
+
1496
1508
  settings["hooks"].each do |event, groups|
1497
1509
  next unless groups.is_a?(Array)
1498
1510
 
1499
1511
  settings["hooks"][event] = groups.map do |group|
1500
1512
  if group.is_a?(Hash) && group["hooks"].is_a?(Array)
1501
- group["hooks"].reject! { |h| HookRegistry.claude_purge_command?(h["command"]) }
1513
+ group["hooks"].reject! do |h|
1514
+ HookRegistry.claude_purge_command?(h["command"]) && (removed << [event, h["command"]])
1515
+ end
1502
1516
  group unless group["hooks"].empty?
1503
1517
  elsif group.is_a?(Hash) && group["command"]
1504
- HookRegistry.claude_purge_command?(group["command"]) ? nil : group
1518
+ if HookRegistry.claude_purge_command?(group["command"])
1519
+ removed << [event, group["command"]]
1520
+ nil
1521
+ else
1522
+ group
1523
+ end
1505
1524
  else
1506
1525
  group
1507
1526
  end
@@ -1510,12 +1529,19 @@ class InstallerCore
1510
1529
 
1511
1530
  settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
1512
1531
  settings.delete("hooks") if settings["hooks"]&.empty?
1532
+
1533
+ statusline_removed = false
1534
+ restored_statusline = nil
1513
1535
  if HookRegistry.claude_purge_command?(settings.dig("statusLine", "command"))
1514
1536
  settings.delete("statusLine")
1537
+ statusline_removed = true
1515
1538
  original_path = File.join(plastic_home, ".cache", "original-statusline.json")
1516
1539
  if File.exist?(original_path)
1517
1540
  original = JSON.parse(File.read(original_path)) rescue nil
1518
- settings["statusLine"] = original if original
1541
+ if original.is_a?(Hash)
1542
+ settings["statusLine"] = original
1543
+ restored_statusline = original["command"]
1544
+ end
1519
1545
  end
1520
1546
  end
1521
1547
 
@@ -1525,7 +1551,10 @@ class InstallerCore
1525
1551
  settings.delete("enabledPlugins") if settings["enabledPlugins"].empty?
1526
1552
  end
1527
1553
 
1528
- write_json_atomic(settings_path, settings)
1554
+ result = write_json_atomic(settings_path, settings)
1555
+ report_removed_hook_entries(removed, "settings.json", qualifier: "Plastic")
1556
+ report_removed_statusline(restored_statusline) if statusline_removed
1557
+ result
1529
1558
  end
1530
1559
 
1531
1560
  # Remove exactly Plastic's entries from ~/.codex/hooks.json (intent 102), mirrors
@@ -1537,6 +1566,7 @@ class InstallerCore
1537
1566
  return nil unless data && data["hooks"]
1538
1567
 
1539
1568
  before = JSON.generate(data)
1569
+ removed = []
1540
1570
 
1541
1571
  data["hooks"].each do |event, groups|
1542
1572
  next unless groups.is_a?(Array)
@@ -1544,7 +1574,9 @@ class InstallerCore
1544
1574
  data["hooks"][event] = groups.map do |g|
1545
1575
  next g unless g.is_a?(Hash) && Array(g["hooks"]).is_a?(Array)
1546
1576
 
1547
- g["hooks"] = Array(g["hooks"]).reject { |h| HookRegistry.codex_purge_command?(h["command"]) }
1577
+ g["hooks"] = Array(g["hooks"]).reject do |h|
1578
+ HookRegistry.codex_purge_command?(h["command"]) && (removed << [event, h["command"]])
1579
+ end
1548
1580
  g["hooks"].empty? ? nil : g
1549
1581
  end.compact
1550
1582
  end
@@ -1557,6 +1589,7 @@ class InstallerCore
1557
1589
  else
1558
1590
  write_json_atomic(hooks_json_path, data)
1559
1591
  end
1592
+ report_removed_hook_entries(removed, "hooks.json", qualifier: "Plastic")
1560
1593
  hooks_json_path
1561
1594
  end
1562
1595
 
@@ -15,7 +15,7 @@
15
15
  # maintenance-run --tool rebuild-graph [--plastic-home PATH] [--apply]
16
16
  # maintenance-run --tool restore-intent-v1 <id> --at <ref> [--plastic-home PATH] [--apply] [--skip-links]
17
17
  # maintenance-run --tool rebuild-savepoint --intent <id> [--store <key>] [--plastic-home PATH] [--apply]
18
- # maintenance-run --tool register-exclusions [--rule <name>] [--store <key>] [--plastic-home PATH] [--apply]
18
+ # maintenance-run --tool register-exclusions [--prune] [--rule <name>] [--store <key>] [--plastic-home PATH] [--apply]
19
19
  #
20
20
  # project-links here is ALWAYS single-intent: --intent is required. A store-wide
21
21
  # project-links sweep is the rare, owner-approved batch exception (D2) and is run directly
@@ -77,7 +77,7 @@ end
77
77
 
78
78
  def parse_argv(argv)
79
79
  opts = { tool: nil, intent: nil, store: nil, plastic_home: DEFAULT_HOME, apply: false,
80
- at: nil, skip_links: false, id: nil, rule: nil }
80
+ at: nil, skip_links: false, id: nil, rule: nil, prune: false }
81
81
  i = 0
82
82
  while i < argv.length
83
83
  case argv[i]
@@ -89,6 +89,7 @@ def parse_argv(argv)
89
89
  when "--at" then opts[:at] = argv[i += 1]
90
90
  when "--skip-links" then opts[:skip_links] = true
91
91
  when "--rule" then opts[:rule] = argv[i += 1]
92
+ when "--prune" then opts[:prune] = true
92
93
  else
93
94
  opts[:id] ||= argv[i] # positional id, restore-intent-v1 only
94
95
  end
@@ -311,12 +312,53 @@ end
311
312
  # structurally edit an intent's OWN files) does not apply, and writing one would mean editing
312
313
  # every touched Completed intent directory - forbidden, completed intents are immutable. The
313
314
  # scoped commit plus the diffable exclusion file itself are the receipt.
314
- def run_register_exclusions(home, rule, store, apply)
315
+ #
316
+ # `prune:` (intent 280) reverses the direction: instead of adding newly-violating ids, it
317
+ # removes rows that `DoctorExclusions.dead_rows` reports as suppressing nothing, through the
318
+ # SAME walk, the SAME comment-preserving writer, and the SAME dry-run/--apply gate. `known_ids`
319
+ # (post-review fix) is resolved against a direct scan of the store's own directory listing, never
320
+ # against which ids the INDEX walk happened to visit - an id can have a real directory without
321
+ # being listed in INDEX at all (a de-indexed "ghost"), and walk membership alone misclassified
322
+ # that as :no_intent (deleted) even though the directory plainly still exists. `evaluated_ids` is
323
+ # the narrower set the walk DOES visit; an id with a real directory the walk never evaluated
324
+ # carries no evidence either way, so `dead_rows` leaves it out of its result entirely - never
325
+ # called dead by the reporter, so `prune` never sees it as a pruning candidate in the first
326
+ # place.
327
+ #
328
+ # The walk here only ever evaluates `savepoint_operational`'s own finding bucket
329
+ # (`findings[:operational_gap]`), regardless of `--rule`: `run_register_exclusions` refuses
330
+ # `--prune --rule <other>` outright (a second review fix) rather than silently computing
331
+ # `found_ids` from an unrelated check and misreporting every row under `<other>` dead.
332
+ #
333
+ # Three classes of row are additionally held harmless before anything is written
334
+ # (`protected_ids`), belt-and-suspenders on top of the `evaluated_ids` gate: an id whose dir was
335
+ # skipped for a fresh delivery lock (D6 - the lock skip runs AFTER the id already lands in
336
+ # `evaluated_ids`, but BEFORE it can ever reach `found_ids`, since findings are never computed
337
+ # for it), an id whose intent has not reached a terminal state yet (D6a - savepoint_operational
338
+ # only fires on a terminal intent, so the row has nothing to suppress YET), and an id with a real
339
+ # directory that is not in `evaluated_ids` at all (on disk, unindexed - this last class never
340
+ # actually reaches `held` in practice, since `dead_rows`'s own gate already excludes it; the
341
+ # protection stays explicit anyway rather than relying solely on that gate). A rule left with
342
+ # zero ids after pruning is dropped from the hash entirely (D7): `render_exclusions_file` would
343
+ # otherwise write a bare `rule_name` line that `DoctorExclusions.parse` rejects as "lists no
344
+ # intent ids".
345
+ def run_register_exclusions(home, rule, store, apply, prune = false)
315
346
  rule ||= "savepoint_operational"
316
347
  unless RuleCatalog.excludable_check?(rule)
317
348
  abort_loud("--rule #{rule.inspect} is not excludable (expected one of: " \
318
349
  "#{RuleCatalog::EXCLUDABLE_CHECKS.keys.join(", ")})")
319
350
  end
351
+ # Review fix: the walk below only ever evaluates savepoint_operational's own finding bucket
352
+ # (`findings[:operational_gap]`), regardless of --rule. Passing --prune --rule <other> would
353
+ # compute `found_ids` from an entirely unrelated check and subtract it against <other>'s
354
+ # registered ids, misreporting every one of them dead. Unreachable in v1 (EXCLUDABLE_CHECKS
355
+ # carries exactly one key) but one catalog entry away, so refuse it explicitly rather than
356
+ # silently mis-pruning the day a second excludable check exists.
357
+ if prune && rule != "savepoint_operational"
358
+ abort_loud("--prune evaluates only savepoint_operational; #{rule.inspect} rows are left " \
359
+ "untouched (the walk this tool runs only ever checks that one rule's finding " \
360
+ "bucket, regardless of --rule)")
361
+ end
320
362
 
321
363
  doctor = Doctor.new(plastic_home: home)
322
364
  stores = doctor.done_signal_stores(store ? [store] : nil)
@@ -340,15 +382,45 @@ def run_register_exclusions(home, rule, store, apply)
340
382
  existing_text = File.exist?(existing[:path]) ? File.read(existing[:path]).scrub : nil
341
383
 
342
384
  found_ids = []
385
+ protected_ids = []
386
+
387
+ # `known_ids` (post-review fix): every id with a REAL DIRECTORY in this store, scanned
388
+ # directly from disk - not from the INDEX walk below. Mirrors doctor.rb's own fix: an id can
389
+ # have a directory on disk without being listed in INDEX (a de-indexed "ghost"), and the walk
390
+ # alone would never visit it, misclassifying that ghost as :no_intent (deleted) even though
391
+ # it plainly still exists.
392
+ # Shares doctor.store_intent_dirs (doctor.rb:159, intent 189's store-discovery helper)
393
+ # rather than reimplementing the same directory scan (review fix): one predicate for "what
394
+ # is an intent directory in this store", never two that could drift apart.
395
+ known_ids = if File.directory?(s[:store_dir])
396
+ doctor.store_intent_dirs(s[:store_dir]).map { |e| e.split("--", 2).first }
397
+ else
398
+ []
399
+ end
400
+ # `evaluated_ids`: the narrower set the walk below actually judges (INDEX-listed and on disk).
401
+ evaluated_ids = []
402
+
343
403
  doctor.index_sections_by_dir(s[:index]).each do |dirname, in_sections|
344
404
  dir = File.join(s[:store_dir], dirname)
345
405
  next unless File.directory?(dir)
346
406
 
407
+ walked_id = dirname.split("--", 2).first
408
+ evaluated_ids << walked_id
409
+ # D6a: savepoint_operational only fires on a terminal intent, so a row naming a live
410
+ # non-terminal intent has nothing to suppress YET. Doctor reports it; prune leaves it.
411
+ protected_ids << walked_id unless (in_sections & ["Completed", "Abandoned"]).any?
412
+
347
413
  terminal = (in_sections & ["Completed", "Abandoned"]).any?
348
414
  next unless terminal
349
415
 
350
416
  if Lock.fresh?(dir)
351
417
  skip_lines << "#{s[:scope]}: #{dirname} skipped (fresh delivery lock)"
418
+ # Prune direction (intent 280 D6, post-review fix): the lock skip runs AFTER
419
+ # evaluated_ids already recorded this id (just above) but BEFORE findings are computed,
420
+ # so it never reaches `consumed` - dead_rows would read it as :no_finding (evaluated,
421
+ # nothing consumed) purely because we never checked. Hold it harmless via protected_ids
422
+ # regardless of what dead_rows would report.
423
+ protected_ids << walked_id
352
424
  next
353
425
  end
354
426
 
@@ -357,36 +429,74 @@ def run_register_exclusions(home, rule, store, apply)
357
429
  dir, label: "#{s[:scope]} store/#{dirname}", scope: s[:scope], dirname: dirname,
358
430
  terminal: terminal, active: active, excluded_rules: []
359
431
  )
360
- found_ids << dirname.split("--", 2).first if findings[:operational_gap].any?
432
+ found_ids << walked_id if findings[:operational_gap].any?
361
433
  end
362
434
 
435
+ # Post-review fix: an id with a real directory that INDEX never lists at all is walked by
436
+ # neither branch above, so its terminal state is unknowable here. PURE DEFENSE-IN-DEPTH:
437
+ # `dead_rows`'s own `evaluated_ids` gate already excludes such an id from `dead` entirely (it
438
+ # is neither :no_finding nor :no_intent - no evidence either way), so `held` below never
439
+ # actually contains one and this concat's protection never needs to fire. Unlike D6 (fresh
440
+ # lock) and D6a (not yet terminal), which DO reach `held` and print "kept (protected, still
441
+ # live)", an unindexed id is never named in that skip output - it was never a live pruning
442
+ # candidate to begin with.
443
+ protected_ids.concat(known_ids - evaluated_ids)
444
+
363
445
  already = existing[:rules][rule] || []
364
- added = found_ids - already
365
- next if added.empty?
366
446
 
367
- merged_rules = existing[:rules].merge(rule => (already | found_ids).sort)
368
- plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text), added: added.sort }
447
+ if prune
448
+ # This tool calls done_signal_findings_for_dir with excluded_rules: [], so a registered id
449
+ # that still has a gap shows up in found_ids rather than in the :excluded bucket. found_ids
450
+ # IS the consumed set for this rule.
451
+ dead = DoctorExclusions.dead_rows(existing, consumed: { rule => found_ids }, known_ids: known_ids,
452
+ evaluated_ids: evaluated_ids)
453
+ .select { |row| row[:rule] == rule }
454
+ .map { |row| row[:id] }
455
+ held = dead & protected_ids # D6 (fresh lock) + D6a (not terminal) + on-disk-but-unindexed
456
+ held.each { |id| skip_lines << "#{s[:scope]}: #{id} kept (protected, still live)" }
457
+ dead -= held
458
+ next if dead.empty?
459
+
460
+ remaining = already - dead
461
+ merged_rules = existing[:rules].merge(rule => remaining.sort)
462
+ merged_rules.delete(rule) if remaining.empty? # D7: a bare rule line fails to reload
463
+ plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text),
464
+ removed: dead.sort }
465
+ else
466
+ added = found_ids - already
467
+ next if added.empty?
468
+
469
+ merged_rules = existing[:rules].merge(rule => (already | found_ids).sort)
470
+ plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text),
471
+ added: added.sort }
472
+ end
369
473
  end
370
474
 
371
475
  puts skip_lines.join("\n") unless skip_lines.empty?
372
476
 
373
477
  if plan.empty?
374
- puts "maintenance-run: no new #{rule} violations to register."
478
+ puts(prune ? "maintenance-run: no dead #{rule} exclusion rows to prune."
479
+ : "maintenance-run: no new #{rule} violations to register.")
375
480
  exit 0
376
481
  end
377
482
 
378
483
  unless apply
379
484
  plan.each do |s, info|
380
- puts "maintenance-run: DRY RUN, #{s[:scope]} would register #{info[:added].size} " \
381
- "id(s) under #{rule}: #{info[:added].join(", ")}"
485
+ puts(if prune
486
+ "maintenance-run: DRY RUN, #{s[:scope]} would prune #{info[:removed].size} " \
487
+ "dead row(s) under #{rule}: #{info[:removed].join(", ")}"
488
+ else
489
+ "maintenance-run: DRY RUN, #{s[:scope]} would register #{info[:added].size} " \
490
+ "id(s) under #{rule}: #{info[:added].join(", ")}"
491
+ end)
382
492
  end
383
493
  exit 0
384
494
  end
385
495
 
386
496
  begin
387
497
  result = MaintenanceGit.run_scoped(
388
- repo_dir: home, branch_name: "maintenance/register-exclusions-#{stamp}",
389
- commit_message: "chore: maintenance - register doctor exclusions (#{rule})"
498
+ repo_dir: home, branch_name: "maintenance/#{prune ? "prune" : "register"}-exclusions-#{stamp}",
499
+ commit_message: "chore: maintenance - #{prune ? "prune dead" : "register"} doctor exclusions (#{rule})"
390
500
  ) do
391
501
  plan.each { |s, info| File.write(DoctorExclusions.path_for(s[:index]), info[:content]) }
392
502
  end
@@ -414,7 +524,7 @@ def main(argv)
414
524
  when "rebuild-savepoint"
415
525
  run_rebuild_savepoint(opts[:plastic_home], opts[:intent], opts[:store], opts[:apply])
416
526
  when "register-exclusions"
417
- run_register_exclusions(opts[:plastic_home], opts[:rule], opts[:store], opts[:apply])
527
+ run_register_exclusions(opts[:plastic_home], opts[:rule], opts[:store], opts[:apply], opts[:prune])
418
528
  else
419
529
  abort_loud("unknown --tool #{opts[:tool].inspect} (expected project-links|rebuild-graph|" \
420
530
  "restore-intent-v1|rebuild-savepoint|register-exclusions)")
@@ -19,7 +19,7 @@ when the trigger in the second column applies to the work in front of you.
19
19
  | `references/knowledge-graph.md` | when creating, linking, curating, or indexing intents and you need the sources-vs-chain doctrine, the tiers of influence, the `## Links` projection, or branch-vs-root directory semantics |
20
20
  | `references/lifecycle-and-savepoints.md` | when running a lifecycle stage or a savepoint and you need the subagent report-home contract for how an insight reaches the intent |
21
21
  | `references/tiers-and-dispatch.md` | when sizing an intent, choosing agent models, routing to the advisor, or writing an auto-mode human report |
22
- | `references/gates-and-enforcement.md` | when a transition gate blocks you, or before using an audited escape, for the gate mechanics and the logging contract, or when naming, registering, or retiring a hook |
22
+ | `references/gates-and-enforcement.md` | when a transition gate blocks you, or before using an audited escape, for the gate mechanics and the logging contract, or when naming, registering, or retiring a hook or skill |
23
23
  | `references/locks-and-worktrees.md` | before taking or releasing a delivery lock, and when working with claims, worktrees, solo mode, or the station ledger |
24
24
  | `references/completion-and-done.md` | when ending an intent, for what "intent done" means and the End-stage tail |
25
25
  | `references/maintenance-and-revisions.md` | before any structural maintenance edit, for WORK vs MAINTENANCE, the `revisions.md` move-and-record contract, the violation-tag catalog, and the context-economy measurement buckets |
@@ -2,14 +2,19 @@
2
2
 
3
3
  This chapter holds the escape-and-logging depth for each transition gate.
4
4
 
5
- #### Hook naming and ownership
6
-
7
- The `plastic-` prefix on an installed hook launcher is reserved for hooks `HookRegistry`
8
- registers. A user-owned hook must never take it: the installer purges Plastic's registrations
9
- from the agent's hook config on every update, matching by registry launcher name (current plus
10
- `RETIRED_HOOK_NAMES`), and doctor's `hooks_no_orphans` reports any unregistered `plastic-*`
11
- launcher on disk. Renaming or removing a hook from `events` means adding its old name to
12
- `RETIRED_HOOK_NAMES` in the same change, or every existing install keeps a dead registration.
5
+ #### Hook and skill naming and ownership
6
+
7
+ The `plastic-` prefix is reserved for both surfaces: hooks `HookRegistry` registers and skills
8
+ Plastic ships. A user-owned hook or skill must never take it: the installer purges Plastic's
9
+ registrations from the agent's hook config on every update, matching by registry launcher name
10
+ (current plus `RETIRED_HOOK_NAMES`), and doctor reports every violation it can find. On disk,
11
+ `hooks_no_orphans` reports an unregistered `plastic-*` launcher file the registry does not know.
12
+ In the live config, `hooks_entries_owned` (Claude) and `codex_hooks_entries_owned` (Codex)
13
+ report a config entry that is neither a current registration nor recognizably ours, and,
14
+ separately, a current registration whose launcher file is missing from disk. For skills,
15
+ `stray_skills` reports a `plastic-*` skill directory the manifest does not track. Renaming or
16
+ removing a hook from `events` means adding its old name to `RETIRED_HOOK_NAMES` in the same
17
+ change, or every existing install keeps a dead registration.
13
18
 
14
19
  #### The gates by name
15
20
 
@@ -190,6 +190,20 @@ immutable forbids outright. The receipt is instead the scoped git commit
190
190
  durable, diffable record - not a missing safeguard, a deliberate substitution for a receipt
191
191
  shape that would otherwise require an illegal write.
192
192
 
193
+ `--prune` (intent 280) reverses the same tool's direction under the identical carve-out: instead
194
+ of adding newly-violating ids, it removes rows that suppress nothing this run (the intent's gap
195
+ got repaired, the id was mistyped, or the intent directory is gone), computed via the same
196
+ `DoctorExclusions.dead_rows` predicate doctor itself reports from, so the reporter and the
197
+ remover can never disagree about what a dead row is. Same dry-run-by-default, same `--apply`
198
+ gate, same comment-preserving writer, same one scoped commit, same no-`revisions.md`-entry rule -
199
+ this direction still modifies no intent directory, only the store-level table. It holds back two
200
+ kinds of row before writing even when they read as dead: an id whose intent dir carries a fresh
201
+ delivery lock (the lock skip would otherwise leave it out of the walk entirely and misclassify
202
+ it), and an id whose intent has not reached a terminal state yet (`savepoint_operational` only
203
+ fires on a terminal intent, so the row has nothing to suppress *yet*). Both are named in the
204
+ output as kept, never silently dropped, and a rule left with zero ids after pruning is removed
205
+ from the file rather than written as a bare `rule_name` line the loader would reject.
206
+
193
207
  Like every other tool behind `maintenance-run`, it dry-runs by default (the owner-approval
194
208
  gate), unions with any existing hand-edited file content so a manually added id is never
195
209
  dropped, and skips (never aborts on) any intent dir holding a fresh delivery lock.
@@ -218,6 +218,12 @@ folds in the count and the file's path, e.g. `"... (3 excluded via ~/.plastic/do
218
218
  A malformed line in the file forces the check to `warn` with the parse error in `details`, even
219
219
  when zero real gaps remain, so a broken file is never silently permissive.
220
220
 
221
+ **Both surfaces, one line.** A registration is honored by the store-wide `savepoint_operational`
222
+ check and by the per-intent `doctor.rb --intent <id>` run, which reports the same missing
223
+ `savepoint.md` under the check name `intent_savepoint_truthful`. Register the id once. The
224
+ per-intent run honors it only for an intent that is terminal in `INDEX.md`, and never suppresses
225
+ a phantom-savepoint-line finding.
226
+
221
227
  **Hand-editing.** The file is plain text; add a line (or append ids to an existing rule line) and
222
228
  save. No installer step, no reindex, and no `revisions.md` entry is required or written.
223
229
 
@@ -233,6 +239,14 @@ without writing anything. Review the output, then re-run with `--apply` to write
233
239
  land one scoped git commit. It unions with any existing hand-added ids (never drops one) and
234
240
  skips, rather than aborts on, any intent dir holding a fresh delivery lock.
235
241
 
242
+ **Dead-row notice.** A registered row can go dead (gap repaired, id mistyped, or the intent
243
+ directory gone). When any row is dead, the message adds a second suffix next to the exclusion
244
+ count naming the count, the file, and the prune command - purely informational, status and exit
245
+ code unchanged. Prune it the same way, dry-run first: register-exclusions --prune [--apply]. It
246
+ removes exactly the dead rows through the same writer and commit, but holds back an id whose
247
+ intent dir carries a fresh lock or has not gone terminal yet (nothing to suppress there yet),
248
+ naming both as kept.
249
+
236
250
  ## References
237
251
 
238
252
  - Read `references/gates-stuck-detection.md` for the full gate enforcement table, bridge file pattern, and the recorded stuck-detection signals when diagnosing gate failures or stuck agents
@@ -40,6 +40,9 @@ routes each authoring task to the reference that holds the depth.
40
40
  them (use commas, periods, parentheses, colons). Existing internal files and the
41
41
  sanctioned template emissions (templates/index.md's INDEX line shape) are not
42
42
  violations.
43
+ - The `plastic-` prefix is reserved for skills and hooks Plastic itself ships. A skill
44
+ authored outside Plastic's own tree takes a different name; doctor's ownership checks
45
+ and the installer's purge both key off the prefix.
43
46
 
44
47
  ## Route the authoring task to its reference
45
48
 
@@ -58,6 +58,9 @@ Rules [A5]:
58
58
  5. Must not contain `anthropic` or `claude`.
59
59
  6. Prefer the gerund form, which reads as a capability (`processing-pdfs`, `creating-skills`,
60
60
  not `pdf-tool`).
61
+ 7. Never start with `plastic-`. That prefix is reserved for skills and hooks Plastic itself
62
+ ships; doctor's ownership checks (`stray_skills`) and the installer's purge both key off it,
63
+ so a user-authored skill carrying it reads as squatting on Plastic's own namespace.
61
64
 
62
65
  ## The `description` field (triggering)
63
66