From b7e137abaab3934bd1a2a9e77229b2ed9d34168a Mon Sep 17 00:00:00 2001 From: KrishP147 Date: Sun, 18 Oct 2026 21:03:24 -0400 Subject: [PATCH] feat(site): guard-git as third hook; third-party author tags on home list - guard-git entry from agents/README.md section, in unattended-backlog workflow; --skill guard-git -> agent-hooks - README hooks section names guard-git; ponytail tagged (Dietrich Gebert) - home list shows author for third-party entries - fix test_remind date time-bomb (2nd reminder due on real clock after 2026-10-17) Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + README.md | 6 ++--- agents/README.md | 12 ++++++++- find-me-a-skill/CATALOG.md | 14 +++++----- scripts/build_site.py | 48 +++++++++++++++++++++-------------- scripts/site_catalog.py | 2 +- scripts/site_detail.py | 41 ++++++++++++++++++++---------- scripts/site_map.py | 11 ++++---- scripts/test_build_site.py | 22 +++++++++++++--- scripts/test_remind.py | 1 + scripts/test_site_detail.py | 7 ++--- scripts/test_workflow_deps.py | 15 +++++++---- scripts/workflow_deps.py | 6 +++-- site/README.md | 7 ++--- site/data/meta.json | 5 ++-- site/data/workflows.json | 1 + site/src/style.css | 1 + site/test/filter.test.mjs | 6 ++--- 18 files changed, 136 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b36c4ca..f383ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ version in [`.claude-plugin/plugin.json`](.claude-plugin/plugin.json); ## [0.1.0] - 2026-10-15 +- Site lists `guard-git` as the third hook (page from `agents/README.md`, in the unattended-backlog workflow; `--skill guard-git` copies `agents/hooks/`), and the home list tags third-party entries with their author, like the README. - Docs pass before launch: plainer, shorter READMEs and site copy; "Needs:" lines name the `guard-git` hook that any agent install brings; internal decision ids dropped; stale facts fixed (`switch` pre-switch notes, `score-loop` example total, `lanes` decrease, broken `repo-scrub` and `kanban` links). - `install-skills --skill ` / `-Skill ` (#233) installs one skill, agent or hook plus what it needs; `--list-skills` / `-ListSkills` lists the names. Combines with `--workflow`. Optional companions are no longer pulled in (e.g. `find-me-a-skill` no longer brings `meta-orchestrator`; `pr-watch` fixes inline without `pair`). Each README says "Works on its own." or what it needs, each site page shows its one-line install, and a test installs every entry alone and checks it links to nothing missing. - `/switch setup [name]` (#234) runs first-time setup from inside Claude: it shows the profile and settings diff (`setup --dry-run`, new), asks in chat, then writes with `--yes` and says to open a new terminal. `name` names the config dir you're on (`$CLAUDE_CONFIG_DIR`, else `~/.claude`; `main` by default) and is refused when that dir or name is already registered differently. Falls back to `python` when `python3` is the Windows Store stub. The README's first-time steps lead with it; the shell command stays as the fallback. diff --git a/README.md b/README.md index 68a70e0..45310e4 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Run one task, or a whole backlog, with agents. | [`meta-orchestrator`](meta-orchestrator/README.md) | Runs a repo's backlog unattended. It loops `planner` → `manager` → `verifier` per issue (`shape: flat` uses `implementer` instead of `manager`; `shape: swarm` uses a swarm of haiku implementers; the implementer model follows issue size). It holds the merge gate itself: no merge until tests and CI are green. It reads `skilleddocs/` for the design record and appends every event to `skilleddocs/orchestrator/ledger.md` (committed in the repo, unless `docs_commit: uncommitted`). After a budget of merges it writes `orchestrator-handoff.md`, so `/meta-orchestrator resume` continues in a fresh session. | `gh`; the four agents installed; a kanban board, `status:*` labels, or a roadmap doc (whatever `next` finds) | | [`lanes`](lanes/README.md) | Sets how many issues a `meta-orchestrator` run works on at once: predicts each queued issue's files, groups overlapping issues into the same lane, proposes a count with a token-cost line, and on your yes writes named lanes to `scope.md`. `/lanes \| increase \| decrease`; changes apply at the orchestrator's next loop. | a `meta-orchestrator` run; `gh` | | [`score-loop`](score-loop/README.md) | Pushes one rerunnable numeric score up or down, unattended. It builds a local harness that mirrors the grader (correctness check, median of N runs, noise margin from baseline runs). It then commits one hypothesis at a time on `score-loop/`, keeps a commit only when it beats the best by more than the margin, and `git revert`s the rest. Every experiment is logged to `skilleddocs/experiments.md`. It stops on budget, deadline freeze or plateau. Official submissions stay inside a quota you approve; paid ones go through `spend-gate`. | a score you can compute locally | -| [`ponytail`](ponytail/README.md) *(DietrichGebert)* | Lazy-senior-developer mode for the whole session: the smallest change that fully solves the task, plus a closing line on what was skipped and any risk. Levels `lite`, `full` (default), `ultra`; stays on until "stop ponytail". | none | +| [`ponytail`](ponytail/README.md) *(Dietrich Gebert)* | Lazy-senior-developer mode for the whole session: the smallest change that fully solves the task, plus a closing line on what was skipped and any risk. Levels `lite`, `full` (default), `ultra`; stays on until "stop ponytail". | none | ### Sessions and accounts @@ -136,7 +136,7 @@ Full explainer (inputs, outputs, procedure, guard hook): [`agents/README.md`](ag ### [`hooks/`](hooks/) — optional per-repo hooks -Not auto-installed. [`smartzone.py`](hooks/smartzone.py) is a `UserPromptSubmit` hook that warns in-conversation when the transcript suggests context has left the smart zone (~75k tokens, `SMARTZONE_TOKENS`), so a session wraps up instead of degrading. [`spend-guard.py`](hooks/spend-guard.py) is a `PreToolUse` hook that blocks known billable commands/MCP calls (GPU pod creation, paid deploys, ...) unless an approval token from the [`spend-gate`](spend-gate/README.md) skill already exists. Copy + settings snippets in [`hooks/README.md`](hooks/README.md). +Not auto-installed. The third hook, [`guard-git.py`](agents/hooks/guard-git.py), lives under `agents/hooks/` and comes with the agents: it stops the `implementer` and `manager` from pushing to `main`, force pushing or merging, so only the orchestrator merges ([details](agents/README.md#hooksguard-gitpy)). [`smartzone.py`](hooks/smartzone.py) is a `UserPromptSubmit` hook that warns in-conversation when the transcript suggests context has left the smart zone (~75k tokens, `SMARTZONE_TOKENS`), so a session wraps up instead of degrading. [`spend-guard.py`](hooks/spend-guard.py) is a `PreToolUse` hook that blocks known billable commands/MCP calls (GPU pod creation, paid deploys, ...) unless an approval token from the [`spend-gate`](spend-gate/README.md) skill already exists. Copy + settings snippets in [`hooks/README.md`](hooks/README.md). ### [`site/`](site/) — static skills site @@ -388,4 +388,4 @@ Pass `--prefix

