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