@zalom/plastic 1.10.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/README.md CHANGED
@@ -139,7 +139,7 @@ Walk this once in
139
139
  | Intent | `plastic-continuing`, `plastic-intent-creating`, `plastic-intent-starting`, `plastic-intent-brainstorming`, `plastic-intent-grilling`, `plastic-intent-speccing`, `plastic-intent-planning`, `plastic-intent-executing`, `plastic-intent-ending`, `plastic-intent-continuing`, `plastic-intent-researching` |
140
140
  | Project and delivery | `plastic-project-creating`, `plastic-project-continuing`, `plastic-roadmap`, `plastic-roadmap-continuing`, `plastic-releasing` |
141
141
  | Skill | `plastic-skill-creating`, `plastic-skill-evaluating` |
142
- | Product | `plastic-install`, `plastic-update`, `plastic-uninstall`, `plastic-rollback`, `plastic-doctor`, `plastic-humanizer` |
142
+ | Product | `plastic-install`, `plastic-update`, `plastic-uninstall`, `plastic-rollback`, `plastic-doctor` |
143
143
 
144
144
  See [`docs/guides/index.md`](docs/guides/index.md) for task-oriented walkthroughs.
145
145
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zalom/plastic",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "Intent-driven idea development system for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
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/stalled are nil or a finding
535
- # string, and gap is an ARRAY (0, 1, or 2 strings): the original inline code pushed the
536
- # "outcome missing" gap and the "audit echo missing" gap as two SEPARATE, independent `if`
537
- # blocks (never elsif), so a single terminal dir missing BOTH can legitimately contribute
538
- # two distinct gap strings in the same pass; collapsing that into one nilable field would
539
- # silently drop one of the two on a dir where both are true (verified against
540
- # test/doctor_done_signals_test.rb's fixtures before this extraction, per plan.md Step 3).
541
- def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:)
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
- findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md is missing " \
583
- "entirely (operational - reconstructible)"
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
- findings[:operational_gap] << "#{label}: terminal in INDEX but savepoint.md has no " \
586
- "`Done delivered|abandoned` line (operational - reconstructible)"
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, NEW): missing savepoint.md or missing Done echo -
666
- # repairable via maintenance-run --tool rebuild-savepoint, so this stays warn+fixable.
667
- if operational_gaps.empty?
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
- CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
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 stale launchers)"
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"].to_s.include?("plastic-") }
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| c.include?("plastic-") }
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 existing_command.include?("plastic-")
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 and
945
- # a different purge predicate (the dispatcher command contains "codex-hook",
946
- # not the "plastic-" substring merge_claude_hooks matches, since the path is
947
- # "~/.plastic/scripts/codex-hook", not "~/.claude/hooks/plastic-<name>").
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
- codex_cmd = ->(cmd) { cmd.to_s.include?("codex-hook") }
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! { |h| codex_cmd.call(h["command"]) }
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
- codex_cmd.call(group["command"]) ? nil : group
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"].to_s.include?("plastic-") }
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 && !existing_status.dig("command").to_s.include?("plastic-")
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
- plastic_cmd = ->(cmd) { cmd.to_s.include?("plastic-") }
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! { |h| plastic_cmd.call(h["command"]) }
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
- plastic_cmd.call(group["command"]) ? nil : group
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"].to_s.include?("plastic-") }
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"].to_s.include?("plastic-") ? nil : group
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").to_s.include?("plastic-")
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"].to_s.include?("codex-hook") }
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
@@ -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
- abort_loud("--tool is required (project-links|rebuild-graph|restore-intent-v1|rebuild-savepoint)") unless opts[:tool]
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
- "(expected project-links|rebuild-graph|restore-intent-v1|rebuild-savepoint)")
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
 
@@ -57,11 +57,9 @@ CONSUMES the intent 74 report to WRITE the human briefing defined here. The two
57
57
 
58
58
  ## Brevity: point, don't repeat
59
59
 
60
- Surface rules (no em-dashes, plain words, no filler openers, and so on) are owned by the
61
- shipped `plastic-humanizer` skill and the always-on plain-language layer. This contract does not
62
- re-list that catalog. It restates only the hard bans as one line: no em-dashes, no "not X but Y",
63
- no rule of three, no hype words, no sycophancy, no over-bolding. Apply `plastic-humanizer` and the
64
- always-on layer for everything else.
60
+ Surface rules are owned by the `writing-style` skill. This contract does not restate them, in full
61
+ or in summary. It defines the report's shape only: what to say, in what order, and when to stop.
62
+ Apply the `writing-style` skill for the wording.
65
63
 
66
64
  ## Emission: guided vs auto
67
65
 