` (sh) / `-Prefix

` (ps1) to fill in the branch-prefix plac ## License -MIT (see [`LICENSE`](LICENSE)) for everything original in this repo. Matt Pocock's skills and DietrichGebert's `ponytail` carry their own MIT notices; see [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). +MIT (see [`LICENSE`](LICENSE)) for everything original in this repo. Matt Pocock's skills and Dietrich Gebert's `ponytail` carry their own MIT notices; see [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md). diff --git a/agents/README.md b/agents/README.md index ea2fd67..1dd00c2 100644 --- a/agents/README.md +++ b/agents/README.md @@ -187,7 +187,7 @@ Needs: `consult-plan`, `next`, `update-progress`, the `guard-git` hook (installe ## hooks/guard-git.py -`PreToolUse` hook on `Bash|PowerShell`, wired in the `implementer` and `manager` frontmatter. Exits 2 (blocks, reason on stderr) or 0 (allows). +`PreToolUse` hook that stops the `implementer` and `manager` agents from pushing to protected branches, force pushing, merging PRs or writing other remote state. It runs on `Bash|PowerShell`, is wired in their frontmatter, and exits 2 (blocks, reason on stderr) or 0 (allows). **As a plugin.** Claude Code ignores `hooks` frontmatter in plugin agents, so the plugin wires the same command once, plugin-wide, in [`.claude-plugin/plugin.json`](../.claude-plugin/plugin.json), with `--plugin`. @@ -352,6 +352,16 @@ Protected branches: `GUARD_PROTECTED_BRANCHES` (comma list), default `main,maste - A block is working as intended, not something for the agent to route around. - Self-test (every case above, current branch injected, plus stdin end-to-end runs and the `--plugin` agent filter): `python agents/hooks/guard-git.py --self-test`. +Example output when a guarded agent tries to merge its own PR, then push to main: +the hook exits 2 and prints one line on stderr for each. + +```transcript +⏺ Bash(gh pr merge 12 --squash) + ⎿ Blocked: gh pr merge is a write; guarded agents get read-only gh + pr create; card moves, comments and labels belong to the caller. +⏺ Bash(git push origin main) + ⎿ Blocked: push to protected branch 'main' is not allowed. +``` + ## Example One issue through planner, manager and verifier, as an orchestrator (or you) diff --git a/find-me-a-skill/CATALOG.md b/find-me-a-skill/CATALOG.md index 84ba6de..182d397 100644 --- a/find-me-a-skill/CATALOG.md +++ b/find-me-a-skill/CATALOG.md @@ -49,6 +49,7 @@ Install section. | manager | agent | opus | high | `Agent(subagent_type="manager")` | Owns one task inside a worktree — briefs an implementer subagent, reviews its diff and reruns tests itself, restarts a fresh implementer from skilleddocs/HANDOFF.md when one stalls (≤3 rounds). | unattended-backlog, pair-to-pr, orchestration-shapes | | planner | agent | opus | medium | `Agent(subagent_type="planner")` | Picks the next issue from a repo's board, applies the run's scope exclusions, decides whether to divide, rates its size, recommends a shape and model, and returns a ≤40-line kickoff brief. | unattended-backlog, orchestration-shapes | | verifier | agent | opus | high | `Agent(subagent_type="verifier")` | Verifies an implementation session: checks its handoff's claims against git, tests and CI, reviews the diff, updates docs/board/issues, reconciles deviations via consult-plan, and ends with the next task. | unattended-backlog, orchestration-shapes | +| guard-git | hook | haiku | low | install: comes with the agents | PreToolUse hook that stops the implementer and manager agents from pushing to protected branches, force pushing, merging PRs or writing other remote state. | unattended-backlog | | smartzone | hook | haiku | low | install: hooks/README.md | "Your context has left the smart zone" warning. | wrap-up-long-session | | spend-guard | hook | haiku | low | install: hooks/README.md | Block billable commands without an approval. | paid-tests | @@ -113,12 +114,13 @@ human-in-the-loop points: nothing continues without you. 5. planner (agent, opus · medium): Runs next, picks exactly one issue and returns a kickoff brief of at most 40 lines. 6. manager (agent, opus · high): Briefs an implementer, reviews every round inline and reruns the tests itself, for up to 3 rounds. 7. implementer (agent, sonnet · medium): Implements on its branch, commits small, watches the tests pass and ends with session-handoff. -8. verifier (agent, opus · high): Runs update-progress end to end, answers or parks deviations, then runs next. -9. you: When the statusline warns near the limit (90%), type /switch between merges; it is user-only, so Claude can't start it. -10. /switch (skill, haiku · low): The session resumes on the other account; running subagents pause (resume each with SendMessage to its id) and background commands end, hence between merges. -11. /remind (skill, haiku · low): /remind when 's session limit resets pops a toast at the reset time and says it once at your next prompt. -12. /progress-report (skill, sonnet · low): Every N merges, writes a two-minute report of what happened since the last one, never overlapping. -13. you: Read the report and do the manual steps it lists. +8. guard-git (hook, haiku · low): Runs on every manager and implementer shell command: a feature-branch push and gh pr create get through, while pushes to main, force pushes, merges and other remote writes are blocked, so only the orchestrator merges. +9. verifier (agent, opus · high): Runs update-progress end to end, answers or parks deviations, then runs next. +10. you: When the statusline warns near the limit (90%), type /switch between merges; it is user-only, so Claude can't start it. +11. /switch (skill, haiku · low): The session resumes on the other account; running subagents pause (resume each with SendMessage to its id) and background commands end, hence between merges. +12. /remind (skill, haiku · low): /remind when 's session limit resets pops a toast at the reset time and says it once at your next prompt. +13. /progress-report (skill, sonnet · low): Every N merges, writes a two-minute report of what happened since the last one, never overlapping. +14. you: Read the report and do the manual steps it lists. ### pair-to-pr: One issue to a reviewed PR diff --git a/scripts/build_site.py b/scripts/build_site.py index 1d89345..c91b4db 100644 --- a/scripts/build_site.py +++ b/scripts/build_site.py @@ -98,34 +98,39 @@ def collect(root): def hook_files(root): - """hooks/*.py, sorted. Agent hooks (agents/hooks/) are not listed.""" - d = os.path.join(root, "hooks") - if not os.path.isdir(d): - return [] - return [os.path.join(d, fn) for fn in sorted(os.listdir(d)) if fn.endswith(".py")] + """(path, readme) per hook, sorted by name: hooks/*.py, documented in + hooks/README.md, and agents/hooks/*.py (guard-git), documented in + agents/README.md.""" + out = [] + for d, readme in (("hooks", "hooks/README.md"), ("agents/hooks", "agents/README.md")): + full = os.path.join(root, *d.split("/")) + if os.path.isdir(full): + out += [(os.path.join(full, fn), readme) for fn in os.listdir(full) if fn.endswith(".py")] + return sorted(out, key=lambda t: os.path.basename(t[0])) def collect_hooks(root): - """One entry per hooks/.py. Its page text is the "## `.py`" - section of hooks/README.md. Its summary is the heading tagline (text after - the em dash), capitalized and ending in a period like skill summaries; its + """One entry per hook file. Its page text is the "## `.py`" section + of hooks/README.md (agents/README.md: "## hooks/.py"). Its summary + is the heading tagline (text after the em dash), capitalized and ending in + a period like skill summaries, else the section's first sentence; its description is that summary plus the section's first paragraph.""" - files = hook_files(root) - if not files: - return [] - readme = read_text(os.path.join(root, "hooks", "README.md")) out = [] - for path in files: + readmes = {} + for path, readme_rel in hook_files(root): fn = os.path.basename(path) - heading, body = site_detail.hook_section(readme, fn) + if readme_rel not in readmes: + readmes[readme_rel] = read_text(os.path.join(root, *readme_rel.split("/"))) + heading, body = site_detail.hook_section(readmes[readme_rel], fn) if not body: - raise SystemExit("hooks/README.md: no '## `%s`' section" % fn) + raise SystemExit("%s: no '## `%s`' section" % (readme_rel, fn)) e = _entry({}, os.path.splitext(fn)[0], "hooks", posix_rel(path, root), body + "\n", "") lead = site_detail.first_paragraph_text(body) e["summary"] = hook_tagline(heading) or first_sentence(lead) e["description"] = e["summary"] + " " + lead if lead else e["summary"] e["_x"]["lead"] = lead e["_x"]["heading"] = heading + e["_x"]["hook_readme"] = readme_rel out.append(e) return out @@ -189,7 +194,10 @@ def normalize_base(base): return "/" + base if base else "" -def render_list(entries, base=""): +def render_list(entries, base="", meta=None): + """Home list by category; third-party entries show their author + (meta.json authors) before the summary, like the README's tags.""" + authors = (meta or {}).get("authors", {}) out = [] for cat in dict.fromkeys(e["category"] for e in entries): # entries are in category order items = [e for e in entries if e["category"] == cat] @@ -198,10 +206,12 @@ def render_list(entries, base=""): % (esc(cat), esc(site_map.group_title(cat)), len(items))) out.append('

    ') for e in items: + by = authors.get(e["name"]) + by = '(%s) ' % esc(by) if by else "" out.append( '
  • /%s' - '%s
  • ' - % (esc(base), esc(e["name"]), esc(e["name"]), esc(e["summary"]))) + '%s%s' + % (esc(base), esc(e["name"]), esc(e["name"]), by, esc(e["summary"]))) out.append("
