@zalom/plastic 1.11.0 → 1.12.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 +68 -19
- package/scripts/lib/doctor_core.rb +9 -4
- package/scripts/lib/doctor_exclusions.rb +93 -0
- package/scripts/lib/hook_registry.rb +84 -0
- package/scripts/lib/installer_core.rb +73 -20
- package/scripts/lib/rule_catalog.rb +62 -0
- package/scripts/maintenance-run +138 -4
- 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/package.json
CHANGED
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: " \
|
|
@@ -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
|
|
@@ -243,7 +245,10 @@ class Doctor
|
|
|
243
245
|
message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
|
|
244
246
|
details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
245
247
|
fixable: true,
|
|
246
|
-
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes
|
|
248
|
+
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes " \
|
|
249
|
+
"stale launchers). The plastic- prefix is reserved for Plastic's own hooks: " \
|
|
250
|
+
"if one of these is yours, rename it (for example to ~/.claude/hooks/" \
|
|
251
|
+
"writing-style) and re-register it in settings.json before re-running."
|
|
247
252
|
)
|
|
248
253
|
end
|
|
249
254
|
|
|
@@ -271,7 +276,7 @@ class Doctor
|
|
|
271
276
|
|
|
272
277
|
groups.any? do |group|
|
|
273
278
|
group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
|
|
274
|
-
group["hooks"].any? { |h| h["command"]
|
|
279
|
+
group["hooks"].any? { |h| HookRegistry.claude_purge_command?(h["command"]) }
|
|
275
280
|
end
|
|
276
281
|
end
|
|
277
282
|
|
|
@@ -309,7 +314,7 @@ class Doctor
|
|
|
309
314
|
live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
|
|
310
315
|
Array(groups).flat_map do |g|
|
|
311
316
|
next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
|
|
312
|
-
g["hooks"].map { |h| h["command"].to_s }.select { |c|
|
|
317
|
+
g["hooks"].map { |h| h["command"].to_s }.select { |c| HookRegistry.claude_purge_command?(c) }
|
|
313
318
|
.map { |c| "#{event}: #{c}" }
|
|
314
319
|
end
|
|
315
320
|
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,90 @@ 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 ~/.codex/hooks.json command one of ours? Every Plastic Codex entry
|
|
248
|
+
# invokes our dispatcher by path (`"<plastic_home>/scripts/codex-hook" <name>`),
|
|
249
|
+
# so the dispatcher's filename identifies it. Basename EQUALITY, so a user's
|
|
250
|
+
# ~/bin/codex-hook-wrapper is not ours; the argument is not filtered on, because
|
|
251
|
+
# a command that already runs our dispatcher is ours whatever gate it names, and
|
|
252
|
+
# filtering would strand any name we forgot to retire.
|
|
253
|
+
#
|
|
254
|
+
# Tokenised the same way claude_purge_command? is (command_basenames), NOT a
|
|
255
|
+
# naive cmd.split.first: a first-token split breaks whenever plastic_home
|
|
256
|
+
# contains a space, since the shell-quoted dispatcher path then splits across
|
|
257
|
+
# multiple whitespace tokens and the true first token is only half the path.
|
|
258
|
+
# command_basenames strips quote characters per token, so whichever token
|
|
259
|
+
# carries the dispatcher's trailing `/codex-hook"` still resolves to the bare
|
|
260
|
+
# basename "codex-hook" after its trailing quote is stripped.
|
|
261
|
+
def codex_purge_command?(cmd)
|
|
262
|
+
command_basenames(cmd).any? { |name| CODEX_DISPATCHER_BASENAMES.include?(name) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Each whitespace-separated token reduced to a comparable launcher name:
|
|
266
|
+
# quotes stripped, directories dropped, a trailing .rb removed.
|
|
267
|
+
def command_basenames(cmd)
|
|
268
|
+
cmd.to_s.split(/\s+/).reject(&:empty?).map do |token|
|
|
269
|
+
File.basename(token.delete("\"'")).sub(/\.rb\z/, "")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
189
273
|
# The settings.json shape merge_claude_hooks expects: single-group events map
|
|
190
274
|
# to a Hash, multi-group events to an Array (the merge loop handles both).
|
|
191
275
|
def claude_settings_hooks(hook_dir:)
|
|
@@ -206,7 +206,7 @@ class InstallerCore
|
|
|
206
206
|
def statusline_choice(settings_path, argv: [], input: $stdin, reinstall: false)
|
|
207
207
|
existing_command = read_json_safe(settings_path)&.dig("statusLine", "command").to_s
|
|
208
208
|
return :plastic if existing_command.empty?
|
|
209
|
-
return :plastic if
|
|
209
|
+
return :plastic if HookRegistry.claude_purge_command?(existing_command)
|
|
210
210
|
|
|
211
211
|
idx = argv.index("--statusline")
|
|
212
212
|
flag = idx && argv[idx + 1]
|
|
@@ -412,6 +412,8 @@ class InstallerCore
|
|
|
412
412
|
"scripts/exec-worktree" => "scripts/exec-worktree",
|
|
413
413
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
414
414
|
"scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
|
|
415
|
+
"scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
|
|
416
|
+
"scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
|
|
415
417
|
"scripts/dashboard.rb" => "scripts/dashboard.rb",
|
|
416
418
|
"scripts/skill-lint" => "scripts/skill-lint",
|
|
417
419
|
"scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
|
|
@@ -941,14 +943,16 @@ class InstallerCore
|
|
|
941
943
|
|
|
942
944
|
# ~/.codex/hooks.json merge (intent 102). Guide-settled shape [guide Part 3]:
|
|
943
945
|
# top-level {"hooks": {<Event>: [...]}}, identical to Claude's settings.json
|
|
944
|
-
# hooks shape, so this mirrors merge_claude_hooks against a different file
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
#
|
|
946
|
+
# hooks shape, so this mirrors merge_claude_hooks against a different file.
|
|
947
|
+
# Both harnesses now match ownership by registry (intent 275), not a
|
|
948
|
+
# substring: Codex by dispatcher filename equality, because its hooks are
|
|
949
|
+
# arguments to one shared dispatcher command rather than per-hook launcher
|
|
950
|
+
# files the way Claude's plastic-<name> launchers are.
|
|
948
951
|
def merge_codex_hooks(hooks_json_path)
|
|
949
952
|
data = read_json_safe(hooks_json_path) || {}
|
|
950
953
|
hooks = data["hooks"] ||= {}
|
|
951
|
-
purge_stale_codex_hooks(hooks)
|
|
954
|
+
removed = purge_stale_codex_hooks(hooks)
|
|
955
|
+
report_removed_hook_entries(removed, "hooks.json")
|
|
952
956
|
plastic = HookRegistry.codex_hooks_json(dispatcher_path: codex_dispatcher_path)
|
|
953
957
|
plastic.each do |event, groups|
|
|
954
958
|
hooks[event] ||= []
|
|
@@ -957,23 +961,34 @@ class InstallerCore
|
|
|
957
961
|
write_json_atomic(hooks_json_path, data)
|
|
958
962
|
end
|
|
959
963
|
|
|
964
|
+
# Returns [[event, command], ...] for every entry removed, mirroring
|
|
965
|
+
# purge_stale_plastic_hooks. Ownership comes from HookRegistry (intent 275).
|
|
960
966
|
def purge_stale_codex_hooks(hooks)
|
|
961
|
-
|
|
967
|
+
removed = []
|
|
962
968
|
|
|
963
969
|
hooks.each do |event, groups|
|
|
964
970
|
next unless groups.is_a?(Array)
|
|
965
971
|
|
|
966
972
|
hooks[event] = groups.map do |group|
|
|
967
973
|
if group.is_a?(Hash) && group["hooks"].is_a?(Array)
|
|
968
|
-
group["hooks"].reject!
|
|
974
|
+
group["hooks"].reject! do |h|
|
|
975
|
+
HookRegistry.codex_purge_command?(h["command"]) && (removed << [event, h["command"]])
|
|
976
|
+
end
|
|
969
977
|
group unless group["hooks"].empty?
|
|
970
978
|
elsif group.is_a?(Hash) && group["command"]
|
|
971
|
-
|
|
979
|
+
if HookRegistry.codex_purge_command?(group["command"])
|
|
980
|
+
removed << [event, group["command"]]
|
|
981
|
+
nil
|
|
982
|
+
else
|
|
983
|
+
group
|
|
984
|
+
end
|
|
972
985
|
else
|
|
973
986
|
group
|
|
974
987
|
end
|
|
975
988
|
end.compact
|
|
976
989
|
end
|
|
990
|
+
|
|
991
|
+
removed
|
|
977
992
|
end
|
|
978
993
|
|
|
979
994
|
def install_hermes(config, force)
|
|
@@ -1202,6 +1217,32 @@ class InstallerCore
|
|
|
1202
1217
|
path.sub(Dir.home, "~")
|
|
1203
1218
|
end
|
|
1204
1219
|
|
|
1220
|
+
def report_removed_hook_entries(removed, file_label)
|
|
1221
|
+
return if removed.nil? || removed.empty?
|
|
1222
|
+
|
|
1223
|
+
puts " \u{1f9f9} Removed #{removed.size} stale Plastic hook entr#{removed.size == 1 ? "y" : "ies"} from #{file_label}:"
|
|
1224
|
+
removed.each { |event, command| puts " - #{event}: #{tilde(command.to_s)}" }
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
# The other half of intent 275: a hook the purge KEPT because the registry does not
|
|
1228
|
+
# know it, but whose name carries Plastic's prefix. Silence here is what let the
|
|
1229
|
+
# 1.11.0 update delete the owner's plastic-writing-style hook unnoticed; now the
|
|
1230
|
+
# update says the prefix is reserved and the hook stays.
|
|
1231
|
+
def report_reserved_prefix_hooks(hooks)
|
|
1232
|
+
kept = hooks.flat_map do |_event, groups|
|
|
1233
|
+
next [] unless groups.is_a?(Array)
|
|
1234
|
+
|
|
1235
|
+
groups.flat_map { |g| g.is_a?(Hash) ? Array(g["hooks"]).map { |h| h["command"] } + [g["command"]] : [] }
|
|
1236
|
+
end.compact.select { |cmd| cmd.to_s.include?("plastic-") }.uniq
|
|
1237
|
+
|
|
1238
|
+
return if kept.empty?
|
|
1239
|
+
|
|
1240
|
+
puts " \u{2139}\u{fe0f} Kept #{kept.size} hook(s) Plastic does not own, named with the reserved plastic- prefix:"
|
|
1241
|
+
kept.each { |cmd| puts " - #{tilde(cmd.to_s)}" }
|
|
1242
|
+
puts " The plastic- prefix is reserved for Plastic's own hooks. Rename yours (for"
|
|
1243
|
+
puts " example ~/.claude/hooks/writing-style) so a future update never mistakes it."
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1205
1246
|
# --- settings.json merge (read-modify-write, never clobber) ---
|
|
1206
1247
|
|
|
1207
1248
|
def merge_claude_hooks(settings_path, choice: :plastic)
|
|
@@ -1211,7 +1252,9 @@ class InstallerCore
|
|
|
1211
1252
|
hooks = settings["hooks"] ||= {}
|
|
1212
1253
|
hook_dir = File.join(Dir.home, ".claude", "hooks")
|
|
1213
1254
|
|
|
1214
|
-
purge_stale_plastic_hooks(hooks)
|
|
1255
|
+
removed = purge_stale_plastic_hooks(hooks)
|
|
1256
|
+
report_removed_hook_entries(removed, "settings.json")
|
|
1257
|
+
report_reserved_prefix_hooks(hooks)
|
|
1215
1258
|
|
|
1216
1259
|
# Single source of truth (intent 108, D7): registrations live in
|
|
1217
1260
|
# HookRegistry; this merge only translates them into settings.json.
|
|
@@ -1228,7 +1271,7 @@ class InstallerCore
|
|
|
1228
1271
|
groups.each do |g|
|
|
1229
1272
|
existing = hooks[event].find do |h|
|
|
1230
1273
|
h.is_a?(Hash) && h["matcher"] == g["matcher"] &&
|
|
1231
|
-
h["hooks"].is_a?(Array) && h["hooks"].any? { |x| x["command"]
|
|
1274
|
+
h["hooks"].is_a?(Array) && h["hooks"].any? { |x| HookRegistry.claude_purge_command?(x["command"]) }
|
|
1232
1275
|
end
|
|
1233
1276
|
|
|
1234
1277
|
if existing
|
|
@@ -1240,7 +1283,7 @@ class InstallerCore
|
|
|
1240
1283
|
end
|
|
1241
1284
|
|
|
1242
1285
|
existing_status = settings["statusLine"]
|
|
1243
|
-
if existing_status && !
|
|
1286
|
+
if existing_status && !HookRegistry.claude_purge_command?(existing_status["command"])
|
|
1244
1287
|
cache_dir = File.join(plastic_home, ".cache")
|
|
1245
1288
|
FileUtils.mkdir_p(cache_dir)
|
|
1246
1289
|
File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
|
|
@@ -1254,9 +1297,10 @@ class InstallerCore
|
|
|
1254
1297
|
write_json_atomic(settings_path, settings)
|
|
1255
1298
|
end
|
|
1256
1299
|
|
|
1300
|
+
# Returns [[event, command], ...] for every entry removed, so merge_claude_hooks
|
|
1301
|
+
# can report it. Ownership comes from HookRegistry (intent 275), never a substring.
|
|
1257
1302
|
def purge_stale_plastic_hooks(hooks)
|
|
1258
|
-
|
|
1259
|
-
|
|
1303
|
+
removed = []
|
|
1260
1304
|
hooks.delete("statusLine")
|
|
1261
1305
|
|
|
1262
1306
|
hooks.each do |event, groups|
|
|
@@ -1264,15 +1308,24 @@ class InstallerCore
|
|
|
1264
1308
|
|
|
1265
1309
|
hooks[event] = groups.map do |group|
|
|
1266
1310
|
if group.is_a?(Hash) && group["hooks"].is_a?(Array)
|
|
1267
|
-
group["hooks"].reject!
|
|
1311
|
+
group["hooks"].reject! do |h|
|
|
1312
|
+
HookRegistry.claude_purge_command?(h["command"]) && (removed << [event, h["command"]])
|
|
1313
|
+
end
|
|
1268
1314
|
group unless group["hooks"].empty?
|
|
1269
1315
|
elsif group.is_a?(Hash) && group["command"]
|
|
1270
|
-
|
|
1316
|
+
if HookRegistry.claude_purge_command?(group["command"])
|
|
1317
|
+
removed << [event, group["command"]]
|
|
1318
|
+
nil
|
|
1319
|
+
else
|
|
1320
|
+
group
|
|
1321
|
+
end
|
|
1271
1322
|
else
|
|
1272
1323
|
group
|
|
1273
1324
|
end
|
|
1274
1325
|
end.compact
|
|
1275
1326
|
end
|
|
1327
|
+
|
|
1328
|
+
removed
|
|
1276
1329
|
end
|
|
1277
1330
|
|
|
1278
1331
|
# --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
|
|
@@ -1445,10 +1498,10 @@ class InstallerCore
|
|
|
1445
1498
|
|
|
1446
1499
|
settings["hooks"][event] = groups.map do |group|
|
|
1447
1500
|
if group.is_a?(Hash) && group["hooks"].is_a?(Array)
|
|
1448
|
-
group["hooks"].reject! { |h| h["command"]
|
|
1501
|
+
group["hooks"].reject! { |h| HookRegistry.claude_purge_command?(h["command"]) }
|
|
1449
1502
|
group unless group["hooks"].empty?
|
|
1450
1503
|
elsif group.is_a?(Hash) && group["command"]
|
|
1451
|
-
group["command"]
|
|
1504
|
+
HookRegistry.claude_purge_command?(group["command"]) ? nil : group
|
|
1452
1505
|
else
|
|
1453
1506
|
group
|
|
1454
1507
|
end
|
|
@@ -1457,7 +1510,7 @@ class InstallerCore
|
|
|
1457
1510
|
|
|
1458
1511
|
settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
|
|
1459
1512
|
settings.delete("hooks") if settings["hooks"]&.empty?
|
|
1460
|
-
if settings.dig("statusLine", "command")
|
|
1513
|
+
if HookRegistry.claude_purge_command?(settings.dig("statusLine", "command"))
|
|
1461
1514
|
settings.delete("statusLine")
|
|
1462
1515
|
original_path = File.join(plastic_home, ".cache", "original-statusline.json")
|
|
1463
1516
|
if File.exist?(original_path)
|
|
@@ -1491,7 +1544,7 @@ class InstallerCore
|
|
|
1491
1544
|
data["hooks"][event] = groups.map do |g|
|
|
1492
1545
|
next g unless g.is_a?(Hash) && Array(g["hooks"]).is_a?(Array)
|
|
1493
1546
|
|
|
1494
|
-
g["hooks"] = Array(g["hooks"]).reject { |h| h["command"]
|
|
1547
|
+
g["hooks"] = Array(g["hooks"]).reject { |h| HookRegistry.codex_purge_command?(h["command"]) }
|
|
1495
1548
|
g["hooks"].empty? ? nil : g
|
|
1496
1549
|
end.compact
|
|
1497
1550
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# RuleCatalog - the one rule vocabulary in Plastic (intent 274), two curated named sets on two
|
|
5
|
+
# different axes so there is exactly one place to look up or register a rule name:
|
|
6
|
+
#
|
|
7
|
+
# EXCLUDABLE_CHECKS - a doctor check `name` a per-store doctor-exclusions file may name. A
|
|
8
|
+
# check name says WHICH DIAGNOSTIC FIRED. v1 carries exactly one key, savepoint_operational
|
|
9
|
+
# (see spec D3): most doctor checks have no exclusion mechanism at all, and this is the only
|
|
10
|
+
# one the owner asked to make skippable.
|
|
11
|
+
#
|
|
12
|
+
# REVISION_RULES - the `[rule: <tag>]` vocabulary every revisions.md entry must carry
|
|
13
|
+
# (scripts/lib/revisions_writer.rb). A tag says WHY an intent's files were structurally
|
|
14
|
+
# edited. Most check names have no repair verb and most repair verbs are not checks, so this
|
|
15
|
+
# is a genuinely separate axis, not an alias of EXCLUDABLE_CHECKS.
|
|
16
|
+
#
|
|
17
|
+
# REVISION_RULES is enforced by test only (test/rule_catalog_test.rb), never at
|
|
18
|
+
# RevisionsWriter runtime (spec D2): a receipt writer that refuses to write on an unrecognized
|
|
19
|
+
# tag is a guard that fails harder than the bug it would be catching, so append! keeps
|
|
20
|
+
# accepting any tag and the test catches an unregistered one before it ships.
|
|
21
|
+
#
|
|
22
|
+
# Zero requires (boot-safe): nothing here pulls in json/yaml/io, so this file can load from
|
|
23
|
+
# anywhere, including the SessionStart boot path, without cost.
|
|
24
|
+
#
|
|
25
|
+
# Packaging note (test/packaging_no_store_ids_test.rb): every token below is letter-leading,
|
|
26
|
+
# never digit-leading, so nothing here trips the Folgezettel-id-literal scan. Never put an
|
|
27
|
+
# intent id in this file.
|
|
28
|
+
module RuleCatalog
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
EXCLUDABLE_CHECKS = {
|
|
32
|
+
"savepoint_operational" => "savepoint.md missing entirely, or missing its Done " \
|
|
33
|
+
"delivered|abandoned echo, on a terminal intent. Usually " \
|
|
34
|
+
"repairable via maintenance-run --tool rebuild-savepoint; " \
|
|
35
|
+
"excludable for the gaps 219 D6 forbids ever repairing.",
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Measured from live store data across all eight stores, 2026-08-24 (spec D1).
|
|
39
|
+
REVISION_RULES = [
|
|
40
|
+
"links-projection",
|
|
41
|
+
"broken-chain",
|
|
42
|
+
"stray-file",
|
|
43
|
+
"savepoint-operational-reconstruction",
|
|
44
|
+
"unsanctioned-section",
|
|
45
|
+
"missing-reciprocity",
|
|
46
|
+
"misplaced-content",
|
|
47
|
+
"missing-required-frontmatter",
|
|
48
|
+
"savepoint-truthfulness",
|
|
49
|
+
"restored-to-v1",
|
|
50
|
+
"relocation",
|
|
51
|
+
"graph-rebuild",
|
|
52
|
+
"dangling-ref",
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
def excludable_check?(name)
|
|
56
|
+
EXCLUDABLE_CHECKS.key?(name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def revision_rule?(tag)
|
|
60
|
+
REVISION_RULES.include?(tag)
|
|
61
|
+
end
|
|
62
|
+
end
|
package/scripts/maintenance-run
CHANGED
|
@@ -15,6 +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
19
|
#
|
|
19
20
|
# project-links here is ALWAYS single-intent: --intent is required. A store-wide
|
|
20
21
|
# project-links sweep is the rare, owner-approved batch exception (D2) and is run directly
|
|
@@ -38,6 +39,7 @@ require_relative "lib/lock"
|
|
|
38
39
|
require_relative "lib/maintenance_git"
|
|
39
40
|
require_relative "lib/bridge"
|
|
40
41
|
require_relative "lib/revisions_writer"
|
|
42
|
+
require_relative "doctor" # safe: doctor.rb's CLI is behind $PROGRAM_NAME == __FILE__
|
|
41
43
|
|
|
42
44
|
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
43
45
|
|
|
@@ -75,7 +77,7 @@ end
|
|
|
75
77
|
|
|
76
78
|
def parse_argv(argv)
|
|
77
79
|
opts = { tool: nil, intent: nil, store: nil, plastic_home: DEFAULT_HOME, apply: false,
|
|
78
|
-
at: nil, skip_links: false, id: nil }
|
|
80
|
+
at: nil, skip_links: false, id: nil, rule: nil }
|
|
79
81
|
i = 0
|
|
80
82
|
while i < argv.length
|
|
81
83
|
case argv[i]
|
|
@@ -86,6 +88,7 @@ def parse_argv(argv)
|
|
|
86
88
|
when "--apply" then opts[:apply] = true
|
|
87
89
|
when "--at" then opts[:at] = argv[i += 1]
|
|
88
90
|
when "--skip-links" then opts[:skip_links] = true
|
|
91
|
+
when "--rule" then opts[:rule] = argv[i += 1]
|
|
89
92
|
else
|
|
90
93
|
opts[:id] ||= argv[i] # positional id, restore-intent-v1 only
|
|
91
94
|
end
|
|
@@ -269,9 +272,138 @@ def run_rebuild_savepoint(home, intent, store, apply)
|
|
|
269
272
|
report_result(result)
|
|
270
273
|
end
|
|
271
274
|
|
|
275
|
+
# Renders one store's canonical doctor-exclusions content (spec D6/D7): a leading comment
|
|
276
|
+
# block documenting the format, then one `rule_name id id id` line per rule, ids sorted. Pure.
|
|
277
|
+
# `existing_text:` (review F2) is the file's raw content BEFORE this run, or nil when the
|
|
278
|
+
# file does not exist yet. A hand-edited exclusion file's comments are the only home for an
|
|
279
|
+
# exemption's justification (D8: no revisions.md receipt exists for this tool), so an update
|
|
280
|
+
# preserves every comment and blank line from `existing_text` verbatim, in original order,
|
|
281
|
+
# ahead of the freshly rendered rule lines - it never re-renders over them. The boilerplate
|
|
282
|
+
# header is written only when there is no existing file to preserve anything from.
|
|
283
|
+
def render_exclusions_file(rules, existing_text: nil)
|
|
284
|
+
if existing_text
|
|
285
|
+
lines = existing_text.each_line.select { |l| l.strip.empty? || l.strip.start_with?("#") }.map(&:chomp)
|
|
286
|
+
else
|
|
287
|
+
lines = [
|
|
288
|
+
"# doctor-exclusions - knowingly-exempt (intent_id, rule) pairs (intent 274).",
|
|
289
|
+
"# Format: rule_name id id id (one line per rule, ids space-separated).",
|
|
290
|
+
"# Blank lines and lines starting with # are ignored. Ids only, never any other",
|
|
291
|
+
"# content - doctor reports how many findings this file suppressed and where",
|
|
292
|
+
"# this file lives, so the count always stays honest.",
|
|
293
|
+
"",
|
|
294
|
+
]
|
|
295
|
+
end
|
|
296
|
+
rules.keys.sort.each do |rule_name|
|
|
297
|
+
lines << "#{rule_name} #{rules[rule_name].sort.join(" ")}"
|
|
298
|
+
end
|
|
299
|
+
"#{lines.join("\n")}\n"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# register-exclusions (intent 274, spec D7/D8): the one-time population tool. Computes
|
|
303
|
+
# violations through Doctor's OWN done_signal_findings_for_dir rather than a reimplementation
|
|
304
|
+
# of that predicate (the drift 222 extracted that function to prevent), unions with any
|
|
305
|
+
# existing hand-edited file, skips (never aborts on) any intent dir holding a fresh delivery
|
|
306
|
+
# lock, and writes ALL stores in one scoped commit (they already live in the single ~/.plastic
|
|
307
|
+
# git repo, so a cross-store write is still one repo and one scoped commit - D7).
|
|
308
|
+
#
|
|
309
|
+
# Writes NO revisions.md entries (D8): this tool modifies no intent directory, only one
|
|
310
|
+
# store-level table per store, so 197's receipt-before-write rule (which covers tools that
|
|
311
|
+
# structurally edit an intent's OWN files) does not apply, and writing one would mean editing
|
|
312
|
+
# every touched Completed intent directory - forbidden, completed intents are immutable. The
|
|
313
|
+
# scoped commit plus the diffable exclusion file itself are the receipt.
|
|
314
|
+
def run_register_exclusions(home, rule, store, apply)
|
|
315
|
+
rule ||= "savepoint_operational"
|
|
316
|
+
unless RuleCatalog.excludable_check?(rule)
|
|
317
|
+
abort_loud("--rule #{rule.inspect} is not excludable (expected one of: " \
|
|
318
|
+
"#{RuleCatalog::EXCLUDABLE_CHECKS.keys.join(", ")})")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
doctor = Doctor.new(plastic_home: home)
|
|
322
|
+
stores = doctor.done_signal_stores(store ? [store] : nil)
|
|
323
|
+
abort_loud("no store matches --store #{store.inspect}") if store && stores.empty?
|
|
324
|
+
|
|
325
|
+
plan = {}
|
|
326
|
+
skip_lines = []
|
|
327
|
+
|
|
328
|
+
stores.each do |s|
|
|
329
|
+
existing = DoctorExclusions.load(s[:index])
|
|
330
|
+
if existing[:errors].any?
|
|
331
|
+
abort_loud("#{s[:scope]}: existing doctor-exclusions is malformed, refusing to rewrite " \
|
|
332
|
+
"it (#{existing[:errors].join("; ")})", 1)
|
|
333
|
+
end
|
|
334
|
+
# scrub: DoctorExclusions.load already scrubs internally (never raises, D5), but this
|
|
335
|
+
# raw read feeds render_exclusions_file's own line scan below, which was NOT going
|
|
336
|
+
# through that scrub - an invalid byte in a hand-written comment raised
|
|
337
|
+
# Encoding::CompatibilityError here on every register-exclusions run, dry-run included,
|
|
338
|
+
# even though `existing` itself (loaded via DoctorExclusions.load) reported the file
|
|
339
|
+
# clean. Scrub this read the same way so both paths agree.
|
|
340
|
+
existing_text = File.exist?(existing[:path]) ? File.read(existing[:path]).scrub : nil
|
|
341
|
+
|
|
342
|
+
found_ids = []
|
|
343
|
+
doctor.index_sections_by_dir(s[:index]).each do |dirname, in_sections|
|
|
344
|
+
dir = File.join(s[:store_dir], dirname)
|
|
345
|
+
next unless File.directory?(dir)
|
|
346
|
+
|
|
347
|
+
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
348
|
+
next unless terminal
|
|
349
|
+
|
|
350
|
+
if Lock.fresh?(dir)
|
|
351
|
+
skip_lines << "#{s[:scope]}: #{dirname} skipped (fresh delivery lock)"
|
|
352
|
+
next
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
active = in_sections.include?("Active") && !terminal
|
|
356
|
+
findings = doctor.done_signal_findings_for_dir(
|
|
357
|
+
dir, label: "#{s[:scope]} store/#{dirname}", scope: s[:scope], dirname: dirname,
|
|
358
|
+
terminal: terminal, active: active, excluded_rules: []
|
|
359
|
+
)
|
|
360
|
+
found_ids << dirname.split("--", 2).first if findings[:operational_gap].any?
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
already = existing[:rules][rule] || []
|
|
364
|
+
added = found_ids - already
|
|
365
|
+
next if added.empty?
|
|
366
|
+
|
|
367
|
+
merged_rules = existing[:rules].merge(rule => (already | found_ids).sort)
|
|
368
|
+
plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text), added: added.sort }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
puts skip_lines.join("\n") unless skip_lines.empty?
|
|
372
|
+
|
|
373
|
+
if plan.empty?
|
|
374
|
+
puts "maintenance-run: no new #{rule} violations to register."
|
|
375
|
+
exit 0
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
unless apply
|
|
379
|
+
plan.each do |s, info|
|
|
380
|
+
puts "maintenance-run: DRY RUN, #{s[:scope]} would register #{info[:added].size} " \
|
|
381
|
+
"id(s) under #{rule}: #{info[:added].join(", ")}"
|
|
382
|
+
end
|
|
383
|
+
exit 0
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
begin
|
|
387
|
+
result = MaintenanceGit.run_scoped(
|
|
388
|
+
repo_dir: home, branch_name: "maintenance/register-exclusions-#{stamp}",
|
|
389
|
+
commit_message: "chore: maintenance - register doctor exclusions (#{rule})"
|
|
390
|
+
) do
|
|
391
|
+
plan.each { |s, info| File.write(DoctorExclusions.path_for(s[:index]), info[:content]) }
|
|
392
|
+
end
|
|
393
|
+
rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
|
|
394
|
+
abort_loud(e.message, 4)
|
|
395
|
+
rescue RuntimeError => e
|
|
396
|
+
abort_loud(e.message, 3)
|
|
397
|
+
end
|
|
398
|
+
report_result(result)
|
|
399
|
+
end
|
|
400
|
+
|
|
272
401
|
def main(argv)
|
|
273
402
|
opts = parse_argv(argv)
|
|
274
|
-
|
|
403
|
+
unless opts[:tool]
|
|
404
|
+
abort_loud("--tool is required " \
|
|
405
|
+
"(project-links|rebuild-graph|restore-intent-v1|rebuild-savepoint|register-exclusions)")
|
|
406
|
+
end
|
|
275
407
|
|
|
276
408
|
case opts[:tool]
|
|
277
409
|
when "project-links"
|
|
@@ -281,9 +413,11 @@ def main(argv)
|
|
|
281
413
|
run_restore_intent_v1(opts[:plastic_home], opts[:id], opts[:at], opts[:store], opts[:apply], opts[:skip_links])
|
|
282
414
|
when "rebuild-savepoint"
|
|
283
415
|
run_rebuild_savepoint(opts[:plastic_home], opts[:intent], opts[:store], opts[:apply])
|
|
416
|
+
when "register-exclusions"
|
|
417
|
+
run_register_exclusions(opts[:plastic_home], opts[:rule], opts[:store], opts[:apply])
|
|
284
418
|
else
|
|
285
|
-
abort_loud("unknown --tool #{opts[:tool].inspect} " \
|
|
286
|
-
"
|
|
419
|
+
abort_loud("unknown --tool #{opts[:tool].inspect} (expected project-links|rebuild-graph|" \
|
|
420
|
+
"restore-intent-v1|rebuild-savepoint|register-exclusions)")
|
|
287
421
|
end
|
|
288
422
|
end
|
|
289
423
|
|
|
@@ -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 |
|
|
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 |
|
|
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,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
This chapter holds the escape-and-logging depth for each transition gate.
|
|
4
4
|
|
|
5
|
+
#### Hook naming and ownership
|
|
6
|
+
|
|
7
|
+
The `plastic-` prefix on an installed hook launcher is reserved for hooks `HookRegistry`
|
|
8
|
+
registers. A user-owned hook must never take it: the installer purges Plastic's registrations
|
|
9
|
+
from the agent's hook config on every update, matching by registry launcher name (current plus
|
|
10
|
+
`RETIRED_HOOK_NAMES`), and doctor's `hooks_no_orphans` reports any unregistered `plastic-*`
|
|
11
|
+
launcher on disk. Renaming or removing a hook from `events` means adding its old name to
|
|
12
|
+
`RETIRED_HOOK_NAMES` in the same change, or every existing install keeps a dead registration.
|
|
13
|
+
|
|
5
14
|
#### The gates by name
|
|
6
15
|
|
|
7
16
|
Each gate guards one thing. `scripts/lib/hook_registry.rb` is the single source of truth for
|
|
@@ -72,10 +72,12 @@ No commit anywhere, store or project repo, uses `git add -A`; every maintenance
|
|
|
72
72
|
commit stages only the paths it actually changed (`scripts/end-intent`'s `store_commit`,
|
|
73
73
|
`scripts/maintenance-run`).
|
|
74
74
|
|
|
75
|
-
The one condition on every maintenance action
|
|
76
|
-
|
|
77
|
-
`
|
|
78
|
-
|
|
75
|
+
The one condition on every maintenance action that STRUCTURALLY EDITS AN INTENT'S OWN FILES is
|
|
76
|
+
that it is recorded. (One narrow carve-out exists for a tool that edits no intent directory at
|
|
77
|
+
all - see `register-exclusions` below.) Every such maintenance action, whether run by a tool or
|
|
78
|
+
made by hand, must leave an append-only `revisions.md` entry on its target intent (`## Revision
|
|
79
|
+
vN`, a `Why ... [rule: tag]` line, a `Prior location`, and the change itself). If the file
|
|
80
|
+
already exists, a new run appends
|
|
79
81
|
`vN+1`; it never overwrites an earlier entry (precedent: intent 124's `revisions.md` v3
|
|
80
82
|
corrects v2 by appending a correction entry and explicitly leaving v2 in place). This is
|
|
81
83
|
tool-enforced, not prose alone: `scripts/project-links`, `scripts/rebuild-graph`, and
|
|
@@ -160,6 +162,38 @@ Violation tags (starter set, free-text tags allowed):
|
|
|
160
162
|
- `links-projection`: a tool-authored `## Links` regeneration (project-links; intent 197)
|
|
161
163
|
- `graph-rebuild`: a tool-authored sources/chain frontmatter rebuild (rebuild-graph; intent 197)
|
|
162
164
|
|
|
165
|
+
This is a starter set; free-text tags are allowed. `RuleCatalog::REVISION_RULES`
|
|
166
|
+
(`scripts/lib/rule_catalog.rb`, intent 274) is the canonical, currently-in-use vocabulary,
|
|
167
|
+
measured from live store data rather than hand-curated, and `test/rule_catalog_test.rb` pins
|
|
168
|
+
every `[rule:]` literal hardcoded under `scripts/` as a registered member - so an unregistered
|
|
169
|
+
tag is caught before it ships, without `RevisionsWriter.append!` itself ever refusing to write
|
|
170
|
+
one (a receipt writer that refuses on an unrecognized tag would fail harder than the bug it is
|
|
171
|
+
meant to catch).
|
|
172
|
+
|
|
173
|
+
#### register-exclusions: a maintenance tool that writes no revisions.md entry
|
|
174
|
+
|
|
175
|
+
`scripts/maintenance-run --tool register-exclusions [--rule <name>] [--store <key>] [--apply]`
|
|
176
|
+
(intent 274) is the one narrow exception to the "every maintenance action is recorded in
|
|
177
|
+
`revisions.md`" rule above. It populates each store's `doctor-exclusions` file (the per-store
|
|
178
|
+
record of knowingly-exempt `(intent_id, rule)` pairs `doctor`'s `savepoint_operational` check
|
|
179
|
+
honors - see `skills/doctor/SKILL.md`) by computing violations through
|
|
180
|
+
`Doctor#done_signal_findings_for_dir` directly, the same function `check_done_signals` itself
|
|
181
|
+
calls, so the registry can never disagree with the checker about what counts as a violation.
|
|
182
|
+
|
|
183
|
+
The carve-out: this tool modifies no intent directory at all. It writes exactly one
|
|
184
|
+
store-level table per store (`doctor-exclusions`, sibling to `INDEX.md`), never an intent's
|
|
185
|
+
own files, so the receipt rule above - which covers tools that structurally edit an intent's
|
|
186
|
+
own files - does not apply to it. Writing a `revisions.md` receipt anyway would mean editing
|
|
187
|
+
every touched Completed intent directory, which the standing rule that completed intents are
|
|
188
|
+
immutable forbids outright. The receipt is instead the scoped git commit
|
|
189
|
+
(`MaintenanceGit.run_scoped`) plus the exclusion file itself, where every line is its own
|
|
190
|
+
durable, diffable record - not a missing safeguard, a deliberate substitution for a receipt
|
|
191
|
+
shape that would otherwise require an illegal write.
|
|
192
|
+
|
|
193
|
+
Like every other tool behind `maintenance-run`, it dry-runs by default (the owner-approval
|
|
194
|
+
gate), unions with any existing hand-edited file content so a manually added id is never
|
|
195
|
+
dropped, and skips (never aborts on) any intent dir holding a fresh delivery lock.
|
|
196
|
+
|
|
163
197
|
### Context-economy measurement buckets (84a)
|
|
164
198
|
|
|
165
199
|
Intent 84 defines three buckets for sibling 84a to audit against; 84 does not run the audit.
|
package/skills/doctor/SKILL.md
CHANGED
|
@@ -205,6 +205,34 @@ This keeps the update flow clean when nothing is wrong.
|
|
|
205
205
|
- Non-zero exit codes mean "issues found", not "script crashed".
|
|
206
206
|
Always parse stdout regardless of exit code.
|
|
207
207
|
|
|
208
|
+
## Doctor-Exclusions: Known-Exempt Findings
|
|
209
|
+
|
|
210
|
+
Some `savepoint_operational` findings can never legitimately close (a terminal intent with no
|
|
211
|
+
real `outcome.md` has no disposition to echo, and doctor never invents one), so each store
|
|
212
|
+
carries a `doctor-exclusions` file, sibling to that store's `INDEX.md`, recording
|
|
213
|
+
knowingly-exempt `(intent_id, rule)` pairs. Format: one `rule_name id id id` line per rule,
|
|
214
|
+
blank lines and `#` comments ignored. v1 honors exactly one rule, `savepoint_operational`.
|
|
215
|
+
|
|
216
|
+
**Reading the count.** When any exclusion applies, the `savepoint_operational` check's message
|
|
217
|
+
folds in the count and the file's path, e.g. `"... (3 excluded via ~/.plastic/doctor-exclusions)"`.
|
|
218
|
+
A malformed line in the file forces the check to `warn` with the parse error in `details`, even
|
|
219
|
+
when zero real gaps remain, so a broken file is never silently permissive.
|
|
220
|
+
|
|
221
|
+
**Hand-editing.** The file is plain text; add a line (or append ids to an existing rule line) and
|
|
222
|
+
save. No installer step, no reindex, and no `revisions.md` entry is required or written.
|
|
223
|
+
|
|
224
|
+
**Populating it in bulk.** Run the maintenance tool, dry-run first:
|
|
225
|
+
|
|
226
|
+
```bash
|
|
227
|
+
ruby ~/.plastic/scripts/maintenance-run --tool register-exclusions
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
This computes every current `savepoint_operational` violation across all stores (or one store
|
|
231
|
+
via `--store <key>`), through doctor's own finding function, and prints what it would register
|
|
232
|
+
without writing anything. Review the output, then re-run with `--apply` to write the file(s) and
|
|
233
|
+
land one scoped git commit. It unions with any existing hand-added ids (never drops one) and
|
|
234
|
+
skips, rather than aborts on, any intent dir holding a fresh delivery lock.
|
|
235
|
+
|
|
208
236
|
## References
|
|
209
237
|
|
|
210
238
|
- 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
|