@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/hooks/statusline +300 -155
- package/package.json +1 -1
- package/scripts/doctor.rb +131 -9
- package/scripts/lib/bridge.rb +4 -3
- package/scripts/lib/doctor_core.rb +289 -35
- package/scripts/lib/doctor_exclusions.rb +54 -0
- package/scripts/lib/hook_registry.rb +20 -0
- package/scripts/lib/installer_core.rb +40 -7
- package/scripts/maintenance-run +124 -14
- package/scripts/scaffold-intent +2 -2
- package/skills/conventions/SKILL.md +1 -1
- package/skills/conventions/references/gates-and-enforcement.md +13 -8
- package/skills/conventions/references/maintenance-and-revisions.md +14 -0
- package/skills/doctor/SKILL.md +14 -0
- package/skills/skill-creating/SKILL.md +3 -0
- package/skills/skill-creating/references/skills.md +3 -0
|
@@ -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
|
-
|
|
275
|
-
|
|
424
|
+
event_commands(hooks[event]).any? { |cmd| HookRegistry.claude_current_command?(cmd) }
|
|
425
|
+
end
|
|
276
426
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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"].
|
|
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
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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
|
|
450
|
-
check(
|
|
451
|
-
|
|
452
|
-
|
|
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: "#{
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
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}
|
|
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!
|
|
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"])
|
|
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
|
-
|
|
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
|
|
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
|
|