") out.append("") return "\n".join(out) @@ -255,7 +265,7 @@ def nav_data(entries, meta=None): def render_index(template, entries, base="", meta=None): return (template.replace("{{BASE}}", esc(base)) .replace("{{COUNT}}", esc(count_line(entries))) - .replace("{{LIST}}", render_list(entries, base)) + .replace("{{LIST}}", render_list(entries, base, meta)) .replace("{{DATA}}", nav_data(entries, meta))) diff --git a/scripts/site_catalog.py b/scripts/site_catalog.py index b2919d6..a892319 100644 --- a/scripts/site_catalog.py +++ b/scripts/site_catalog.py @@ -40,7 +40,7 @@ def invoke(e): if kind == "agent": return '`Agent(subagent_type="%s")`' % e["name"] if kind == "hook": - return "install: hooks/README.md" + return "install: %s" % ("comes with the agents" if e["source"].startswith("agents/") else "hooks/README.md") return "`/%s`" % e["name"] diff --git a/scripts/site_detail.py b/scripts/site_detail.py index 7a20c7a..72cbba0 100644 --- a/scripts/site_detail.py +++ b/scripts/site_detail.py @@ -180,11 +180,11 @@ def __init__(self, root, entries, base=""): self.names = {e["name"] for e in entries} self.agents = {e["name"] for e in entries if e["group"] == "agents"} self.dir_to_name = {} - self.hook_anchors = {} # hooks/README.md# -> hook name + self.hook_anchors = {} # (hooks/README.md or agents/README.md, ) -> hook name for e in entries: self.dir_to_name[e["source"]] = e["name"] # skill dir, agents/.md, hooks/.py if e["group"] == "hooks": - self.hook_anchors[hook_anchor(e["_x"]["heading"])] = e["name"] + self.hook_anchors[(hook_readme(e), hook_anchor(e["_x"]["heading"]))] = e["name"] elif e["group"] != "agents": self.dir_to_name[e["source"] + "/README.md"] = e["name"] @@ -196,6 +196,8 @@ def rewrite(url): if not path: if agents_readme and frag in self.agents: return self._page(frag, on_page_link) + if agents_readme and ("agents/README.md", frag) in self.hook_anchors: + return self._page(self.hook_anchors[("agents/README.md", frag)], on_page_link) if agents_readme: return "%s/blob/master/agents/README.md#%s" % (REPO_URL, frag) return url # in-page anchor @@ -207,8 +209,8 @@ def rewrite(url): return self._page(self.dir_to_name[key], on_page_link) if key == "agents/README.md" and frag in self.agents: return self._page(frag, on_page_link) - if key == "hooks/README.md" and frag in self.hook_anchors: - return self._page(self.hook_anchors[frag], on_page_link) + if (key, frag) in self.hook_anchors: + return self._page(self.hook_anchors[(key, frag)], on_page_link) kind = "tree" if os.path.isdir(os.path.join(self.root, *key.split("/"))) else "blob" return "%s/%s/master/%s%s" % (REPO_URL, kind, key, "#" + frag if frag else "") return rewrite @@ -230,14 +232,20 @@ def agent_section(agents_readme, name): def hook_section(hooks_readme, filename): - """(heading, body) of the '## `` ...' section, else ("", "").""" + """(heading, body) of the '## `` ...' section (agents/README.md: + '## hooks/'), else ("", "").""" _pre, secs = split_h2(hooks_readme) for heading, body in secs: - if heading.startswith("`%s`" % filename): + if heading.startswith(("`%s`" % filename, "hooks/%s" % filename)): return heading, body return "", "" +def hook_readme(e): + """Repo path of the README that documents hook entry e.""" + return e["_x"].get("hook_readme", "hooks/README.md") + + def hook_anchor(heading): """GitHub anchor of a hooks/README.md heading.""" from md_render import slugify @@ -489,7 +497,8 @@ def build_pages(root, entries, meta, template, data_json, site_base="", workflow if e["group"] == "agents": base_dir, agents_md = "agents", True elif e["group"] == "hooks": - base_dir, agents_md = "hooks", False + base_dir = posixpath.dirname(hook_readme(e)) + agents_md = base_dir == "agents" else: base_dir, agents_md = e["source"], False targets = set() @@ -640,10 +649,14 @@ def install_section(e, sid, needs_html=""): """'Install' section, first on every detail page; needs_html is the rendered README needs line, shown under the commands.""" sh, ps = install_cmds(e["name"]) - note = ("Install just this, from a clone of the repo. It is copied, not wired: " - "the installer prints the settings.json snippet to add." - if e["group"] == "hooks" else - "Install just this, from a clone of the repo. Anything it needs comes with it.") + if e["group"] != "hooks": + note = "Install just this, from a clone of the repo. Anything it needs comes with it." + elif hook_readme(e) == "agents/README.md": + note = ("Comes with every agent install and is wired in the agents' own frontmatter; " + "this copies it on its own, from a clone of the repo.") + else: + note = ("Install just this, from a clone of the repo. It is copied, not wired: " + "the installer prints the settings.json snippet to add.") needs = '

