@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 +1 -1
- package/scripts/doctor.rb +131 -9
- package/scripts/lib/doctor_core.rb +253 -28
- package/scripts/lib/doctor_exclusions.rb +54 -0
- package/scripts/lib/installer_core.rb +40 -7
- package/scripts/maintenance-run +124 -14
- 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
package/package.json
CHANGED
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: [],
|
|
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
|
-
|
|
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
|
|
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)
|
|
726
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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"].
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
|
479
|
-
check(
|
|
480
|
-
|
|
481
|
-
|
|
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: "#{
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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}
|
|
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
|
|
package/scripts/maintenance-run
CHANGED
|
@@ -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
|
-
|
|
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 <<
|
|
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
|
-
|
|
368
|
-
|
|
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
|
|
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
|
|
381
|
-
|
|
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
|
|
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
|
|
8
|
-
|
|
9
|
-
from the agent's hook config on every update, matching by registry launcher name
|
|
10
|
-
`RETIRED_HOOK_NAMES`), and doctor
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
package/skills/doctor/SKILL.md
CHANGED
|
@@ -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
|
|