Content
Hook
Hooks
What grounded-copy runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add HiroHyun/grounded-copy > /plugin install grounded-copy@hirohyun-plugins
Ships with grounded-copy. Installing the plugin gets these hooks.
Where it lives
- hooks/_hook_io.pyGitHub
Read the script
#!/usr/bin/env python3 """Hook transport: stdin and argv. Kept apart from the preference and policy vocabulary. Both entrypoints call both functions here, which is what earns the module its own file: plugin_root() carries three resolution rules, and duplicating them across two entrypoints is how they drift apart. Neither entrypoint reads the event's fields, so the stdin side is a drain. """ import os import sys def utf8_streams(): """Speak UTF-8 to the host on every platform. The payloads carry em dashes and the arrow in the workflow step, and a `copy` policy carries more. Python picks the encoding for a pipe from the platform, so a Windows interpreter at a legacy code page hands the host cp1252 bytes for those characters; a host decoding UTF-8 then reads the session policy mojibaked, and a character the code page cannot hold raises UnicodeEncodeError, which the entrypoint swallows into a silent exit 0. Measured: `PYTHONIOENCODING=cp1252` made a UTF-8 reader see byte 0x97 and return no policy at all. Call it before writing. `errors="replace"` keeps a payload flowing when one character will not encode, since a style reminder is worth less than the session it would break. """ for stream in (sys.stdout, sys.stderr): try: stream.reconfigure(encoding="utf-8", errors="replace") except (AttributeError, ValueError, OSError): pass def drain_stdin(): """Consume the hook event. The hook contract hands the event in on stdin. Neither entrypoint reads its fields, and a hook that leaves stdin unread can break the writer's pipe. """ try: sys.stdin.read() except Exception: pass def plugin_root(argv, hook_dir): """The plugin root, in order of preference. 1. `--plugin-root DIR` from argv, which the test suite and manual runs pass 2. $PLUGIN_ROOT from the environment (host-neutral adapter seam) 3. $CLAUDE_PLUGIN_ROOT from the environment, which a Claude hook run inherits 4. the hooks directory's parent, which holds for every shipped layout A value still carrying a literal `${` is an unsubstituted placeholder and falls through to the next source. """ candidates = [] for i, arg in enumerate(argv): if arg == "--plugin-root" and i + 1 < len(argv): candidates.append(argv[i + 1]) candidates.extend(( os.environ.get("PLUGIN_ROOT"), os.environ.get("CLAUDE_PLUGIN_ROOT"), )) for value in candidates: if value and value.strip() and "${" not in value: return os.path.abspath(os.path.expanduser(value)) return os.path.dirname(os.path.abspath(hook_dir)) - hooks/_policy.pyGitHub
Read the script
#!/usr/bin/env python3 """The policy text: what the hooks and `--set` put in front of the model. Three shapes come out of this module, and skills/grounded-copy/references/setup.md defines each one under `### Profile lifecycle`: - the **session policy**, which SessionStart injects - the **turn reminder**, which UserPromptSubmit injects - a **governing directive**, which `--set` prints when it records a new preference Every one of them is assembled from SKILL.md at runtime, so a rule edit lands without a code change. The workflow, the integrity rules, and the three excluded closures load with the skill itself when a copy task calls for them, and so do the trigger catalogs and rewrite tables in skills/grounded-copy/references/patterns.md. Transport stays outside this module: it reads no environment and takes the plugin root as an argument. """ import os import re # Where the skill sits under the plugin root. One path covers every shipped # layout: the Claude plugin, a skills-directory clone, the generated Codex # tree, and a checkout. SKILL_RELATIVE = os.path.join("skills", "grounded-copy", "SKILL.md") # Sections both profiles carry, in payload order. CORE_HEADINGS = ( "## The one banned move", "## Positive forms", "## Scope and precedence", "## Sourcing", "## Suspended lists", ) # The section the `copy` profile adds. MARKETING_HEADING = "## Marketing register" LOOPHOLE_HEADING = "## Loophole closures" # The closures the `copy` profile adds, in SKILL.md order. Two others came out # into the rules that already stated them: the quote closure into # `## Scope and precedence`, whose verbatim rule covers invented testimonials, # and the headline closure into `## Marketing register`, which enumerates # headline and CTA scope. These two carry content no core rule states. COPY_LABELS = ( '- **"It\'s a different language."**', '- **"The linter passed, so it\'s fine."**', ) COPY_LEAD = "The copy profile adds these closures from the skill:" SESSION_HEADER = "GROUNDED PROSE ACTIVE — profile: {profile}" # The Claude slash command. A copied entrypoint passes its own host's verb # instead; this module names one host in a default and reads no environment to # discover another. CLAUDE_SWITCH = "/grounded-copy:grounded chat|copy|off" SWITCH_LINE = "Profile: {profile}. Switch: `{switch}`." # The reminder carries the sourcing rule's selection cue each turn. Exact # reproduction applies to quotations; other source material may be summarized. TURN_REMINDER = ( "GROUNDED PROSE ({profile}). State what the subject is or does. No " "contrast, era-ending, or hype. Rules hold in quotes, fences, and " "comments. Select relevant facts; verbatim quotations stay exact. " "A user instruction outranks " "this; name the rule." ) DIRECTIVE_HEADER = "GROUNDED PROSE — governing profile: {profile} ({source})" DIRECTIVE_LEAD = ( "This directive supersedes every grounded-copy policy statement earlier in " "this transcript. Those statements stay in the transcript as a record; the " "rules below are what governs from this turn onward." ) DIRECTIVE_LEAD_OFF = ( "This directive supersedes every grounded-copy policy statement earlier in " "this transcript. Those statements stay in the transcript as a record. No " "grounded-copy rule governs from this turn onward." ) DIRECTIVE_LEAD_EMPTY = ( "The preference is recorded. SKILL.md was unreadable on this run, so the " "rules for this profile arrive at the next session start." ) # Extraction sizes measured 2026-09-15 against SKILL.md, including fact selection # and paragraph review across languages. These count the rules body alone; hook stdout # adds the header and switch line, 106 bytes. The READMEs publish the same figures under "What each # mode costs", and `grounded_activate.py --self-test` reports the current ones. BASELINE_BYTES = 4212 COPY_BASELINE_BYTES = 5645 TURN_BASELINE_BYTES = 239 # (floor, ceiling) per payload. The ceiling bounds growth against the figure the # documentation published when the range was set: a payload that passes it fails # the self-test. An exact-size assertion would fail on every intentional rule # addition and get muted, so the band leaves room for one. The floor catches an # extraction that returns a stub while every structural assertion still passes. BYTE_RANGE = (3350, 4300) COPY_BYTE_RANGE = (4850, 6200) TURN_BYTE_RANGE = (160, 260) def _strip_frontmatter(text): return re.sub(r"^---.*?---\s*", "", text, flags=re.S) def _section(lines, start): """The lines from a heading up to the next `## ` heading.""" end = len(lines) for i in range(start + 1, len(lines)): if lines[i].startswith("## "): end = i break return "\n".join(lines[start:end]).strip() def _find(lines, heading): for i, line in enumerate(lines): if line.startswith(heading): return i return None def _bullet(section, label): """One bold-labelled bullet out of a section, or an empty string.""" match = re.search( re.escape(label) + r".*?(?=\n- \*\*|\n## |\Z)", section, flags=re.S ) return match.group(0).strip() if match else "" def extract(skill_text): """The policy pieces as a dict. Missing pieces come back empty.""" body = _strip_frontmatter(skill_text) lines = body.splitlines() pieces = {"intro": "", "core": [], "marketing": "", "closures": []} first = None for i, line in enumerate(lines): if line.startswith("## "): first = i break if first is None: return pieces pieces["intro"] = "\n".join(lines[:first]).strip() for heading in CORE_HEADINGS: start = _find(lines, heading) pieces["core"].append(_section(lines, start) if start is not None else "") start = _find(lines, MARKETING_HEADING) if start is not None: pieces["marketing"] = _section(lines, start) start = _find(lin - hooks/_preference.pyGitHub
Read the script
#!/usr/bin/env python3 """The profile preference: the value stored on disk. The preference lives at <config-dir>/grounded-copy/profile, config-dir being $CLAUDE_CONFIG_DIR when set and ~/.claude otherwise. One fixed path keeps hook runs and shell runs on the same file, and keeps the preference somewhere the user can cat and edit. skills/grounded-copy/references/setup.md records why ${CLAUDE_PLUGIN_DATA} stays out of it. This module is the sole writer of the preference, and the `--set` path of grounded_tracker.py is its only caller. resolve_preference() is read-only, and both hooks are read-only. Transport stays outside this module: it reads no stdin and parses no argv. """ import os VALID = ("chat", "copy", "off") DEFAULT = "chat" ACTIVE = ("chat", "copy") MAX_PREFERENCE_BYTES = 64 # `technical` was the stored name for `chat` before the profiles took their # user-facing names. A preference written by an earlier install keeps working. LEGACY = {"technical": "chat"} # Words `--set` accepts: the three profile names, plus `technical` for the # legacy stored value and `marketing` for the register `copy` carries. `on`, # `stop`, and `disable` came out with the natural-language switch that used # them; nothing documented or tested them afterwards. ARGUMENTS = { "chat": "chat", "technical": "chat", "copy": "copy", "marketing": "copy", "off": "off", } # The Claude command that restores an active profile. A copied entrypoint # passes its own host's verb instead. CLAUDE_RESTORE = "--set chat" # Why resolve_preference() returned the name it returned. RECORDED = "recorded" ABSENT = "absent" UNREADABLE = "unreadable" def config_dir(): """Return Claude's config directory for the canonical entrypoints.""" return os.environ.get("CLAUDE_CONFIG_DIR") or os.path.join( os.path.expanduser("~"), ".claude" ) def data_dir(): return os.path.join(config_dir(), "grounded-copy") def preference_path(path=None): """Resolve an explicit profile file or Claude's canonical default.""" if path is not None: return os.path.abspath(os.path.expanduser(os.fspath(path))) return os.path.join(data_dir(), "profile") def canonical_argument(value): """A profile name from a `--set` value, or None.""" return ARGUMENTS.get(str(value).strip().lower()) def _read_preference(path=None): """The recorded name, or None when the file is absent or untrusted. A symlink, an oversized file, or an unrecognized value reads as untrusted, so neither hook emits bytes it did not write. """ path = preference_path(path) try: if os.path.islink(path): return None, UNREADABLE if not os.path.exists(path): return None, ABSENT if os.path.getsize(path) > MAX_PREFERENCE_BYTES: return None, UNREADABLE with open(path, encoding="utf-8") as handle: value = handle.read().strip().lower() except Exception: return None, UNREADABLE value = LEGACY.get(value, value) if value in VALID: return value, RECORDED return None, UNREADABLE def resolve_preference(path=None): """(profile, source): a name from VALID, and why. Read-only.""" value, source = _read_preference(path) return (value if value else DEFAULT), source def status_line(path=None, restore=None): """One line naming the profile, the reason, and the resolved path. ``restore`` is the host's command for returning to an active profile. The Claude flag is the default; the Codex entrypoint passes its own verb. """ profile, source = resolve_preference(path) path = preference_path(path) if source == RECORDED: line = "grounded profile: %s (recorded at %s)" % (profile, path) if profile == "off": line += "; run %s to restore" % (restore or CLAUDE_RESTORE) return line if source == ABSENT: return "grounded profile: %s (default, no preference at %s)" % ( profile, path ) return "grounded profile: %s (default, unreadable preference at %s)" % ( profile, path ) def write_preference(profile, path=None): """Record the preference. Returns (ok, detail). The write is followed by a read-back through _read_preference(), so a write that lands somewhere the resolver cannot use reports failure here instead of resolving stale later. Creates the data directory on first write. A symlink at the path is reported, never removed. _read_preference() reads one as untrusted, so the link is already inert, and deleting a file the user put there reaches past what this function owns. """ if profile not in VALID: return False, "grounded: refusing to record %r" % (profile,) path = preference_path(path) if os.path.islink(path): return False, ( "grounded: %s is a symlink, which resolve_preference() reads as " "unreadable. Remove it and run --set again." % path ) try: os.makedirs(os.path.dirname(path), exist_ok=True) # Replace a same-directory temporary file so readers never observe a # partially written profile. The symlink check above remains the guard # that prevents replacing a user-owned link. import tempfile fd, temporary = tempfile.mkstemp(prefix=".profile-", dir=os.path.dirname(path)) try: with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle: handle.write(profile + "\n") os.replace(temporary, path) finally: try: os.unlink(temporary) except OSError: pass except Exception as exc: return False, "grounded: write failed at %s: %s" % (path, exc) stored, source = _read_preference(path) if stored != profile: return False, ( "grounded: wrote %s at %s, read back %s (%s)" % (profile, path, stored, so - hooks/grounded_activate.pyGitHub
Read the script
#!/usr/bin/env python3 """SessionStart hook: put the session policy in context. Stdout becomes session context, so the rules arrive with no skill-trigger judgment involved. Registered with no matcher, so it also fires after every compaction. _policy.py assembles the text from SKILL.md at runtime, which lets a rule edit land with no code change. This hook is read-only: it resolves the preference and writes nothing. Usage: grounded_activate.py [--plugin-root DIR] grounded_activate.py --self-test [--plugin-root DIR] """ import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _hook_io # noqa: E402 import _policy # noqa: E402 import _preference # noqa: E402 # Adapter seams: a copied entrypoint sets these for its host. The skill path # needs none — every layout puts SKILL.md at the same place under the plugin # root, which is what `skills/grounded-copy/` as the one canonical directory # buys. scripts/build_codex_adapter.py rewrites each seam and fails the build # when one goes missing. PREFERENCE_PATH = None SWITCH_HINT = None def _preference_path(argv): if "--preference-path" in argv: index = argv.index("--preference-path") if index + 1 < len(argv): return argv[index + 1] return PREFERENCE_PATH def main(argv): _hook_io.utf8_streams() hook_dir = os.path.dirname(os.path.abspath(__file__)) root = _hook_io.plugin_root(argv, hook_dir) if "--self-test" in argv: return _policy.self_test(root) _hook_io.drain_stdin() path = _preference_path(argv) profile, _source = _preference.resolve_preference(path) if profile not in _preference.ACTIVE: return 0 skill = _policy.read_skill(root) if not skill: return 0 sys.stdout.write(_policy.session_policy(skill, profile, SWITCH_HINT)) return 0 if __name__ == "__main__": argv = sys.argv[1:] if "--self-test" in argv: sys.exit(main(argv)) try: sys.exit(main(argv)) except Exception: sys.exit(0) - hooks/grounded_tracker.pyGitHub
Read the script
#!/usr/bin/env python3 """UserPromptSubmit hook, and the profile control CLI. Two roles with a boundary between them: - As a hook it is **read-only**. It resolves the preference and emits the turn reminder, which holds against the per-turn injections other plugins make. - As `--set` it is the mutation path. _preference.write_preference() is the sole writer and this is its only caller. It prints the status line and the governing directive for the new profile, so the switch reaches the transcript in the same turn the user typed the command. An earlier build also parsed whole-prompt control instructions here. skills/grounded-copy/references/setup.md records the removal and the evidence behind it. Usage: grounded_tracker.py [--plugin-root DIR] grounded_tracker.py --set chat|copy|off grounded_tracker.py --status Exit codes for `--set`: 0 recorded, 1 persistence failure, 2 rejected value. An empty value reports status and exits 0, since it requests no change. """ import json import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _hook_io # noqa: E402 import _policy # noqa: E402 import _preference # noqa: E402 # Adapter seams: a copied entrypoint sets these for its host. The skill path # needs none — every layout puts SKILL.md at the same place under the plugin # root. scripts/build_codex_adapter.py rewrites each seam and fails the build # when one goes missing. PREFERENCE_PATH = None RESTORE_HINT = None EXIT_OK = 0 EXIT_PERSISTENCE = 1 EXIT_REJECTED = 2 CONTROL_FLAGS = ("--set", "--status") def _preference_path(argv): if "--preference-path" in argv: index = argv.index("--preference-path") if index + 1 < len(argv): return argv[index + 1] return PREFERENCE_PATH def set_mode(argv, hook_dir): """`--set PROFILE`: record the preference, print the governing directive.""" index = argv.index("--set") raw = argv[index + 1] if index + 1 < len(argv) else "" path = _preference_path(argv) if not raw.strip(): print(_preference.status_line(path, RESTORE_HINT)) return EXIT_OK profile = _preference.canonical_argument(raw) if profile is None: print( "grounded: unknown profile %r; choose chat, copy, or off" % raw.strip().lower() ) return EXIT_REJECTED ok, detail = _preference.write_preference(profile, path) if not ok: print(detail) return EXIT_PERSISTENCE print(_preference.status_line(path, RESTORE_HINT)) print() skill = _policy.read_skill(_hook_io.plugin_root(argv, hook_dir)) source = "recorded at " + _preference.preference_path(path) print(_policy.governing_directive(skill, profile, source)) return EXIT_OK def main(argv): _hook_io.utf8_streams() hook_dir = os.path.dirname(os.path.abspath(__file__)) # `--set` outranks `--status`: it prints the status line itself, so the two # together still report, and the write the user asked for happens. if "--set" in argv: return set_mode(argv, hook_dir) if "--status" in argv: print(_preference.status_line(_preference_path(argv), RESTORE_HINT)) return EXIT_OK _hook_io.drain_stdin() profile, _source = _preference.resolve_preference(_preference_path(argv)) if profile not in _preference.ACTIVE: return EXIT_OK sys.stdout.write(json.dumps({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": _policy.turn_reminder(profile), } })) return EXIT_OK if __name__ == "__main__": argv = sys.argv[1:] if any(flag in argv for flag in CONTROL_FLAGS): # An explicit request reports its own outcome, failures included. sys.exit(main(argv)) try: # A hook stays non-blocking: a style reminder that breaks a session # start costs more than the reminder is worth. sys.exit(main(argv)) except Exception: sys.exit(0) - hooks/run.shGitHub
Read the script
#!/bin/sh # Resolve a Python 3 interpreter once, then run the named hook script once. # # `python X.py || python3 X.py` re-runs the script whenever the first # interpreter exits nonzero for any reason, which on SessionStart emits the # session policy twice. Probing first and running once avoids that. # # The first argument is matched against a closed set and a literal is assigned # on match, so the executed command line derives from this file. An unknown # name exits 0 with no output. # # `exec` hands the interpreter's exit code back to the caller, which the # `--set` path needs: 0 recorded, 1 persistence failure, 2 rejected value. # # Exit 0 when no interpreter resolves: a style hook stays non-blocking. # # Usage: sh run.sh grounded_activate.py|grounded_tracker.py [args...] # Parameter expansion, no external `dirname`: this script has to survive a # degraded PATH, which is one of the cases it exists to handle. hookdir=${0%/*} [ "$hookdir" = "$0" ] && hookdir=. case "$1" in grounded_activate.py) script=grounded_activate.py ;; grounded_tracker.py) script=grounded_tracker.py ;; *) exit 0 ;; esac shift for candidate in python python3; do if "$candidate" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1; then exec "$candidate" "$hookdir/$script" "$@" fi done exit 0
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withgrounded-copy
AI coding agents (Claude Code, Codex, Cursor) love filler phrases, endless em-dashes, and convoluted phrasing for simple ideas. grounded-copy forces your agent to state plain facts directly. When writing copy, AI defaults to absurd hype and sensational claims.
Get the whole plugin
Stats
20
Stars
0
Forks
Active
Maintenance
Python
Language
AGPL-3.0
License
3d ago
Last commit
2mo ago
Created
Repo: HiroHyun/grounded-copy

