@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.
@@ -107,6 +107,150 @@ class Doctor
107
107
  path.sub(Dir.home, "~")
108
108
  end
109
109
 
110
+ # Every hook command string registered under one settings.json event, flattened
111
+ # across that event's matcher groups. Shape-tolerant on purpose: settings.json is
112
+ # hand-editable and may carry anything at all under an event key.
113
+ def event_commands(groups)
114
+ return [] unless groups.is_a?(Array)
115
+
116
+ groups.flat_map do |group|
117
+ next [] unless group.is_a?(Hash) && group["hooks"].is_a?(Array)
118
+
119
+ group["hooks"].map { |h| h.is_a?(Hash) ? h["command"].to_s : "" }
120
+ end
121
+ end
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
+
110
254
  def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
111
255
  result = {
112
256
  category: category,
@@ -270,14 +414,30 @@ class Doctor
270
414
  )
271
415
  else
272
416
  hooks = settings["hooks"] || {}
417
+
418
+ # A live registration is a launcher Plastic ships TODAY (intent 277).
419
+ # claude_purge_command?, which this replaced, answers "was this ever ours":
420
+ # right for the installer's purge, wrong here, because a SessionStart
421
+ # carrying only the retired plastic-lock-gate satisfied the event while
422
+ # nothing shipped to run it.
273
423
  missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
274
- groups = hooks[event]
275
- next false unless groups.is_a?(Array)
424
+ event_commands(hooks[event]).any? { |cmd| HookRegistry.claude_current_command?(cmd) }
425
+ end
276
426
 
277
- groups.any? do |group|
278
- group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
279
- group["hooks"].any? { |h| HookRegistry.claude_purge_command?(h["command"]) }
280
- end
427
+ # Name the launcher when a missing event still carries a Plastic-owned
428
+ # command. Inside a missing event every such command is by construction not
429
+ # a current one, and a bare "SessionStart" reads as "nothing registered" to
430
+ # someone looking at a settings.json that plainly holds a plastic- entry.
431
+ # Events with no Plastic entry keep the bare name: two tests compare details
432
+ # by element equality and by count.
433
+ missing_details = missing_events.map do |event|
434
+ stale = event_commands(hooks[event])
435
+ .flat_map { |cmd| HookRegistry.command_basenames(cmd) }
436
+ .select { |name| HookRegistry.claude_purgeable_launcher_names.include?(name) }
437
+ .uniq
438
+ next event if stale.empty?
439
+
440
+ "#{event} (registered command is not a current Plastic hook: #{stale.join(', ')})"
281
441
  end
282
442
 
283
443
  if missing_events.empty?
@@ -289,7 +449,7 @@ class Doctor
289
449
  checks << check(
290
450
  category: "agent_registration", name: "hooks_registered", status: "fail",
291
451
  message: "#{missing_events.size} hook event(s) not registered in settings.json",
292
- details: missing_events,
452
+ details: missing_details,
293
453
  fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
294
454
  )
295
455
  end
@@ -300,21 +460,27 @@ class Doctor
300
460
  # dead once already.
301
461
  expected = HookRegistry.claude_settings_hooks(hook_dir: hooks_dir)
302
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"] : {}
303
467
  expected.each do |event, group|
304
468
  groups = group.is_a?(Array) ? group : [group]
305
- live = settings.dig("hooks", event) || []
469
+ live = hooks_value[event]
470
+ live = [] unless live.is_a?(Array)
306
471
  groups.each do |g|
307
472
  matches = live.select { |h| h.is_a?(Hash) && h["matcher"] == g["matcher"] }
308
473
  wanted = g["hooks"].map { |h| h["command"] }
309
- 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"] } }
310
475
  missing = wanted - got
311
476
  diffs << "#{event}[#{g['matcher']}] missing: #{missing.join(', ')}" unless missing.empty?
312
477
  end
313
478
  end
314
- live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
479
+ live_plastic = hooks_value.flat_map do |event, groups|
315
480
  Array(groups).flat_map do |g|
316
481
  next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
317
- 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) }
318
484
  .map { |c| "#{event}: #{c}" }
319
485
  end
320
486
  end
@@ -332,6 +498,9 @@ class Doctor
332
498
  details: diffs, fixable: true,
333
499
  fix_hint: "Re-run the installer merge: npx @zalom/plastic update (or ruby ~/.plastic/scripts/install.rb)")
334
500
  end
501
+
502
+ # hooks_entries_owned: unfiltered unowned/missing-launcher scan (intent 276).
503
+ checks << hooks_entries_owned_check(settings)
335
504
  end
336
505
 
337
506
  # skills_exist — flat, hyphen-namespaced personal skills (plastic-<name>/)
@@ -339,8 +508,7 @@ class Doctor
339
508
 
340
509
  # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
341
510
  # e.g. an old-name copy after a rename; intent 158a AC15)
342
- stray_check = stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
343
- checks << stray_check if stray_check
511
+ checks << stray_skills_check(agent_dir, "--claude", File.join(agent_dir, "plastic", "manifest.json"))
344
512
 
