@zalom/plastic 1.11.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
 
@@ -31,8 +31,8 @@
31
31
  #
32
32
  # outcome is an end-of-Exec step. Run it once the diff and the test summary exist, right
33
33
  # before plastic-intent-ending writes the narrative. Writing outcome.md makes the intent
34
- # read as Done to the statusline and to any stage-derived display (Bridge.derive_stage
35
- # keys on outcome.md's presence). This does NOT purge a bridge:
34
+ # read as Done to any stage-derived display (Bridge.derive_stage keys on outcome.md's
35
+ # presence). This does NOT purge a bridge:
36
36
  # Bridge.purge_done_bridges keys on INDEX Active status plus lock presence, never on the
37
37
  # derived stage.
38
38
 
@@ -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.
@@ -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