@zalom/plastic 1.11.0 → 1.13.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 +68 -19
- package/scripts/lib/bridge.rb +4 -3
- package/scripts/lib/doctor_core.rb +44 -10
- package/scripts/lib/doctor_exclusions.rb +93 -0
- package/scripts/lib/hook_registry.rb +104 -0
- package/scripts/lib/installer_core.rb +73 -20
- package/scripts/lib/rule_catalog.rb +62 -0
- package/scripts/maintenance-run +138 -4
- package/scripts/scaffold-intent +2 -2
- package/skills/conventions/SKILL.md +1 -1
- package/skills/conventions/references/gates-and-enforcement.md +9 -0
- package/skills/conventions/references/maintenance-and-revisions.md +38 -4
- package/skills/doctor/SKILL.md +28 -0
package/scripts/doctor.rb
CHANGED
|
@@ -13,6 +13,7 @@ require "date"
|
|
|
13
13
|
|
|
14
14
|
require_relative "lib/doctor_core"
|
|
15
15
|
|
|
16
|
+
require_relative "lib/doctor_exclusions"
|
|
16
17
|
require_relative "lib/qmd_sync"
|
|
17
18
|
require_relative "lib/intent_validator"
|
|
18
19
|
require_relative "lib/graph_rebuild"
|
|
@@ -531,17 +532,24 @@ class Doctor
|
|
|
531
532
|
# check_done_signals's store-wide loop (211's territory, unchanged severities) and the new
|
|
532
533
|
# per-intent check (check_intent_end) call, so the two surfaces can never independently
|
|
533
534
|
# drift on what counts as a phantom line or a completeness gap. Returns
|
|
534
|
-
# {conflict:, phantom:, gap:, stalled:} where conflict/phantom/
|
|
535
|
-
# string, and gap
|
|
536
|
-
# "outcome missing" gap and the "audit echo
|
|
537
|
-
# blocks (never elsif), so a single terminal
|
|
538
|
-
# two distinct gap strings in the same pass;
|
|
539
|
-
# silently drop one of the two on a dir where
|
|
540
|
-
# test/doctor_done_signals_test.rb's fixtures before this
|
|
541
|
-
|
|
535
|
+
# {conflict:, phantom:, gap:, operational_gap:, excluded:, stalled:} where conflict/phantom/
|
|
536
|
+
# stalled are nil or a finding string, and gap/operational_gap/excluded are ARRAYs (0, 1, or 2
|
|
537
|
+
# strings): the original inline code pushed the "outcome missing" gap and the "audit echo
|
|
538
|
+
# missing" gap as two SEPARATE, independent `if` blocks (never elsif), so a single terminal
|
|
539
|
+
# dir missing BOTH can legitimately contribute two distinct gap strings in the same pass;
|
|
540
|
+
# collapsing that into one nilable field would silently drop one of the two on a dir where
|
|
541
|
+
# both are true (verified against test/doctor_done_signals_test.rb's fixtures before this
|
|
542
|
+
# extraction, per plan.md Step 3).
|
|
543
|
+
#
|
|
544
|
+
# `excluded_rules:` (intent 274) is the set of rule names the caller's doctor-exclusions file
|
|
545
|
+
# names for this one intent id. A savepoint_operational finding routes to :excluded instead of
|
|
546
|
+
# :operational_gap when "savepoint_operational" is in that set; the :gap bucket (signals_complete,
|
|
547
|
+
# the outcome.md check) never consults it, which is what keeps the exclusion key (intent_id,
|
|
548
|
+
# rule) rather than just intent_id (see test/doctor_done_signals_test.rb case 12).
|
|
549
|
+
def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:, excluded_rules: [])
|
|
542
550
|
outcome = File.join(dir, "outcome.md")
|
|
543
551
|
outcome_real = Bridge.stage_file_present?(outcome)
|
|
544
|
-
findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], stalled: nil }
|
|
552
|
+
findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], excluded: [], stalled: nil }
|
|
545
553
|
|
|
546
554
|
# HARD conflict: the deliverable exists but INDEX still says Active. This
|
|
547
555
|
# is the one true INDEX-wins disagreement, so it stays a fail.
|
|
@@ -578,12 +586,13 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
|
|
|
578
586
|
# reconstructible via maintenance-run --tool rebuild-savepoint, so this is repairable and
|
|
579
587
|
# reported as a fixable warn (savepoint_operational).
|
|
580
588
|
savepoint = File.join(dir, "savepoint.md")
|
|
589
|
+
bucket = excluded_rules.include?("savepoint_operational") ? findings[:excluded] : findings[:operational_gap]
|
|
581
590
|
if !File.exist?(savepoint)
|
|
582
|
-
|
|
583
|
-
|
|
591
|
+
bucket << "#{label}: terminal in INDEX but savepoint.md is missing " \
|
|
592
|
+
"entirely (operational - reconstructible)"
|
|
584
593
|
elsif File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
585
|
-
|
|
586
|
-
|
|
594
|
+
bucket << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
595
|
+
"`Done delivered|abandoned` line (operational - reconstructible)"
|
|
587
596
|
end
|
|
588
597
|
|
|
589
598
|
# Stalled completion: unchanged, never consulted amnesty.
|
|
@@ -601,10 +610,20 @@ def check_done_signals(scopes: nil)
|
|
|
601
610
|
conflicts = []
|
|
602
611
|
gaps = [] # delivery-claim (outcome.md) gaps only - legacy, informational
|
|
603
612
|
operational_gaps = [] # savepoint gaps (missing file, or missing Done echo) - repairable
|
|
613
|
+
excluded = [] # savepoint gaps knowingly exempted via doctor-exclusions (intent 274)
|
|
614
|
+
exclusion_errors = [] # malformed doctor-exclusions lines, scope-tagged
|
|
615
|
+
exclusion_error_paths = []
|
|
616
|
+
exclusion_paths = [] # files that actually contributed a live exclusion
|
|
604
617
|
stalled = []
|
|
605
618
|
phantoms = []
|
|
606
619
|
|
|
607
620
|
done_signal_stores(scopes).each do |store|
|
|
621
|
+
exclusions = DoctorExclusions.load(store[:index])
|
|
622
|
+
if exclusions[:errors].any?
|
|
623
|
+
exclusion_errors.concat(exclusions[:errors].map { |e| "#{store[:scope]}: #{e}" })
|
|
624
|
+
exclusion_error_paths << exclusions[:path]
|
|
625
|
+
end
|
|
626
|
+
|
|
608
627
|
index_sections_by_dir(store[:index]).each do |dirname, in_sections|
|
|
609
628
|
dir = File.join(store[:store_dir], dirname)
|
|
610
629
|
next unless File.directory?(dir)
|
|
@@ -612,17 +631,26 @@ def check_done_signals(scopes: nil)
|
|
|
612
631
|
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
613
632
|
active = in_sections.include?("Active") && !terminal
|
|
614
633
|
label = "#{store[:scope]} store/#{dirname}"
|
|
634
|
+
intent_id = dirname.split("--", 2).first
|
|
635
|
+
excluded_rules = DoctorExclusions.rules_for(exclusions, intent_id)
|
|
615
636
|
|
|
616
637
|
findings = done_signal_findings_for_dir(
|
|
617
|
-
dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active
|
|
638
|
+
dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active,
|
|
639
|
+
excluded_rules: excluded_rules
|
|
618
640
|
)
|
|
619
641
|
conflicts << findings[:conflict] if findings[:conflict]
|
|
620
642
|
phantoms << findings[:phantom] if findings[:phantom]
|
|
621
643
|
gaps.concat(findings[:gap])
|
|
622
644
|
operational_gaps.concat(findings[:operational_gap])
|
|
645
|
+
if findings[:excluded].any?
|
|
646
|
+
excluded.concat(findings[:excluded])
|
|
647
|
+
exclusion_paths << exclusions[:path]
|
|
648
|
+
end
|
|
623
649
|
stalled << findings[:stalled] if findings[:stalled]
|
|
624
650
|
end
|
|
625
651
|
end
|
|
652
|
+
exclusion_paths.uniq!
|
|
653
|
+
exclusion_error_paths.uniq!
|
|
626
654
|
|
|
627
655
|
checks = []
|
|
628
656
|
|
|
@@ -662,18 +690,39 @@ def check_done_signals(scopes: nil)
|
|
|
662
690
|
)
|
|
663
691
|
end
|
|
664
692
|
|
|
665
|
-
# savepoint_operational (intent 211
|
|
666
|
-
# repairable via maintenance-run --tool
|
|
667
|
-
|
|
693
|
+
# savepoint_operational (intent 211; intent 274 adds the per-store doctor-exclusions index):
|
|
694
|
+
# missing savepoint.md or missing Done echo - repairable via maintenance-run --tool
|
|
695
|
+
# rebuild-savepoint for most gaps, or knowingly excluded for the ones 219 D6 forbids ever
|
|
696
|
+
# repairing (no real outcome.md to echo a disposition from). Three branches (spec D4/D5):
|
|
697
|
+
# a malformed exclusion file can never report pass (loud), a clean remaining gap set reports
|
|
698
|
+
# pass with the exclusion count folded in, and a real remaining gap set stays warn, same as
|
|
699
|
+
# before intent 274, with the same count folded in when exclusions applied.
|
|
700
|
+
exclusion_suffix = excluded.empty? ? "" : " (#{excluded.size} excluded via #{exclusion_paths.join(", ")})"
|
|
701
|
+
|
|
702
|
+
if exclusion_errors.any?
|
|
703
|
+
checks << check(
|
|
704
|
+
category: "done_signals", name: "savepoint_operational", status: "warn",
|
|
705
|
+
message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
|
|
706
|
+
"missing an operational savepoint.md or its Done echo, and " \
|
|
707
|
+
"#{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,
|
|
710
|
+
fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}) - " \
|
|
711
|
+
"format `rule_name id id id`, blank lines and # comments ignored - then reconstruct " \
|
|
712
|
+
"any remaining real gap via `maintenance-run --tool rebuild-savepoint --intent <id> " \
|
|
713
|
+
"--apply` (197-conformant: receipt-before-write via RevisionsWriter, one intent per " \
|
|
714
|
+
"invocation, owner-approval-gated)."
|
|
715
|
+
)
|
|
716
|
+
elsif operational_gaps.empty?
|
|
668
717
|
checks << check(
|
|
669
718
|
category: "done_signals", name: "savepoint_operational", status: "pass",
|
|
670
|
-
message: "No terminal intent is missing an operational savepoint.md or its Done echo"
|
|
719
|
+
message: "No terminal intent is missing an operational savepoint.md or its Done echo#{exclusion_suffix}"
|
|
671
720
|
)
|
|
672
721
|
else
|
|
673
722
|
checks << check(
|
|
674
723
|
category: "done_signals", name: "savepoint_operational", status: "warn",
|
|
675
724
|
message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
|
|
676
|
-
"missing an operational savepoint.md or its Done echo (reconstructible)",
|
|
725
|
+
"missing an operational savepoint.md or its Done echo (reconstructible)#{exclusion_suffix}",
|
|
677
726
|
details: operational_gaps, fixable: true,
|
|
678
727
|
fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
|
|
679
728
|
"`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
|
package/scripts/lib/bridge.rb
CHANGED
|
@@ -37,8 +37,9 @@ module Bridge
|
|
|
37
37
|
# `## Active` block); such bridges are purged. An Active intent's bridge is kept
|
|
38
38
|
# unconditionally, because while the intent is live the bridge is still load-
|
|
39
39
|
# bearing: it is the continuation signal (a parked or interrupted run resumes
|
|
40
|
-
# from it) and the anti-collision lock
|
|
41
|
-
#
|
|
40
|
+
# from it) and the anti-collision lock for parallel deliveries on one store,
|
|
41
|
+
# keeping each session's gate checks and locks from overwriting another's. An
|
|
42
|
+
# age window was the wrong
|
|
42
43
|
# axis: it left dead bridges resident for ~2 days AND could reap bridges of
|
|
43
44
|
# interrupted-but-still-active intents, which are exactly the ones to preserve.
|
|
44
45
|
|
|
@@ -121,7 +122,7 @@ module Bridge
|
|
|
121
122
|
# The CLAUDE_CODE_SESSION_ID fallback (intent 79) carries the bg/headless real
|
|
122
123
|
# session id (Claude Code passes session_id on stdin, not via an env var; the
|
|
123
124
|
# headless id lives in CLAUDE_CODE_SESSION_ID). Keying by the real id (instead of
|
|
124
|
-
# a derived hash) lets the
|
|
125
|
+
# a derived hash) lets the gate hooks, which receive that same id on stdin, find
|
|
125
126
|
# the bridge by direct filename lookup.
|
|
126
127
|
def self.resolve_session(explicit, intent_id:, store:)
|
|
127
128
|
return explicit.to_s.strip unless blank?(explicit)
|
|
@@ -32,7 +32,9 @@ class Doctor
|
|
|
32
32
|
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
33
33
|
# outside HookRegistry.events entirely, so it must be excluded from the
|
|
34
34
|
# orphan-launcher scan below or a correct install would report a false orphan.
|
|
35
|
-
|
|
35
|
+
# Defined in HookRegistry (intent 275) so the installer's purge can read it too;
|
|
36
|
+
# this is an alias, not a second source of truth.
|
|
37
|
+
CLAUDE_NON_HOOK_LAUNCHERS = HookRegistry::CLAUDE_NON_HOOK_LAUNCHERS
|
|
36
38
|
|
|
37
39
|
REQUIRED_SCRIPTS = %w[
|
|
38
40
|
folgezettel-id
|
|
@@ -105,6 +107,19 @@ class Doctor
|
|
|
105
107
|
path.sub(Dir.home, "~")
|
|
106
108
|
end
|
|
107
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
|
+
|
|
108
123
|
def check(category:, name:, status:, message:, details: [], fixable: false, fix_hint: nil)
|
|
109
124
|
result = {
|
|
110
125
|
category: category,
|
|
@@ -243,7 +258,10 @@ class Doctor
|
|
|
243
258
|
message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
|
|
244
259
|
details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
245
260
|
fixable: true,
|
|
246
|
-
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes
|
|
261
|
+
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes " \
|
|
262
|
+
"stale launchers). The plastic- prefix is reserved for Plastic's own hooks: " \
|
|
263
|
+
"if one of these is yours, rename it (for example to ~/.claude/hooks/" \
|
|
264
|
+
"writing-style) and re-register it in settings.json before re-running."
|
|
247
265
|
)
|
|
248
266
|
end
|
|
249
267
|
|
|
@@ -265,14 +283,30 @@ class Doctor
|
|
|
265
283
|
)
|
|
266
284
|
else
|
|
267
285
|
hooks = settings["hooks"] || {}
|
|
286
|
+
|
|
287
|
+
# A live registration is a launcher Plastic ships TODAY (intent 277).
|
|
288
|
+
# claude_purge_command?, which this replaced, answers "was this ever ours":
|
|
289
|
+
# right for the installer's purge, wrong here, because a SessionStart
|
|
290
|
+
# carrying only the retired plastic-lock-gate satisfied the event while
|
|
291
|
+
# nothing shipped to run it.
|
|
268
292
|
missing_events = CLAUDE_HOOK_EVENTS.reject do |event|
|
|
269
|
-
|
|
270
|
-
|
|
293
|
+
event_commands(hooks[event]).any? { |cmd| HookRegistry.claude_current_command?(cmd) }
|
|
294
|
+
end
|
|
271
295
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
296
|
+
# Name the launcher when a missing event still carries a Plastic-owned
|
|
297
|
+
# command. Inside a missing event every such command is by construction not
|
|
298
|
+
# a current one, and a bare "SessionStart" reads as "nothing registered" to
|
|
299
|
+
# someone looking at a settings.json that plainly holds a plastic- entry.
|
|
300
|
+
# Events with no Plastic entry keep the bare name: two tests compare details
|
|
301
|
+
# by element equality and by count.
|
|
302
|
+
missing_details = missing_events.map do |event|
|
|
303
|
+
stale = event_commands(hooks[event])
|
|
304
|
+
.flat_map { |cmd| HookRegistry.command_basenames(cmd) }
|
|
305
|
+
.select { |name| HookRegistry.claude_purgeable_launcher_names.include?(name) }
|
|
306
|
+
.uniq
|
|
307
|
+
next event if stale.empty?
|
|
308
|
+
|
|
309
|
+
"#{event} (registered command is not a current Plastic hook: #{stale.join(', ')})"
|
|
276
310
|
end
|
|
277
311
|
|
|
278
312
|
if missing_events.empty?
|
|
@@ -284,7 +318,7 @@ class Doctor
|
|
|
284
318
|
checks << check(
|
|
285
319
|
category: "agent_registration", name: "hooks_registered", status: "fail",
|
|
286
320
|
message: "#{missing_events.size} hook event(s) not registered in settings.json",
|
|
287
|
-
details:
|
|
321
|
+
details: missing_details,
|
|
288
322
|
fixable: true, fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude"
|
|
289
323
|
)
|
|
290
324
|
end
|
|
@@ -309,7 +343,7 @@ class Doctor
|
|
|
309
343
|
live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
|
|
310
344
|
Array(groups).flat_map do |g|
|
|
311
345
|
next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
|
|
312
|
-
g["hooks"].map { |h| h["command"].to_s }.select { |c|
|
|
346
|
+
g["hooks"].map { |h| h["command"].to_s }.select { |c| HookRegistry.claude_purge_command?(c) }
|
|
313
347
|
.map { |c| "#{event}: #{c}" }
|
|
314
348
|
end
|
|
315
349
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "rule_catalog"
|
|
5
|
+
|
|
6
|
+
# DoctorExclusions - resolves, reads, and parses one store's per-store `doctor-exclusions`
|
|
7
|
+
# table (intent 274): the record of knowingly-exempt (intent_id, rule) pairs that lets doctor
|
|
8
|
+
# skip a finding it can never legitimately repair (219 D6 forbids inventing a disposition for
|
|
9
|
+
# an unrepairable gap).
|
|
10
|
+
#
|
|
11
|
+
# Location (spec D6): sibling to that store's INDEX.md, resolved via `path_for` from the same
|
|
12
|
+
# `store[:index]` Doctor#done_signal_stores already yields - zero new store-discovery logic.
|
|
13
|
+
# Deliberately no `.md` extension: this is a config table, not a markdown document indexed by
|
|
14
|
+
# QMD or walked by lifecycle machinery.
|
|
15
|
+
#
|
|
16
|
+
# Format (spec D6), `/etc/hosts`-shaped: `rule_name id id id`, one rule per line. Blank lines
|
|
17
|
+
# and `#`-comment lines are ignored. Duplicate rule lines union their ids.
|
|
18
|
+
#
|
|
19
|
+
# Error contract (spec D5): fail open, loud in doctor. A missing file is the normal case -
|
|
20
|
+
# zero exclusions, zero errors, identical to before this file existed. A malformed line never
|
|
21
|
+
# excludes anything (fail milder than the bug: a typo must not silently suppress a real
|
|
22
|
+
# regression) and contributes one error string naming its 1-based line number. An unreadable
|
|
23
|
+
# file contributes one error and zero exclusions. This module NEVER raises.
|
|
24
|
+
module DoctorExclusions
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
FILENAME = "doctor-exclusions"
|
|
28
|
+
|
|
29
|
+
# Same shape test/packaging_no_store_ids_test.rb already defines for a real Folgezettel id:
|
|
30
|
+
# digit-leading, then any mix of letters and digits.
|
|
31
|
+
FOLGEZETTEL_ID = /\A\d+[a-zA-Z0-9]*\z/
|
|
32
|
+
|
|
33
|
+
def path_for(index_path)
|
|
34
|
+
File.join(File.dirname(index_path), FILENAME)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# PURE. { rules: { rule_name => [ids] }, errors: [String] }. A line producing any error
|
|
38
|
+
# contributes nothing to rules; duplicate rule lines union their ids without an error.
|
|
39
|
+
#
|
|
40
|
+
# `scrub` (never raises) before any regex/String op: a hand-edited file can carry a byte
|
|
41
|
+
# sequence invalid in its declared encoding (e.g. a stray Latin-1 byte in a comment), and
|
|
42
|
+
# String#strip/split/=~ all raise Encoding::CompatibilityError on that input. Scrubbing
|
|
43
|
+
# replaces the invalid byte with U+FFFD and keeps this module's never-raises contract (D5)
|
|
44
|
+
# true for every input, not just well-formed UTF-8.
|
|
45
|
+
def parse(text)
|
|
46
|
+
rules = {}
|
|
47
|
+
errors = []
|
|
48
|
+
|
|
49
|
+
text.to_s.scrub.each_line.with_index(1) do |raw_line, n|
|
|
50
|
+
line = raw_line.strip
|
|
51
|
+
next if line.empty? || line.start_with?("#")
|
|
52
|
+
|
|
53
|
+
line = line.sub(/(?:\A|\s)#.*\z/, "").rstrip
|
|
54
|
+
tokens = line.split
|
|
55
|
+
rule = tokens.shift
|
|
56
|
+
line_errors = []
|
|
57
|
+
|
|
58
|
+
line_errors << "line #{n}: rule \"#{rule}\" lists no intent ids" if tokens.empty?
|
|
59
|
+
unless RuleCatalog.excludable_check?(rule)
|
|
60
|
+
line_errors << "line #{n}: unknown or non-excludable rule \"#{rule}\""
|
|
61
|
+
end
|
|
62
|
+
tokens.each do |tok|
|
|
63
|
+
line_errors << "line #{n}: \"#{tok}\" is not a Folgezettel intent id" unless tok =~ FOLGEZETTEL_ID
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if line_errors.empty?
|
|
67
|
+
(rules[rule] ||= []).concat(tokens)
|
|
68
|
+
rules[rule].uniq!
|
|
69
|
+
else
|
|
70
|
+
errors.concat(line_errors)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{ rules: rules, errors: errors }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# IO. `parse`'s shape plus `path:`. Never raises: a missing file is the normal case (zero
|
|
78
|
+
# exclusions, zero errors); an unreadable file (permission, is-a-directory, any
|
|
79
|
+
# SystemCallError) yields one error and zero exclusions.
|
|
80
|
+
def load(index_path)
|
|
81
|
+
path = path_for(index_path)
|
|
82
|
+
return { rules: {}, errors: [], path: path } unless File.exist?(path)
|
|
83
|
+
|
|
84
|
+
parse(File.read(path)).merge(path: path)
|
|
85
|
+
rescue SystemCallError => e
|
|
86
|
+
{ rules: {}, errors: ["#{path}: unreadable (#{e.message})"], path: path }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Rule names excluding `intent_id` in an already-`load`ed result. [] when none.
|
|
90
|
+
def rules_for(loaded, intent_id)
|
|
91
|
+
loaded[:rules].select { |_rule, ids| ids.include?(intent_id) }.keys
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -186,6 +186,110 @@ module HookRegistry
|
|
|
186
186
|
.uniq.sort.map { |name| "plastic-#{name}" }
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# Launchers the installer places in the agent's hooks dir that `events` does not
|
|
190
|
+
# register (intent 204): plastic-statusline is the settings["statusLine"] command.
|
|
191
|
+
# Defined here rather than in doctor_core so the installer's purge can recognise it
|
|
192
|
+
# without depending on the doctor; Doctor::CLAUDE_NON_HOOK_LAUNCHERS aliases it.
|
|
193
|
+
CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
|
|
194
|
+
|
|
195
|
+
# Hook names Plastic HAS registered and no longer does (intent 275). Purge-only:
|
|
196
|
+
# an old install still carries these entries in settings.json / hooks.json, and
|
|
197
|
+
# nothing else can tell us they were ever ours.
|
|
198
|
+
#
|
|
199
|
+
# MAINTENANCE DUTY: renaming or removing a hook from `events` means adding its old
|
|
200
|
+
# name here in the SAME change. Skip it and every existing install keeps a dead
|
|
201
|
+
# registration no update will ever clean up.
|
|
202
|
+
#
|
|
203
|
+
# Never fold these into claude_launcher_names: that method is what doctor's
|
|
204
|
+
# hooks_exist demands be present on disk, so a retired name there makes a correct
|
|
205
|
+
# install report missing launchers.
|
|
206
|
+
RETIRED_HOOK_NAMES = %w[
|
|
207
|
+
code-gate create-gate links-gate lock-gate savepoint-pre
|
|
208
|
+
qmd-search retrieval-gate model-instructions opus-manual
|
|
209
|
+
].freeze
|
|
210
|
+
|
|
211
|
+
RETIRED_CLAUDE_LAUNCHERS = RETIRED_HOOK_NAMES.map { |n| "plastic-#{n}" }.freeze
|
|
212
|
+
|
|
213
|
+
# Filenames of Plastic's Codex dispatcher, current and retired. Codex hooks are
|
|
214
|
+
# not per-hook launcher files: every command is `"<dispatcher>" <name>`, so the
|
|
215
|
+
# dispatcher's own filename is what identifies an entry as ours.
|
|
216
|
+
CODEX_DISPATCHER_BASENAMES = %w[codex-hook].freeze
|
|
217
|
+
|
|
218
|
+
# Every launcher name the installer may purge from settings.json: what we register
|
|
219
|
+
# now, the non-hook launchers we place, and what we used to register.
|
|
220
|
+
def claude_purgeable_launcher_names
|
|
221
|
+
(claude_launcher_names + CLAUDE_NON_HOOK_LAUNCHERS + RETIRED_CLAUDE_LAUNCHERS).uniq.sort
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Current Codex hook names, from the same sources codex_hooks_json builds from.
|
|
225
|
+
def codex_hook_names
|
|
226
|
+
live = CODEX_LIVE_STATE_EVENTS.flat_map do |event|
|
|
227
|
+
events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
228
|
+
end
|
|
229
|
+
(CODEX_PRE_HOOKS + CODEX_POST_HOOKS + CODEX_BASH_HOOKS + live).uniq.sort
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def codex_purgeable_hook_names
|
|
233
|
+
(codex_hook_names + RETIRED_HOOK_NAMES).uniq.sort
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Is this settings.json hook command one of OURS? (intent 275)
|
|
237
|
+
#
|
|
238
|
+
# Ownership is registry membership, never a substring: the substring test this
|
|
239
|
+
# replaced deleted a user's own ~/.claude/hooks/plastic-writing-style hook on
|
|
240
|
+
# update. Tokenised rather than first-token-only because legacy entries take the
|
|
241
|
+
# form `ruby <path>/plastic-<name>.rb`, and those must still be purged.
|
|
242
|
+
def claude_purge_command?(cmd)
|
|
243
|
+
known = claude_purgeable_launcher_names
|
|
244
|
+
command_basenames(cmd).any? { |name| known.include?(name) }
|
|
245
|
+
end
|
|
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
|
+
|
|
267
|
+
# Is this ~/.codex/hooks.json command one of ours? Every Plastic Codex entry
|
|
268
|
+
# invokes our dispatcher by path (`"<plastic_home>/scripts/codex-hook" <name>`),
|
|
269
|
+
# so the dispatcher's filename identifies it. Basename EQUALITY, so a user's
|
|
270
|
+
# ~/bin/codex-hook-wrapper is not ours; the argument is not filtered on, because
|
|
271
|
+
# a command that already runs our dispatcher is ours whatever gate it names, and
|
|
272
|
+
# filtering would strand any name we forgot to retire.
|
|
273
|
+
#
|
|
274
|
+
# Tokenised the same way claude_purge_command? is (command_basenames), NOT a
|
|
275
|
+
# naive cmd.split.first: a first-token split breaks whenever plastic_home
|
|
276
|
+
# contains a space, since the shell-quoted dispatcher path then splits across
|
|
277
|
+
# multiple whitespace tokens and the true first token is only half the path.
|
|
278
|
+
# command_basenames strips quote characters per token, so whichever token
|
|
279
|
+
# carries the dispatcher's trailing `/codex-hook"` still resolves to the bare
|
|
280
|
+
# basename "codex-hook" after its trailing quote is stripped.
|
|
281
|
+
def codex_purge_command?(cmd)
|
|
282
|
+
command_basenames(cmd).any? { |name| CODEX_DISPATCHER_BASENAMES.include?(name) }
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Each whitespace-separated token reduced to a comparable launcher name:
|
|
286
|
+
# quotes stripped, directories dropped, a trailing .rb removed.
|
|
287
|
+
def command_basenames(cmd)
|
|
288
|
+
cmd.to_s.split(/\s+/).reject(&:empty?).map do |token|
|
|
289
|
+
File.basename(token.delete("\"'")).sub(/\.rb\z/, "")
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
189
293
|
# The settings.json shape merge_claude_hooks expects: single-group events map
|
|
190
294
|
# to a Hash, multi-group events to an Array (the merge loop handles both).
|
|
191
295
|
def claude_settings_hooks(hook_dir:)
|