@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 +1 -1
- package/package.json +1 -1
- package/scripts/doctor.rb +68 -19
- package/scripts/lib/doctor_core.rb +9 -4
- package/scripts/lib/doctor_exclusions.rb +93 -0
- package/scripts/lib/hook_registry.rb +84 -0
- package/scripts/lib/installer_core.rb +73 -20
- package/scripts/lib/rule_catalog.rb +62 -0
- package/scripts/maintenance-run +138 -4
- package/skills/auto/references/human-report-contract.md +3 -5
- 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/conventions/references/tiers-and-dispatch.md +3 -3
- package/skills/doctor/SKILL.md +28 -0
- package/skills/humanizer/SKILL.md +0 -40
- package/skills/humanizer/evals/evals.json +0 -70
- package/skills/humanizer/references/always-on-snippet.md +0 -9
- package/skills/humanizer/references/examples.md +0 -48
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
|
|
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
package/scripts/doctor.rb
CHANGED
|
@@ -13,6 +13,7 @@ require "date"
|
|
|
13
13
|
|
|
14
14
|
require_relative "lib/doctor_core"
|
|
15
15
|
|
|
16
|
+
require_relative "lib/doctor_exclusions"
|
|
16
17
|
require_relative "lib/qmd_sync"
|
|
17
18
|
require_relative "lib/intent_validator"
|
|
18
19
|
require_relative "lib/graph_rebuild"
|
|
@@ -531,17 +532,24 @@ class Doctor
|
|
|
531
532
|
# check_done_signals's store-wide loop (211's territory, unchanged severities) and the new
|
|
532
533
|
# per-intent check (check_intent_end) call, so the two surfaces can never independently
|
|
533
534
|
# drift on what counts as a phantom line or a completeness gap. Returns
|
|
534
|
-
# {conflict:, phantom:, gap:, stalled:} where conflict/phantom/
|
|
535
|
-
# string, and gap
|
|
536
|
-
# "outcome missing" gap and the "audit echo
|
|
537
|
-
# blocks (never elsif), so a single terminal
|
|
538
|
-
# two distinct gap strings in the same pass;
|
|
539
|
-
# silently drop one of the two on a dir where
|
|
540
|
-
# test/doctor_done_signals_test.rb's fixtures before this
|
|
541
|
-
|
|
535
|
+
# {conflict:, phantom:, gap:, operational_gap:, excluded:, stalled:} where conflict/phantom/
|
|
536
|
+
# stalled are nil or a finding string, and gap/operational_gap/excluded are ARRAYs (0, 1, or 2
|
|
537
|
+
# strings): the original inline code pushed the "outcome missing" gap and the "audit echo
|
|
538
|
+
# missing" gap as two SEPARATE, independent `if` blocks (never elsif), so a single terminal
|
|
539
|
+
# dir missing BOTH can legitimately contribute two distinct gap strings in the same pass;
|
|
540
|
+
# collapsing that into one nilable field would silently drop one of the two on a dir where
|
|
541
|
+
# both are true (verified against test/doctor_done_signals_test.rb's fixtures before this
|
|
542
|
+
# extraction, per plan.md Step 3).
|
|
543
|
+
#
|
|
544
|
+
# `excluded_rules:` (intent 274) is the set of rule names the caller's doctor-exclusions file
|
|
545
|
+
# names for this one intent id. A savepoint_operational finding routes to :excluded instead of
|
|
546
|
+
# :operational_gap when "savepoint_operational" is in that set; the :gap bucket (signals_complete,
|
|
547
|
+
# the outcome.md check) never consults it, which is what keeps the exclusion key (intent_id,
|
|
548
|
+
# rule) rather than just intent_id (see test/doctor_done_signals_test.rb case 12).
|
|
549
|
+
def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, active:, excluded_rules: [])
|
|
542
550
|
outcome = File.join(dir, "outcome.md")
|
|
543
551
|
outcome_real = Bridge.stage_file_present?(outcome)
|
|
544
|
-
findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], stalled: nil }
|
|
552
|
+
findings = { conflict: nil, phantom: nil, gap: [], operational_gap: [], excluded: [], stalled: nil }
|
|
545
553
|
|
|
546
554
|
# HARD conflict: the deliverable exists but INDEX still says Active. This
|
|
547
555
|
# is the one true INDEX-wins disagreement, so it stays a fail.
|
|
@@ -578,12 +586,13 @@ def done_signal_findings_for_dir(dir, label:, scope:, dirname:, terminal:, activ
|
|
|
578
586
|
# reconstructible via maintenance-run --tool rebuild-savepoint, so this is repairable and
|
|
579
587
|
# reported as a fixable warn (savepoint_operational).
|
|
580
588
|
savepoint = File.join(dir, "savepoint.md")
|
|
589
|
+
bucket = excluded_rules.include?("savepoint_operational") ? findings[:excluded] : findings[:operational_gap]
|
|
581
590
|
if !File.exist?(savepoint)
|
|
582
|
-
|
|
583
|
-
|
|
591
|
+
bucket << "#{label}: terminal in INDEX but savepoint.md is missing " \
|
|
592
|
+
"entirely (operational - reconstructible)"
|
|
584
593
|
elsif File.read(savepoint) !~ /\bDone\b.*\b(delivered|abandoned)\b/
|
|
585
|
-
|
|
586
|
-
|
|
594
|
+
bucket << "#{label}: terminal in INDEX but savepoint.md has no " \
|
|
595
|
+
"`Done delivered|abandoned` line (operational - reconstructible)"
|
|
587
596
|
end
|
|
588
597
|
|
|
589
598
|
# Stalled completion: unchanged, never consulted amnesty.
|
|
@@ -601,10 +610,20 @@ def check_done_signals(scopes: nil)
|
|
|
601
610
|
conflicts = []
|
|
602
611
|
gaps = [] # delivery-claim (outcome.md) gaps only - legacy, informational
|
|
603
612
|
operational_gaps = [] # savepoint gaps (missing file, or missing Done echo) - repairable
|
|
613
|
+
excluded = [] # savepoint gaps knowingly exempted via doctor-exclusions (intent 274)
|
|
614
|
+
exclusion_errors = [] # malformed doctor-exclusions lines, scope-tagged
|
|
615
|
+
exclusion_error_paths = []
|
|
616
|
+
exclusion_paths = [] # files that actually contributed a live exclusion
|
|
604
617
|
stalled = []
|
|
605
618
|
phantoms = []
|
|
606
619
|
|
|
607
620
|
done_signal_stores(scopes).each do |store|
|
|
621
|
+
exclusions = DoctorExclusions.load(store[:index])
|
|
622
|
+
if exclusions[:errors].any?
|
|
623
|
+
exclusion_errors.concat(exclusions[:errors].map { |e| "#{store[:scope]}: #{e}" })
|
|
624
|
+
exclusion_error_paths << exclusions[:path]
|
|
625
|
+
end
|
|
626
|
+
|
|
608
627
|
index_sections_by_dir(store[:index]).each do |dirname, in_sections|
|
|
609
628
|
dir = File.join(store[:store_dir], dirname)
|
|
610
629
|
next unless File.directory?(dir)
|
|
@@ -612,17 +631,26 @@ def check_done_signals(scopes: nil)
|
|
|
612
631
|
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
613
632
|
active = in_sections.include?("Active") && !terminal
|
|
614
633
|
label = "#{store[:scope]} store/#{dirname}"
|
|
634
|
+
intent_id = dirname.split("--", 2).first
|
|
635
|
+
excluded_rules = DoctorExclusions.rules_for(exclusions, intent_id)
|
|
615
636
|
|
|
616
637
|
findings = done_signal_findings_for_dir(
|
|
617
|
-
dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active
|
|
638
|
+
dir, label: label, scope: store[:scope], dirname: dirname, terminal: terminal, active: active,
|
|
639
|
+
excluded_rules: excluded_rules
|
|
618
640
|
)
|
|
619
641
|
conflicts << findings[:conflict] if findings[:conflict]
|
|
620
642
|
phantoms << findings[:phantom] if findings[:phantom]
|
|
621
643
|
gaps.concat(findings[:gap])
|
|
622
644
|
operational_gaps.concat(findings[:operational_gap])
|
|
645
|
+
if findings[:excluded].any?
|
|
646
|
+
excluded.concat(findings[:excluded])
|
|
647
|
+
exclusion_paths << exclusions[:path]
|
|
648
|
+
end
|
|
623
649
|
stalled << findings[:stalled] if findings[:stalled]
|
|
624
650
|
end
|
|
625
651
|
end
|
|
652
|
+
exclusion_paths.uniq!
|
|
653
|
+
exclusion_error_paths.uniq!
|
|
626
654
|
|
|
627
655
|
checks = []
|
|
628
656
|
|
|
@@ -662,18 +690,39 @@ def check_done_signals(scopes: nil)
|
|
|
662
690
|
)
|
|
663
691
|
end
|
|
664
692
|
|
|
665
|
-
# savepoint_operational (intent 211
|
|
666
|
-
# repairable via maintenance-run --tool
|
|
667
|
-
|
|
693
|
+
# savepoint_operational (intent 211; intent 274 adds the per-store doctor-exclusions index):
|
|
694
|
+
# missing savepoint.md or missing Done echo - repairable via maintenance-run --tool
|
|
695
|
+
# rebuild-savepoint for most gaps, or knowingly excluded for the ones 219 D6 forbids ever
|
|
696
|
+
# repairing (no real outcome.md to echo a disposition from). Three branches (spec D4/D5):
|
|
697
|
+
# a malformed exclusion file can never report pass (loud), a clean remaining gap set reports
|
|
698
|
+
# pass with the exclusion count folded in, and a real remaining gap set stays warn, same as
|
|
699
|
+
# before intent 274, with the same count folded in when exclusions applied.
|
|
700
|
+
exclusion_suffix = excluded.empty? ? "" : " (#{excluded.size} excluded via #{exclusion_paths.join(", ")})"
|
|
701
|
+
|
|
702
|
+
if exclusion_errors.any?
|
|
703
|
+
checks << check(
|
|
704
|
+
category: "done_signals", name: "savepoint_operational", status: "warn",
|
|
705
|
+
message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
|
|
706
|
+
"missing an operational savepoint.md or its Done echo, and " \
|
|
707
|
+
"#{exclusion_errors.size} doctor-exclusions error#{exclusion_errors.size == 1 ? "" : "s"} " \
|
|
708
|
+
"(a malformed exclusion file never suppresses a finding)#{exclusion_suffix}",
|
|
709
|
+
details: operational_gaps + exclusion_errors, fixable: true,
|
|
710
|
+
fix_hint: "Fix the malformed doctor-exclusions file(s) (#{exclusion_error_paths.join(", ")}) - " \
|
|
711
|
+
"format `rule_name id id id`, blank lines and # comments ignored - then reconstruct " \
|
|
712
|
+
"any remaining real gap via `maintenance-run --tool rebuild-savepoint --intent <id> " \
|
|
713
|
+
"--apply` (197-conformant: receipt-before-write via RevisionsWriter, one intent per " \
|
|
714
|
+
"invocation, owner-approval-gated)."
|
|
715
|
+
)
|
|
716
|
+
elsif operational_gaps.empty?
|
|
668
717
|
checks << check(
|
|
669
718
|
category: "done_signals", name: "savepoint_operational", status: "pass",
|
|
670
|
-
message: "No terminal intent is missing an operational savepoint.md or its Done echo"
|
|
719
|
+
message: "No terminal intent is missing an operational savepoint.md or its Done echo#{exclusion_suffix}"
|
|
671
720
|
)
|
|
672
721
|
else
|
|
673
722
|
checks << check(
|
|
674
723
|
category: "done_signals", name: "savepoint_operational", status: "warn",
|
|
675
724
|
message: "#{operational_gaps.size} terminal intent#{operational_gaps.size == 1 ? "" : "s"} " \
|
|
676
|
-
"missing an operational savepoint.md or its Done echo (reconstructible)",
|
|
725
|
+
"missing an operational savepoint.md or its Done echo (reconstructible)#{exclusion_suffix}",
|
|
677
726
|
details: operational_gaps, fixable: true,
|
|
678
727
|
fix_hint: "Reconstruct the minimal two-line started/Done echo via " \
|
|
679
728
|
"`maintenance-run --tool rebuild-savepoint --intent <id> --apply` (197-conformant: " \
|
|
@@ -32,7 +32,9 @@ class Doctor
|
|
|
32
32
|
# (intent 204): plastic-statusline is the settings["statusLine"] command, wired
|
|
33
33
|
# outside HookRegistry.events entirely, so it must be excluded from the
|
|
34
34
|
# orphan-launcher scan below or a correct install would report a false orphan.
|
|
35
|
-
|
|
35
|
+
# Defined in HookRegistry (intent 275) so the installer's purge can read it too;
|
|
36
|
+
# this is an alias, not a second source of truth.
|
|
37
|
+
CLAUDE_NON_HOOK_LAUNCHERS = HookRegistry::CLAUDE_NON_HOOK_LAUNCHERS
|
|
36
38
|
|
|
37
39
|
REQUIRED_SCRIPTS = %w[
|
|
38
40
|
folgezettel-id
|
|
@@ -243,7 +245,10 @@ class Doctor
|
|
|
243
245
|
message: "#{orphans.size} hook launcher(s) on disk are not registered in HookRegistry",
|
|
244
246
|
details: orphans.map { |h| "#{tilde(hooks_dir)}/#{h}" },
|
|
245
247
|
fixable: true,
|
|
246
|
-
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes
|
|
248
|
+
fix_hint: "Re-run the Plastic installer: npx @zalom/plastic@latest --claude (prunes " \
|
|
249
|
+
"stale launchers). The plastic- prefix is reserved for Plastic's own hooks: " \
|
|
250
|
+
"if one of these is yours, rename it (for example to ~/.claude/hooks/" \
|
|
251
|
+
"writing-style) and re-register it in settings.json before re-running."
|
|
247
252
|
)
|
|
248
253
|
end
|
|
249
254
|
|
|
@@ -271,7 +276,7 @@ class Doctor
|
|
|
271
276
|
|
|
272
277
|
groups.any? do |group|
|
|
273
278
|
group.is_a?(Hash) && group["hooks"].is_a?(Array) &&
|
|
274
|
-
group["hooks"].any? { |h| h["command"]
|
|
279
|
+
group["hooks"].any? { |h| HookRegistry.claude_purge_command?(h["command"]) }
|
|
275
280
|
end
|
|
276
281
|
end
|
|
277
282
|
|
|
@@ -309,7 +314,7 @@ class Doctor
|
|
|
309
314
|
live_plastic = (settings["hooks"] || {}).flat_map do |event, groups|
|
|
310
315
|
Array(groups).flat_map do |g|
|
|
311
316
|
next [] unless g.is_a?(Hash) && g["hooks"].is_a?(Array)
|
|
312
|
-
g["hooks"].map { |h| h["command"].to_s }.select { |c|
|
|
317
|
+
g["hooks"].map { |h| h["command"].to_s }.select { |c| HookRegistry.claude_purge_command?(c) }
|
|
313
318
|
.map { |c| "#{event}: #{c}" }
|
|
314
319
|
end
|
|
315
320
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "rule_catalog"
|
|
5
|
+
|
|
6
|
+
# DoctorExclusions - resolves, reads, and parses one store's per-store `doctor-exclusions`
|
|
7
|
+
# table (intent 274): the record of knowingly-exempt (intent_id, rule) pairs that lets doctor
|
|
8
|
+
# skip a finding it can never legitimately repair (219 D6 forbids inventing a disposition for
|
|
9
|
+
# an unrepairable gap).
|
|
10
|
+
#
|
|
11
|
+
# Location (spec D6): sibling to that store's INDEX.md, resolved via `path_for` from the same
|
|
12
|
+
# `store[:index]` Doctor#done_signal_stores already yields - zero new store-discovery logic.
|
|
13
|
+
# Deliberately no `.md` extension: this is a config table, not a markdown document indexed by
|
|
14
|
+
# QMD or walked by lifecycle machinery.
|
|
15
|
+
#
|
|
16
|
+
# Format (spec D6), `/etc/hosts`-shaped: `rule_name id id id`, one rule per line. Blank lines
|
|
17
|
+
# and `#`-comment lines are ignored. Duplicate rule lines union their ids.
|
|
18
|
+
#
|
|
19
|
+
# Error contract (spec D5): fail open, loud in doctor. A missing file is the normal case -
|
|
20
|
+
# zero exclusions, zero errors, identical to before this file existed. A malformed line never
|
|
21
|
+
# excludes anything (fail milder than the bug: a typo must not silently suppress a real
|
|
22
|
+
# regression) and contributes one error string naming its 1-based line number. An unreadable
|
|
23
|
+
# file contributes one error and zero exclusions. This module NEVER raises.
|
|
24
|
+
module DoctorExclusions
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
FILENAME = "doctor-exclusions"
|
|
28
|
+
|
|
29
|
+
# Same shape test/packaging_no_store_ids_test.rb already defines for a real Folgezettel id:
|
|
30
|
+
# digit-leading, then any mix of letters and digits.
|
|
31
|
+
FOLGEZETTEL_ID = /\A\d+[a-zA-Z0-9]*\z/
|
|
32
|
+
|
|
33
|
+
def path_for(index_path)
|
|
34
|
+
File.join(File.dirname(index_path), FILENAME)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# PURE. { rules: { rule_name => [ids] }, errors: [String] }. A line producing any error
|
|
38
|
+
# contributes nothing to rules; duplicate rule lines union their ids without an error.
|
|
39
|
+
#
|
|
40
|
+
# `scrub` (never raises) before any regex/String op: a hand-edited file can carry a byte
|
|
41
|
+
# sequence invalid in its declared encoding (e.g. a stray Latin-1 byte in a comment), and
|
|
42
|
+
# String#strip/split/=~ all raise Encoding::CompatibilityError on that input. Scrubbing
|
|
43
|
+
# replaces the invalid byte with U+FFFD and keeps this module's never-raises contract (D5)
|
|
44
|
+
# true for every input, not just well-formed UTF-8.
|
|
45
|
+
def parse(text)
|
|
46
|
+
rules = {}
|
|
47
|
+
errors = []
|
|
48
|
+
|
|
49
|
+
text.to_s.scrub.each_line.with_index(1) do |raw_line, n|
|
|
50
|
+
line = raw_line.strip
|
|
51
|
+
next if line.empty? || line.start_with?("#")
|
|
52
|
+
|
|
53
|
+
line = line.sub(/(?:\A|\s)#.*\z/, "").rstrip
|
|
54
|
+
tokens = line.split
|
|
55
|
+
rule = tokens.shift
|
|
56
|
+
line_errors = []
|
|
57
|
+
|
|
58
|
+
line_errors << "line #{n}: rule \"#{rule}\" lists no intent ids" if tokens.empty?
|
|
59
|
+
unless RuleCatalog.excludable_check?(rule)
|
|
60
|
+
line_errors << "line #{n}: unknown or non-excludable rule \"#{rule}\""
|
|
61
|
+
end
|
|
62
|
+
tokens.each do |tok|
|
|
63
|
+
line_errors << "line #{n}: \"#{tok}\" is not a Folgezettel intent id" unless tok =~ FOLGEZETTEL_ID
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
if line_errors.empty?
|
|
67
|
+
(rules[rule] ||= []).concat(tokens)
|
|
68
|
+
rules[rule].uniq!
|
|
69
|
+
else
|
|
70
|
+
errors.concat(line_errors)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{ rules: rules, errors: errors }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# IO. `parse`'s shape plus `path:`. Never raises: a missing file is the normal case (zero
|
|
78
|
+
# exclusions, zero errors); an unreadable file (permission, is-a-directory, any
|
|
79
|
+
# SystemCallError) yields one error and zero exclusions.
|
|
80
|
+
def load(index_path)
|
|
81
|
+
path = path_for(index_path)
|
|
82
|
+
return { rules: {}, errors: [], path: path } unless File.exist?(path)
|
|
83
|
+
|
|
84
|
+
parse(File.read(path)).merge(path: path)
|
|
85
|
+
rescue SystemCallError => e
|
|
86
|
+
{ rules: {}, errors: ["#{path}: unreadable (#{e.message})"], path: path }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Rule names excluding `intent_id` in an already-`load`ed result. [] when none.
|
|
90
|
+
def rules_for(loaded, intent_id)
|
|
91
|
+
loaded[:rules].select { |_rule, ids| ids.include?(intent_id) }.keys
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -186,6 +186,90 @@ module HookRegistry
|
|
|
186
186
|
.uniq.sort.map { |name| "plastic-#{name}" }
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# Launchers the installer places in the agent's hooks dir that `events` does not
|
|
190
|
+
# register (intent 204): plastic-statusline is the settings["statusLine"] command.
|
|
191
|
+
# Defined here rather than in doctor_core so the installer's purge can recognise it
|
|
192
|
+
# without depending on the doctor; Doctor::CLAUDE_NON_HOOK_LAUNCHERS aliases it.
|
|
193
|
+
CLAUDE_NON_HOOK_LAUNCHERS = %w[plastic-statusline].freeze
|
|
194
|
+
|
|
195
|
+
# Hook names Plastic HAS registered and no longer does (intent 275). Purge-only:
|
|
196
|
+
# an old install still carries these entries in settings.json / hooks.json, and
|
|
197
|
+
# nothing else can tell us they were ever ours.
|
|
198
|
+
#
|
|
199
|
+
# MAINTENANCE DUTY: renaming or removing a hook from `events` means adding its old
|
|
200
|
+
# name here in the SAME change. Skip it and every existing install keeps a dead
|
|
201
|
+
# registration no update will ever clean up.
|
|
202
|
+
#
|
|
203
|
+
# Never fold these into claude_launcher_names: that method is what doctor's
|
|
204
|
+
# hooks_exist demands be present on disk, so a retired name there makes a correct
|
|
205
|
+
# install report missing launchers.
|
|
206
|
+
RETIRED_HOOK_NAMES = %w[
|
|
207
|
+
code-gate create-gate links-gate lock-gate savepoint-pre
|
|
208
|
+
qmd-search retrieval-gate model-instructions opus-manual
|
|
209
|
+
].freeze
|
|
210
|
+
|
|
211
|
+
RETIRED_CLAUDE_LAUNCHERS = RETIRED_HOOK_NAMES.map { |n| "plastic-#{n}" }.freeze
|
|
212
|
+
|
|
213
|
+
# Filenames of Plastic's Codex dispatcher, current and retired. Codex hooks are
|
|
214
|
+
# not per-hook launcher files: every command is `"<dispatcher>" <name>`, so the
|
|
215
|
+
# dispatcher's own filename is what identifies an entry as ours.
|
|
216
|
+
CODEX_DISPATCHER_BASENAMES = %w[codex-hook].freeze
|
|
217
|
+
|
|
218
|
+
# Every launcher name the installer may purge from settings.json: what we register
|
|
219
|
+
# now, the non-hook launchers we place, and what we used to register.
|
|
220
|
+
def claude_purgeable_launcher_names
|
|
221
|
+
(claude_launcher_names + CLAUDE_NON_HOOK_LAUNCHERS + RETIRED_CLAUDE_LAUNCHERS).uniq.sort
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Current Codex hook names, from the same sources codex_hooks_json builds from.
|
|
225
|
+
def codex_hook_names
|
|
226
|
+
live = CODEX_LIVE_STATE_EVENTS.flat_map do |event|
|
|
227
|
+
events[event].flat_map { |g| g["hooks"].map { |h| h["name"] } }
|
|
228
|
+
end
|
|
229
|
+
(CODEX_PRE_HOOKS + CODEX_POST_HOOKS + CODEX_BASH_HOOKS + live).uniq.sort
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def codex_purgeable_hook_names
|
|
233
|
+
(codex_hook_names + RETIRED_HOOK_NAMES).uniq.sort
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Is this settings.json hook command one of OURS? (intent 275)
|
|
237
|
+
#
|
|
238
|
+
# Ownership is registry membership, never a substring: the substring test this
|
|
239
|
+
# replaced deleted a user's own ~/.claude/hooks/plastic-writing-style hook on
|
|
240
|
+
# update. Tokenised rather than first-token-only because legacy entries take the
|
|
241
|
+
# form `ruby <path>/plastic-<name>.rb`, and those must still be purged.
|
|
242
|
+
def claude_purge_command?(cmd)
|
|
243
|
+
known = claude_purgeable_launcher_names
|
|
244
|
+
command_basenames(cmd).any? { |name| known.include?(name) }
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Is this ~/.codex/hooks.json command one of ours? Every Plastic Codex entry
|
|
248
|
+
# invokes our dispatcher by path (`"<plastic_home>/scripts/codex-hook" <name>`),
|
|
249
|
+
# so the dispatcher's filename identifies it. Basename EQUALITY, so a user's
|
|
250
|
+
# ~/bin/codex-hook-wrapper is not ours; the argument is not filtered on, because
|
|
251
|
+
# a command that already runs our dispatcher is ours whatever gate it names, and
|
|
252
|
+
# filtering would strand any name we forgot to retire.
|
|
253
|
+
#
|
|
254
|
+
# Tokenised the same way claude_purge_command? is (command_basenames), NOT a
|
|
255
|
+
# naive cmd.split.first: a first-token split breaks whenever plastic_home
|
|
256
|
+
# contains a space, since the shell-quoted dispatcher path then splits across
|
|
257
|
+
# multiple whitespace tokens and the true first token is only half the path.
|
|
258
|
+
# command_basenames strips quote characters per token, so whichever token
|
|
259
|
+
# carries the dispatcher's trailing `/codex-hook"` still resolves to the bare
|
|
260
|
+
# basename "codex-hook" after its trailing quote is stripped.
|
|
261
|
+
def codex_purge_command?(cmd)
|
|
262
|
+
command_basenames(cmd).any? { |name| CODEX_DISPATCHER_BASENAMES.include?(name) }
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Each whitespace-separated token reduced to a comparable launcher name:
|
|
266
|
+
# quotes stripped, directories dropped, a trailing .rb removed.
|
|
267
|
+
def command_basenames(cmd)
|
|
268
|
+
cmd.to_s.split(/\s+/).reject(&:empty?).map do |token|
|
|
269
|
+
File.basename(token.delete("\"'")).sub(/\.rb\z/, "")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
189
273
|
# The settings.json shape merge_claude_hooks expects: single-group events map
|
|
190
274
|
# to a Hash, multi-group events to an Array (the merge loop handles both).
|
|
191
275
|
def claude_settings_hooks(hook_dir:)
|
|
@@ -206,7 +206,7 @@ class InstallerCore
|
|
|
206
206
|
def statusline_choice(settings_path, argv: [], input: $stdin, reinstall: false)
|
|
207
207
|
existing_command = read_json_safe(settings_path)&.dig("statusLine", "command").to_s
|
|
208
208
|
return :plastic if existing_command.empty?
|
|
209
|
-
return :plastic if
|
|
209
|
+
return :plastic if HookRegistry.claude_purge_command?(existing_command)
|
|
210
210
|
|
|
211
211
|
idx = argv.index("--statusline")
|
|
212
212
|
flag = idx && argv[idx + 1]
|
|
@@ -412,6 +412,8 @@ class InstallerCore
|
|
|
412
412
|
"scripts/exec-worktree" => "scripts/exec-worktree",
|
|
413
413
|
"scripts/doctor.rb" => "scripts/doctor.rb",
|
|
414
414
|
"scripts/lib/doctor_core.rb" => "scripts/lib/doctor_core.rb",
|
|
415
|
+
"scripts/lib/rule_catalog.rb" => "scripts/lib/rule_catalog.rb",
|
|
416
|
+
"scripts/lib/doctor_exclusions.rb" => "scripts/lib/doctor_exclusions.rb",
|
|
415
417
|
"scripts/dashboard.rb" => "scripts/dashboard.rb",
|
|
416
418
|
"scripts/skill-lint" => "scripts/skill-lint",
|
|
417
419
|
"scripts/lib/skill_lint.rb" => "scripts/lib/skill_lint.rb",
|
|
@@ -941,14 +943,16 @@ class InstallerCore
|
|
|
941
943
|
|
|
942
944
|
# ~/.codex/hooks.json merge (intent 102). Guide-settled shape [guide Part 3]:
|
|
943
945
|
# top-level {"hooks": {<Event>: [...]}}, identical to Claude's settings.json
|
|
944
|
-
# hooks shape, so this mirrors merge_claude_hooks against a different file
|
|
945
|
-
#
|
|
946
|
-
#
|
|
947
|
-
#
|
|
946
|
+
# hooks shape, so this mirrors merge_claude_hooks against a different file.
|
|
947
|
+
# Both harnesses now match ownership by registry (intent 275), not a
|
|
948
|
+
# substring: Codex by dispatcher filename equality, because its hooks are
|
|
949
|
+
# arguments to one shared dispatcher command rather than per-hook launcher
|
|
950
|
+
# files the way Claude's plastic-<name> launchers are.
|
|
948
951
|
def merge_codex_hooks(hooks_json_path)
|
|
949
952
|
data = read_json_safe(hooks_json_path) || {}
|
|
950
953
|
hooks = data["hooks"] ||= {}
|
|
951
|
-
purge_stale_codex_hooks(hooks)
|
|
954
|
+
removed = purge_stale_codex_hooks(hooks)
|
|
955
|
+
report_removed_hook_entries(removed, "hooks.json")
|
|
952
956
|
plastic = HookRegistry.codex_hooks_json(dispatcher_path: codex_dispatcher_path)
|
|
953
957
|
plastic.each do |event, groups|
|
|
954
958
|
hooks[event] ||= []
|
|
@@ -957,23 +961,34 @@ class InstallerCore
|
|
|
957
961
|
write_json_atomic(hooks_json_path, data)
|
|
958
962
|
end
|
|
959
963
|
|
|
964
|
+
# Returns [[event, command], ...] for every entry removed, mirroring
|
|
965
|
+
# purge_stale_plastic_hooks. Ownership comes from HookRegistry (intent 275).
|
|
960
966
|
def purge_stale_codex_hooks(hooks)
|
|
961
|
-
|
|
967
|
+
removed = []
|
|
962
968
|
|
|
963
969
|
hooks.each do |event, groups|
|
|
964
970
|
next unless groups.is_a?(Array)
|
|
965
971
|
|
|
966
972
|
hooks[event] = groups.map do |group|
|
|
967
973
|
if group.is_a?(Hash) && group["hooks"].is_a?(Array)
|
|
968
|
-
group["hooks"].reject!
|
|
974
|
+
group["hooks"].reject! do |h|
|
|
975
|
+
HookRegistry.codex_purge_command?(h["command"]) && (removed << [event, h["command"]])
|
|
976
|
+
end
|
|
969
977
|
group unless group["hooks"].empty?
|
|
970
978
|
elsif group.is_a?(Hash) && group["command"]
|
|
971
|
-
|
|
979
|
+
if HookRegistry.codex_purge_command?(group["command"])
|
|
980
|
+
removed << [event, group["command"]]
|
|
981
|
+
nil
|
|
982
|
+
else
|
|
983
|
+
group
|
|
984
|
+
end
|
|
972
985
|
else
|
|
973
986
|
group
|
|
974
987
|
end
|
|
975
988
|
end.compact
|
|
976
989
|
end
|
|
990
|
+
|
|
991
|
+
removed
|
|
977
992
|
end
|
|
978
993
|
|
|
979
994
|
def install_hermes(config, force)
|
|
@@ -1202,6 +1217,32 @@ class InstallerCore
|
|
|
1202
1217
|
path.sub(Dir.home, "~")
|
|
1203
1218
|
end
|
|
1204
1219
|
|
|
1220
|
+
def report_removed_hook_entries(removed, file_label)
|
|
1221
|
+
return if removed.nil? || removed.empty?
|
|
1222
|
+
|
|
1223
|
+
puts " \u{1f9f9} Removed #{removed.size} stale Plastic hook entr#{removed.size == 1 ? "y" : "ies"} from #{file_label}:"
|
|
1224
|
+
removed.each { |event, command| puts " - #{event}: #{tilde(command.to_s)}" }
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
# The other half of intent 275: a hook the purge KEPT because the registry does not
|
|
1228
|
+
# know it, but whose name carries Plastic's prefix. Silence here is what let the
|
|
1229
|
+
# 1.11.0 update delete the owner's plastic-writing-style hook unnoticed; now the
|
|
1230
|
+
# update says the prefix is reserved and the hook stays.
|
|
1231
|
+
def report_reserved_prefix_hooks(hooks)
|
|
1232
|
+
kept = hooks.flat_map do |_event, groups|
|
|
1233
|
+
next [] unless groups.is_a?(Array)
|
|
1234
|
+
|
|
1235
|
+
groups.flat_map { |g| g.is_a?(Hash) ? Array(g["hooks"]).map { |h| h["command"] } + [g["command"]] : [] }
|
|
1236
|
+
end.compact.select { |cmd| cmd.to_s.include?("plastic-") }.uniq
|
|
1237
|
+
|
|
1238
|
+
return if kept.empty?
|
|
1239
|
+
|
|
1240
|
+
puts " \u{2139}\u{fe0f} Kept #{kept.size} hook(s) Plastic does not own, named with the reserved plastic- prefix:"
|
|
1241
|
+
kept.each { |cmd| puts " - #{tilde(cmd.to_s)}" }
|
|
1242
|
+
puts " The plastic- prefix is reserved for Plastic's own hooks. Rename yours (for"
|
|
1243
|
+
puts " example ~/.claude/hooks/writing-style) so a future update never mistakes it."
|
|
1244
|
+
end
|
|
1245
|
+
|
|
1205
1246
|
# --- settings.json merge (read-modify-write, never clobber) ---
|
|
1206
1247
|
|
|
1207
1248
|
def merge_claude_hooks(settings_path, choice: :plastic)
|
|
@@ -1211,7 +1252,9 @@ class InstallerCore
|
|
|
1211
1252
|
hooks = settings["hooks"] ||= {}
|
|
1212
1253
|
hook_dir = File.join(Dir.home, ".claude", "hooks")
|
|
1213
1254
|
|
|
1214
|
-
purge_stale_plastic_hooks(hooks)
|
|
1255
|
+
removed = purge_stale_plastic_hooks(hooks)
|
|
1256
|
+
report_removed_hook_entries(removed, "settings.json")
|
|
1257
|
+
report_reserved_prefix_hooks(hooks)
|
|
1215
1258
|
|
|
1216
1259
|
# Single source of truth (intent 108, D7): registrations live in
|
|
1217
1260
|
# HookRegistry; this merge only translates them into settings.json.
|
|
@@ -1228,7 +1271,7 @@ class InstallerCore
|
|
|
1228
1271
|
groups.each do |g|
|
|
1229
1272
|
existing = hooks[event].find do |h|
|
|
1230
1273
|
h.is_a?(Hash) && h["matcher"] == g["matcher"] &&
|
|
1231
|
-
h["hooks"].is_a?(Array) && h["hooks"].any? { |x| x["command"]
|
|
1274
|
+
h["hooks"].is_a?(Array) && h["hooks"].any? { |x| HookRegistry.claude_purge_command?(x["command"]) }
|
|
1232
1275
|
end
|
|
1233
1276
|
|
|
1234
1277
|
if existing
|
|
@@ -1240,7 +1283,7 @@ class InstallerCore
|
|
|
1240
1283
|
end
|
|
1241
1284
|
|
|
1242
1285
|
existing_status = settings["statusLine"]
|
|
1243
|
-
if existing_status && !
|
|
1286
|
+
if existing_status && !HookRegistry.claude_purge_command?(existing_status["command"])
|
|
1244
1287
|
cache_dir = File.join(plastic_home, ".cache")
|
|
1245
1288
|
FileUtils.mkdir_p(cache_dir)
|
|
1246
1289
|
File.write(File.join(cache_dir, "original-statusline.json"), JSON.pretty_generate(existing_status))
|
|
@@ -1254,9 +1297,10 @@ class InstallerCore
|
|
|
1254
1297
|
write_json_atomic(settings_path, settings)
|
|
1255
1298
|
end
|
|
1256
1299
|
|
|
1300
|
+
# Returns [[event, command], ...] for every entry removed, so merge_claude_hooks
|
|
1301
|
+
# can report it. Ownership comes from HookRegistry (intent 275), never a substring.
|
|
1257
1302
|
def purge_stale_plastic_hooks(hooks)
|
|
1258
|
-
|
|
1259
|
-
|
|
1303
|
+
removed = []
|
|
1260
1304
|
hooks.delete("statusLine")
|
|
1261
1305
|
|
|
1262
1306
|
hooks.each do |event, groups|
|
|
@@ -1264,15 +1308,24 @@ class InstallerCore
|
|
|
1264
1308
|
|
|
1265
1309
|
hooks[event] = groups.map do |group|
|
|
1266
1310
|
if group.is_a?(Hash) && group["hooks"].is_a?(Array)
|
|
1267
|
-
group["hooks"].reject!
|
|
1311
|
+
group["hooks"].reject! do |h|
|
|
1312
|
+
HookRegistry.claude_purge_command?(h["command"]) && (removed << [event, h["command"]])
|
|
1313
|
+
end
|
|
1268
1314
|
group unless group["hooks"].empty?
|
|
1269
1315
|
elsif group.is_a?(Hash) && group["command"]
|
|
1270
|
-
|
|
1316
|
+
if HookRegistry.claude_purge_command?(group["command"])
|
|
1317
|
+
removed << [event, group["command"]]
|
|
1318
|
+
nil
|
|
1319
|
+
else
|
|
1320
|
+
group
|
|
1321
|
+
end
|
|
1271
1322
|
else
|
|
1272
1323
|
group
|
|
1273
1324
|
end
|
|
1274
1325
|
end.compact
|
|
1275
1326
|
end
|
|
1327
|
+
|
|
1328
|
+
removed
|
|
1276
1329
|
end
|
|
1277
1330
|
|
|
1278
1331
|
# --- Codex AGENTS.md marked-section injection (22a/Beads pattern) ---
|
|
@@ -1445,10 +1498,10 @@ class InstallerCore
|
|
|
1445
1498
|
|
|
1446
1499
|
settings["hooks"][event] = groups.map do |group|
|
|
1447
1500
|
if group.is_a?(Hash) && group["hooks"].is_a?(Array)
|
|
1448
|
-
group["hooks"].reject! { |h| h["command"]
|
|
1501
|
+
group["hooks"].reject! { |h| HookRegistry.claude_purge_command?(h["command"]) }
|
|
1449
1502
|
group unless group["hooks"].empty?
|
|
1450
1503
|
elsif group.is_a?(Hash) && group["command"]
|
|
1451
|
-
group["command"]
|
|
1504
|
+
HookRegistry.claude_purge_command?(group["command"]) ? nil : group
|
|
1452
1505
|
else
|
|
1453
1506
|
group
|
|
1454
1507
|
end
|
|
@@ -1457,7 +1510,7 @@ class InstallerCore
|
|
|
1457
1510
|
|
|
1458
1511
|
settings["hooks"].delete_if { |_, v| v.is_a?(Array) && v.empty? }
|
|
1459
1512
|
settings.delete("hooks") if settings["hooks"]&.empty?
|
|
1460
|
-
if settings.dig("statusLine", "command")
|
|
1513
|
+
if HookRegistry.claude_purge_command?(settings.dig("statusLine", "command"))
|
|
1461
1514
|
settings.delete("statusLine")
|
|
1462
1515
|
original_path = File.join(plastic_home, ".cache", "original-statusline.json")
|
|
1463
1516
|
if File.exist?(original_path)
|
|
@@ -1491,7 +1544,7 @@ class InstallerCore
|
|
|
1491
1544
|
data["hooks"][event] = groups.map do |g|
|
|
1492
1545
|
next g unless g.is_a?(Hash) && Array(g["hooks"]).is_a?(Array)
|
|
1493
1546
|
|
|
1494
|
-
g["hooks"] = Array(g["hooks"]).reject { |h| h["command"]
|
|
1547
|
+
g["hooks"] = Array(g["hooks"]).reject { |h| HookRegistry.codex_purge_command?(h["command"]) }
|
|
1495
1548
|
g["hooks"].empty? ? nil : g
|
|
1496
1549
|
end.compact
|
|
1497
1550
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# RuleCatalog - the one rule vocabulary in Plastic (intent 274), two curated named sets on two
|
|
5
|
+
# different axes so there is exactly one place to look up or register a rule name:
|
|
6
|
+
#
|
|
7
|
+
# EXCLUDABLE_CHECKS - a doctor check `name` a per-store doctor-exclusions file may name. A
|
|
8
|
+
# check name says WHICH DIAGNOSTIC FIRED. v1 carries exactly one key, savepoint_operational
|
|
9
|
+
# (see spec D3): most doctor checks have no exclusion mechanism at all, and this is the only
|
|
10
|
+
# one the owner asked to make skippable.
|
|
11
|
+
#
|
|
12
|
+
# REVISION_RULES - the `[rule: <tag>]` vocabulary every revisions.md entry must carry
|
|
13
|
+
# (scripts/lib/revisions_writer.rb). A tag says WHY an intent's files were structurally
|
|
14
|
+
# edited. Most check names have no repair verb and most repair verbs are not checks, so this
|
|
15
|
+
# is a genuinely separate axis, not an alias of EXCLUDABLE_CHECKS.
|
|
16
|
+
#
|
|
17
|
+
# REVISION_RULES is enforced by test only (test/rule_catalog_test.rb), never at
|
|
18
|
+
# RevisionsWriter runtime (spec D2): a receipt writer that refuses to write on an unrecognized
|
|
19
|
+
# tag is a guard that fails harder than the bug it would be catching, so append! keeps
|
|
20
|
+
# accepting any tag and the test catches an unregistered one before it ships.
|
|
21
|
+
#
|
|
22
|
+
# Zero requires (boot-safe): nothing here pulls in json/yaml/io, so this file can load from
|
|
23
|
+
# anywhere, including the SessionStart boot path, without cost.
|
|
24
|
+
#
|
|
25
|
+
# Packaging note (test/packaging_no_store_ids_test.rb): every token below is letter-leading,
|
|
26
|
+
# never digit-leading, so nothing here trips the Folgezettel-id-literal scan. Never put an
|
|
27
|
+
# intent id in this file.
|
|
28
|
+
module RuleCatalog
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
EXCLUDABLE_CHECKS = {
|
|
32
|
+
"savepoint_operational" => "savepoint.md missing entirely, or missing its Done " \
|
|
33
|
+
"delivered|abandoned echo, on a terminal intent. Usually " \
|
|
34
|
+
"repairable via maintenance-run --tool rebuild-savepoint; " \
|
|
35
|
+
"excludable for the gaps 219 D6 forbids ever repairing.",
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
# Measured from live store data across all eight stores, 2026-08-24 (spec D1).
|
|
39
|
+
REVISION_RULES = [
|
|
40
|
+
"links-projection",
|
|
41
|
+
"broken-chain",
|
|
42
|
+
"stray-file",
|
|
43
|
+
"savepoint-operational-reconstruction",
|
|
44
|
+
"unsanctioned-section",
|
|
45
|
+
"missing-reciprocity",
|
|
46
|
+
"misplaced-content",
|
|
47
|
+
"missing-required-frontmatter",
|
|
48
|
+
"savepoint-truthfulness",
|
|
49
|
+
"restored-to-v1",
|
|
50
|
+
"relocation",
|
|
51
|
+
"graph-rebuild",
|
|
52
|
+
"dangling-ref",
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
def excludable_check?(name)
|
|
56
|
+
EXCLUDABLE_CHECKS.key?(name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def revision_rule?(tag)
|
|
60
|
+
REVISION_RULES.include?(tag)
|
|
61
|
+
end
|
|
62
|
+
end
|
package/scripts/maintenance-run
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
# maintenance-run --tool rebuild-graph [--plastic-home PATH] [--apply]
|
|
16
16
|
# maintenance-run --tool restore-intent-v1 <id> --at <ref> [--plastic-home PATH] [--apply] [--skip-links]
|
|
17
17
|
# maintenance-run --tool rebuild-savepoint --intent <id> [--store <key>] [--plastic-home PATH] [--apply]
|
|
18
|
+
# maintenance-run --tool register-exclusions [--rule <name>] [--store <key>] [--plastic-home PATH] [--apply]
|
|
18
19
|
#
|
|
19
20
|
# project-links here is ALWAYS single-intent: --intent is required. A store-wide
|
|
20
21
|
# project-links sweep is the rare, owner-approved batch exception (D2) and is run directly
|
|
@@ -38,6 +39,7 @@ require_relative "lib/lock"
|
|
|
38
39
|
require_relative "lib/maintenance_git"
|
|
39
40
|
require_relative "lib/bridge"
|
|
40
41
|
require_relative "lib/revisions_writer"
|
|
42
|
+
require_relative "doctor" # safe: doctor.rb's CLI is behind $PROGRAM_NAME == __FILE__
|
|
41
43
|
|
|
42
44
|
DEFAULT_HOME = File.join(Dir.home, ".plastic")
|
|
43
45
|
|
|
@@ -75,7 +77,7 @@ end
|
|
|
75
77
|
|
|
76
78
|
def parse_argv(argv)
|
|
77
79
|
opts = { tool: nil, intent: nil, store: nil, plastic_home: DEFAULT_HOME, apply: false,
|
|
78
|
-
at: nil, skip_links: false, id: nil }
|
|
80
|
+
at: nil, skip_links: false, id: nil, rule: nil }
|
|
79
81
|
i = 0
|
|
80
82
|
while i < argv.length
|
|
81
83
|
case argv[i]
|
|
@@ -86,6 +88,7 @@ def parse_argv(argv)
|
|
|
86
88
|
when "--apply" then opts[:apply] = true
|
|
87
89
|
when "--at" then opts[:at] = argv[i += 1]
|
|
88
90
|
when "--skip-links" then opts[:skip_links] = true
|
|
91
|
+
when "--rule" then opts[:rule] = argv[i += 1]
|
|
89
92
|
else
|
|
90
93
|
opts[:id] ||= argv[i] # positional id, restore-intent-v1 only
|
|
91
94
|
end
|
|
@@ -269,9 +272,138 @@ def run_rebuild_savepoint(home, intent, store, apply)
|
|
|
269
272
|
report_result(result)
|
|
270
273
|
end
|
|
271
274
|
|
|
275
|
+
# Renders one store's canonical doctor-exclusions content (spec D6/D7): a leading comment
|
|
276
|
+
# block documenting the format, then one `rule_name id id id` line per rule, ids sorted. Pure.
|
|
277
|
+
# `existing_text:` (review F2) is the file's raw content BEFORE this run, or nil when the
|
|
278
|
+
# file does not exist yet. A hand-edited exclusion file's comments are the only home for an
|
|
279
|
+
# exemption's justification (D8: no revisions.md receipt exists for this tool), so an update
|
|
280
|
+
# preserves every comment and blank line from `existing_text` verbatim, in original order,
|
|
281
|
+
# ahead of the freshly rendered rule lines - it never re-renders over them. The boilerplate
|
|
282
|
+
# header is written only when there is no existing file to preserve anything from.
|
|
283
|
+
def render_exclusions_file(rules, existing_text: nil)
|
|
284
|
+
if existing_text
|
|
285
|
+
lines = existing_text.each_line.select { |l| l.strip.empty? || l.strip.start_with?("#") }.map(&:chomp)
|
|
286
|
+
else
|
|
287
|
+
lines = [
|
|
288
|
+
"# doctor-exclusions - knowingly-exempt (intent_id, rule) pairs (intent 274).",
|
|
289
|
+
"# Format: rule_name id id id (one line per rule, ids space-separated).",
|
|
290
|
+
"# Blank lines and lines starting with # are ignored. Ids only, never any other",
|
|
291
|
+
"# content - doctor reports how many findings this file suppressed and where",
|
|
292
|
+
"# this file lives, so the count always stays honest.",
|
|
293
|
+
"",
|
|
294
|
+
]
|
|
295
|
+
end
|
|
296
|
+
rules.keys.sort.each do |rule_name|
|
|
297
|
+
lines << "#{rule_name} #{rules[rule_name].sort.join(" ")}"
|
|
298
|
+
end
|
|
299
|
+
"#{lines.join("\n")}\n"
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# register-exclusions (intent 274, spec D7/D8): the one-time population tool. Computes
|
|
303
|
+
# violations through Doctor's OWN done_signal_findings_for_dir rather than a reimplementation
|
|
304
|
+
# of that predicate (the drift 222 extracted that function to prevent), unions with any
|
|
305
|
+
# existing hand-edited file, skips (never aborts on) any intent dir holding a fresh delivery
|
|
306
|
+
# lock, and writes ALL stores in one scoped commit (they already live in the single ~/.plastic
|
|
307
|
+
# git repo, so a cross-store write is still one repo and one scoped commit - D7).
|
|
308
|
+
#
|
|
309
|
+
# Writes NO revisions.md entries (D8): this tool modifies no intent directory, only one
|
|
310
|
+
# store-level table per store, so 197's receipt-before-write rule (which covers tools that
|
|
311
|
+
# structurally edit an intent's OWN files) does not apply, and writing one would mean editing
|
|
312
|
+
# every touched Completed intent directory - forbidden, completed intents are immutable. The
|
|
313
|
+
# scoped commit plus the diffable exclusion file itself are the receipt.
|
|
314
|
+
def run_register_exclusions(home, rule, store, apply)
|
|
315
|
+
rule ||= "savepoint_operational"
|
|
316
|
+
unless RuleCatalog.excludable_check?(rule)
|
|
317
|
+
abort_loud("--rule #{rule.inspect} is not excludable (expected one of: " \
|
|
318
|
+
"#{RuleCatalog::EXCLUDABLE_CHECKS.keys.join(", ")})")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
doctor = Doctor.new(plastic_home: home)
|
|
322
|
+
stores = doctor.done_signal_stores(store ? [store] : nil)
|
|
323
|
+
abort_loud("no store matches --store #{store.inspect}") if store && stores.empty?
|
|
324
|
+
|
|
325
|
+
plan = {}
|
|
326
|
+
skip_lines = []
|
|
327
|
+
|
|
328
|
+
stores.each do |s|
|
|
329
|
+
existing = DoctorExclusions.load(s[:index])
|
|
330
|
+
if existing[:errors].any?
|
|
331
|
+
abort_loud("#{s[:scope]}: existing doctor-exclusions is malformed, refusing to rewrite " \
|
|
332
|
+
"it (#{existing[:errors].join("; ")})", 1)
|
|
333
|
+
end
|
|
334
|
+
# scrub: DoctorExclusions.load already scrubs internally (never raises, D5), but this
|
|
335
|
+
# raw read feeds render_exclusions_file's own line scan below, which was NOT going
|
|
336
|
+
# through that scrub - an invalid byte in a hand-written comment raised
|
|
337
|
+
# Encoding::CompatibilityError here on every register-exclusions run, dry-run included,
|
|
338
|
+
# even though `existing` itself (loaded via DoctorExclusions.load) reported the file
|
|
339
|
+
# clean. Scrub this read the same way so both paths agree.
|
|
340
|
+
existing_text = File.exist?(existing[:path]) ? File.read(existing[:path]).scrub : nil
|
|
341
|
+
|
|
342
|
+
found_ids = []
|
|
343
|
+
doctor.index_sections_by_dir(s[:index]).each do |dirname, in_sections|
|
|
344
|
+
dir = File.join(s[:store_dir], dirname)
|
|
345
|
+
next unless File.directory?(dir)
|
|
346
|
+
|
|
347
|
+
terminal = (in_sections & ["Completed", "Abandoned"]).any?
|
|
348
|
+
next unless terminal
|
|
349
|
+
|
|
350
|
+
if Lock.fresh?(dir)
|
|
351
|
+
skip_lines << "#{s[:scope]}: #{dirname} skipped (fresh delivery lock)"
|
|
352
|
+
next
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
active = in_sections.include?("Active") && !terminal
|
|
356
|
+
findings = doctor.done_signal_findings_for_dir(
|
|
357
|
+
dir, label: "#{s[:scope]} store/#{dirname}", scope: s[:scope], dirname: dirname,
|
|
358
|
+
terminal: terminal, active: active, excluded_rules: []
|
|
359
|
+
)
|
|
360
|
+
found_ids << dirname.split("--", 2).first if findings[:operational_gap].any?
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
already = existing[:rules][rule] || []
|
|
364
|
+
added = found_ids - already
|
|
365
|
+
next if added.empty?
|
|
366
|
+
|
|
367
|
+
merged_rules = existing[:rules].merge(rule => (already | found_ids).sort)
|
|
368
|
+
plan[s] = { content: render_exclusions_file(merged_rules, existing_text: existing_text), added: added.sort }
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
puts skip_lines.join("\n") unless skip_lines.empty?
|
|
372
|
+
|
|
373
|
+
if plan.empty?
|
|
374
|
+
puts "maintenance-run: no new #{rule} violations to register."
|
|
375
|
+
exit 0
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
unless apply
|
|
379
|
+
plan.each do |s, info|
|
|
380
|
+
puts "maintenance-run: DRY RUN, #{s[:scope]} would register #{info[:added].size} " \
|
|
381
|
+
"id(s) under #{rule}: #{info[:added].join(", ")}"
|
|
382
|
+
end
|
|
383
|
+
exit 0
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
begin
|
|
387
|
+
result = MaintenanceGit.run_scoped(
|
|
388
|
+
repo_dir: home, branch_name: "maintenance/register-exclusions-#{stamp}",
|
|
389
|
+
commit_message: "chore: maintenance - register doctor exclusions (#{rule})"
|
|
390
|
+
) do
|
|
391
|
+
plan.each { |s, info| File.write(DoctorExclusions.path_for(s[:index]), info[:content]) }
|
|
392
|
+
end
|
|
393
|
+
rescue MaintenanceGit::DirtyWorkingTree, MaintenanceGit::NotAGitRepo => e
|
|
394
|
+
abort_loud(e.message, 4)
|
|
395
|
+
rescue RuntimeError => e
|
|
396
|
+
abort_loud(e.message, 3)
|
|
397
|
+
end
|
|
398
|
+
report_result(result)
|
|
399
|
+
end
|
|
400
|
+
|
|
272
401
|
def main(argv)
|
|
273
402
|
opts = parse_argv(argv)
|
|
274
|
-
|
|
403
|
+
unless opts[:tool]
|
|
404
|
+
abort_loud("--tool is required " \
|
|
405
|
+
"(project-links|rebuild-graph|restore-intent-v1|rebuild-savepoint|register-exclusions)")
|
|
406
|
+
end
|
|
275
407
|
|
|
276
408
|
case opts[:tool]
|
|
277
409
|
when "project-links"
|
|
@@ -281,9 +413,11 @@ def main(argv)
|
|
|
281
413
|
run_restore_intent_v1(opts[:plastic_home], opts[:id], opts[:at], opts[:store], opts[:apply], opts[:skip_links])
|
|
282
414
|
when "rebuild-savepoint"
|
|
283
415
|
run_rebuild_savepoint(opts[:plastic_home], opts[:intent], opts[:store], opts[:apply])
|
|
416
|
+
when "register-exclusions"
|
|
417
|
+
run_register_exclusions(opts[:plastic_home], opts[:rule], opts[:store], opts[:apply])
|
|
284
418
|
else
|
|
285
|
-
abort_loud("unknown --tool #{opts[:tool].inspect} " \
|
|
286
|
-
"
|
|
419
|
+
abort_loud("unknown --tool #{opts[:tool].inspect} (expected project-links|rebuild-graph|" \
|
|
420
|
+
"restore-intent-v1|rebuild-savepoint|register-exclusions)")
|
|
287
421
|
end
|
|
288
422
|
end
|
|
289
423
|
|
|
@@ -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
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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.
|
|
@@ -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.
|
|
134
|
-
the
|
|
135
|
-
|
|
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.
|
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
|
|
@@ -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.
|