%s

\n' % needs_html if needs_html else "" return ('
\n

Install

\n

%s

\n' '
%s   # bash, Git Bash, macOS, Linux\n%s   # Windows PowerShell
\n' @@ -658,10 +671,10 @@ def _source_section(e): links.append(("agents/README.md#%s" % e["name"], "%s/blob/master/agents/README.md#%s" % (REPO_URL, e["name"]))) elif e["group"] == "hooks": - anchor = hook_anchor(e["_x"]["heading"]) + anchor, readme = hook_anchor(e["_x"]["heading"]), hook_readme(e) links.append((e["source"], "%s/blob/master/%s" % (REPO_URL, e["source"]))) - links.append(("hooks/README.md#%s" % anchor, - "%s/blob/master/hooks/README.md#%s" % (REPO_URL, anchor))) + links.append(("%s#%s" % (readme, anchor), + "%s/blob/master/%s#%s" % (REPO_URL, readme, anchor))) else: links.append((e["source"] + "/SKILL.md", "%s/blob/master/%s/SKILL.md" % (REPO_URL, e["source"]))) if e["readme"]: diff --git a/scripts/site_map.py b/scripts/site_map.py index 3899e05..34ccde6 100644 --- a/scripts/site_map.py +++ b/scripts/site_map.py @@ -111,7 +111,8 @@ def related_md(root, e, agents_readme): pre, pairs, _base = site_detail.raw_sections(root, e, agents_readme) ordered, _extras = site_detail.organise(pre, pairs) body = next((b for k, _t, b in ordered if k == "related"), "") - base_dir = {"agents": "agents", "hooks": "hooks"}.get(e["group"], e["source"]) + base_dir = ("agents" if e["group"] == "agents" else + posixpath.dirname(site_detail.hook_readme(e)) if e["group"] == "hooks" else e["source"]) return body, base_dir @@ -125,7 +126,7 @@ def link_targets(root, entries, name, md, base_dir, cache=None): if e["group"] == "agents": agents.append(e["name"]) elif e["group"] == "hooks": - hook_anchors[site_detail.hook_anchor(e["_x"]["heading"])] = e["name"] + hook_anchors[(site_detail.hook_readme(e), site_detail.hook_anchor(e["_x"]["heading"]))] = e["name"] else: by_path[e["source"] + "/README.md"] = e["name"] out = [] @@ -141,12 +142,12 @@ def link_targets(root, entries, name, md, base_dir, cache=None): _fail("%s leaves the repo" % where) if not os.path.exists(os.path.join(root, *key.split("/"))): _fail("%s: no such file" % where) - if key == "agents/README.md" and frag in agents: + if (key, frag) in hook_anchors: + out.append(hook_anchors[(key, frag)]) + elif key == "agents/README.md" and frag in agents: out.append(frag) elif key == "agents/README.md" and not frag: out.extend(n for n in _TICK_RE.findall(text) if n in agents) - elif key == "hooks/README.md" and frag in hook_anchors: - out.append(hook_anchors[frag]) else: if frag and key.endswith(".md") and frag not in _anchors(root, key, cache): _fail("%s: no heading #%s" % (where, frag)) diff --git a/scripts/test_build_site.py b/scripts/test_build_site.py index 13d5190..fbcb290 100644 --- a/scripts/test_build_site.py +++ b/scripts/test_build_site.py @@ -130,8 +130,9 @@ def test_counts_match_lint(self): skills, agents, hooks = build_site.counts(self.entries) self.assertEqual(skills, len(lint.find_skill_files(ROOT))) self.assertEqual(agents, len(lint.find_agent_files(ROOT))) - self.assertEqual(hooks, len(glob(os.path.join(ROOT, "hooks", "*.py")))) - self.assertEqual(hooks, 2) + self.assertEqual(hooks, len(glob(os.path.join(ROOT, "hooks", "*.py"))) + + len(glob(os.path.join(ROOT, "agents", "hooks", "*.py")))) + self.assertEqual(hooks, 3) line = build_site.count_line(self.entries) self.assertEqual(line, "%d skills · %d agents · %d hooks" % (skills, agents, hooks)) self.assertIn(build_site.esc(line), self.index) @@ -151,8 +152,23 @@ def test_hook_entries(self): "Block billable commands without an approval. PreToolUse hook for Bash")) with open(os.path.join(self.out, "spend-guard", "index.html"), encoding="utf-8") as f: self.assertIn('invocation PreToolUse hook', f.read()) - self.assertNotIn("guard-git", by) + g = by["guard-git"] # agents/hooks/, documented in agents/README.md + self.assertEqual((g["group"], g["source"]), ("hooks", "agents/hooks/guard-git.py")) + self.assertTrue(g["summary"].startswith("PreToolUse hook that stops the implementer and manager")) + self.assertNotIn("\n## ", g["readme"]) + with open(os.path.join(self.out, "guard-git", "index.html"), encoding="utf-8") as f: + page = f.read() + self.assertIn('invocation PreToolUse hook', page) + self.assertIn("agents/README.md#hooksguard-gitpy", page) self.assertIn('href="/spend-guard"', self.index) + self.assertIn('href="/guard-git"', self.index) + + def test_list_shows_third_party_author(self): + self.assertIn('/ponytail(Dietrich Gebert) ', + self.index) + self.assertIn('/grilling(Matt Pocock) ', + self.index) + self.assertIn('/pairRun one', self.index) def test_name_collision_fails(self): real = build_site.collect(ROOT) diff --git a/scripts/test_remind.py b/scripts/test_remind.py index c7df853..98b8363 100644 --- a/scripts/test_remind.py +++ b/scripts/test_remind.py @@ -327,6 +327,7 @@ def test_merged_with_guard_once(self): self.assertTrue((self.sw / "remind.py").is_file()) data = self.store() data["reminders"][0]["at"] = time.time() - 1 # the copy runs on the real clock + data["reminders"][1]["at"] = time.time() + 3600 # not due on any real date (self.sw / "reminders.json").write_text(json.dumps(data)) self.assertEqual(self.switch_hook(), "work at 92%: run /switch personal\nReminder 1: tea") self.assertIsNone(self.switch_hook()) diff --git a/scripts/test_site_detail.py b/scripts/test_site_detail.py index bf30b03..20eeb60 100644 --- a/scripts/test_site_detail.py +++ b/scripts/test_site_detail.py @@ -124,7 +124,8 @@ def test_install_section_first(self): self.assertEqual(first.groups(), ("install", "Install"), name) self.assertIn("./scripts/install-skills.sh --skill %s #" % name, page, name) self.assertIn(".\\scripts\\install-skills.ps1 -Skill %s #" % name, page, name) - self.assertEqual("copied, not wired" in page, self.by[name]["group"] == "hooks", name) + self.assertEqual("copied, not wired" in page, + self.by[name]["group"] == "hooks" and name != "guard-git", name) def test_needs_line_in_install_not_body(self): # the README's "Works on its own." / "Needs: ..." line sits under the @@ -303,8 +304,8 @@ def _example(self, name): return m.group(1) def test_every_entry_has_example_no_warnings(self): - self.assertEqual(len(self.entries), 35) # 29 skills, 4 agents, 2 hooks - self.assertEqual(build_site.counts(self.entries), (29, 4, 2)) + self.assertEqual(len(self.entries), 36) # 29 skills, 4 agents, 3 hooks + self.assertEqual(build_site.counts(self.entries), (29, 4, 3)) self.assertNotIn("WARN", self.stderr) for name in self.pages: self.assertIn('
', diff --git a/scripts/test_workflow_deps.py b/scripts/test_workflow_deps.py index 17bbdf5..8101091 100644 --- a/scripts/test_workflow_deps.py +++ b/scripts/test_workflow_deps.py @@ -39,11 +39,12 @@ def test_every_workflow_closed_and_real(self): for n in r["agent"]: self.assertTrue(os.path.isfile(os.path.join(ROOT, "agents", n + ".md")), n) for n in r["hook"]: - self.assertTrue(os.path.isfile(os.path.join(ROOT, "hooks", n + ".py")), n) + self.assertTrue(os.path.isfile(os.path.join(ROOT, self.g.by_name[n]["source"])), n) # hooks only come from the workflow's own steps self.assertEqual(set(r["hook"]), {s["name"] for s in wf["steps"] if s["kind"] == "hook"}, wf["name"]) lines = self.g.lines([wf["name"]]) - self.assertEqual("agent-hooks" in lines, bool(r["agent"]), wf["name"]) + self.assertEqual("agent-hooks" in lines, bool(r["agent"]) or "guard-git" in r["hook"], wf["name"]) + self.assertNotIn("hook guard-git", lines, wf["name"]) # agents/hooks/, not hooks/ def test_no_installed_skill_links_outside_selection(self): # every relative link from a selected skill's own files into another @@ -90,7 +91,9 @@ def test_snippet(self): self.assertEqual(data["hooks"]["PreToolUse"][0]["matcher"], "Bash|PowerShell|mcp__.*") def test_every_hook_has_wiring(self): - hooks = [n for n, k in self.g.kinds.items() if k == "hook"] + # agents/hooks/ (guard-git) are wired in agent frontmatter, not by a snippet + hooks = [n for n, k in self.g.kinds.items() + if k == "hook" and not self.g.by_name[n]["source"].startswith("agents/")] self.assertEqual(sorted(hooks), sorted(workflow_deps.HOOK_WIRING)) @@ -103,12 +106,13 @@ def copy_like_installer(g, r, dest): for n in r["skill"]: shutil.copytree(os.path.join(ROOT, g.by_name[n]["source"]), os.path.join(dest, "skills", n), ignore=shutil.ignore_patterns("__pycache__")) - if r["agent"]: + agent_hooks = [n for n in r["hook"] if g.by_name[n]["source"].startswith("agents/")] + if r["agent"] or agent_hooks: shutil.copytree(os.path.join(ROOT, "agents", "hooks"), os.path.join(dest, "agents", "hooks"), ignore=shutil.ignore_patterns("__pycache__")) for n in r["agent"]: shutil.copy(os.path.join(ROOT, "agents", n + ".md"), os.path.join(dest, "agents")) - for n in r["hook"]: + for n in (h for h in r["hook"] if h not in agent_hooks): os.makedirs(os.path.join(dest, "hooks"), exist_ok=True) shutil.copy(os.path.join(ROOT, "hooks", n + ".py"), os.path.join(dest, "hooks")) @@ -162,6 +166,7 @@ def test_each_kind_alone(self): self.assertIn("next", r["skill"]) self.assertIn("agent-hooks", self.g.lines(names=["planner"])) self.assertEqual(self.g.resolve(names=["spend-guard"]), {"skill": [], "agent": [], "hook": ["spend-guard"]}) + self.assertEqual(self.g.lines(names=["guard-git"]), ["agent-hooks"]) def test_unknown_name_raises(self): with self.assertRaises(workflow_deps.UnknownName): diff --git a/scripts/workflow_deps.py b/scripts/workflow_deps.py index 8332b7b..c212134 100644 --- a/scripts/workflow_deps.py +++ b/scripts/workflow_deps.py @@ -12,6 +12,7 @@ agent agents/.md hook hooks/.py (copied, never wired) agent-hooks agents/hooks/ (guard-git), once if any agent + or guard-git itself is selected workflow_deps.py snippet HOOKS_DIR HOOK [HOOK ...] settings.json snippet for hooks copied into HOOKS_DIR @@ -177,8 +178,9 @@ def lines(self, wf_names=(), names=()): r = self.resolve(wf_names, names) out = ["skill " + self.by_name[n]["source"] for n in r["skill"]] out += ["agent " + n for n in r["agent"]] - out += ["hook " + n for n in r["hook"]] - if r["agent"]: + agent_hooks = [n for n in r["hook"] if self.by_name[n]["source"].startswith("agents/")] + out += ["hook " + n for n in r["hook"] if n not in agent_hooks] + if r["agent"] or agent_hooks: out.append("agent-hooks") return out diff --git a/site/README.md b/site/README.md index 73816e5..479aa5f 100644 --- a/site/README.md +++ b/site/README.md @@ -28,8 +28,9 @@ only), with [`site_detail.py`](../scripts/site_detail.py) for detail pages and [`md_render.py`](../scripts/md_render.py), a small Markdown renderer that escapes all raw HTML and drops non-http(s)/mailto link URLs. It reuses `scripts/lint.py`'s discovery, so the site lists exactly what lint counts. Groups: top-level skill dirs = core, `kanban/*` = kanban, -`agents/*.md` (minus README) = agents, `hooks/*.py` = hooks (lint doesn't -count hooks; `agents/hooks/guard-git.py` is not listed). The home count line +`agents/*.md` (minus README) = agents, `hooks/*.py` and `agents/hooks/*.py` += hooks (lint doesn't count hooks; `guard-git` takes its page from the +`## hooks/guard-git.py` section of `agents/README.md`). The home count line reads `N skills · M agents · K hooks`. The build fails on a name shared by two entries of any kind. @@ -150,7 +151,7 @@ preview deploys. inherited from a group README; entries in no workflow get no section. - **Warning.** An entry with no Example prints `WARN: : no Example section ...` on stderr (the build still - succeeds); a test keeps all 35 entries (29 skills, 4 agents, 2 hooks) at + succeeds); a test keeps all 36 entries (29 skills, 4 agents, 3 hooks) at zero warnings. - **Back home.** A visible `← all commands` link above the echoed command; on the prompt, Esc clears the text, then (bare `/`, menu open or not) diff --git a/site/data/meta.json b/site/data/meta.json index 06f363c..11b9dd4 100644 --- a/site/data/meta.json +++ b/site/data/meta.json @@ -50,7 +50,7 @@ "repo-and-safety": ["repo-showcase", "repo-scrub", "spend-gate"], "kanban-board": ["next", "session-handoff", "update-progress", "divide", "kanban-setup"], "agents": ["planner", "manager", "implementer", "verifier"], - "hooks": ["smartzone", "spend-guard"] + "hooks": ["guard-git", "smartzone", "spend-guard"] }, "authors_note": "Default author is Krish. Third-party entries must match THIRD_PARTY_LICENSES.md.", "default_author": "Krish", @@ -60,7 +60,7 @@ "handoff": "derived from Matt Pocock", "handoff-auto": "derived from Matt Pocock", "wayfinder": "derived from Matt Pocock", - "ponytail": "DietrichGebert" + "ponytail": "Dietrich Gebert" }, "model_effort_note": "Suggested model (haiku|sonnet|opus) and /effort (low|medium|high|xhigh|max) per entry; every entry exactly once. Must match an entry's own model/effort frontmatter when it sets one. Hooks run no model: cheapest values.", "model_effort": { @@ -96,6 +96,7 @@ "manager": {"model": "opus", "effort": "high"}, "planner": {"model": "opus", "effort": "medium"}, "verifier": {"model": "opus", "effort": "high"}, + "guard-git": {"model": "haiku", "effort": "low"}, "smartzone": {"model": "haiku", "effort": "low"}, "spend-guard": {"model": "haiku", "effort": "low"}, "find-me-a-skill": {"model": "haiku", "effort": "low"} diff --git a/site/data/workflows.json b/site/data/workflows.json index 0387cd7..d7bb0c3 100644 --- a/site/data/workflows.json +++ b/site/data/workflows.json @@ -72,6 +72,7 @@ {"kind": "agent", "name": "planner", "why": "Runs next, picks exactly one issue and returns a kickoff brief of at most 40 lines."}, {"kind": "agent", "name": "manager", "why": "Briefs an implementer, reviews every round inline and reruns the tests itself, for up to 3 rounds."}, {"kind": "agent", "name": "implementer", "why": "Implements on its branch, commits small, watches the tests pass and ends with session-handoff."}, + {"kind": "hook", "name": "guard-git", "why": "Runs on every manager and implementer shell command: a feature-branch push and gh pr create get through, while pushes to main, force pushes, merges and other remote writes are blocked, so only the orchestrator merges."}, {"kind": "agent", "name": "verifier", "why": "Runs update-progress end to end, answers or parks deviations, then runs next."}, {"kind": "human", "why": "When the statusline warns near the limit (90%), type /switch between merges; it is user-only, so Claude can't start it."}, {"kind": "skill", "name": "switch", "why": "The session resumes on the other account; running subagents pause (resume each with SendMessage to its id) and background commands end, hence between merges."}, diff --git a/site/src/style.css b/site/src/style.css index f171ac0..7583730 100644 --- a/site/src/style.css +++ b/site/src/style.css @@ -270,6 +270,7 @@ body:not(.page-home):not(.detail) .wrap { padding-top: 84px; } .skill-list a:hover { background: var(--sel-bg); } .skill-list a:hover .nm { color: var(--accent-sel); } .skill-list .nm { flex: 0 0 11em; color: var(--accent); } +.skill-list .by { font-style: italic; } .skill-list .ds { color: var(--dim); display: -webkit-box; diff --git a/site/test/filter.test.mjs b/site/test/filter.test.mjs index e903366..66fd107 100644 --- a/site/test/filter.test.mjs +++ b/site/test/filter.test.mjs @@ -398,12 +398,12 @@ test("real nav data shape: kind derived from group, aliases optional", () => { test("hooks are in the real nav data and searchable", () => { const hooks = REAL.filter((x) => x.kind === "hook").map((x) => x.name).sort(); - assert.deepEqual(hooks, ["smartzone", "spend-guard"]); - assert.ok(!REAL.some((x) => x.name === "guard-git"), "agent hook stays out"); + assert.deepEqual(hooks, ["guard-git", "smartzone", "spend-guard"]); // guard-git from agents/hooks/ // prefix, mid-word fragment, 1-2 char typos (the per-name loop below covers more) for (const [buf, name] of [["/smart", "smartzone"], ["/spend-gu", "spend-guard"], ["/artzon", "smartzone"], ["/end-gua", "spend-guard"], ["/smratzone", "smartzone"], ["/spend-gaurd", "spend-guard"], - ["/smartzn", "smartzone"], ["/spnd-gurd", "spend-guard"], ["/smart-zone", "smartzone"]]) { + ["/smartzn", "smartzone"], ["/spnd-gurd", "spend-guard"], ["/smart-zone", "smartzone"], + ["/guard", "guard-git"], ["/gaurd-git", "guard-git"]]) { assert.ok(names(F.search(buf, REAL).options).includes(name), buf + " -> " + name); } });