345
513
  # agents_exist — auto-mode role files (plastic-*.md) synced into <dir>/agents
346
514
  checks << flat_agents_check(agent_dir, "--claude")
@@ -348,6 +516,59 @@ class Doctor
348
516
  checks
349
517
  end
350
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
+
351
572
  def claude_dispatcher_gate_names(source)
352
573
  case_start = source.index(/^\s*case gate\b/)
353
574
  return nil unless case_start
@@ -431,34 +652,42 @@ class Doctor
431
652
  end
432
653
  end
433
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.
434
657
  def stray_skills_check(agent_dir, installer_flag, manifest_path)
435
658
  skills_root = File.join(agent_dir, "skills")
436
- manifest = read_json_safe(manifest_path)
437
- files = manifest.is_a?(Hash) ? manifest["files"] : nil
438
- return nil unless files.is_a?(Hash)
439
-
440
659
  installed = Dir.glob(File.join(skills_root, "plastic-*", "SKILL.md"))
441
660
  .map { |f| File.basename(File.dirname(f)) }
442
- tracked = files.keys
443
- .select { |p| p.start_with?("#{skills_root}/") }
444
- .map { |p| p.sub("#{skills_root}/", "").split("/").first }
445
- .uniq
446
-
447
- strays = (installed - tracked).sort
661
+ manifest = read_json_safe(manifest_path)
662
+ files = manifest.is_a?(Hash) ? manifest["files"] : nil
448
663
 
449
- if strays.empty?
450
- check(
451
- category: "agent_registration", name: "stray_skills", status: "pass",
452
- message: "No stray plastic-* skill directories in #{tilde(skills_root)}"
453
- )
454
- else
455
- 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(
456
669
  category: "agent_registration", name: "stray_skills", status: "warn",
457
- message: "#{strays.size} installed skill dir(s) not in the current manifest (stray, e.g. a leftover old-name copy)",
458
- details: strays,
459
- 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}"
460
674
  )
461
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
+ )
462
691
  end
463
692
 
464
693
  def flat_agents_check(agent_dir, installer_flag)
@@ -487,8 +716,7 @@ class Doctor
487
716
 
488
717
  # stray_skills — installed plastic-* skill dir with no manifest entry (a leftover,
489
718
  # e.g. an old-name copy after a rename; intent 158a AC15)
490
- stray_check = stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
491
- checks << stray_check if stray_check
719
+ checks << stray_skills_check(agent_dir, "--#{agent_key}", File.join(agent_dir, "plastic", "manifest.json"))
492
720
 
493
721
  checks
494
722
  end
@@ -538,6 +766,7 @@ class Doctor
538
766
 
539
767
  hooks_check = codex_hooks_registered_check(config)
540
768
  checks << hooks_check
769
+ codex_hooks_entries_owned_check(config).tap { |c| checks << c if c }
541
770
  checks << codex_hooks_implemented_check(config)
542
771
  checks << codex_hook_trust_advisory_check if hooks_check[:status] == "pass"
543
772
  codex_config_toml_advisory_check(config).tap { |c| checks << c if c }
@@ -644,6 +873,31 @@ class Doctor
644
873
  end
645
874
  end
646
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
+
647
901
  # codex_hooks_implemented (intent 200): codex_hooks_registered_check above proves
648
902
  # hooks.json content matches what HookRegistry would emit; both sides of THAT
649
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
@@ -244,6 +244,26 @@ module HookRegistry
244
244
  command_basenames(cmd).any? { |name| known.include?(name) }
245
245
  end
246
246
 
247
+ # Is this settings.json hook command one Plastic registers TODAY? (intent 277)
248
+ #
249
+ # The narrower twin of claude_purge_command?. The purge asks "was this ever
250
+ # ours", because it has to recognise an old entry in order to remove it. A
251
+ # liveness check asks "is there something here that still runs", and the two
252
+ # sets differ by exactly the entries that make the answers disagree: a
253
+ # SessionStart carrying only plastic-lock-gate satisfied doctor's
254
+ # hooks_registered while no such launcher ships anymore.
255
+ #
256
+ # Sourced from claude_launcher_names alone, so RETIRED_CLAUDE_LAUNCHERS is out
257
+ # (that is the bug) and CLAUDE_NON_HOOK_LAUNCHERS is out too: plastic-statusline
258
+ # is settings["statusLine"], not a hook of any event, so a group whose only
259
+ # Plastic entry is the statusline command has no hook registered in it.
260
+ # Tokenised through command_basenames like the purge predicate, so a quoted
261
+ # path or the legacy `ruby <path>/plastic-<name>.rb` form still resolves.
262
+ def claude_current_command?(cmd)
263
+ known = claude_launcher_names
264
+ command_basenames(cmd).any? { |name| known.include?(name) }
265
+ end
266
+
247
267
  # Is this ~/.codex/hooks.json command one of ours? Every Plastic Codex entry
248
268
  # invokes our dispatcher by path (`"<plastic_home>/scripts/codex-hook" <name>`),
249
269
  # so the dispatcher's filename identifies it. Basename EQUALITY, so a user's
@@ -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