@@ -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, with no exception, is that it is recorded.
76
- Every maintenance action, whether run by a tool or made by hand, must leave an append-only
77
- `revisions.md` entry on its target intent (`## Revision vN`, a `Why ... [rule: tag]` line, a
78
- `Prior location`, and the change itself). If the file already exists, a new run appends
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.
@@ -130,6 +130,6 @@ succession at a glance (last line = where we are).
130
130
  In auto mode the orchestrator briefs the human at every lifecycle stage boundary in a fixed,
131
131
  impact-first shape (the EM-to-CTO report contract): State, then Risk, then Call. This is the
132
132
  depth at M and L; at S the briefing fires once, at How. It leads with
133
- what changed and why it matters, names one risk, and leaves the decision to the human. Separately,
134
- the `plastic-humanizer` skill cleans authored prose (specs, outcomes, READMEs, release notes) of
135
- AI tells and slop; it is for documents, not for every reply.
133
+ what changed and why it matters, names one risk, and leaves the decision to the human. Wording
134
+ rules for the briefing, and for authored prose such as specs, outcomes, READMEs, and release
135
+ notes, are owned by the `writing-style` skill, not by this contract.
@@ -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
@@ -1,40 +0,0 @@
1
- ---
2
- name: plastic-humanizer
3
- description: Use to clean authored prose so it reads human and clutter-free. Removes AI tells and slop from a document, spec, outcome, README, report, or release note. Use when the user says "humanize", "de-slop", "remove the AI tells", or "clean up the wording". For documents, not for every chat reply, and not for code.
4
- user-invocable: true
5
- ---
6
-
7
- # Humanizer
8
-
9
- Make writing read like a person wrote it: plain, direct, low-clutter. Run this as a pass over authored prose (specs, outcomes, README, reports). Do not run it on code or on every chat turn.
10
-
11
- ## Lead (house style)
12
- Reframe your answers and any questions you have for me like you are speaking to a well-educated non-English native person. Reduce slang, and rarely used words and terms in the general communication. And cut all the fancy words from explanations. Technical terms and concepts are fine and need no gloss.
13
-
14
- ## Voice
15
- Answer like a busy bartender or a senior support engineer who has seen almost every ticket. Help fast, give the fix first, earn the tip.
16
-
17
- ## Job 1 - remove these surface tells
18
- 1. Em-dashes and en-dashes - use a comma or a full stop.
19
- 2. "Not X but Y" (and "it's not just X, it's Y").
20
- 3. Rule of three - three items only for rhythm.
21
- 4. Hype / AI words - delve, robust, comprehensive, seamless, leverage, crucial, unlock, landscape.
22
- 5. Filler openers / signposting - "It's worth noting", "It's important to", "Let's dive in".
23
- 6. Hedging pile-up - might, could, perhaps, generally, when not needed.
24
- 7. Sycophancy - "Great question", "You're absolutely right".
25
- 8. Over-bolding - bold only what carries weight.
26
-
27
- ## Job 2 - fix the structure
28
- - Lead with the one main point.
29
- - Cut sentences that only restate.
30
- - Pick concrete words over abstract ones.
31
- - Match the user's voice when samples of their writing exist.
32
-
33
- ## Process
34
- Write, check once against the rules above, then send. On documents, run this pass last.
35
-
36
- ## Length
37
- Keep it short. If a sentence does not help the reader, cut it.
38
-
39
- ## More
40
- For before/after examples, read `references/examples.md`. To make the house style always-on in chat, see `references/always-on-snippet.md`. The full 33-pattern catalog and the research behind this skill live in intent 92's `resources/`. They are background and are not loaded here.
@@ -1,70 +0,0 @@
1
- {
2
- "skill_name": "plastic-humanizer",
3
- "notes": "Intent 92. Cleans authored prose: removes 8 surface tells and applies structural levers (one point, cut restatement, concrete words, match voice). Triggers on document-cleanup asks, not on code. Runner is intent 76; assertions are human for now.",
4
- "evals": [
5
- {
6
- "id": 1,
7
- "scope": "trigger",
8
- "set": "validation",
9
- "prompt": "Humanize this README - it reads like AI wrote it.",
10
- "expected_output": "Selects plastic-humanizer and runs the cleanup pass over the document.",
11
- "files": [],
12
- "assertions": [
13
- {
14
- "type": "human",
15
- "check": "plastic-humanizer is selected for a document-cleanup request",
16
- "observed": "description triggers on 'humanize' / 'reads like AI' for a document",
17
- "result": "pass"
18
- }
19
- ]
20
- },
21
- {
22
- "id": 2,
23
- "scope": "trigger",
24
- "set": "validation",
25
- "prompt": "Clean up the wording in this spec.",
26
- "expected_output": "Selects plastic-humanizer and cleans the spec prose.",
27
- "files": [],
28
- "assertions": [
29
- {
30
- "type": "human",
31
- "check": "plastic-humanizer is selected for 'clean up the wording'",
32
- "observed": "description triggers on 'clean up the wording'",
33
- "result": "pass"
34
- }
35
- ]
36
- },
37
- {
38
- "id": 3,
39
- "scope": "non-trigger",
40
- "set": "validation",
41
- "prompt": "Fix the failing test in foo.rb.",
42
- "expected_output": "Does NOT select plastic-humanizer; this is a code task, not prose cleanup.",
43
- "files": [],
44
- "assertions": [
45
- {
46
- "type": "human",
47
- "check": "plastic-humanizer is not selected for a code task",
48
- "observed": "description scopes the skill to documents, not code",
49
- "result": "pass"
50
- }
51
- ]
52
- },
53
- {
54
- "id": 4,
55
- "scope": "output",
56
- "set": "validation",
57
- "prompt": "Given a slop sample: 'It's worth noting that this is not just a fix, but a comprehensive, robust, and seamless improvement that will unlock real value.' Humanize it.",
58
- "expected_output": "Cleaned text has no em-dashes and none of the 8 tells, leads with one point, and drops restatement. For example: 'This fix makes X faster.'",
59
- "files": [],
60
- "assertions": [
61
- {
62
- "type": "human",
63
- "check": "output drops the 8 tells, leads with one point, cuts restatement",
64
- "observed": "no em-dashes; no 'not X but Y'; no rule-of-three; no hype words; no filler opener; one load-bearing point",
65
- "result": "pass"
66
- }
67
- ]
68
- }
69
- ]
70
- }
@@ -1,9 +0,0 @@
1
- # Always-on chat style (copy-paste)
2
-
3
- Paste this into your global `CLAUDE.md` (applies to all projects) or a project `AGENTS.md` (one project). It keeps the chat style on at near-zero cost. The full cleanup for documents is the `plastic-humanizer` skill.
4
-
5
- ```
6
- Reframe your answers and any questions like you are speaking to a well-educated person who is not a native English speaker. Reduce slang and rarely used words. Cut fancy words from explanations. Technical terms and concepts are fine.
7
-
8
- Answer like a busy bartender or a senior support engineer who has seen almost every ticket: help fast, give the fix first, earn the tip.
9
- ```
@@ -1,48 +0,0 @@
1
- # Examples - before and after
2
-
3
- Each pair shows a tell or a slop pattern, then the fix.
4
-
5
- ## 1. Em-dash
6
- Before: The release is ready - we just need sign-off.
7
- After: The release is ready. We just need sign-off.
8
- Why: Use a full stop or comma, not an em-dash.
9
-
10
- ## 2. "Not X but Y"
11
- Before: This is not just a config change, it's a rethink of the flow.
12
- After: This change reworks the flow.
13
- Why: State the point directly.
14
-
15
- ## 3. Rule of three
16
- Before: The tool is fast, robust, and powerful.
17
- After: The tool is fast.
18
- Why: Drop the list-for-rhythm; keep the claim you can back.
19
-
20
- ## 4. Hype / AI word
21
- Before: We leverage a comprehensive caching layer to unlock performance.
22
- After: We cache results to make it faster.
23
- Why: Plain verbs and nouns.
24
-
25
- ## 5. Filler opener / signposting
26
- Before: It's worth noting that the cache expires after five minutes.
27
- After: The cache expires after five minutes.
28
- Why: Say the thing; skip the runway.
29
-
30
- ## 6. Hedging pile-up
31
- Before: This might possibly help reduce cost in some cases, generally.
32
- After: This reduces cost.
33
- Why: One clear claim. Add a caveat only if it is real.
34
-
35
- ## 7. Sycophancy
36
- Before: Great question! That's a really insightful point. Here's the answer.
37
- After: Here's the answer.
38
- Why: Drop the praise; answer.
39
-
40
- ## 8. Structural fix (the mush)
41
- Before: There are several important considerations to keep in mind when thinking about how best to approach the broad and multifaceted topic of caching, each of which carries its own trade-offs.
42
- After: Cache reads, not writes. Reads repeat; writes do not.
43
- Why: One load-bearing point. Cut the restatement and the abstract throat-clearing.
44
-
45
- ## 9. Over-bolding
46
- Before: **Note:** the cache **expires** after **five minutes**, so **always** check it.
47
- After: The cache expires after five minutes, so check it first.
48
- Why: Bold nothing here; the sentence carries itself.