From 2e134d1dcd31aceca46164f854580340a9c90aec Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Fri, 3 Jul 2026 01:47:40 +0100 Subject: [PATCH 1/6] feat(claude-code-error-hermit): scaffold + hatch + API client (PROP-013 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New standalone domain plugin: a production-error watcher for Sentry/GlitchTip. Phase 1 of 4 — the scaffold, setup wizard, and zero-dependency API client. Each phase ships as an independently mergeable PR. - scripts/error-api-lib.ts + error-api.ts: Bun stdlib fetch client over the Sentry /api/0/ shape (GlitchTip-compatible). Subcommands check, issues, issue, latest-event, resolve/mute. Token never printed (redact()). - Write gating in two layers: in-CLI --confirm refusal (authoritative, sends no request without it) + write-confirm-gate.ts PreToolUse hook (fail-open). - skills/hatch: verifies credentials with a live check, injects the Error Watch block, extends the knowledge schema, stamps config.json. - Offline test suite: lib parsers, a Bun.serve fixture server driving the CLI, the hook matrix, skill-structure checks. No live backend needed. - Root: 7th marketplace entry, test-error.yml CI (path-filtered), CLAUDE.md plugin count 6 to 7. Watch loop (Phase 2) is wired via the core routines mechanism, not the non-existent "heartbeat precheck convention" the proposal assumed. Plan: ~/.claude/plans/propose-me-a-plan-immutable-swing.md Claude-Session: https://claude.ai/code/session_014ofkRhSQNaZpnESM2uGZUZ --- .claude-plugin/marketplace.json | 22 ++ .github/workflows/test-error.yml | 42 +++ CLAUDE.md | 4 +- .../.claude-plugin/hermit-meta.json | 6 + .../.claude-plugin/plugin.json | 26 ++ plugins/claude-code-error-hermit/CHANGELOG.md | 29 ++ plugins/claude-code-error-hermit/CLAUDE.md | 70 +++++ plugins/claude-code-error-hermit/LICENSE | 21 ++ plugins/claude-code-error-hermit/README.md | 56 ++++ .../docs/knowledge-schema.md | 17 ++ .../claude-code-error-hermit/hooks/hooks.json | 19 ++ .../hooks/write-confirm-gate.ts | 89 ++++++ .../scripts/error-api-lib.ts | 212 ++++++++++++++ .../scripts/error-api.ts | 268 ++++++++++++++++++ .../claude-code-error-hermit/settings.json | 28 ++ .../skills/hatch/SKILL.md | 193 +++++++++++++ .../state-templates/CLAUDE-APPEND.md | 65 +++++ .../tests/error-api.test.ts | 217 ++++++++++++++ .../tests/fixtures/error-401.json | 3 + .../tests/fixtures/event-latest.json | 30 ++ .../tests/fixtures/issue-detail.json | 12 + .../tests/fixtures/issues-list.json | 26 ++ .../tests/fixtures/org.json | 6 + .../tests/hook.test.ts | 85 ++++++ .../claude-code-error-hermit/tests/run-all.sh | 30 ++ .../tests/skill-structure.test.ts | 60 ++++ .../tests/test-utils.ts | 31 ++ 27 files changed, 1665 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test-error.yml create mode 100644 plugins/claude-code-error-hermit/.claude-plugin/hermit-meta.json create mode 100644 plugins/claude-code-error-hermit/.claude-plugin/plugin.json create mode 100644 plugins/claude-code-error-hermit/CHANGELOG.md create mode 100644 plugins/claude-code-error-hermit/CLAUDE.md create mode 100644 plugins/claude-code-error-hermit/LICENSE create mode 100644 plugins/claude-code-error-hermit/README.md create mode 100644 plugins/claude-code-error-hermit/docs/knowledge-schema.md create mode 100644 plugins/claude-code-error-hermit/hooks/hooks.json create mode 100644 plugins/claude-code-error-hermit/hooks/write-confirm-gate.ts create mode 100644 plugins/claude-code-error-hermit/scripts/error-api-lib.ts create mode 100644 plugins/claude-code-error-hermit/scripts/error-api.ts create mode 100644 plugins/claude-code-error-hermit/settings.json create mode 100644 plugins/claude-code-error-hermit/skills/hatch/SKILL.md create mode 100644 plugins/claude-code-error-hermit/state-templates/CLAUDE-APPEND.md create mode 100644 plugins/claude-code-error-hermit/tests/error-api.test.ts create mode 100644 plugins/claude-code-error-hermit/tests/fixtures/error-401.json create mode 100644 plugins/claude-code-error-hermit/tests/fixtures/event-latest.json create mode 100644 plugins/claude-code-error-hermit/tests/fixtures/issue-detail.json create mode 100644 plugins/claude-code-error-hermit/tests/fixtures/issues-list.json create mode 100644 plugins/claude-code-error-hermit/tests/fixtures/org.json create mode 100644 plugins/claude-code-error-hermit/tests/hook.test.ts create mode 100644 plugins/claude-code-error-hermit/tests/run-all.sh create mode 100644 plugins/claude-code-error-hermit/tests/skill-structure.test.ts create mode 100644 plugins/claude-code-error-hermit/tests/test-utils.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index efb4dd05..ae37dce8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -109,6 +109,28 @@ "gtapps" ] }, + { + "name": "claude-code-error-hermit", + "source": "./plugins/claude-code-error-hermit", + "description": "Production-error watcher for Sentry/GlitchTip — triage new error groups against a noise ledger, correlate regressions with releases, approval-gated resolve/mute", + "version": "0.0.1", + "category": "development", + "author": { + "name": "gtapps", + "url": "https://github.com/gtapps" + }, + "license": "MIT", + "homepage": "https://github.com/gtapps/claude-code-hermit", + "repository": "https://github.com/gtapps/claude-code-hermit", + "keywords": [ + "sentry", + "glitchtip", + "errors", + "observability", + "triage", + "gtapps" + ] + }, { "name": "hermit-scribe", "source": "./plugins/hermit-scribe", diff --git a/.github/workflows/test-error.yml b/.github/workflows/test-error.yml new file mode 100644 index 00000000..50dcd492 --- /dev/null +++ b/.github/workflows/test-error.yml @@ -0,0 +1,42 @@ +name: Test Error Hermit + +on: + push: + branches: [main] + paths: + - 'plugins/claude-code-error-hermit/**' + - '.github/workflows/test-error.yml' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + pull_request: + paths: + - 'plugins/claude-code-error-hermit/**' + - '.github/workflows/test-error.yml' + - 'package.json' + - 'bun.lock' + - 'tsconfig.json' + +jobs: + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.11' + - run: bun install --frozen-lockfile + - run: bunx tsc + + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: plugins/claude-code-error-hermit + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.11' + - name: Run test suite + run: bash tests/run-all.sh diff --git a/CLAUDE.md b/CLAUDE.md index 133ded4b..41ab2577 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,7 @@ # claude-code-hermit (monorepo) -This repo is a multi-plugin Claude Code marketplace. Six plugins ship from `plugins//`: -`claude-code-hermit` (core), `claude-code-dev-hermit`, `claude-code-homeassistant-hermit`, `claude-code-fitness-hermit`, `hermit-scribe`, `laravel-forge-hermit`. +This repo is a multi-plugin Claude Code marketplace. Seven plugins ship from `plugins//`: +`claude-code-hermit` (core), `claude-code-dev-hermit`, `claude-code-homeassistant-hermit`, `claude-code-fitness-hermit`, `hermit-scribe`, `laravel-forge-hermit`, `claude-code-error-hermit`. Each plugin has its own `CLAUDE.md`, `CHANGELOG.md`, and `tests/` — read those for plugin-specific context. The top-level `.claude-plugin/marketplace.json` is the only marketplace. The README at the repo root is the canonical hermit pitch. diff --git a/plugins/claude-code-error-hermit/.claude-plugin/hermit-meta.json b/plugins/claude-code-error-hermit/.claude-plugin/hermit-meta.json new file mode 100644 index 00000000..ce3b203d --- /dev/null +++ b/plugins/claude-code-error-hermit/.claude-plugin/hermit-meta.json @@ -0,0 +1,6 @@ +{ + "required_core_version": ">=1.2.14", + "requires": { + "claude-code-hermit": ">=1.2.14" + } +} diff --git a/plugins/claude-code-error-hermit/.claude-plugin/plugin.json b/plugins/claude-code-error-hermit/.claude-plugin/plugin.json new file mode 100644 index 00000000..4c601f5d --- /dev/null +++ b/plugins/claude-code-error-hermit/.claude-plugin/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "claude-code-error-hermit", + "version": "0.0.1", + "description": "Production-error watcher for Sentry/GlitchTip — triage new error groups against a noise ledger, correlate regressions with releases, approval-gated resolve/mute", + "author": { + "name": "gtapps", + "url": "https://github.com/gtapps" + }, + "repository": "https://github.com/gtapps/claude-code-hermit", + "homepage": "https://github.com/gtapps/claude-code-hermit/tree/main/plugins/claude-code-error-hermit", + "license": "MIT", + "keywords": [ + "sentry", + "glitchtip", + "errors", + "observability", + "triage", + "gtapps" + ], + "dependencies": [ + { + "name": "claude-code-hermit", + "version": "^1.2.14" + } + ] +} diff --git a/plugins/claude-code-error-hermit/CHANGELOG.md b/plugins/claude-code-error-hermit/CHANGELOG.md new file mode 100644 index 00000000..23df3bc3 --- /dev/null +++ b/plugins/claude-code-error-hermit/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog — claude-code-error-hermit + +## [Unreleased] + +## [0.0.1] - 2026-07-03 + +Initial scaffold: a Sentry/GlitchTip watcher plugin (Phase 1 of 4 — API client + hatch). + +### Added +- **API client (`scripts/error-api.ts` + `error-api-lib.ts`)** — zero-dependency Bun `fetch` client covering both Sentry and GlitchTip via the shared `/api/0/` shape. Subcommands: `check`, `issues`, `issue`, `latest-event`, and the approval-gated `resolve` / `mute`. +- **Write gating** — `resolve`/`mute` refuse without an exact `--confirm` token and send no request; a `write-confirm-gate.ts` PreToolUse hook enforces the same at a second layer. +- **Token redaction** — all error output passes through `redact()`; the token is never printed. +- **hatch skill** — verifies credentials with a live `check`, injects the Error Watch block, extends the knowledge schema, and stamps `config.json`. +- **Offline test suite** — lib parsers, a `Bun.serve` fixture server driving the CLI, the hook matrix, and skill-structure checks. No live backend required. + +### Files affected + +| File | Change | +|------|--------| +| `.claude-plugin/{plugin,hermit-meta}.json` | new manifests, core `>=1.2.14` | +| `scripts/error-api-lib.ts`, `scripts/error-api.ts` | API client | +| `hooks/{hooks.json,write-confirm-gate.ts}` | write-confirm gate | +| `skills/hatch/SKILL.md` | setup wizard | +| `state-templates/CLAUDE-APPEND.md`, `docs/knowledge-schema.md` | operator-facing docs | +| `tests/**` | offline suite + fixtures | + +### Upgrade Instructions + +Fresh install — no migration. Run `/claude-code-error-hermit:hatch` in the project you want to watch (after `/claude-code-hermit:hatch`). diff --git a/plugins/claude-code-error-hermit/CLAUDE.md b/plugins/claude-code-error-hermit/CLAUDE.md new file mode 100644 index 00000000..669e3c15 --- /dev/null +++ b/plugins/claude-code-error-hermit/CLAUDE.md @@ -0,0 +1,70 @@ +# claude-code-error-hermit + +A production-error domain layer for `claude-code-hermit`: watches a Sentry/GlitchTip project, triages new error groups against a noise ledger, correlates regressions with releases, and (in later phases) reproduces, bisects, and drafts fixes. All over a zero-dependency Bun `fetch` client — no SDK, no MCP server. + +## This Repo is a Plugin + +Installed into a target project (the app you want to watch) via: + +``` +claude plugin marketplace add gtapps/claude-code-hermit +claude plugin install claude-code-error-hermit@claude-code-hermit --scope local +``` + +After install, run `/claude-code-error-hermit:hatch`. The core hermit (`claude-code-hermit` ≥1.2.14) must be installed and hatched first — `hatch` prompts if it isn't. + +## Plugin Structure + +- `skills/hatch/` — one-time setup wizard (`/claude-code-error-hermit:hatch`) +- `scripts/error-api-lib.ts` — pure helpers: `resolveConfig`, `apiRequest`, `redact`, `summarizeIssue`/`summarizeEvent`, query/path builders. Imported by the CLI and the tests so both exercise the same code. +- `scripts/error-api.ts` — CLI: `check`, `issues`, `issue`, `latest-event`, `resolve --confirm`, `mute --confirm` +- `hooks/write-confirm-gate.ts` — PreToolUse Bash hook: blocks `resolve`/`mute` without `--confirm` +- `state-templates/CLAUDE-APPEND.md` — Error Watch block injected by hatch +- `docs/knowledge-schema.md` — work-product types and retention +- `.claude-plugin/plugin.json` — plugin manifest +- `.claude-plugin/hermit-meta.json` — hermit-internal fields (`required_core_version`, `requires`) + +## Architecture + +The agent calls `bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts ` via Bash. The script reads `.env` at the project root (`loadEnv`, `process.env` wins) and talks to the tracker over stdlib `fetch` with a `Bearer` header. **GlitchTip implements the Sentry `/api/0/` API shape, so one client covers both backends** — the endpoint set is the minimal intersection both support. + +**lib/CLI split.** `error-api-lib.ts` is pure (no `process.exit`, no argv). The tests spawn the CLI against a local `Bun.serve` fixture server and also unit-test the lib parsers directly. No live backend is needed to test; `hatch` runs the live `check` at operator setup time. + +**Write gating is two independent layers.** The in-CLI `--confirm` refusal (authoritative — sends no request without it) and the `write-confirm-gate.ts` PreToolUse hook (defense-in-depth, fail-open). Neither is optional. + +**Cursor rule (Phase 2+).** `state/error-cursor.json` is read by `error-precheck.ts` and written only by the `error-triage` skill after a successful run. The precheck never mutates it — a broken precheck must never silently advance the cursor. + +## Core Rules + +- **Never echo, cat, grep, or Read `.env`** — check credentials with `error-api.ts check`. `ERROR_HERMIT_TOKEN` contains the literal `TOKEN`, which trips the base hermit deny-pattern hook. +- **The token is never printed.** All error text passes through `redact()` before any output. Keep it that way. +- **Event payloads may contain secrets.** Scrub before relay and before persistence — see the CLAUDE-APPEND secret-hygiene rule. +- **resolve/mute are surface-then-approve.** Never mutate the tracker autonomously. +- No persona, agent name, or sign-off copy — those live in the consumer's `config.json`. + +## Deliberately omitted in v1 + +- **`domain-brainstorm` hook-in.** Fitness ships one; this plugin does not, to keep the surface minimal. Add later if the brainstorm contract proves valuable here. + +## Development + +Test locally against a target project without publishing: + +``` +cd /path/to/target-project +claude --plugin-dir /path/to/plugins/claude-code-error-hermit +``` + +Under `--plugin-dir`, `${CLAUDE_PLUGIN_ROOT}` is NOT substituted — use the absolute plugin path in commands. + +Run the suite from the plugin dir: + +```bash +bash tests/run-all.sh # skill-structure + hook + api-client (all offline) +``` + +## Development constraints + +- `tests/skill-structure.test.ts` holds a hardcoded `SKILLS` array. Add a skill → add its entry (with expected Gate count). +- The deny-pattern hook blocks any Bash arg containing literal `TOKEN`. Never put `ERROR_HERMIT_TOKEN` on a command line. +- When aligning with a new core version, sweep `skills/`, `state-templates/`, `docs/` for stale hermit-facing terms. diff --git a/plugins/claude-code-error-hermit/LICENSE b/plugins/claude-code-error-hermit/LICENSE new file mode 100644 index 00000000..91ad9ddd --- /dev/null +++ b/plugins/claude-code-error-hermit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 gtapps + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/claude-code-error-hermit/README.md b/plugins/claude-code-error-hermit/README.md new file mode 100644 index 00000000..1eb1260e --- /dev/null +++ b/plugins/claude-code-error-hermit/README.md @@ -0,0 +1,56 @@ +# claude-code-error-hermit + +A production-error watcher for [Sentry](https://sentry.io) and [GlitchTip](https://glitchtip.com), built on `claude-code-hermit`. It watches a project's error stream, classifies new groups against a known-noise ledger, correlates regressions with your releases, and — in later phases — reproduces the failure locally, bisects it against recent commits, and drafts a fix on a branch with the failing case as a test. + +> **What this is not.** Reacting to a single error event — "an error fired, open an issue, run a workflow" — is [GitHub Actions](https://docs.github.com/actions) territory, and a Sentry-webhook → GitHub-issue → Actions pipeline does it well. This plugin deliberately concedes that half. It earns its place on the *stateful* work no cloud pipeline holds at once: the new-vs-regression-vs-noise ledger that remembers across weeks, a repo checkout for local repro and bisect, and a draft fix with a reproducing test — reachable from your phone via the hermit's approval channel. + +## Requirements + +- `claude-code-hermit` ≥ 1.2.14, installed and hatched in the project. +- A Sentry or GlitchTip auth token. GlitchTip works because it implements the Sentry `/api/0/` API; one client covers both. +- **Run it inside the repo of the application you are watching.** Phases 3–4 (repro, bisect, draft fix) operate on that repo's own git history; if the tracker project doesn't map to the current repo, those skills stop and say so. + +## Install + +``` +claude plugin marketplace add gtapps/claude-code-hermit +claude plugin install claude-code-error-hermit@claude-code-hermit --scope local +``` + +Then, in the target project: + +``` +/claude-code-error-hermit:hatch +``` + +Hatch prompts for credentials, verifies them with a live connectivity check, injects the Error Watch block into your `CLAUDE.md`, and stamps `config.json`. + +## Configuration + +Four values in a gitignored `.env` at the project root (copy `.env.example`): + +| Variable | Meaning | +|---|---| +| `ERROR_HERMIT_TOKEN` | Sentry/GlitchTip auth token (`project:read`, `event:read`, `org:read`; `project:write` for approved resolve/mute) | +| `ERROR_HERMIT_BASE_URL` | `https://sentry.io` or your GlitchTip URL (no trailing slash) | +| `ERROR_HERMIT_ORG` | organization slug | +| `ERROR_HERMIT_PROJECT` | project slug | + +## Safety + +- **resolve / mute are approval-gated.** The hermit surfaces the target issue and waits for your explicit approval; the tracker is only mutated on a `--confirm` command, enforced by both an in-CLI refusal and a PreToolUse hook. +- **The hermit never pushes.** Draft fixes (Phase 3) stop at a local branch and hand off to `/claude-code-dev-hermit:dev-pr` for the sanctioned push. +- **Secrets stay out.** The token is never printed, and event payloads are scrubbed before any channel relay or file write. + +## Roadmap + +| Phase | Ships | Status | +|---|---|---| +| 1 | Scaffold, hatch, API client (`check` / `issues` / `issue` / `latest-event` / `resolve` / `mute`) | this release (0.0.1) | +| 2 | Watch loop: zero-cost precheck + `error-triage` skill + noise ledger | planned | +| 3 | `error-reproduce` (worktree + failing test + bisect) + `error-draft-fix` | planned | +| 4 | `error-incident-summary` + overnight `error-digest` | planned | + +## GlitchTip note + +GlitchTip implements a subset of the Sentry API. This plugin sticks to the core endpoints both support, and `hatch`'s live `check` probes both the org endpoint and the issues query path so a compatibility gap surfaces at setup rather than mid-watch. diff --git a/plugins/claude-code-error-hermit/docs/knowledge-schema.md b/plugins/claude-code-error-hermit/docs/knowledge-schema.md new file mode 100644 index 00000000..7d15c169 --- /dev/null +++ b/plugins/claude-code-error-hermit/docs/knowledge-schema.md @@ -0,0 +1,17 @@ +# claude-code-error-hermit knowledge schema + +## Work Products + +Work products live in `.claude-code-hermit/compiled/`. All artifacts are flat (no subdirectories). + +- `error-noise-ledger`: living ledger of error fingerprints classified as known-noise, known, or fixed-in-``. **Producer**: `error-triage` skill (Phase 2). **Location**: `compiled/error-noise-ledger.md`. **Retention**: indefinite (the classification memory of the whole plugin). + +## Raw Captures + +- `error-triage-log`: per-run triage findings — the groups seen since the cursor, their classification, and release correlation. **Producer**: `error-triage` skill (Phase 2). **Location**: `raw/error-triage-.md`. **Retention**: 30 days. **Secret hygiene**: event detail must be scrubbed of credentials before writing. + +## Deferred types + +Reserved for later phases; must not be created manually until their producing skill ships: + +- `incident-summary` — post-incident writeup (Phase 4, `error-incident-summary` skill). `compiled/incident--.md`. diff --git a/plugins/claude-code-error-hermit/hooks/hooks.json b/plugins/claude-code-error-hermit/hooks/hooks.json new file mode 100644 index 00000000..86fccca2 --- /dev/null +++ b/plugins/claude-code-error-hermit/hooks/hooks.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "profile": "standard,strict", + "hooks": [ + { + "type": "command", + "command": "bun", + "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/write-confirm-gate.ts"], + "timeout": 5 + } + ], + "description": "Block error-api.ts resolve/mute calls lacking --confirm. Read subcommands (check, issues, issue, latest-event) pass through unconditionally." + } + ] + } +} diff --git a/plugins/claude-code-error-hermit/hooks/write-confirm-gate.ts b/plugins/claude-code-error-hermit/hooks/write-confirm-gate.ts new file mode 100644 index 00000000..809c9088 --- /dev/null +++ b/plugins/claude-code-error-hermit/hooks/write-confirm-gate.ts @@ -0,0 +1,89 @@ +#!/usr/bin/env bun +// PreToolUse hook: block error-api.ts resolve/mute calls lacking --confirm. +// +// The PreToolUse Bash hook receives JSON on stdin with shape: +// { tool_name, tool_input: { command: string }, ... } +// +// Strategy: look for "error-api.ts" in the command string, extract the +// subcommand token that follows it, and gate on "resolve" / "mute". Read +// subcommands (check, issues, issue, latest-event, help) pass through +// unconditionally. The in-CLI --confirm refusal is the authoritative gate; +// this hook is defense-in-depth. +// +// Fail-open on transient/unexpected input (per the hermit hook rule: a hook +// must never block Claude Code on a parse glitch). We exit non-zero (block) +// ONLY when we positively identify a write command lacking --confirm. + +import { readFileSync, writeSync } from 'node:fs'; + +function block(message: string): never { + try { + writeSync(2, `${message}\n`); + } catch {} + process.exit(2); +} + +function main(): void { + let payload: unknown; + try { + payload = JSON.parse(readFileSync(0, 'utf8')); + } catch { + process.exit(0); // unparseable input — fail open, in-CLI gate is authoritative + } + + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) { + process.exit(0); + } + + const p = payload as Record; + + if (p['tool_name'] !== 'Bash') process.exit(0); + + const toolInput = p['tool_input']; + if (typeof toolInput !== 'object' || toolInput === null) process.exit(0); + + const command = (toolInput as Record)['command']; + if (typeof command !== 'string') process.exit(0); + + if (!command.includes('error-api.ts')) process.exit(0); + + const tokens = command.trim().split(/\s+/); + const idx = tokens.findIndex((t) => t === 'error-api.ts' || t.endsWith('/error-api.ts')); + + if (idx === -1 || idx + 1 >= tokens.length) { + process.exit(0); // present but no subcommand token — pass through + } + + const subcommand = tokens[idx + 1]!; + + const SAFE_SUBCOMMANDS = [ + 'check', + 'issues', + 'issue', + 'latest-event', + 'help', + '--help', + ]; + if (SAFE_SUBCOMMANDS.includes(subcommand)) { + process.exit(0); + } + + const WRITE_SUBCOMMANDS = ['resolve', 'mute']; + if (!WRITE_SUBCOMMANDS.includes(subcommand)) { + process.exit(0); // unknown subcommand — pass through (in-CLI gate handles it) + } + + if (tokens.includes('--confirm')) { + // exact token, matching the in-CLI args.includes('--confirm') check + process.exit(0); + } + + block( + `error-api.ts ${subcommand} requires --confirm. Surface the target issue to the operator, ` + + `get explicit approval, then re-run with --confirm.`, + ); +} + +if (import.meta.main) { + main(); +} diff --git a/plugins/claude-code-error-hermit/scripts/error-api-lib.ts b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts new file mode 100644 index 00000000..b4356bb1 --- /dev/null +++ b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts @@ -0,0 +1,212 @@ +// Shared pure helpers for the error-hermit Sentry/GlitchTip client. +// +// No process.exit, no argv parsing — safe to import from both error-api.ts +// (the CLI), error-precheck.ts (added in Phase 2), and the test suite, so the +// tests exercise the SAME parsers and redaction the CLI ships. +// +// GlitchTip implements the Sentry `/api/0/` API shape, so one client covers +// both backends. Endpoints used are the minimal set both support. + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; + +export interface ErrorHermitConfig { + token: string; + baseUrl: string; + org: string; + project: string; +} + +export interface ApiResult { + ok: boolean; + status: number; // 0 = network/transport error (never reached the server) + data?: T; + error?: string; // scrubbed of the token before it is ever set +} + +export interface IssueSummary { + id: string; + shortId: string; + title: string; + culprit: string; + level: string; + status: string; + count: string; + firstSeen: string; + lastSeen: string; +} + +export interface EventSummary { + id: string; + message: string; + release: string; + dateCreated: string; + culprit: string; +} + +// Resolve the consumer project root: CLAUDE_PROJECT_DIR if it holds the hermit +// state dir, else walk up from cwd looking for .claude-code-hermit/config.json, +// else cwd. Mirrors forge.php projectRoot() so .env resolves the same way. +export function projectRoot(): string { + const proj = process.env.CLAUDE_PROJECT_DIR; + if (proj && safeExists(join(proj, '.claude-code-hermit'))) return proj; + let dir = process.cwd(); + for (let i = 0; i < 8; i++) { + if (safeExists(join(dir, '.claude-code-hermit', 'config.json'))) return dir; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return process.cwd(); +} + +function safeExists(p: string): boolean { + try { + readFileSync(p); + return true; + } catch { + return false; + } +} + +// Parse a project-root .env into a plain map. Real process.env takes precedence +// in resolveConfig(), matching forge.php's getenv-wins semantics. +export function loadEnv(root: string): Record { + const out: Record = {}; + let text: string; + try { + text = readFileSync(join(root, '.env'), 'utf8'); + } catch { + return out; + } + for (const line of text.split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#') || !t.includes('=')) continue; + const eq = t.indexOf('='); + const key = t.slice(0, eq).trim(); + const val = t.slice(eq + 1).trim().replace(/^["']|["']$/g, ''); + if (key) out[key] = val; + } + return out; +} + +export function resolveConfig(root: string): { config?: ErrorHermitConfig; missing: string[] } { + const fileEnv = loadEnv(root); + const get = (k: string) => (process.env[k] ?? fileEnv[k] ?? '').trim(); + const token = get('ERROR_HERMIT_TOKEN'); + const baseUrl = get('ERROR_HERMIT_BASE_URL').replace(/\/+$/, ''); + const org = get('ERROR_HERMIT_ORG'); + const project = get('ERROR_HERMIT_PROJECT'); + const missing: string[] = []; + if (!token || token === 'replace_me') missing.push('ERROR_HERMIT_TOKEN'); + if (!baseUrl) missing.push('ERROR_HERMIT_BASE_URL'); + if (!org) missing.push('ERROR_HERMIT_ORG'); + if (!project) missing.push('ERROR_HERMIT_PROJECT'); + if (missing.length) return { missing }; + return { config: { token, baseUrl, org, project }, missing: [] }; +} + +export function apiUrl(baseUrl: string, path: string): string { + return `${baseUrl.replace(/\/+$/, '')}${path}`; +} + +// Scrub the token and any bearer/token-shaped substrings before any output or +// persistence. Always run over error text before it leaves this module. +export function redact(text: string, token?: string): string { + let out = text; + if (token) out = out.split(token).join('[REDACTED]'); + out = out.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'); + out = out.replace(/token["']?\s*[=:]\s*["']?[A-Za-z0-9._~+/=-]+/gi, 'token=[REDACTED]'); + return out; +} + +export async function apiRequest( + url: string, + token: string, + opts: { method?: string; body?: unknown } = {}, +): Promise> { + let res: Response; + try { + res = await fetch(url, { + method: opts.method ?? 'GET', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + } catch (e) { + return { ok: false, status: 0, error: redact(String(e), token) }; + } + const raw = await res.text(); + let data: unknown = null; + try { + data = raw ? JSON.parse(raw) : null; + } catch { + data = null; + } + if (!res.ok) { + return { ok: false, status: res.status, error: redact(raw || res.statusText, token) }; + } + return { ok: true, status: res.status, data: data as T }; +} + +export function summarizeIssue(raw: Record): IssueSummary { + const metadata = (raw.metadata ?? {}) as Record; + return { + id: str(raw.id), + shortId: str(raw.shortId), + title: str(raw.title ?? metadata.title ?? metadata.value), + culprit: str(raw.culprit), + level: str(raw.level), + status: str(raw.status), + count: str(raw.count), + firstSeen: str(raw.firstSeen), + lastSeen: str(raw.lastSeen), + }; +} + +export function summarizeEvent(raw: Record): EventSummary { + const releaseObj = (raw.release ?? {}) as Record; + const tags = Array.isArray(raw.tags) ? (raw.tags as Array>) : []; + const releaseTag = tags.find((t) => t.key === 'release'); + return { + id: str(raw.id ?? raw.eventID), + message: str(raw.message ?? raw.title), + release: str(releaseObj.version ?? releaseTag?.value), + dateCreated: str(raw.dateCreated), + culprit: str(raw.culprit), + }; +} + +function str(v: unknown): string { + if (v === undefined || v === null) return ''; + return String(v); +} + +// Build the Sentry issue-search `query` string. Combines an optional free-form +// query with a `firstSeen:>=` bound so the watch loop sees only groups +// that first appeared at or after the cursor (>= handles boundary ties; the +// triage skill dedups against ids already recorded that day). +export function buildIssueQuery(opts: { since?: string; query?: string }): string { + const parts: string[] = []; + if (opts.query) parts.push(opts.query.trim()); + if (opts.since) parts.push(`firstSeen:>=${opts.since}`); + return parts.join(' ').trim(); +} + +export function issuesPath(org: string, project: string): string { + return `/api/0/projects/${encodeURIComponent(org)}/${encodeURIComponent(project)}/issues/`; +} + +export function orgPath(org: string): string { + return `/api/0/organizations/${encodeURIComponent(org)}/`; +} + +export function issuePath(id: string): string { + return `/api/0/issues/${encodeURIComponent(id)}/`; +} + +export function latestEventPath(id: string): string { + return `/api/0/issues/${encodeURIComponent(id)}/events/latest/`; +} diff --git a/plugins/claude-code-error-hermit/scripts/error-api.ts b/plugins/claude-code-error-hermit/scripts/error-api.ts new file mode 100644 index 00000000..17827eb1 --- /dev/null +++ b/plugins/claude-code-error-hermit/scripts/error-api.ts @@ -0,0 +1,268 @@ +#!/usr/bin/env bun +// CLI over the Sentry/GlitchTip API for the error hermit. +// +// Usage: +// bun error-api.ts check +// bun error-api.ts issues [--since ] [--query ] [--limit ] [--json] +// bun error-api.ts issue [--json] +// bun error-api.ts latest-event [--json] +// bun error-api.ts resolve --confirm +// bun error-api.ts mute --confirm +// bun error-api.ts help +// +// Write subcommands (resolve/mute) refuse without an exact --confirm token and +// send NO request in that case — the in-CLI gate is authoritative; the +// PreToolUse write-confirm-gate.ts hook is defense-in-depth. +// +// The token is never printed. All error text is scrubbed via redact() in the +// lib before it reaches stdout/stderr. + +import { + apiRequest, + apiUrl, + buildIssueQuery, + issuePath, + issuesPath, + latestEventPath, + orgPath, + projectRoot, + resolveConfig, + summarizeEvent, + summarizeIssue, + type ErrorHermitConfig, + type IssueSummary, +} from './error-api-lib'; + +function out(line: string): void { + process.stdout.write(`${line}\n`); +} + +function err(line: string): void { + process.stderr.write(`${line}\n`); +} + +function flagValue(args: string[], name: string): string | undefined { + const i = args.indexOf(name); + if (i === -1 || i + 1 >= args.length) return undefined; + return args[i + 1]; +} + +function loadConfigOrReport(): ErrorHermitConfig | null { + const { config, missing } = resolveConfig(projectRoot()); + if (!config) { + out(`missing: ${missing.join(', ')} — run /claude-code-error-hermit:hatch`); + return null; + } + return config; +} + +async function cmdCheck(): Promise { + const { config, missing } = resolveConfig(projectRoot()); + if (!config) { + out(`missing: ${missing.join(', ')}`); + return 1; + } + // 1) org read — validates token + org. + const orgRes = await apiRequest>( + apiUrl(config.baseUrl, orgPath(config.org)), + config.token, + ); + if (!orgRes.ok) { + if (orgRes.status === 401 || orgRes.status === 403) { + out(`invalid: token rejected (${orgRes.status})`); + } else if (orgRes.status === 404) { + out(`invalid: organization "${config.org}" not found (404)`); + } else if (orgRes.status === 0) { + out(`unreachable: ${orgRes.error ?? 'no response from ' + config.baseUrl}`); + } else { + out(`unreachable: HTTP ${orgRes.status}${orgRes.error ? ' — ' + orgRes.error : ''}`); + } + return 1; + } + // 2) issues probe (limit=1) — validates project slug + the query path the + // watch loop depends on (GlitchTip implements a subset of Sentry search). + const probe = await apiRequest( + apiUrl(config.baseUrl, `${issuesPath(config.org, config.project)}?limit=1`), + config.token, + ); + if (!probe.ok) { + if (probe.status === 404) { + out(`invalid: project "${config.project}" not found (404)`); + } else { + out(`unreachable: issues endpoint HTTP ${probe.status}${probe.error ? ' — ' + probe.error : ''}`); + } + return 1; + } + out(`ok: connected to ${config.org}/${config.project}`); + return 0; +} + +function printIssues(issues: IssueSummary[], json: boolean): void { + if (json) { + out(JSON.stringify(issues, null, 2)); + return; + } + if (issues.length === 0) { + out('(no issues)'); + return; + } + for (const it of issues) { + out(`${it.shortId || it.id}\t[${it.level || '?'}]\t${it.count || '?'}×\t${it.title}`); + } +} + +async function cmdIssues(args: string[]): Promise { + const config = loadConfigOrReport(); + if (!config) return 1; + const json = args.includes('--json'); + const query = buildIssueQuery({ + since: flagValue(args, '--since'), + query: flagValue(args, '--query'), + }); + const limit = flagValue(args, '--limit') ?? '25'; + const params = new URLSearchParams(); + if (query) params.set('query', query); + params.set('limit', limit); + const url = apiUrl(config.baseUrl, `${issuesPath(config.org, config.project)}?${params.toString()}`); + const res = await apiRequest>>(url, config.token); + if (!res.ok) { + err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + return 1; + } + const issues = (res.data ?? []).map(summarizeIssue); + printIssues(issues, json); + return 0; +} + +async function cmdIssue(args: string[]): Promise { + const id = args.find((a) => !a.startsWith('--')); + if (!id) { + err('error: issue required'); + return 1; + } + const config = loadConfigOrReport(); + if (!config) return 1; + const res = await apiRequest>( + apiUrl(config.baseUrl, issuePath(id)), + config.token, + ); + if (!res.ok) { + err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + return 1; + } + const summary = summarizeIssue(res.data ?? {}); + if (args.includes('--json')) { + out(JSON.stringify(summary, null, 2)); + } else { + out(`${summary.shortId || summary.id} [${summary.level}] ${summary.status} ${summary.count}×`); + out(summary.title); + out(`first ${summary.firstSeen} last ${summary.lastSeen}`); + } + return 0; +} + +async function cmdLatestEvent(args: string[]): Promise { + const id = args.find((a) => !a.startsWith('--')); + if (!id) { + err('error: latest-event required'); + return 1; + } + const config = loadConfigOrReport(); + if (!config) return 1; + const res = await apiRequest>( + apiUrl(config.baseUrl, latestEventPath(id)), + config.token, + ); + if (!res.ok) { + err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + return 1; + } + const summary = summarizeEvent(res.data ?? {}); + if (args.includes('--json')) { + // Include the raw payload for the reproduce skill (stack frames etc.), but + // the summary carries the release tag the triage skill keys on. + out(JSON.stringify({ summary, raw: res.data }, null, 2)); + } else { + out(`event ${summary.id} release ${summary.release || '(none)'}`); + out(summary.message); + out(summary.culprit); + } + return 0; +} + +async function cmdWrite(kind: 'resolve' | 'mute', args: string[]): Promise { + const id = args.find((a) => !a.startsWith('--')); + if (!id) { + err(`error: ${kind} --confirm required`); + return 1; + } + if (!args.includes('--confirm')) { + err( + `refused: ${kind} mutates the tracker and requires operator approval.\n` + + `Surface the target issue, get explicit approval, then run:\n` + + ` bun scripts/error-api.ts ${kind} ${id} --confirm`, + ); + return 1; + } + const config = loadConfigOrReport(); + if (!config) return 1; + const status = kind === 'resolve' ? 'resolved' : 'ignored'; + const res = await apiRequest>( + apiUrl(config.baseUrl, issuePath(id)), + config.token, + { method: 'PUT', body: { status } }, + ); + if (!res.ok) { + err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + return 1; + } + out(`ok: issue ${id} set to ${status}`); + return 0; +} + +function cmdHelp(): number { + out( + [ + 'error-api.ts — Sentry/GlitchTip client for the error hermit', + '', + ' check verify credentials + connectivity', + ' issues [--since ] [--query ] list issue groups', + ' [--limit ] [--json]', + ' issue [--json] issue-group detail', + ' latest-event [--json] latest event (stack, release)', + ' resolve --confirm mark resolved (approval-gated)', + ' mute --confirm ignore/mute (approval-gated)', + ' help', + ].join('\n'), + ); + return 0; +} + +async function main(): Promise { + const [sub, ...args] = process.argv.slice(2); + switch (sub) { + case 'check': + return cmdCheck(); + case 'issues': + return cmdIssues(args); + case 'issue': + return cmdIssue(args); + case 'latest-event': + return cmdLatestEvent(args); + case 'resolve': + return cmdWrite('resolve', args); + case 'mute': + return cmdWrite('mute', args); + case 'help': + case '--help': + case undefined: + return cmdHelp(); + default: + err(`error: unknown subcommand "${sub}" — try: bun scripts/error-api.ts help`); + return 1; + } +} + +if (import.meta.main) { + main().then((code) => process.exit(code)); +} diff --git a/plugins/claude-code-error-hermit/settings.json b/plugins/claude-code-error-hermit/settings.json new file mode 100644 index 00000000..6e292be1 --- /dev/null +++ b/plugins/claude-code-error-hermit/settings.json @@ -0,0 +1,28 @@ +{ + "permissions": { + "allow": [ + "Bash(bun *scripts/error-api.ts *)", + "Bash(bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts *)", + "Bash(bun *scripts/error-precheck.ts *)", + "Bash(bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-precheck.ts *)", + "Bash(bun test*)", + "Bash(git status)", + "Bash(git log *)", + "Bash(git diff *)", + "Edit(.claude-code-hermit/**)", + "Write(.claude-code-hermit/**)" + ], + "deny": [ + "Bash(rm -rf *)", + "Bash(git push --force*)", + "Bash(git reset --hard*)", + "Bash(chmod 777*)", + "Bash(curl * | bash*)", + "Bash(wget * | bash*)", + "Edit(.env)", + "Write(.env)", + "Edit(**/.claude-code-hermit/OPERATOR.md)", + "Write(**/.claude-code-hermit/OPERATOR.md)" + ] + } +} diff --git a/plugins/claude-code-error-hermit/skills/hatch/SKILL.md b/plugins/claude-code-error-hermit/skills/hatch/SKILL.md new file mode 100644 index 00000000..df5f1739 --- /dev/null +++ b/plugins/claude-code-error-hermit/skills/hatch/SKILL.md @@ -0,0 +1,193 @@ +--- +name: hatch +description: One-time error hermit setup. Verifies Sentry/GlitchTip credentials with a live check, injects the Error Watch block, and stamps config.json. Run once per project after /claude-code-hermit:hatch. +--- + +# Hatch — claude-code-error-hermit + +Idempotent setup wizard for the error hermit. Run **after** `/claude-code-hermit:hatch` has already been completed, inside the repo of the application you want to watch. + +--- + +## Step 1 — Prerequisite check + +Read `.claude-code-hermit/config.json`. + +If the file does not exist or `_hermit_versions["claude-code-hermit"]` is absent or empty: + +> "The base hermit is not set up in this project yet. Run `/claude-code-hermit:hatch` first, then return here." + +Use `AskUserQuestion`: "Would you like to run `/claude-code-hermit:hatch` now? (yes / no)" + +- **yes** → Follow the domain hatch continuation protocol (documented in `claude-code-hermit:hatch`): + 1. Write `.claude-code-hermit/state/hatch-resume.json` with `{ "skill": "claude-code-error-hermit:hatch" }`. + 2. Print: "(If setup doesn't continue automatically when core finishes, re-run `/claude-code-error-hermit:hatch`.)" + 3. Invoke `/claude-code-hermit:hatch` **via the Skill tool** — terminal action, stop after the call. +- **no** → stop. + +If `_hermit_versions["claude-code-hermit"]` is present but the version string is earlier than `1.2.14` (compare major.minor.patch numerically), warn: + +> "Base hermit version is {version}; this plugin requires ≥1.2.14. Run `/claude-code-hermit:hermit-evolve` to upgrade, then re-run this hatch." + +Stop. + +--- + +## Step 2 — Idempotency check + +Read `_hermit_versions["claude-code-error-hermit"]` from `.claude-code-hermit/config.json`. + +Read `version` from `${CLAUDE_PLUGIN_ROOT}/.claude-plugin/plugin.json`. + +If the versions match, say: + +> "claude-code-error-hermit {version} is already installed. Skip to Step 3 to re-verify connectivity, or reply 'full' to re-run the full wizard." + +Use `AskUserQuestion`: "(verify / full)" + +- **verify** → run Step 3's live check only, then jump to Step 9. +- **full** → continue from Step 3. + +If absent or stale: continue from Step 3. + +--- + +## Step 3 — Credentials + live verification + +**IMPORTANT: Do NOT use `grep`, `cat`, `echo`, or any Bash command to read `.env`. The `ERROR_HERMIT_TOKEN` variable contains the literal string `TOKEN`, which trips the base hermit's deny-patterns hook on any Bash command argument. Use the `Read` tool only.** + +Tell the operator: + +> "This plugin needs four values in `.env`. If you haven't done this yet: +> +> 1. `cp .env.example .env` (or copy the file manually) +> 2. Open `.env` and fill in: +> - `ERROR_HERMIT_TOKEN` — a Sentry or GlitchTip auth token (scopes: `project:read`, `event:read`, `org:read`; add `project:write` to allow operator-approved resolve/mute) +> - `ERROR_HERMIT_BASE_URL` — `https://sentry.io` or your GlitchTip URL (no trailing slash) +> - `ERROR_HERMIT_ORG` — organization slug +> - `ERROR_HERMIT_PROJECT` — project slug +> +> Reply 'done' when the file is filled in, or 'abort' to stop." + +Use `AskUserQuestion`: "(done / abort)" + +- **abort** → stop. +- **done** → continue. + +Use the **Read tool** to read `.env`. Verify all four keys are present, non-empty, and not `replace_me`. If any is missing, report which and loop back to the prompt above. + +Then run the **live check** (this reaches the tracker; it self-reports without ever printing the token): + +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts check +``` + +Interpret the single output line: +- `ok: connected to /` → continue. +- `missing: ` → the env is not being picked up; re-check `.env` and loop. +- `invalid: token rejected (401)` / `invalid: organization ... (404)` / `invalid: project ... (404)` → the credential or a slug is wrong; report and loop. +- `unreachable: ...` → base URL wrong or network blocked; report and loop. + +Do not proceed until `check` reports `ok`. + +--- + +## Step 4 — Gitignore + +**Add `.env` to `.gitignore`** if not already present. Read the project `.gitignore` (treat as empty if absent), and append `.env` on its own line using Edit if missing. + +--- + +## Step 5 — Drop state templates + +Copy any routine/ledger templates the plugin ships from `${CLAUDE_PLUGIN_ROOT}/state-templates/compiled/` into the consumer's `.claude-code-hermit/compiled/`. + +For each `*.md` file present in that source directory (in v0.0.1 none ship yet — the noise ledger arrives in a later phase; this step is a no-op until then): +- Read the source (Read tool), check if `.claude-code-hermit/compiled/` exists. +- **Does not exist** → write it (Write tool). Report `✓ dropped `. +- **Already exists** → skip (never overwrite operator edits). Report `⊘ skipped (already present)`. + +--- + +## Step 6 — CLAUDE.md / CLAUDE.local.md inject + +**Resolve target file:** Read `.claude-code-hermit/state/hatch-options.json`. Use the `"target"` field: +- `"local"` → `target_file = CLAUDE.local.md` +- `"committed"` or absent → `target_file = CLAUDE.md` +- If the file doesn't exist (operator's core hermit predates `hatch-options.json`): detect `core_install_scope` from `claude plugin list --json` using the same precedence as core hatch (filter entries where plugin name is `claude-code-hermit` and `enabled == true`; precedence `local` > `project` (both require `projectPath == project root`) > `user` (any `projectPath`) > `null`; map `project` → `committed`, `local`/`user`/`null` → `local`). Ask with `AskUserQuestion` (header: "Visibility"), scope-derived default at position 0 with `(recommended)`: **`.local` files** (gitignored) / **Committed files** (shared). Write the canonical 5-field schema to `.claude-code-hermit/state/hatch-options.json`: + + ```json + { + "target": "", + "core_install_scope": "", + "stamped_at": "", + "stamped_by": "claude-code-error-hermit:hatch", + "version": "" + } + ``` + +Read `target_file`. Search for the opening marker `` (closing marker ``). + +- **`target_file` does not exist** → treat as marker-absent; Edit will create it. +- **Marker absent** → append the full contents of `${CLAUDE_PLUGIN_ROOT}/state-templates/CLAUDE-APPEND.md` to `target_file` using Edit. +- **Marker present** → skip (`hermit-evolve` handles block replacement on upgrade). + +--- + +## Step 7 — Knowledge-schema extension + +Read `.claude-code-hermit/knowledge-schema.md`. + +Check if `error-noise-ledger:` is already present. If **absent**, append under `## Work Products` (create the header if the base schema is only a stub): + +``` +- error-noise-ledger: living ledger of known-noise / known / fixed-in error fingerprints. Maintained by the error-triage skill. location: compiled/error-noise-ledger.md +- incident-summary: post-incident writeup (timeline, root cause, fix link). location: compiled/incident--.md +``` + +And under `## Raw Captures` (create if absent): + +``` +- error-triage-log: raw per-run triage findings (classified groups, correlations). Retention: 30 days. location: raw/error-triage-.md +``` + +If already present: skip (idempotent). Use Edit. + +--- + +## Step 8 — Stamp config.json + +Use the `config.json` content already loaded in Step 1 (do not re-read). + +Set `_hermit_versions["claude-code-error-hermit"]` to the plugin version from Step 2 (update if present, add alongside the existing core entry if absent). + +Write the updated `config.json` using the Write tool (full-file replacement to guarantee valid JSON). + +> The watch routine (`error-triage`) is registered here in a later phase; v0.0.1 ships the API client and hatch only. + +--- + +## Step 9 — Final report + +Print a structured summary: + +``` +claude-code-error-hermit {version} setup complete. + +Installation summary: + ✓ Prerequisite: claude-code-hermit {base_version} confirmed + ✓ .env: all four credentials present + ✓ Live check: connected to {org}/{project} + ✓ .gitignore: .env covered + ✓ CLAUDE.md: Error Watch block injected (or was already present) + ✓ knowledge-schema.md: error types added (or were already present) + ✓ config.json: _hermit_versions stamped + +What v0.0.1 ships: + bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts check — verify connectivity + bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issues — list error groups + bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issue — group detail + (resolve / mute are approval-gated — surface first, then --confirm) + +Security reminder: .env holds a real tracker token. It is gitignored — verify before any git push. +``` diff --git a/plugins/claude-code-error-hermit/state-templates/CLAUDE-APPEND.md b/plugins/claude-code-error-hermit/state-templates/CLAUDE-APPEND.md new file mode 100644 index 00000000..83694b48 --- /dev/null +++ b/plugins/claude-code-error-hermit/state-templates/CLAUDE-APPEND.md @@ -0,0 +1,65 @@ + + +## Production Error Watch + +This project uses `claude-code-error-hermit` to watch a Sentry/GlitchTip project: triage new error groups against a noise ledger, correlate regressions with releases, and (in later phases) reproduce, bisect, and draft fixes on a branch. + +**Scope boundary.** Reacting to a single error event is GitHub Actions territory. This hermit earns its keep on the *stateful* work: the new-vs-regression-vs-noise ledger, cursor-based watch, local repro/bisect, and draft-fix-with-failing-test. + +--- + +### Safety rule — resolve/mute are surface-then-approve + +Never resolve or mute an issue in the tracker autonomously. The write path is: + +1. Surface the target issue (shortId, title, count) to the operator. +2. Wait for explicit approval. +3. Run `bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts resolve --confirm` (or `mute`). + +The `write-confirm-gate.ts` hook and the in-CLI `--confirm` refusal enforce this at two layers — neither is bypassable. + +--- + +### Tools + +Read commands are unrestricted: + +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts check # connectivity +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issues --json # list groups +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issue --json # group detail +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts latest-event --json # stack + release +``` + +--- + +### Credentials + +`ERROR_HERMIT_TOKEN` lives in the gitignored `.env` at the project root. + +- **Never `cat`, `echo`, `grep`, or Read `.env`** to inspect the token — run `error-api.ts check` instead. It self-reports `ok`/`invalid`/`unreachable`/`missing` without revealing the value. +- `TOKEN` appears in the key name: the base hermit's deny-pattern hook blocks any Bash arg containing the literal string `TOKEN`. + +--- + +### Secret hygiene + +Error events and stack traces may contain request bodies, headers, and env values. This applies to **channel relay AND persistence**: + +- Never paste raw event payloads into a channel message. +- Never write raw event content to `compiled/` or `raw/`. +- Scrub credential-pattern lines to `[REDACTED]` before sharing or persisting. + +--- + +### Proposal categories + +| Prefix | Meaning | +|---|---| +| `[regression]` | error group correlated with a recent release | +| `[noise]` | recurring known-noise pattern worth muting or ledgering | +| `[reliability]` | systemic failure pattern across error groups | + +--- + + diff --git a/plugins/claude-code-error-hermit/tests/error-api.test.ts b/plugins/claude-code-error-hermit/tests/error-api.test.ts new file mode 100644 index 00000000..eb7cb5cd --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/error-api.test.ts @@ -0,0 +1,217 @@ +// Unit + CLI integration tests for the Sentry/GlitchTip client. +// Offline: a Bun.serve fixture server stands in for the tracker; the CLI is +// spawned as a child process pointed at it via ERROR_HERMIT_BASE_URL. +// Run with: bun test tests/error-api.test.ts + +import { test, expect, describe, beforeAll, afterAll } from 'bun:test'; +import path from 'node:path'; +import fs from 'node:fs'; +import { + summarizeIssue, + summarizeEvent, + buildIssueQuery, + redact, +} from '../scripts/error-api-lib'; + +const CLI = path.join(import.meta.dir, '..', 'scripts', 'error-api.ts'); +const FIX = path.join(import.meta.dir, 'fixtures'); +const load = (f: string) => JSON.parse(fs.readFileSync(path.join(FIX, f), 'utf8')); + +const GOOD_TOKEN = 'good-secret-token-value-xyz'; + +type Recorded = { method: string; path: string; body: string }; +const requests: Recorded[] = []; + +let server: ReturnType | undefined; +let baseUrl = ''; + +function jsonResponse(obj: unknown, status = 200): Response { + return new Response(JSON.stringify(obj), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +beforeAll(() => { + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + const body = req.method === 'GET' ? '' : await req.text(); + requests.push({ method: req.method, path: url.pathname, body }); + + const auth = req.headers.get('authorization') ?? ''; + if (!auth.includes(GOOD_TOKEN)) { + return jsonResponse(load('error-401.json'), 401); + } + + const p = url.pathname; + if (p === '/api/0/organizations/acme/') return jsonResponse(load('org.json')); + if (p === '/api/0/projects/acme/web/issues/') { + if (url.searchParams.get('limit') === '1') return jsonResponse([load('issues-list.json')[0]]); + return jsonResponse(load('issues-list.json')); + } + if (p === '/api/0/issues/1001/') { + if (req.method === 'PUT') return jsonResponse({ status: JSON.parse(body || '{}').status }); + return jsonResponse(load('issue-detail.json')); + } + if (p === '/api/0/issues/1001/events/latest/') return jsonResponse(load('event-latest.json')); + return new Response('not found', { status: 404 }); + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; +}); + +afterAll(() => server?.stop(true)); + +// Spawn the CLI ASYNCHRONOUSLY. spawnSync would block this thread, and the +// fixture Bun.serve above runs on this same event loop — a blocked parent can't +// answer the child's request, deadlocking every server-backed call. +async function runCli(args: string[], envOverride: Record = {}) { + const proc = Bun.spawn(['bun', CLI, ...args], { + stdout: 'pipe', + stderr: 'pipe', + env: { + ...process.env, + ERROR_HERMIT_BASE_URL: baseUrl, + ERROR_HERMIT_TOKEN: GOOD_TOKEN, + ERROR_HERMIT_ORG: 'acme', + ERROR_HERMIT_PROJECT: 'web', + // Force projectRoot()'s first branch to miss so no stray .env is loaded; + // process.env values above take precedence over any file anyway. + CLAUDE_PROJECT_DIR: '/nonexistent-error-hermit-test', + ...envOverride, + }, + }); + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + return { code, stdout, stderr }; +} + +describe('lib parsers', () => { + test('summarizeIssue pulls the fields the triage skill keys on', () => { + const s = summarizeIssue(load('issues-list.json')[0]); + expect(s.shortId).toBe('ACME-7'); + expect(s.level).toBe('error'); + expect(s.count).toBe('42'); + expect(s.firstSeen).toBe('2026-07-02T23:10:00Z'); + }); + + test('summarizeEvent extracts the release tag', () => { + const s = summarizeEvent(load('event-latest.json')); + expect(s.release).toBe('web@2026.7.2-a1b2c3d'); + expect(s.culprit).toContain('applyCoupon'); + }); + + test('buildIssueQuery composes free-form query and the firstSeen bound', () => { + expect(buildIssueQuery({ since: '2026-07-03T00:00:00Z' })).toBe('firstSeen:>=2026-07-03T00:00:00Z'); + expect(buildIssueQuery({ query: 'is:unresolved', since: 'X' })).toBe('is:unresolved firstSeen:>=X'); + expect(buildIssueQuery({})).toBe(''); + }); + + test('redact scrubs the token and bearer-shaped substrings', () => { + const scrubbed = redact(`Authorization: Bearer ${GOOD_TOKEN}`, GOOD_TOKEN); + expect(scrubbed).not.toContain(GOOD_TOKEN); + expect(scrubbed).toContain('[REDACTED]'); + }); +}); + +describe('CLI: check', () => { + test('ok branch reports connected org/project', async () => { + const r = await runCli(['check']); + expect(r.code).toBe(0); + expect(r.stdout).toContain('ok: connected to acme/web'); + }); + + test('invalid token reports 401 without leaking the token', async () => { + const r = await runCli(['check'], { ERROR_HERMIT_TOKEN: 'badtoken' }); + expect(r.code).toBe(1); + expect(r.stdout).toContain('invalid: token rejected (401)'); + }); + + test('missing config reports which keys are absent', async () => { + const r = await runCli(['check'], { ERROR_HERMIT_TOKEN: '', ERROR_HERMIT_ORG: '' }); + expect(r.code).toBe(1); + expect(r.stdout).toContain('missing:'); + expect(r.stdout).toContain('ERROR_HERMIT_TOKEN'); + }); +}); + +describe('CLI: issues', () => { + test('table output lists issue groups', async () => { + const r = await runCli(['issues']); + expect(r.code).toBe(0); + expect(r.stdout).toContain('ACME-7'); + expect(r.stdout).toContain('ACME-8'); + }); + + test('--json emits a parseable summarized array', async () => { + const r = await runCli(['issues', '--json']); + expect(r.code).toBe(0); + const parsed = JSON.parse(r.stdout); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(2); + expect(parsed[0].shortId).toBe('ACME-7'); + }); + + test('--since is sent as a firstSeen query param', async () => { + await runCli(['issues', '--since', '2026-07-03T00:00:00Z']); + const listReq = requests.filter((q) => q.path === '/api/0/projects/acme/web/issues/').pop(); + expect(listReq).toBeDefined(); + expect(listReq!.path).toBe('/api/0/projects/acme/web/issues/'); + }); +}); + +describe('CLI: issue + latest-event', () => { + test('issue detail --json returns the summary', async () => { + const r = await runCli(['issue', '1001', '--json']); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout).shortId).toBe('ACME-7'); + }); + + test('latest-event --json includes the release for regression correlation', async () => { + const r = await runCli(['latest-event', '1001', '--json']); + expect(r.code).toBe(0); + expect(JSON.parse(r.stdout).summary.release).toBe('web@2026.7.2-a1b2c3d'); + }); +}); + +describe('CLI: write gating', () => { + test('resolve without --confirm refuses and sends NO request', async () => { + const before = requests.filter((q) => q.method === 'PUT').length; + const r = await runCli(['resolve', '1001']); + const after = requests.filter((q) => q.method === 'PUT').length; + expect(r.code).toBe(1); + expect(r.stderr).toContain('refused'); + expect(after).toBe(before); + }); + + test('resolve --confirm sends PUT {status: resolved}', async () => { + const r = await runCli(['resolve', '1001', '--confirm']); + expect(r.code).toBe(0); + expect(r.stdout).toContain('set to resolved'); + const put = requests.filter((q) => q.method === 'PUT' && q.path === '/api/0/issues/1001/').pop(); + expect(put).toBeDefined(); + expect(JSON.parse(put!.body).status).toBe('resolved'); + }); + + test('mute --confirm sends PUT {status: ignored}', async () => { + const r = await runCli(['mute', '1001', '--confirm']); + expect(r.code).toBe(0); + const put = requests.filter((q) => q.method === 'PUT').pop(); + expect(JSON.parse(put!.body).status).toBe('ignored'); + }); +}); + +describe('CLI: token never leaks', () => { + test('token absent from all output across read commands', async () => { + for (const args of [['check'], ['issues'], ['issue', '1001'], ['latest-event', '1001']]) { + const r = await runCli(args); + expect(r.stdout).not.toContain(GOOD_TOKEN); + expect(r.stderr).not.toContain(GOOD_TOKEN); + } + }); +}); diff --git a/plugins/claude-code-error-hermit/tests/fixtures/error-401.json b/plugins/claude-code-error-hermit/tests/fixtures/error-401.json new file mode 100644 index 00000000..0bb488fc --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/fixtures/error-401.json @@ -0,0 +1,3 @@ +{ + "detail": "Invalid token" +} diff --git a/plugins/claude-code-error-hermit/tests/fixtures/event-latest.json b/plugins/claude-code-error-hermit/tests/fixtures/event-latest.json new file mode 100644 index 00000000..4e7ae18e --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/fixtures/event-latest.json @@ -0,0 +1,30 @@ +{ + "id": "eventabc123", + "eventID": "eventabc123", + "message": "TypeError: Cannot read properties of undefined (reading 'id')", + "culprit": "app/checkout/cart.ts in applyCoupon", + "dateCreated": "2026-07-03T00:20:00Z", + "release": { "version": "web@2026.7.2-a1b2c3d" }, + "tags": [ + { "key": "release", "value": "web@2026.7.2-a1b2c3d" }, + { "key": "environment", "value": "production" } + ], + "entries": [ + { + "type": "exception", + "data": { + "values": [ + { + "type": "TypeError", + "value": "Cannot read properties of undefined (reading 'id')", + "stacktrace": { + "frames": [ + { "filename": "app/checkout/cart.ts", "function": "applyCoupon", "lineNo": 88 } + ] + } + } + ] + } + } + ] +} diff --git a/plugins/claude-code-error-hermit/tests/fixtures/issue-detail.json b/plugins/claude-code-error-hermit/tests/fixtures/issue-detail.json new file mode 100644 index 00000000..d184f8d8 --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/fixtures/issue-detail.json @@ -0,0 +1,12 @@ +{ + "id": "1001", + "shortId": "ACME-7", + "title": "TypeError: Cannot read properties of undefined (reading 'id')", + "culprit": "app/checkout/cart.ts in applyCoupon", + "level": "error", + "status": "unresolved", + "count": "42", + "firstSeen": "2026-07-02T23:10:00Z", + "lastSeen": "2026-07-03T00:20:00Z", + "metadata": { "type": "TypeError", "value": "Cannot read properties of undefined" } +} diff --git a/plugins/claude-code-error-hermit/tests/fixtures/issues-list.json b/plugins/claude-code-error-hermit/tests/fixtures/issues-list.json new file mode 100644 index 00000000..52755302 --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/fixtures/issues-list.json @@ -0,0 +1,26 @@ +[ + { + "id": "1001", + "shortId": "ACME-7", + "title": "TypeError: Cannot read properties of undefined (reading 'id')", + "culprit": "app/checkout/cart.ts in applyCoupon", + "level": "error", + "status": "unresolved", + "count": "42", + "firstSeen": "2026-07-02T23:10:00Z", + "lastSeen": "2026-07-03T00:20:00Z", + "metadata": { "type": "TypeError", "value": "Cannot read properties of undefined" } + }, + { + "id": "1002", + "shortId": "ACME-8", + "title": "TimeoutError: upstream request timed out", + "culprit": "app/api/client.ts in fetchWithRetry", + "level": "warning", + "status": "unresolved", + "count": "6", + "firstSeen": "2026-07-03T00:05:00Z", + "lastSeen": "2026-07-03T00:18:00Z", + "metadata": { "type": "TimeoutError", "value": "upstream request timed out" } + } +] diff --git a/plugins/claude-code-error-hermit/tests/fixtures/org.json b/plugins/claude-code-error-hermit/tests/fixtures/org.json new file mode 100644 index 00000000..0878e61b --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/fixtures/org.json @@ -0,0 +1,6 @@ +{ + "id": "1", + "slug": "acme", + "name": "Acme", + "status": { "id": "active", "name": "active" } +} diff --git a/plugins/claude-code-error-hermit/tests/hook.test.ts b/plugins/claude-code-error-hermit/tests/hook.test.ts new file mode 100644 index 00000000..1e149d28 --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/hook.test.ts @@ -0,0 +1,85 @@ +// Structural and behavioral tests for write-confirm-gate.ts +// Run with: bun test tests/hook.test.ts + +import { test, expect, describe } from 'bun:test'; +import { spawnSync } from 'child_process'; +import path from 'node:path'; + +const HOOK = path.join(import.meta.dir, '..', 'hooks', 'write-confirm-gate.ts'); + +function runHook(payload: unknown): { exitCode: number; stderr: string } { + const result = spawnSync('bun', [HOOK], { + input: JSON.stringify(payload), + encoding: 'utf8', + timeout: 5000, + }); + return { exitCode: result.status ?? -1, stderr: result.stderr ?? '' }; +} + +const cmd = (command: string) => ({ tool_name: 'Bash', tool_input: { command } }); + +describe('write-confirm-gate: pass-through cases', () => { + test('non-Bash tool always passes', () => { + expect(runHook({ tool_name: 'Read', tool_input: { file_path: '/x' } }).exitCode).toBe(0); + }); + + test('Bash call not involving error-api.ts passes', () => { + expect(runHook(cmd('ls -la')).exitCode).toBe(0); + }); + + test('error-api.ts check passes', () => { + expect(runHook(cmd('bun /plugin/scripts/error-api.ts check')).exitCode).toBe(0); + }); + + test('error-api.ts issues passes', () => { + expect(runHook(cmd('bun /plugin/scripts/error-api.ts issues --json')).exitCode).toBe(0); + }); + + test('error-api.ts latest-event passes (read-only)', () => { + expect(runHook(cmd('bun /plugin/scripts/error-api.ts latest-event 1001 --json')).exitCode).toBe(0); + }); +}); + +describe('write-confirm-gate: blocked cases', () => { + test('resolve without --confirm is blocked', () => { + const r = runHook(cmd('bun /plugin/scripts/error-api.ts resolve 1001')); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain('--confirm'); + }); + + test('mute without --confirm is blocked', () => { + const r = runHook(cmd('bun /plugin/scripts/error-api.ts mute 1001')); + expect(r.exitCode).toBe(2); + expect(r.stderr).toContain('--confirm'); + }); + + test('a --confirm substring (--confirm-later) does NOT satisfy the gate', () => { + expect(runHook(cmd('bun /plugin/scripts/error-api.ts resolve 1001 --confirm-later')).exitCode).toBe(2); + }); +}); + +describe('write-confirm-gate: allowed write cases', () => { + test('resolve with --confirm passes', () => { + expect(runHook(cmd('bun /plugin/scripts/error-api.ts resolve 1001 --confirm')).exitCode).toBe(0); + }); + + test('mute with --confirm anywhere in args passes', () => { + expect(runHook(cmd('bun /plugin/scripts/error-api.ts mute 1001 --json --confirm')).exitCode).toBe(0); + }); + + test('${CLAUDE_PLUGIN_ROOT} literal path resolve without --confirm is blocked', () => { + expect(runHook(cmd('bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts resolve 1001')).exitCode).toBe(2); + }); +}); + +describe('write-confirm-gate: fail-open on bad input', () => { + test('malformed JSON input passes through', () => { + const r = spawnSync('bun', [HOOK], { input: 'not json', encoding: 'utf8', timeout: 5000 }); + expect(r.status).toBe(0); + }); + + test('empty stdin passes through', () => { + const r = spawnSync('bun', [HOOK], { input: '', encoding: 'utf8', timeout: 5000 }); + expect(r.status).toBe(0); + }); +}); diff --git a/plugins/claude-code-error-hermit/tests/run-all.sh b/plugins/claude-code-error-hermit/tests/run-all.sh new file mode 100644 index 00000000..681585a8 --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/run-all.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -uo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PLUGIN_DIR" + +echo "=== claude-code-error-hermit test suite ===" + +EXIT=0 + +echo "" +echo "--- skill structure (bun tests/skill-structure.test.ts) ---" +if ! bun tests/skill-structure.test.ts; then + EXIT=1 +fi + +echo "" +echo "--- bun tests (hook + api client) ---" +if ! bun test tests/hook.test.ts tests/error-api.test.ts; then + EXIT=1 +fi + +echo "" +if [ "$EXIT" -eq 0 ]; then + echo "All tests passed." +else + echo "Some tests failed." >&2 +fi + +exit "$EXIT" diff --git a/plugins/claude-code-error-hermit/tests/skill-structure.test.ts b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts new file mode 100644 index 00000000..e52a9cd4 --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts @@ -0,0 +1,60 @@ +// Structural invariants for SKILL.md files in claude-code-error-hermit. +// Run with: bun tests/skill-structure.test.ts + +import fs from 'node:fs'; +import path from 'node:path'; +import { parseFrontmatter, makeReporter } from './test-utils'; + +const SKILL_DIR = path.join(import.meta.dir, '..', 'skills'); + +// Grows across phases: hatch (P1) → +error-triage (P2) +// → +error-reproduce, +error-draft-fix (P3) → +error-incident-summary, +error-digest (P4). +const SKILLS = [{ name: 'hatch', gates: 0 }]; + +const { ok, summary } = makeReporter(); + +for (const { name, gates } of SKILLS) { + console.log(`\n${name}/SKILL.md:`); + const file = path.join(SKILL_DIR, name, 'SKILL.md'); + ok('file exists', fs.existsSync(file), file); + if (!fs.existsSync(file)) continue; + + const text = fs.readFileSync(file, 'utf-8'); + const fm = parseFrontmatter(text); + ok('frontmatter parseable', fm !== null); + if (!fm) continue; + + ok('frontmatter has name', !!fm.fields.name, JSON.stringify(fm.fields)); + ok('frontmatter name matches dir', fm.fields.name === name, `${fm.fields.name} vs ${name}`); + ok('frontmatter has description', !!fm.fields.description && fm.fields.description.length > 20); + + const gateMatches = fm.body.match(/^### Gate \d+ —/gm) || []; + ok(`expected ${gates} Gate headers`, gateMatches.length === gates, `found ${gateMatches.length}`); + + if (gates > 0) { + ok('Gate 0 present', /^### Gate 0 —/m.test(fm.body)); + ok(`Gate ${gates - 1} present`, new RegExp(`^### Gate ${gates - 1} —`, 'm').test(fm.body)); + } + + // Internal links: resolve [text](relative/path) and verify the target exists. + const linkRe = /\[[^\]]+\]\(([^)]+)\)/g; + const skillBaseDir = path.dirname(file); + let linkMatch: RegExpExecArray | null; + let linksChecked = 0; + let linksBad = 0; + while ((linkMatch = linkRe.exec(fm.body)) !== null) { + const target = linkMatch[1]; + if (/^(https?:|mailto:|#)/.test(target)) continue; + const cleanTarget = target.split('#')[0]; + if (!cleanTarget) continue; + const resolved = path.resolve(skillBaseDir, cleanTarget); + linksChecked += 1; + if (!fs.existsSync(resolved)) { + linksBad += 1; + console.error(` bad link: ${target} → ${resolved}`); + } + } + ok(`internal links resolve (${linksChecked} checked)`, linksBad === 0, `${linksBad} bad`); +} + +process.exit(summary() === 0 ? 0 : 1); diff --git a/plugins/claude-code-error-hermit/tests/test-utils.ts b/plugins/claude-code-error-hermit/tests/test-utils.ts new file mode 100644 index 00000000..0d89bd26 --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/test-utils.ts @@ -0,0 +1,31 @@ +function parseFrontmatter(text: string) { + const m = text.match(/^---\n([\s\S]*?)\n---\n/); + if (!m) return null; + const fields: Record = {}; + for (const line of m[1].split('\n')) { + const kv = line.match(/^(\w+):\s*(.*)$/); + if (kv) fields[kv[1]] = kv[2].trim(); + } + return { raw: m[1], fields, body: text.slice(m[0].length) }; +} + +function makeReporter() { + let passed = 0; + let failed = 0; + function ok(name: string, cond: boolean, detail?: string) { + if (cond) { + console.log(` ✓ ${name}`); + passed += 1; + } else { + console.error(` ✗ ${name}${detail ? ' — ' + detail : ''}`); + failed += 1; + } + } + function summary(): number { + console.log(`\nResults: ${passed} passed, ${failed} failed`); + return failed; + } + return { ok, summary }; +} + +export { parseFrontmatter, makeReporter }; From ea3662841fb221befd41f12e91d516f17a94b10d Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Fri, 3 Jul 2026 02:27:14 +0100 Subject: [PATCH 2/6] =?UTF-8?q?feat(claude-code-error-hermit):=20watch=20l?= =?UTF-8?q?oop=20=E2=80=94=20precheck=20+=20triage=20skill=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/error-precheck.ts: zero-token gate. Reads state/error-cursor.json (never writes it), queries issues since the cursor, prints one verdict: SKIP | EVALUATE| new groups | EVALUATE|no cursor — bootstrap | ERROR. - skills/error-triage: precheck-gated. Classifies new vs regression vs known-noise against compiled/error-noise-ledger.md, correlates with recent releases, severity-gates to DM-now vs the digest queue, advances the cursor. resolve/mute are only ever proposed, never executed. - state-templates/compiled/error-noise-ledger.md: the classification memory. - hatch: step 5 drops the ledger; step 8 registers the error-triage routine (hourly, run_during_waiting, no model — the precheck is the cost gate; no prompt_file — core doesn't consume it). - tests/precheck.test.ts: SKIP/EVALUATE/bootstrap/ERROR verdicts + cursor immutability, driven by a Bun.serve fixture + a temp cursor dir. Fix: projectRoot() used readFileSync to test path existence, which throws EISDIR on the .claude-code-hermit dir — switched to existsSync so a set CLAUDE_PROJECT_DIR resolves correctly. Claude-Session: https://claude.ai/code/session_014ofkRhSQNaZpnESM2uGZUZ --- .../scripts/error-api-lib.ts | 15 +-- .../scripts/error-precheck.ts | 65 ++++++++++ .../skills/error-triage/SKILL.md | 91 ++++++++++++++ .../skills/hatch/SKILL.md | 51 ++++++-- .../compiled/error-noise-ledger.md | 24 ++++ .../tests/fixtures/issues-empty.json | 1 + .../tests/precheck.test.ts | 118 ++++++++++++++++++ .../claude-code-error-hermit/tests/run-all.sh | 4 +- .../tests/skill-structure.test.ts | 7 +- 9 files changed, 348 insertions(+), 28 deletions(-) create mode 100644 plugins/claude-code-error-hermit/scripts/error-precheck.ts create mode 100644 plugins/claude-code-error-hermit/skills/error-triage/SKILL.md create mode 100644 plugins/claude-code-error-hermit/state-templates/compiled/error-noise-ledger.md create mode 100644 plugins/claude-code-error-hermit/tests/fixtures/issues-empty.json create mode 100644 plugins/claude-code-error-hermit/tests/precheck.test.ts diff --git a/plugins/claude-code-error-hermit/scripts/error-api-lib.ts b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts index b4356bb1..a815d4f3 100644 --- a/plugins/claude-code-error-hermit/scripts/error-api-lib.ts +++ b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts @@ -7,7 +7,7 @@ // GlitchTip implements the Sentry `/api/0/` API shape, so one client covers // both backends. Endpoints used are the minimal set both support. -import { readFileSync } from 'node:fs'; +import { readFileSync, existsSync } from 'node:fs'; import { join, dirname } from 'node:path'; export interface ErrorHermitConfig { @@ -49,10 +49,10 @@ export interface EventSummary { // else cwd. Mirrors forge.php projectRoot() so .env resolves the same way. export function projectRoot(): string { const proj = process.env.CLAUDE_PROJECT_DIR; - if (proj && safeExists(join(proj, '.claude-code-hermit'))) return proj; + if (proj && existsSync(join(proj, '.claude-code-hermit'))) return proj; let dir = process.cwd(); for (let i = 0; i < 8; i++) { - if (safeExists(join(dir, '.claude-code-hermit', 'config.json'))) return dir; + if (existsSync(join(dir, '.claude-code-hermit', 'config.json'))) return dir; const parent = dirname(dir); if (parent === dir) break; dir = parent; @@ -60,15 +60,6 @@ export function projectRoot(): string { return process.cwd(); } -function safeExists(p: string): boolean { - try { - readFileSync(p); - return true; - } catch { - return false; - } -} - // Parse a project-root .env into a plain map. Real process.env takes precedence // in resolveConfig(), matching forge.php's getenv-wins semantics. export function loadEnv(root: string): Record { diff --git a/plugins/claude-code-error-hermit/scripts/error-precheck.ts b/plugins/claude-code-error-hermit/scripts/error-precheck.ts new file mode 100644 index 00000000..5260236d --- /dev/null +++ b/plugins/claude-code-error-hermit/scripts/error-precheck.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env bun +// Zero-token heartbeat/routine precheck for the error watch loop. +// +// Prints exactly one verdict line and exits 0. The routine's error-triage skill +// runs this as step 1 and stops on SKIP — so a quiet tracker costs no LLM tokens. +// +// SKIP|no new error groups nothing since the cursor — stop +// EVALUATE| new groups n groups appeared — run triage +// EVALUATE|no cursor — bootstrap first run, no cursor yet — run triage +// ERROR| config/network/HTTP failure — triage DMs +// the operator after 3 consecutive ERRORs +// +// CURSOR RULE: this script only READS state/error-cursor.json. The error-triage +// skill is the sole writer — a broken precheck must never advance the cursor and +// silently skip real errors forever. + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + apiRequest, + apiUrl, + buildIssueQuery, + issuesPath, + projectRoot, + resolveConfig, +} from './error-api-lib'; + +function emit(line: string): never { + process.stdout.write(`${line}\n`); + process.exit(0); +} + +async function main(): Promise { + const root = projectRoot(); + const { config, missing } = resolveConfig(root); + if (!config) emit(`ERROR|config incomplete: ${missing.join(', ')}`); + + let cursor: string | undefined; + try { + const raw = readFileSync(join(root, '.claude-code-hermit', 'state', 'error-cursor.json'), 'utf8'); + const parsed = JSON.parse(raw) as { last_seen_first_seen?: string }; + cursor = parsed.last_seen_first_seen; + } catch { + cursor = undefined; + } + + if (!cursor) emit('EVALUATE|no cursor — bootstrap'); + + const params = new URLSearchParams(); + const query = buildIssueQuery({ since: cursor }); + if (query) params.set('query', query); + params.set('limit', '25'); + const url = apiUrl(config.baseUrl, `${issuesPath(config.org, config.project)}?${params.toString()}`); + + const res = await apiRequest(url, config.token); + if (!res.ok) emit(`ERROR|HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + + const n = Array.isArray(res.data) ? res.data.length : 0; + if (n === 0) emit('SKIP|no new error groups'); + emit(`EVALUATE|${n} new groups`); +} + +if (import.meta.main) { + main(); +} diff --git a/plugins/claude-code-error-hermit/skills/error-triage/SKILL.md b/plugins/claude-code-error-hermit/skills/error-triage/SKILL.md new file mode 100644 index 00000000..88d097e7 --- /dev/null +++ b/plugins/claude-code-error-hermit/skills/error-triage/SKILL.md @@ -0,0 +1,91 @@ +--- +name: error-triage +description: Watch-loop triage of new Sentry/GlitchTip error groups. Runs the zero-cost precheck first and stops when quiet; otherwise classifies new vs regression vs known-noise against the noise ledger, correlates with recent releases, and DMs or queues by severity. Invoked by the error-triage routine. +--- + +# Error Triage + +The watch loop. Fired by the `error-triage` routine (registered by hatch). Its job: turn new error groups into a triaged verdict cheaply, escalating only what deserves a wake. + +Run inside the repo of the application being watched. + +--- + +## Step 1 — Precheck (the cheap gate) + +Run: + +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-precheck.ts +``` + +Read the single verdict line: + +- **`SKIP|...`** → nothing new. Stop here with one line: `error-triage: no new groups`. Do not read the ledger, do not call the API. This is the common case and must cost near-zero tokens. +- **`ERROR|`** → the precheck could not verify. Read `state/error-cursor.json`, increment `consecutive_failures` (default 0), write it back (this is the one place triage touches the cursor on a failure — it does **not** advance `last_seen_first_seen`). If `consecutive_failures` reaches 3, DM the operator via the core Operator Notification protocol: "error-triage precheck failing () — token or connectivity may be down." Stop. +- **`EVALUATE|...`** → continue to Step 2. Reset `consecutive_failures` to 0. + +--- + +## Step 2 — Pull the new groups + +Read the cursor from `state/error-cursor.json` (`last_seen_first_seen`; absent on bootstrap). Fetch: + +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issues --since --json +``` + +(Omit `--since` on bootstrap to take the current open groups as the baseline.) + +Dedup against ids already recorded in today's `raw/error-triage-.md` — the `firstSeen:>=` query is inclusive, so a group exactly at the cursor boundary can reappear. + +--- + +## Step 3 — Classify each group + +Read `compiled/error-noise-ledger.md`. For each group: + +- **known-noise** — an active ledger row matches the fingerprint (shortId, or culprit+type). Do not escalate. If the group is loud (high count) and not already muted, note a mute *proposal* for the digest. +- **regression** — a ledger row marks this fingerprint `fixed-in `, and that release predates the group's `firstSeen`. This is a returning bug. Classify `[regression]`. +- **new** — no matching row. Classify on its merits; add a row to the ledger once triaged. + +--- + +## Step 4 — Correlate with releases + +For a `new` or `regression` group, pull the latest event to get its release tag: + +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts latest-event --json +``` + +Cross-reference the `release` against recent local deploys (`git log --since='' --oneline`, tags). A spike whose `firstSeen` lands just after a release is a strong regression signal — record the suspect release/commit range for the Phase 3 reproduce skill. + +--- + +## Step 5 — Severity gate + +Decide per group: + +- **DM now** (core Operator Notification protocol) when: level is `fatal`/`error` AND (new-in-the-latest-release OR a sharp count spike). These are the "woke up to a crash" cases. +- **Queue for the digest** otherwise: append to `state/error-digest-queue.json` (`pending` array) for the Phase 4 `error-digest` to summarize. Known-noise and low-count warnings always go here, never to a DM. + +**Never** resolve or mute in the tracker here. Those are surface-then-approve only (`error-api.ts resolve|mute --confirm`), proposed to the operator, executed only on explicit approval. + +--- + +## Step 6 — Persist findings + +Append a scrubbed record to `raw/error-triage-.md`: each group's shortId, classification, release correlation, and the action taken (DM / queued / proposed-mute). **Scrub any credential-shaped content from event data before writing** — event payloads may carry request bodies and headers. + +--- + +## Step 7 — Advance the cursor + +Write `state/error-cursor.json` with `last_check` = now, `last_seen_first_seen` = the max `firstSeen` across the groups processed this run, and `consecutive_failures` = 0. This is the **only** place the cursor advances — a failed run in Step 1 never reaches here. + +--- + +## Step 8 — Report + +One line to the session (and the digest queue does the operator-facing summary): `error-triage: groups — new, regression, noise; DMed, queued`. diff --git a/plugins/claude-code-error-hermit/skills/hatch/SKILL.md b/plugins/claude-code-error-hermit/skills/hatch/SKILL.md index df5f1739..58ecc88f 100644 --- a/plugins/claude-code-error-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-error-hermit/skills/hatch/SKILL.md @@ -100,9 +100,9 @@ Do not proceed until `check` reports `ok`. ## Step 5 — Drop state templates -Copy any routine/ledger templates the plugin ships from `${CLAUDE_PLUGIN_ROOT}/state-templates/compiled/` into the consumer's `.claude-code-hermit/compiled/`. +Copy the templates the plugin ships from `${CLAUDE_PLUGIN_ROOT}/state-templates/compiled/` into the consumer's `.claude-code-hermit/compiled/`. Currently: `error-noise-ledger.md` (the classification memory the `error-triage` skill maintains). -For each `*.md` file present in that source directory (in v0.0.1 none ship yet — the noise ledger arrives in a later phase; this step is a no-op until then): +For each `*.md` file present in that source directory: - Read the source (Read tool), check if `.claude-code-hermit/compiled/` exists. - **Does not exist** → write it (Write tool). Report `✓ dropped `. - **Already exists** → skip (never overwrite operator edits). Report `⊘ skipped (already present)`. @@ -159,11 +159,27 @@ If already present: skip (idempotent). Use Edit. Use the `config.json` content already loaded in Step 1 (do not re-read). +### 8a — Stamp version + Set `_hermit_versions["claude-code-error-hermit"]` to the plugin version from Step 2 (update if present, add alongside the existing core entry if absent). -Write the updated `config.json` using the Write tool (full-file replacement to guarantee valid JSON). +### 8b — Register the watch routine + +In the `routines` array, check for an entry with `id: "error-triage"`. If **absent**, add it. If **present** (by `id`), skip — do not clobber operator edits (they may have retuned the schedule). + +```json +{ + "id": "error-triage", + "schedule": "0 * * * *", + "skill": "claude-code-error-hermit:error-triage", + "enabled": true, + "run_during_waiting": true +} +``` + +Hourly is the default poll; the operator can retune `schedule` via `/claude-code-hermit:hermit-settings`. No `model` field — triage reads the ledger, writes state, and DMs, so it runs in-session (the precheck script, not a cheaper model, is the cost gate). No `prompt_file` — that field is not consumed by core. -> The watch routine (`error-triage`) is registered here in a later phase; v0.0.1 ships the API client and hatch only. +Write the updated `config.json` using the Write tool (full-file replacement to guarantee valid JSON). --- @@ -179,15 +195,28 @@ Installation summary: ✓ .env: all four credentials present ✓ Live check: connected to {org}/{project} ✓ .gitignore: .env covered + ✓ compiled/error-noise-ledger.md: {dropped | already present} ✓ CLAUDE.md: Error Watch block injected (or was already present) ✓ knowledge-schema.md: error types added (or were already present) - ✓ config.json: _hermit_versions stamped - -What v0.0.1 ships: - bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts check — verify connectivity - bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issues — list error groups - bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issue — group detail - (resolve / mute are approval-gated — surface first, then --confirm) + ✓ config.json: _hermit_versions stamped, error-triage routine registered + +The watch loop: + Routine `error-triage` (hourly by default) runs a zero-cost precheck and + triages only when new error groups appear. Retune the schedule via + /claude-code-hermit:hermit-settings. + +Skills: + /claude-code-error-hermit:error-triage — classify new groups (routine-driven) + /claude-code-error-hermit:error-reproduce — worktree repro + failing test + bisect + /claude-code-error-hermit:error-draft-fix — draft a fix on a branch (PR is approval-gated) + /claude-code-error-hermit:error-incident-summary — post-incident writeup + /claude-code-error-hermit:error-digest — overnight digest (optional routine) + (resolve / mute via error-api.ts are approval-gated — surface first, then --confirm) + +Go always-on (recommended): + - Docker: /claude-code-hermit:docker-setup + - Bare tmux: .claude-code-hermit/bin/hermit-start + Or test the routine now: /claude-code-hermit:hermit-routines load Security reminder: .env holds a real tracker token. It is gitignored — verify before any git push. ``` diff --git a/plugins/claude-code-error-hermit/state-templates/compiled/error-noise-ledger.md b/plugins/claude-code-error-hermit/state-templates/compiled/error-noise-ledger.md new file mode 100644 index 00000000..87eb129a --- /dev/null +++ b/plugins/claude-code-error-hermit/state-templates/compiled/error-noise-ledger.md @@ -0,0 +1,24 @@ +--- +type: error-noise-ledger +title: Error Noise Ledger +--- + +# Error Noise Ledger + +The classification memory of the error hermit. Every triaged error group lands here so the next run knows whether a group is genuinely **new**, a **regression** of something previously fixed, or **known-noise** to be muted rather than escalated. + +The `error-triage` skill reads and maintains this file. Operator edits are preserved — the hermit only appends or updates rows, never wipes them. + +## How classification uses this ledger + +- **known-noise** — an active row matching the group's fingerprint. Do not escalate; propose muting if it is loud. +- **fixed-in ``** — a row marked fixed at a release that predates the group's `firstSeen` means this is a **regression**. Escalate as `[regression]`. +- **new** — no matching row. Triage it, and add a row here once classified. + +## Ledger + +| shortId / fingerprint | classification | first added | rationale | review-by | +|---|---|---|---|---| +| _example: TimeoutError in fetchWithRetry_ | known-noise | 2026-07-03 | upstream flakiness, <10/day, non-actionable | 2026-10-01 | + + diff --git a/plugins/claude-code-error-hermit/tests/fixtures/issues-empty.json b/plugins/claude-code-error-hermit/tests/fixtures/issues-empty.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/fixtures/issues-empty.json @@ -0,0 +1 @@ +[] diff --git a/plugins/claude-code-error-hermit/tests/precheck.test.ts b/plugins/claude-code-error-hermit/tests/precheck.test.ts new file mode 100644 index 00000000..55585437 --- /dev/null +++ b/plugins/claude-code-error-hermit/tests/precheck.test.ts @@ -0,0 +1,118 @@ +// Tests for error-precheck.ts — the zero-token watch gate. +// Offline: a Bun.serve fixture server; a temp project dir supplies the cursor. +// Run with: bun test tests/precheck.test.ts + +import { test, expect, describe, beforeAll, afterAll } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const PRECHECK = path.join(import.meta.dir, '..', 'scripts', 'error-precheck.ts'); +const FIX = path.join(import.meta.dir, 'fixtures'); +const load = (f: string) => JSON.parse(fs.readFileSync(path.join(FIX, f), 'utf8')); + +let server: ReturnType | undefined; +let baseUrl = ''; + +function jsonResponse(obj: unknown): Response { + return new Response(JSON.stringify(obj), { status: 200, headers: { 'content-type': 'application/json' } }); +} + +beforeAll(() => { + server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + // Project slug selects the canned response so a single server drives + // both the "quiet" and "new groups" cases. + if (url.pathname === '/api/0/projects/acme/empty/issues/') return jsonResponse(load('issues-empty.json')); + if (url.pathname === '/api/0/projects/acme/web/issues/') return jsonResponse(load('issues-list.json')); + return new Response('not found', { status: 404 }); + }, + }); + baseUrl = `http://127.0.0.1:${server.port}`; +}); + +afterAll(() => server?.stop(true)); + +function makeProjectDir(cursor?: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'err-precheck-')); + fs.mkdirSync(path.join(dir, '.claude-code-hermit', 'state'), { recursive: true }); + if (cursor) { + fs.writeFileSync( + path.join(dir, '.claude-code-hermit', 'state', 'error-cursor.json'), + JSON.stringify(cursor), + ); + } + return dir; +} + +async function runPrecheck(projectDir: string, project: string, envOverride: Record = {}) { + const proc = Bun.spawn(['bun', PRECHECK], { + stdout: 'pipe', + stderr: 'pipe', + env: { + ...process.env, + CLAUDE_PROJECT_DIR: projectDir, + ERROR_HERMIT_BASE_URL: baseUrl, + ERROR_HERMIT_TOKEN: 'precheck-token', + ERROR_HERMIT_ORG: 'acme', + ERROR_HERMIT_PROJECT: project, + ...envOverride, + }, + }); + const stdout = await new Response(proc.stdout).text(); + const code = await proc.exited; + return { code, verdict: stdout.trim() }; +} + +const cursorPath = (dir: string) => path.join(dir, '.claude-code-hermit', 'state', 'error-cursor.json'); + +describe('error-precheck verdicts', () => { + test('no cursor file → bootstrap EVALUATE', async () => { + const dir = makeProjectDir(); + const r = await runPrecheck(dir, 'web'); + expect(r.code).toBe(0); + expect(r.verdict).toBe('EVALUATE|no cursor — bootstrap'); + }); + + test('cursor set, tracker quiet → SKIP', async () => { + const dir = makeProjectDir({ last_seen_first_seen: '2026-07-01T00:00:00Z' }); + const r = await runPrecheck(dir, 'empty'); + expect(r.code).toBe(0); + expect(r.verdict).toBe('SKIP|no new error groups'); + }); + + test('cursor set, new groups present → EVALUATE with count', async () => { + const dir = makeProjectDir({ last_seen_first_seen: '2026-07-01T00:00:00Z' }); + const r = await runPrecheck(dir, 'web'); + expect(r.code).toBe(0); + expect(r.verdict).toBe('EVALUATE|2 new groups'); + }); + + test('missing config → ERROR, exit 0 (verdict is the protocol)', async () => { + const dir = makeProjectDir({ last_seen_first_seen: '2026-07-01T00:00:00Z' }); + const r = await runPrecheck(dir, 'web', { ERROR_HERMIT_TOKEN: '' }); + expect(r.code).toBe(0); + expect(r.verdict).toContain('ERROR|config incomplete'); + expect(r.verdict).toContain('ERROR_HERMIT_TOKEN'); + }); +}); + +describe('error-precheck never mutates the cursor', () => { + test('SKIP leaves the cursor file byte-identical', async () => { + const dir = makeProjectDir({ last_seen_first_seen: '2026-07-01T00:00:00Z', consecutive_failures: 0 }); + const before = fs.readFileSync(cursorPath(dir), 'utf8'); + await runPrecheck(dir, 'empty'); + const after = fs.readFileSync(cursorPath(dir), 'utf8'); + expect(after).toBe(before); + }); + + test('EVALUATE leaves the cursor file byte-identical', async () => { + const dir = makeProjectDir({ last_seen_first_seen: '2026-07-01T00:00:00Z', consecutive_failures: 0 }); + const before = fs.readFileSync(cursorPath(dir), 'utf8'); + await runPrecheck(dir, 'web'); + const after = fs.readFileSync(cursorPath(dir), 'utf8'); + expect(after).toBe(before); + }); +}); diff --git a/plugins/claude-code-error-hermit/tests/run-all.sh b/plugins/claude-code-error-hermit/tests/run-all.sh index 681585a8..74e934ca 100644 --- a/plugins/claude-code-error-hermit/tests/run-all.sh +++ b/plugins/claude-code-error-hermit/tests/run-all.sh @@ -15,8 +15,8 @@ if ! bun tests/skill-structure.test.ts; then fi echo "" -echo "--- bun tests (hook + api client) ---" -if ! bun test tests/hook.test.ts tests/error-api.test.ts; then +echo "--- bun tests (hook + api client + precheck) ---" +if ! bun test tests/hook.test.ts tests/error-api.test.ts tests/precheck.test.ts; then EXIT=1 fi diff --git a/plugins/claude-code-error-hermit/tests/skill-structure.test.ts b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts index e52a9cd4..57ae6c38 100644 --- a/plugins/claude-code-error-hermit/tests/skill-structure.test.ts +++ b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts @@ -7,9 +7,10 @@ import { parseFrontmatter, makeReporter } from './test-utils'; const SKILL_DIR = path.join(import.meta.dir, '..', 'skills'); -// Grows across phases: hatch (P1) → +error-triage (P2) -// → +error-reproduce, +error-draft-fix (P3) → +error-incident-summary, +error-digest (P4). -const SKILLS = [{ name: 'hatch', gates: 0 }]; +const SKILLS = [ + { name: 'hatch', gates: 0 }, + { name: 'error-triage', gates: 0 }, +]; const { ok, summary } = makeReporter(); From 571b1a223f7d34718fd64e5c5b9c64adf9d56fae Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Fri, 3 Jul 2026 02:28:24 +0100 Subject: [PATCH 3/6] feat(claude-code-error-hermit): repro/bisect + draft-fix skills (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills/error-reproduce: pull the event, map release→SHA, check out a throwaway worktree at the offending SHA, write a failing test from the stack, and git-bisect to the introducing commit when unclear. Read-only w.r.t. shared history; guards that it runs in the watched app's repo. - skills/error-draft-fix: branch error-fix/, commit the failing test first then the fix, run the suite. Approval-gated hand-off — delegates to /claude-code-dev-hermit:dev-pr when installed, else stops at a local branch and DMs. Never git push from agent context. Tracker resolve stays --confirm-gated and post-merge only. Claude-Session: https://claude.ai/code/session_014ofkRhSQNaZpnESM2uGZUZ --- .../skills/error-draft-fix/SKILL.md | 55 +++++++++++++++++ .../skills/error-reproduce/SKILL.md | 61 +++++++++++++++++++ .../tests/skill-structure.test.ts | 2 + 3 files changed, 118 insertions(+) create mode 100644 plugins/claude-code-error-hermit/skills/error-draft-fix/SKILL.md create mode 100644 plugins/claude-code-error-hermit/skills/error-reproduce/SKILL.md diff --git a/plugins/claude-code-error-hermit/skills/error-draft-fix/SKILL.md b/plugins/claude-code-error-hermit/skills/error-draft-fix/SKILL.md new file mode 100644 index 00000000..bc583aa6 --- /dev/null +++ b/plugins/claude-code-error-hermit/skills/error-draft-fix/SKILL.md @@ -0,0 +1,55 @@ +--- +name: error-draft-fix +description: Draft a fix for a reproduced error on a branch, failing test committed first. Opening the PR is approval-gated — delegates to /claude-code-dev-hermit:dev-pr when installed, otherwise stops at a local branch and DMs. Never pushes from agent context. +--- + +# Error Draft Fix + +Take a reproduced error and produce a **fix on a branch with the failing test as a guard**. The push and PR are the operator's call — this skill stops short of both. + +**Precondition:** a reproduce record exists (from `error-reproduce`) with a failing test and, ideally, a suspect commit. If there is no reproduction, stop and run `error-reproduce` first — never draft a fix against an unreproduced error. + +**Assumption:** running inside the watched application's repo (same guard as `error-reproduce`). + +--- + +## Step 1 — Branch + +Branch from the default branch (not from a detached repro worktree): + +```bash +git checkout -b error-fix/ origin/ +``` + +--- + +## Step 2 — Commit the failing test first + +Add the failing test from the reproduce record and commit it on its own. Committing the test before the fix makes the guard visible in history and lets the next step prove the fix flips it green. + +--- + +## Step 3 — Draft the fix + +Implement the smallest change that addresses the root cause identified in reproduction. Re-run the project test suite (`commands.test` if configured) and confirm the previously-failing test now passes and nothing else regressed. + +--- + +## Step 4 — Hand off (approval-gated — never push here) + +**Do not `git push` from agent context.** Per the project git-safety rules, the sanctioned push+PR path is `/claude-code-dev-hermit:dev-pr`. + +- **dev-hermit installed** → surface the branch, the fix summary, and the green test to the operator, and tell them to run `/claude-code-dev-hermit:dev-pr` (which runs its own gates, then pushes and opens the PR). +- **dev-hermit not installed** → stop at the local `error-fix/` branch. DM the operator (core Operator Notification protocol) with the branch name and a one-paragraph summary. They push. + +--- + +## Step 5 — Tracker follow-up (later, still gated) + +Resolving the issue in Sentry/GlitchTip happens **after** the fix merges, and is still surface-then-approve: + +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts resolve --confirm +``` + +Propose it; never run it unprompted. Update the noise ledger row to `fixed-in ` once the fix ships (the `error-incident-summary` skill does the full writeup). diff --git a/plugins/claude-code-error-hermit/skills/error-reproduce/SKILL.md b/plugins/claude-code-error-hermit/skills/error-reproduce/SKILL.md new file mode 100644 index 00000000..47018e1f --- /dev/null +++ b/plugins/claude-code-error-hermit/skills/error-reproduce/SKILL.md @@ -0,0 +1,61 @@ +--- +name: error-reproduce +description: Reproduce a Sentry/GlitchTip error group locally — check out the release SHA in a throwaway worktree, write a failing test from the event stack, and git-bisect to the introducing commit when it is not obvious. Operator-invoked or chained from triage. +--- + +# Error Reproduce + +Turn an error group into a **failing test** and, where possible, a **suspect commit**. This is the defensible core of the plugin: a cloud pipeline can forward an event, but it cannot check out your repo at the offending release and bisect. + +**Assumption:** this hermit runs inside the repo of the application that produced the error. If the tracker project does not correspond to the current repo (check the project slug against the repo), **stop and say so** — do not guess. + +--- + +## Step 1 — Gather the event + +```bash +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts issue --json +bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts latest-event --json +``` + +From the latest event, extract: the exception type/value, the top in-repo stack frames (file, function, line), and the `release` tag. **Scrub any request bodies, headers, or env values from the event before persisting or relaying** — they may carry secrets. + +--- + +## Step 2 — Map the release to a commit + +Resolve the `release` tag to a local SHA: try an exact tag match, then `git log --grep=` or the version embedded in the release name. If nothing resolves, fall back to the commit range since the group's `firstSeen` (`git log --since= --oneline`). Record `last_good` (a release before `firstSeen`) and `first_bad` (the offending release) for Step 4. + +--- + +## Step 3 — Reproduce in a throwaway worktree + +Never mutate the working checkout. Create an isolated worktree at the offending SHA: + +```bash +git worktree add +``` + +Write a **failing test** that exercises the offending code path, driven by the stack frame and event context, following the project's existing test conventions (reuse the dev-hermit `commands.test` command if configured; otherwise infer the runner from the repo). Run it and confirm it fails with the same error. Remove the worktree when done (`git worktree remove`). + +`git worktree add` / `git bisect` are not pre-approved in `settings.json` — they will prompt. That is intentional for an operator-invoked skill. + +--- + +## Step 4 — Bisect when the introducing commit is unclear + +If Step 2 did not pin a single commit, bisect between the known-good and first-bad releases using the failing test as the oracle: + +```bash +git bisect start +git bisect run +git bisect reset +``` + +Record the commit `git bisect` fingers as the suspect. + +--- + +## Step 5 — Annotate the triage record + +Append to today's `raw/error-triage-.md` (or create it): the group shortId, repro status (reproduced / could-not-reproduce), the failing-test location, and the suspect commit/range. This record is the input to `error-draft-fix`. Do not open anything or push anything — reproduction is read-only with respect to shared history. diff --git a/plugins/claude-code-error-hermit/tests/skill-structure.test.ts b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts index 57ae6c38..5f9360bd 100644 --- a/plugins/claude-code-error-hermit/tests/skill-structure.test.ts +++ b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts @@ -10,6 +10,8 @@ const SKILL_DIR = path.join(import.meta.dir, '..', 'skills'); const SKILLS = [ { name: 'hatch', gates: 0 }, { name: 'error-triage', gates: 0 }, + { name: 'error-reproduce', gates: 0 }, + { name: 'error-draft-fix', gates: 0 }, ]; const { ok, summary } = makeReporter(); From f02db3ffa0af6cd379076c4cb1aed0244385d6ce Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Fri, 3 Jul 2026 02:30:55 +0100 Subject: [PATCH 4/6] feat(claude-code-error-hermit): incident memory + digest (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - skills/error-incident-summary: post-incident writeup to compiled/incident--.md; updates the ledger row to fixed-in so a recurrence classifies as a regression. - skills/error-digest: drains state/error-digest-queue.json into one channel-friendly summary (new/regression/noise counts, draft branches awaiting a PR, resolve/mute approvals queued as micro-proposals). Ships as its own skill — core has no morning-brief extension point (HA precedent). - hatch step 8c: offers the optional error-digest routine via AskUserQuestion. Consolidate docs to 0.0.1 as the complete initial release (all four phases in one version, nothing shipped yet): CHANGELOG, README, plugin CLAUDE.md, and knowledge-schema (incident-summary now live). Claude-Session: https://claude.ai/code/session_014ofkRhSQNaZpnESM2uGZUZ --- plugins/claude-code-error-hermit/CHANGELOG.md | 20 ++++++----- plugins/claude-code-error-hermit/CLAUDE.md | 11 ++++-- plugins/claude-code-error-hermit/README.md | 18 +++++----- .../docs/knowledge-schema.md | 11 ++---- .../skills/error-digest/SKILL.md | 34 +++++++++++++++++++ .../skills/error-incident-summary/SKILL.md | 33 ++++++++++++++++++ .../skills/hatch/SKILL.md | 17 ++++++++++ .../tests/skill-structure.test.ts | 2 ++ 8 files changed, 118 insertions(+), 28 deletions(-) create mode 100644 plugins/claude-code-error-hermit/skills/error-digest/SKILL.md create mode 100644 plugins/claude-code-error-hermit/skills/error-incident-summary/SKILL.md diff --git a/plugins/claude-code-error-hermit/CHANGELOG.md b/plugins/claude-code-error-hermit/CHANGELOG.md index 23df3bc3..2cce41bd 100644 --- a/plugins/claude-code-error-hermit/CHANGELOG.md +++ b/plugins/claude-code-error-hermit/CHANGELOG.md @@ -4,24 +4,26 @@ ## [0.0.1] - 2026-07-03 -Initial scaffold: a Sentry/GlitchTip watcher plugin (Phase 1 of 4 — API client + hatch). +Initial release: a production-error watcher for Sentry/GlitchTip. Built in four phases (API client → watch loop → repro/draft-fix → incident memory), all shipping together in the first version. ### Added -- **API client (`scripts/error-api.ts` + `error-api-lib.ts`)** — zero-dependency Bun `fetch` client covering both Sentry and GlitchTip via the shared `/api/0/` shape. Subcommands: `check`, `issues`, `issue`, `latest-event`, and the approval-gated `resolve` / `mute`. -- **Write gating** — `resolve`/`mute` refuse without an exact `--confirm` token and send no request; a `write-confirm-gate.ts` PreToolUse hook enforces the same at a second layer. -- **Token redaction** — all error output passes through `redact()`; the token is never printed. -- **hatch skill** — verifies credentials with a live `check`, injects the Error Watch block, extends the knowledge schema, and stamps `config.json`. -- **Offline test suite** — lib parsers, a `Bun.serve` fixture server driving the CLI, the hook matrix, and skill-structure checks. No live backend required. +- **API client (`scripts/error-api.ts` + `error-api-lib.ts`)** — zero-dependency Bun `fetch` client covering both Sentry and GlitchTip via the shared `/api/0/` shape. Subcommands: `check`, `issues`, `issue`, `latest-event`, and the approval-gated `resolve` / `mute`. The token is never printed (`redact()` scrubs all output). +- **Two-layer write gate** — `resolve`/`mute` refuse without an exact `--confirm` token and send no request; a `write-confirm-gate.ts` PreToolUse hook enforces the same at a second, fail-open layer. +- **Watch loop** — `scripts/error-precheck.ts` is a zero-token gate (reads the cursor, never writes it, prints `SKIP` / `EVALUATE` / `ERROR`); `skills/error-triage` classifies new vs regression vs known-noise against `compiled/error-noise-ledger.md`, correlates with releases, and severity-gates to a DM or the digest queue. Wired as an hourly `error-triage` routine. +- **Repro + fix** — `skills/error-reproduce` (throwaway worktree at the offending SHA, failing test from the stack, `git bisect` to the suspect commit) and `skills/error-draft-fix` (fix on an `error-fix/` branch, failing test committed first; push/PR delegated to `/claude-code-dev-hermit:dev-pr`, never improvised). +- **Incident memory** — `skills/error-incident-summary` (post-incident writeup linked from the ledger) and `skills/error-digest` (overnight summary draining the queue into one channel message, approvals as micro-proposals). Digest routine is offered optionally at hatch. +- **hatch skill** — verifies credentials with a live `check`, drops the noise-ledger template, injects the Error Watch block, extends the knowledge schema, stamps `config.json`, and registers the routines. +- **Offline test suite** — lib parsers, a `Bun.serve` fixture server driving the CLI and precheck, the hook matrix, and skill-structure checks. No live backend required. ### Files affected | File | Change | |------|--------| | `.claude-plugin/{plugin,hermit-meta}.json` | new manifests, core `>=1.2.14` | -| `scripts/error-api-lib.ts`, `scripts/error-api.ts` | API client | +| `scripts/error-api-lib.ts`, `error-api.ts`, `error-precheck.ts` | client + watch gate | | `hooks/{hooks.json,write-confirm-gate.ts}` | write-confirm gate | -| `skills/hatch/SKILL.md` | setup wizard | -| `state-templates/CLAUDE-APPEND.md`, `docs/knowledge-schema.md` | operator-facing docs | +| `skills/{hatch,error-triage,error-reproduce,error-draft-fix,error-incident-summary,error-digest}/SKILL.md` | six skills | +| `state-templates/CLAUDE-APPEND.md`, `state-templates/compiled/error-noise-ledger.md`, `docs/knowledge-schema.md` | operator-facing docs + templates | | `tests/**` | offline suite + fixtures | ### Upgrade Instructions diff --git a/plugins/claude-code-error-hermit/CLAUDE.md b/plugins/claude-code-error-hermit/CLAUDE.md index 669e3c15..9740596d 100644 --- a/plugins/claude-code-error-hermit/CLAUDE.md +++ b/plugins/claude-code-error-hermit/CLAUDE.md @@ -16,10 +16,17 @@ After install, run `/claude-code-error-hermit:hatch`. The core hermit (`claude-c ## Plugin Structure - `skills/hatch/` — one-time setup wizard (`/claude-code-error-hermit:hatch`) -- `scripts/error-api-lib.ts` — pure helpers: `resolveConfig`, `apiRequest`, `redact`, `summarizeIssue`/`summarizeEvent`, query/path builders. Imported by the CLI and the tests so both exercise the same code. +- `skills/error-triage/` — watch-loop triage (routine-driven): classify new/regression/noise, correlate releases, DM or queue +- `skills/error-reproduce/` — worktree checkout at the offending SHA, failing test from the stack, `git bisect` +- `skills/error-draft-fix/` — fix on an `error-fix/` branch; push/PR delegated to dev-hermit, never improvised +- `skills/error-incident-summary/` — post-incident writeup linked from the noise ledger +- `skills/error-digest/` — overnight digest draining the triage queue into one channel message +- `scripts/error-api-lib.ts` — pure helpers: `resolveConfig`, `apiRequest`, `redact`, `summarizeIssue`/`summarizeEvent`, query/path builders. Imported by the CLI, the precheck, and the tests so all exercise the same code. - `scripts/error-api.ts` — CLI: `check`, `issues`, `issue`, `latest-event`, `resolve --confirm`, `mute --confirm` +- `scripts/error-precheck.ts` — zero-token watch gate: reads the cursor, prints `SKIP`/`EVALUATE`/`ERROR`, never writes - `hooks/write-confirm-gate.ts` — PreToolUse Bash hook: blocks `resolve`/`mute` without `--confirm` - `state-templates/CLAUDE-APPEND.md` — Error Watch block injected by hatch +- `state-templates/compiled/error-noise-ledger.md` — the classification-memory template hatch drops - `docs/knowledge-schema.md` — work-product types and retention - `.claude-plugin/plugin.json` — plugin manifest - `.claude-plugin/hermit-meta.json` — hermit-internal fields (`required_core_version`, `requires`) @@ -32,7 +39,7 @@ The agent calls `bun ${CLAUDE_PLUGIN_ROOT}/scripts/error-api.ts ` via B **Write gating is two independent layers.** The in-CLI `--confirm` refusal (authoritative — sends no request without it) and the `write-confirm-gate.ts` PreToolUse hook (defense-in-depth, fail-open). Neither is optional. -**Cursor rule (Phase 2+).** `state/error-cursor.json` is read by `error-precheck.ts` and written only by the `error-triage` skill after a successful run. The precheck never mutates it — a broken precheck must never silently advance the cursor. +**Cursor rule.** `state/error-cursor.json` is read by `error-precheck.ts` and written only by the `error-triage` skill after a successful run. The precheck never mutates it — a broken precheck must never silently advance the cursor. ## Core Rules diff --git a/plugins/claude-code-error-hermit/README.md b/plugins/claude-code-error-hermit/README.md index 1eb1260e..9eb9583a 100644 --- a/plugins/claude-code-error-hermit/README.md +++ b/plugins/claude-code-error-hermit/README.md @@ -1,6 +1,6 @@ # claude-code-error-hermit -A production-error watcher for [Sentry](https://sentry.io) and [GlitchTip](https://glitchtip.com), built on `claude-code-hermit`. It watches a project's error stream, classifies new groups against a known-noise ledger, correlates regressions with your releases, and — in later phases — reproduces the failure locally, bisects it against recent commits, and drafts a fix on a branch with the failing case as a test. +A production-error watcher for [Sentry](https://sentry.io) and [GlitchTip](https://glitchtip.com), built on `claude-code-hermit`. It watches a project's error stream, classifies new groups against a known-noise ledger, correlates regressions with your releases, reproduces the failure locally, bisects it against recent commits, and drafts a fix on a branch with the failing case as a test. > **What this is not.** Reacting to a single error event — "an error fired, open an issue, run a workflow" — is [GitHub Actions](https://docs.github.com/actions) territory, and a Sentry-webhook → GitHub-issue → Actions pipeline does it well. This plugin deliberately concedes that half. It earns its place on the *stateful* work no cloud pipeline holds at once: the new-vs-regression-vs-noise ledger that remembers across weeks, a repo checkout for local repro and bisect, and a draft fix with a reproducing test — reachable from your phone via the hermit's approval channel. @@ -8,7 +8,7 @@ A production-error watcher for [Sentry](https://sentry.io) and [GlitchTip](https - `claude-code-hermit` ≥ 1.2.14, installed and hatched in the project. - A Sentry or GlitchTip auth token. GlitchTip works because it implements the Sentry `/api/0/` API; one client covers both. -- **Run it inside the repo of the application you are watching.** Phases 3–4 (repro, bisect, draft fix) operate on that repo's own git history; if the tracker project doesn't map to the current repo, those skills stop and say so. +- **Run it inside the repo of the application you are watching.** The repro, bisect, and draft-fix skills operate on that repo's own git history; if the tracker project doesn't map to the current repo, those skills stop and say so. ## Install @@ -42,14 +42,14 @@ Four values in a gitignored `.env` at the project root (copy `.env.example`): - **The hermit never pushes.** Draft fixes (Phase 3) stop at a local branch and hand off to `/claude-code-dev-hermit:dev-pr` for the sanctioned push. - **Secrets stay out.** The token is never printed, and event payloads are scrubbed before any channel relay or file write. -## Roadmap +## What it does (all shipping in 0.0.1) -| Phase | Ships | Status | -|---|---|---| -| 1 | Scaffold, hatch, API client (`check` / `issues` / `issue` / `latest-event` / `resolve` / `mute`) | this release (0.0.1) | -| 2 | Watch loop: zero-cost precheck + `error-triage` skill + noise ledger | planned | -| 3 | `error-reproduce` (worktree + failing test + bisect) + `error-draft-fix` | planned | -| 4 | `error-incident-summary` + overnight `error-digest` | planned | +| Capability | How | +|---|---| +| **API client** | `check` / `issues` / `issue` / `latest-event` / approval-gated `resolve` / `mute` over the Sentry `/api/0/` API | +| **Watch loop** | zero-cost precheck + hourly `error-triage` routine, classifying new vs regression vs known-noise against a ledger | +| **Repro + fix** | `error-reproduce` (worktree checkout + failing test + `git bisect`) and `error-draft-fix` (fix on a branch, PR handed to dev-hermit) | +| **Incident memory** | `error-incident-summary` writeups linked from the ledger, plus an optional overnight `error-digest` | ## GlitchTip note diff --git a/plugins/claude-code-error-hermit/docs/knowledge-schema.md b/plugins/claude-code-error-hermit/docs/knowledge-schema.md index 7d15c169..d93f64dc 100644 --- a/plugins/claude-code-error-hermit/docs/knowledge-schema.md +++ b/plugins/claude-code-error-hermit/docs/knowledge-schema.md @@ -4,14 +4,9 @@ Work products live in `.claude-code-hermit/compiled/`. All artifacts are flat (no subdirectories). -- `error-noise-ledger`: living ledger of error fingerprints classified as known-noise, known, or fixed-in-``. **Producer**: `error-triage` skill (Phase 2). **Location**: `compiled/error-noise-ledger.md`. **Retention**: indefinite (the classification memory of the whole plugin). +- `error-noise-ledger`: living ledger of error fingerprints classified as known-noise, known, or fixed-in-``. **Producer**: `error-triage` skill. **Location**: `compiled/error-noise-ledger.md`. **Retention**: indefinite (the classification memory of the whole plugin). +- `incident-summary`: post-incident writeup — timeline, root cause, fix link, detection gap. **Producer**: `error-incident-summary` skill. **Location**: `compiled/incident--.md`. **Retention**: indefinite. **Secret hygiene**: scrub quoted event data before writing. ## Raw Captures -- `error-triage-log`: per-run triage findings — the groups seen since the cursor, their classification, and release correlation. **Producer**: `error-triage` skill (Phase 2). **Location**: `raw/error-triage-.md`. **Retention**: 30 days. **Secret hygiene**: event detail must be scrubbed of credentials before writing. - -## Deferred types - -Reserved for later phases; must not be created manually until their producing skill ships: - -- `incident-summary` — post-incident writeup (Phase 4, `error-incident-summary` skill). `compiled/incident--.md`. +- `error-triage-log`: per-run triage findings — the groups seen since the cursor, their classification, and release correlation. **Producer**: `error-triage` skill. **Location**: `raw/error-triage-.md`. **Retention**: 30 days. **Secret hygiene**: event detail must be scrubbed of credentials before writing. diff --git a/plugins/claude-code-error-hermit/skills/error-digest/SKILL.md b/plugins/claude-code-error-hermit/skills/error-digest/SKILL.md new file mode 100644 index 00000000..3f5c88f1 --- /dev/null +++ b/plugins/claude-code-error-hermit/skills/error-digest/SKILL.md @@ -0,0 +1,34 @@ +--- +name: error-digest +description: Overnight error digest — drains the triage queue into a single channel-friendly summary (new / regressed / noise counts, draft branches awaiting a PR, pending resolve/mute approvals). Runs as an optional morning routine or on demand. +--- + +# Error Digest + +The morning read. Triage DMs only the wake-worthy; everything else accumulates in the digest queue. This skill turns that queue into one summary instead of a trickle of notifications. + +There is no core morning-brief extension point, so this ships as its own skill (the Home Assistant briefs are the precedent) — wired as an optional routine by hatch, or run on demand. + +--- + +## Step 1 — Drain the queue + +Read `state/error-digest-queue.json` (`pending` array, written by `error-triage`) and the `raw/error-triage-*.md` records since the last digest. If the queue is empty and nothing new triaged, send a one-line "no new errors overnight" and stop. + +--- + +## Step 2 — Summarize + +Compose a channel-friendly summary (core Operator Notification protocol): + +- **Counts** — new / regression / known-noise since the last digest. +- **Drafts awaiting a PR** — any `error-fix/` branches from `error-draft-fix` not yet pushed. +- **Approvals pending** — resolve/mute the hermit proposed. Queue each as a tiered yes/no micro-proposal in `state/micro-proposals.json` (`pending`, `MP--`) so the operator can approve from the channel; the core brief lifecycle drains it. + +Keep it tight — this is a phone-glanceable digest, not a report. + +--- + +## Step 3 — Clear + +Empty the `pending` array in `state/error-digest-queue.json` (leave the file). Record the digest timestamp so the next run's "since last digest" window is correct. diff --git a/plugins/claude-code-error-hermit/skills/error-incident-summary/SKILL.md b/plugins/claude-code-error-hermit/skills/error-incident-summary/SKILL.md new file mode 100644 index 00000000..e4be6f78 --- /dev/null +++ b/plugins/claude-code-error-hermit/skills/error-incident-summary/SKILL.md @@ -0,0 +1,33 @@ +--- +name: error-incident-summary +description: Write a post-incident summary for a resolved error group and link it from the noise ledger. Produces the compiled incident-summary artifact — timeline, root cause, fix, and the detection gap that let it through. +--- + +# Error Incident Summary + +The memory that makes the next incident cheaper. After an error group is fixed, capture what happened so a recurrence is recognized instantly and the detection gap is visible. + +Operator-invoked, or chained after `error-draft-fix` once a fix has merged. + +--- + +## Step 1 — Assemble the record + +Pull together, from the triage records (`raw/error-triage-*.md`), the reproduce/draft-fix notes, and the tracker: + +- **Timeline** — firstSeen, when it was noticed, when the fix shipped. +- **Root cause** — the suspect commit and the actual defect (from reproduction). +- **Fix** — the `error-fix/` branch / PR / merged release. +- **Detection gap** — why it reached production and how long until it was caught. This is the most valuable line; be honest. + +--- + +## Step 2 — Write the artifact + +Write `compiled/incident--.md` (flat, no subdirs), where `` is a short kebab descriptor of the error. **Scrub any credential-shaped content** from quoted event data. Register the `incident-summary` type in the knowledge schema if not already present. + +--- + +## Step 3 — Link from the noise ledger + +Update the group's row in `compiled/error-noise-ledger.md` to `fixed-in ` and link the incident file. This is what lets `error-triage` classify a later recurrence as a **regression** rather than a new group. diff --git a/plugins/claude-code-error-hermit/skills/hatch/SKILL.md b/plugins/claude-code-error-hermit/skills/hatch/SKILL.md index 58ecc88f..892f9c3c 100644 --- a/plugins/claude-code-error-hermit/skills/hatch/SKILL.md +++ b/plugins/claude-code-error-hermit/skills/hatch/SKILL.md @@ -179,6 +179,23 @@ In the `routines` array, check for an entry with `id: "error-triage"`. If **abse Hourly is the default poll; the operator can retune `schedule` via `/claude-code-hermit:hermit-settings`. No `model` field — triage reads the ledger, writes state, and DMs, so it runs in-session (the precheck script, not a cheaper model, is the cost gate). No `prompt_file` — that field is not consumed by core. +### 8c — Offer the optional digest routine + +Some operators want an overnight digest; others prefer digest-on-demand. Ask with `AskUserQuestion` (header: "Digest"): **Morning digest routine (recommended)** / **On demand only**. + +- **Morning digest** → add an `error-digest` routine by id if absent: + + ```json + { + "id": "error-digest", + "schedule": "30 7 * * *", + "skill": "claude-code-error-hermit:error-digest", + "enabled": true, + "run_during_waiting": true + } + ``` +- **On demand only** → skip; the operator runs `/claude-code-error-hermit:error-digest` when they want it. + Write the updated `config.json` using the Write tool (full-file replacement to guarantee valid JSON). --- diff --git a/plugins/claude-code-error-hermit/tests/skill-structure.test.ts b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts index 5f9360bd..3f71c744 100644 --- a/plugins/claude-code-error-hermit/tests/skill-structure.test.ts +++ b/plugins/claude-code-error-hermit/tests/skill-structure.test.ts @@ -12,6 +12,8 @@ const SKILLS = [ { name: 'error-triage', gates: 0 }, { name: 'error-reproduce', gates: 0 }, { name: 'error-draft-fix', gates: 0 }, + { name: 'error-incident-summary', gates: 0 }, + { name: 'error-digest', gates: 0 }, ]; const { ok, summary } = makeReporter(); From 48e8dcf4b86c0fdc3bb975ef23ce87e851c08092 Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Fri, 3 Jul 2026 02:39:26 +0100 Subject: [PATCH 5/6] refactor(claude-code-error-hermit): /simplify cleanup pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parallel-reviewer cleanup, all behavior-preserving: - error-api-lib: extract buildIssuesUrl — the CLI and precheck built the same issues-search URL; centralize in the lib that exists to be shared. - error-api: extract errHttp helper for the HTTP-error string repeated 4×. - test-utils: centralize loadFixture + jsonResponse used by both test files. - error-api.test: token-leak read commands now run via Promise.all. - loadFixture typed so call-site indexing stays tsc-clean. tsc clean; 42 structure + 35 unit tests green. Claude-Session: https://claude.ai/code/session_014ofkRhSQNaZpnESM2uGZUZ --- .../scripts/error-api-lib.ts | 15 +++++++++++++ .../scripts/error-api.ts | 22 +++++++++---------- .../scripts/error-precheck.ts | 11 ++-------- .../tests/error-api.test.ts | 18 +++++---------- .../tests/precheck.test.ts | 8 ++----- .../tests/test-utils.ts | 21 +++++++++++++++++- 6 files changed, 56 insertions(+), 39 deletions(-) diff --git a/plugins/claude-code-error-hermit/scripts/error-api-lib.ts b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts index a815d4f3..0aef9000 100644 --- a/plugins/claude-code-error-hermit/scripts/error-api-lib.ts +++ b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts @@ -190,6 +190,21 @@ export function issuesPath(org: string, project: string): string { return `/api/0/projects/${encodeURIComponent(org)}/${encodeURIComponent(project)}/issues/`; } +// Build the full issues-list URL: query composition + limit default + path. +// Shared by error-api.ts (cmdIssues, args-sourced since/query/limit) and +// error-precheck.ts (cursor-sourced since only) so both build the exact same +// request the watch loop and the CLI depend on. +export function buildIssuesUrl( + config: ErrorHermitConfig, + opts: { since?: string; query?: string; limit?: string } = {}, +): string { + const query = buildIssueQuery({ since: opts.since, query: opts.query }); + const params = new URLSearchParams(); + if (query) params.set('query', query); + params.set('limit', opts.limit ?? '25'); + return apiUrl(config.baseUrl, `${issuesPath(config.org, config.project)}?${params.toString()}`); +} + export function orgPath(org: string): string { return `/api/0/organizations/${encodeURIComponent(org)}/`; } diff --git a/plugins/claude-code-error-hermit/scripts/error-api.ts b/plugins/claude-code-error-hermit/scripts/error-api.ts index 17827eb1..03fa0241 100644 --- a/plugins/claude-code-error-hermit/scripts/error-api.ts +++ b/plugins/claude-code-error-hermit/scripts/error-api.ts @@ -20,7 +20,7 @@ import { apiRequest, apiUrl, - buildIssueQuery, + buildIssuesUrl, issuePath, issuesPath, latestEventPath, @@ -41,6 +41,10 @@ function err(line: string): void { process.stderr.write(`${line}\n`); } +function errHttp(res: { status: number; error?: string }): void { + err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); +} + function flagValue(args: string[], name: string): string | undefined { const i = args.indexOf(name); if (i === -1 || i + 1 >= args.length) return undefined; @@ -115,18 +119,14 @@ async function cmdIssues(args: string[]): Promise { const config = loadConfigOrReport(); if (!config) return 1; const json = args.includes('--json'); - const query = buildIssueQuery({ + const url = buildIssuesUrl(config, { since: flagValue(args, '--since'), query: flagValue(args, '--query'), + limit: flagValue(args, '--limit'), }); - const limit = flagValue(args, '--limit') ?? '25'; - const params = new URLSearchParams(); - if (query) params.set('query', query); - params.set('limit', limit); - const url = apiUrl(config.baseUrl, `${issuesPath(config.org, config.project)}?${params.toString()}`); const res = await apiRequest>>(url, config.token); if (!res.ok) { - err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + errHttp(res); return 1; } const issues = (res.data ?? []).map(summarizeIssue); @@ -147,7 +147,7 @@ async function cmdIssue(args: string[]): Promise { config.token, ); if (!res.ok) { - err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + errHttp(res); return 1; } const summary = summarizeIssue(res.data ?? {}); @@ -174,7 +174,7 @@ async function cmdLatestEvent(args: string[]): Promise { config.token, ); if (!res.ok) { - err(`error: HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); + errHttp(res); return 1; } const summary = summarizeEvent(res.data ?? {}); @@ -213,7 +213,7 @@ async function cmdWrite(kind: 'resolve' | 'mute', args: string[]): Promise { if (!cursor) emit('EVALUATE|no cursor — bootstrap'); - const params = new URLSearchParams(); - const query = buildIssueQuery({ since: cursor }); - if (query) params.set('query', query); - params.set('limit', '25'); - const url = apiUrl(config.baseUrl, `${issuesPath(config.org, config.project)}?${params.toString()}`); - + const url = buildIssuesUrl(config, { since: cursor }); const res = await apiRequest(url, config.token); if (!res.ok) emit(`ERROR|HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); diff --git a/plugins/claude-code-error-hermit/tests/error-api.test.ts b/plugins/claude-code-error-hermit/tests/error-api.test.ts index eb7cb5cd..535e187c 100644 --- a/plugins/claude-code-error-hermit/tests/error-api.test.ts +++ b/plugins/claude-code-error-hermit/tests/error-api.test.ts @@ -5,17 +5,16 @@ import { test, expect, describe, beforeAll, afterAll } from 'bun:test'; import path from 'node:path'; -import fs from 'node:fs'; import { summarizeIssue, summarizeEvent, buildIssueQuery, redact, } from '../scripts/error-api-lib'; +import { loadFixture, jsonResponse } from './test-utils'; const CLI = path.join(import.meta.dir, '..', 'scripts', 'error-api.ts'); -const FIX = path.join(import.meta.dir, 'fixtures'); -const load = (f: string) => JSON.parse(fs.readFileSync(path.join(FIX, f), 'utf8')); +const load = loadFixture; const GOOD_TOKEN = 'good-secret-token-value-xyz'; @@ -25,13 +24,6 @@ const requests: Recorded[] = []; let server: ReturnType | undefined; let baseUrl = ''; -function jsonResponse(obj: unknown, status = 200): Response { - return new Response(JSON.stringify(obj), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - beforeAll(() => { server = Bun.serve({ port: 0, @@ -208,8 +200,10 @@ describe('CLI: write gating', () => { describe('CLI: token never leaks', () => { test('token absent from all output across read commands', async () => { - for (const args of [['check'], ['issues'], ['issue', '1001'], ['latest-event', '1001']]) { - const r = await runCli(args); + const results = await Promise.all( + [['check'], ['issues'], ['issue', '1001'], ['latest-event', '1001']].map((args) => runCli(args)), + ); + for (const r of results) { expect(r.stdout).not.toContain(GOOD_TOKEN); expect(r.stderr).not.toContain(GOOD_TOKEN); } diff --git a/plugins/claude-code-error-hermit/tests/precheck.test.ts b/plugins/claude-code-error-hermit/tests/precheck.test.ts index 55585437..22a63631 100644 --- a/plugins/claude-code-error-hermit/tests/precheck.test.ts +++ b/plugins/claude-code-error-hermit/tests/precheck.test.ts @@ -6,18 +6,14 @@ import { test, expect, describe, beforeAll, afterAll } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { loadFixture, jsonResponse } from './test-utils'; const PRECHECK = path.join(import.meta.dir, '..', 'scripts', 'error-precheck.ts'); -const FIX = path.join(import.meta.dir, 'fixtures'); -const load = (f: string) => JSON.parse(fs.readFileSync(path.join(FIX, f), 'utf8')); +const load = loadFixture; let server: ReturnType | undefined; let baseUrl = ''; -function jsonResponse(obj: unknown): Response { - return new Response(JSON.stringify(obj), { status: 200, headers: { 'content-type': 'application/json' } }); -} - beforeAll(() => { server = Bun.serve({ port: 0, diff --git a/plugins/claude-code-error-hermit/tests/test-utils.ts b/plugins/claude-code-error-hermit/tests/test-utils.ts index 0d89bd26..726eb5f9 100644 --- a/plugins/claude-code-error-hermit/tests/test-utils.ts +++ b/plugins/claude-code-error-hermit/tests/test-utils.ts @@ -1,3 +1,6 @@ +import fs from 'node:fs'; +import path from 'node:path'; + function parseFrontmatter(text: string) { const m = text.match(/^---\n([\s\S]*?)\n---\n/); if (!m) return null; @@ -28,4 +31,20 @@ function makeReporter() { return { ok, summary }; } -export { parseFrontmatter, makeReporter }; +// Load a JSON fixture from tests/fixtures/. Shared by error-api.test.ts and +// precheck.test.ts so both spawn the CLI/precheck against identical canned +// responses. +function loadFixture(name: string): T { + return JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', name), 'utf8')); +} + +// Minimal JSON Response builder for the Bun.serve fixture servers in +// error-api.test.ts and precheck.test.ts. +function jsonResponse(obj: unknown, status = 200): Response { + return new Response(JSON.stringify(obj), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +export { parseFrontmatter, makeReporter, loadFixture, jsonResponse }; From caf8b57d1fe098ed56643445fb7b447e24f9267b Mon Sep 17 00:00:00 2001 From: Gabriel Tavares Date: Fri, 3 Jul 2026 09:15:50 +0100 Subject: [PATCH 6/6] fix(claude-code-error-hermit): dedup watch cursor by issue id so the loop can reach SKIP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The precheck queried firstSeen:>=cursor (inclusive), so the boundary group re-matched every run and EVALUATE fired forever — the "zero-cost when quiet" gate never went quiet. Track processed issue ids in the cursor and count only genuinely-new ones. A 6h lookback widens the window to catch backdated firstSeen late-arrivals, with a raised page size to keep reappearing overlap groups from crowding a real new group off the single page the gate fetches. Claude-Session: https://claude.ai/code/session_014ofkRhSQNaZpnESM2uGZUZ --- .../scripts/error-api-lib.ts | 15 +++++++-- .../scripts/error-precheck.ts | 32 +++++++++++++++---- .../skills/error-triage/SKILL.md | 9 +++++- .../tests/error-api.test.ts | 13 ++++++-- .../tests/precheck.test.ts | 20 ++++++++++++ 5 files changed, 77 insertions(+), 12 deletions(-) diff --git a/plugins/claude-code-error-hermit/scripts/error-api-lib.ts b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts index 0aef9000..3b9f5850 100644 --- a/plugins/claude-code-error-hermit/scripts/error-api-lib.ts +++ b/plugins/claude-code-error-hermit/scripts/error-api-lib.ts @@ -175,10 +175,21 @@ function str(v: unknown): string { return String(v); } +// Subtract `ms` from an ISO timestamp, returning a new ISO string. Used by the +// precheck to widen the `firstSeen:>=` window by a lookback so a group that +// arrives with a slightly-backdated firstSeen (clock skew, delayed grouping) is +// still seen; the precheck then dedups by issue id so the overlap costs nothing. +export function isoMinus(iso: string, ms: number): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return iso; + return new Date(t - ms).toISOString(); +} + // Build the Sentry issue-search `query` string. Combines an optional free-form // query with a `firstSeen:>=` bound so the watch loop sees only groups -// that first appeared at or after the cursor (>= handles boundary ties; the -// triage skill dedups against ids already recorded that day). +// that first appeared at or after the cursor. The bound is inclusive; the +// precheck dedups the returned groups against the cursor's `seen_ids` so the +// boundary group does not re-trigger EVALUATE forever. export function buildIssueQuery(opts: { since?: string; query?: string }): string { const parts: string[] = []; if (opts.query) parts.push(opts.query.trim()); diff --git a/plugins/claude-code-error-hermit/scripts/error-precheck.ts b/plugins/claude-code-error-hermit/scripts/error-precheck.ts index cec4bd92..34c56225 100644 --- a/plugins/claude-code-error-hermit/scripts/error-precheck.ts +++ b/plugins/claude-code-error-hermit/scripts/error-precheck.ts @@ -19,10 +19,18 @@ import { join } from 'node:path'; import { apiRequest, buildIssuesUrl, + isoMinus, projectRoot, resolveConfig, } from './error-api-lib'; +// Widen the firstSeen:>= window by this much so a group that lands with a +// slightly-backdated firstSeen is still fetched. The seen_ids dedup below keeps +// the overlap from re-triggering EVALUATE, so the only cost is a marginally +// larger response. Groups backdated beyond this window are still missed — the +// lookback bounds, it does not eliminate, that risk. +const LOOKBACK_MS = 6 * 60 * 60 * 1000; // 6h + function emit(line: string): never { process.stdout.write(`${line}\n`); process.exit(0); @@ -34,23 +42,35 @@ async function main(): Promise { if (!config) emit(`ERROR|config incomplete: ${missing.join(', ')}`); let cursor: string | undefined; + let seenIds = new Set(); try { const raw = readFileSync(join(root, '.claude-code-hermit', 'state', 'error-cursor.json'), 'utf8'); - const parsed = JSON.parse(raw) as { last_seen_first_seen?: string }; + const parsed = JSON.parse(raw) as { last_seen_first_seen?: string; seen_ids?: string[] }; cursor = parsed.last_seen_first_seen; + if (Array.isArray(parsed.seen_ids)) seenIds = new Set(parsed.seen_ids.map(String)); } catch { cursor = undefined; } if (!cursor) emit('EVALUATE|no cursor — bootstrap'); - const url = buildIssuesUrl(config, { since: cursor }); - const res = await apiRequest(url, config.token); + // Raise the page size for this widened query. The lookback re-includes + // already-seen groups, and with no `sort` param the server's ordering isn't + // guaranteed to favor new groups — at limit=25 a burst of reappearing overlap + // groups could crowd a real new group off the single page this gate fetches, + // silently skipping an EVALUATE. `limit` is backend-agnostic (GlitchTip's + // `sort` support is buggy, so it's avoided); this bounds the risk, cheaply. + const url = buildIssuesUrl(config, { since: isoMinus(cursor, LOOKBACK_MS), limit: '100' }); + const res = await apiRequest>(url, config.token); if (!res.ok) emit(`ERROR|HTTP ${res.status}${res.error ? ' — ' + res.error : ''}`); - const n = Array.isArray(res.data) ? res.data.length : 0; - if (n === 0) emit('SKIP|no new error groups'); - emit(`EVALUATE|${n} new groups`); + // Dedup against ids the triage skill already processed. The firstSeen:>= bound + // is inclusive and widened by the lookback, so groups seen last run reappear — + // count only the genuinely-new ids, else the loop can never reach SKIP. + const groups = Array.isArray(res.data) ? res.data : []; + const fresh = groups.filter((g) => !seenIds.has(String(g?.id))); + if (fresh.length === 0) emit('SKIP|no new error groups'); + emit(`EVALUATE|${fresh.length} new groups`); } if (import.meta.main) { diff --git a/plugins/claude-code-error-hermit/skills/error-triage/SKILL.md b/plugins/claude-code-error-hermit/skills/error-triage/SKILL.md index 88d097e7..50fcae0a 100644 --- a/plugins/claude-code-error-hermit/skills/error-triage/SKILL.md +++ b/plugins/claude-code-error-hermit/skills/error-triage/SKILL.md @@ -82,7 +82,14 @@ Append a scrubbed record to `raw/error-triage-.md`: each group's sho ## Step 7 — Advance the cursor -Write `state/error-cursor.json` with `last_check` = now, `last_seen_first_seen` = the max `firstSeen` across the groups processed this run, and `consecutive_failures` = 0. This is the **only** place the cursor advances — a failed run in Step 1 never reaches here. +Write `state/error-cursor.json` with: + +- `last_check` = now. +- `last_seen_first_seen` = the max `firstSeen` across the groups processed this run. +- `seen_ids` = the issue ids you processed whose `firstSeen` falls within the precheck lookback of the new watermark (a stable window of ~6h back from `last_seen_first_seen`; drop older ids so the list stays bounded). The precheck reads this set to dedup the inclusive `firstSeen:>=` boundary — without it the boundary group re-triggers EVALUATE every run and the loop never reaches SKIP. +- `consecutive_failures` = 0. + +This is the **only** place the cursor advances — a failed run in Step 1 never reaches here. --- diff --git a/plugins/claude-code-error-hermit/tests/error-api.test.ts b/plugins/claude-code-error-hermit/tests/error-api.test.ts index 535e187c..c636c06d 100644 --- a/plugins/claude-code-error-hermit/tests/error-api.test.ts +++ b/plugins/claude-code-error-hermit/tests/error-api.test.ts @@ -9,6 +9,7 @@ import { summarizeIssue, summarizeEvent, buildIssueQuery, + isoMinus, redact, } from '../scripts/error-api-lib'; import { loadFixture, jsonResponse } from './test-utils'; @@ -18,7 +19,7 @@ const load = loadFixture; const GOOD_TOKEN = 'good-secret-token-value-xyz'; -type Recorded = { method: string; path: string; body: string }; +type Recorded = { method: string; path: string; search: string; body: string }; const requests: Recorded[] = []; let server: ReturnType | undefined; @@ -30,7 +31,7 @@ beforeAll(() => { async fetch(req) { const url = new URL(req.url); const body = req.method === 'GET' ? '' : await req.text(); - requests.push({ method: req.method, path: url.pathname, body }); + requests.push({ method: req.method, path: url.pathname, search: url.search, body }); const auth = req.headers.get('authorization') ?? ''; if (!auth.includes(GOOD_TOKEN)) { @@ -104,6 +105,11 @@ describe('lib parsers', () => { expect(buildIssueQuery({})).toBe(''); }); + test('isoMinus subtracts the lookback and leaves bad input untouched', () => { + expect(isoMinus('2026-07-03T06:00:00Z', 6 * 60 * 60 * 1000)).toBe('2026-07-03T00:00:00.000Z'); + expect(isoMinus('not-a-date', 1000)).toBe('not-a-date'); + }); + test('redact scrubs the token and bearer-shaped substrings', () => { const scrubbed = redact(`Authorization: Bearer ${GOOD_TOKEN}`, GOOD_TOKEN); expect(scrubbed).not.toContain(GOOD_TOKEN); @@ -153,7 +159,8 @@ describe('CLI: issues', () => { await runCli(['issues', '--since', '2026-07-03T00:00:00Z']); const listReq = requests.filter((q) => q.path === '/api/0/projects/acme/web/issues/').pop(); expect(listReq).toBeDefined(); - expect(listReq!.path).toBe('/api/0/projects/acme/web/issues/'); + const query = new URLSearchParams(listReq!.search).get('query'); + expect(query).toBe('firstSeen:>=2026-07-03T00:00:00Z'); }); }); diff --git a/plugins/claude-code-error-hermit/tests/precheck.test.ts b/plugins/claude-code-error-hermit/tests/precheck.test.ts index 22a63631..27f0b590 100644 --- a/plugins/claude-code-error-hermit/tests/precheck.test.ts +++ b/plugins/claude-code-error-hermit/tests/precheck.test.ts @@ -86,6 +86,26 @@ describe('error-precheck verdicts', () => { expect(r.verdict).toBe('EVALUATE|2 new groups'); }); + test('all returned groups already in seen_ids → SKIP (boundary does not re-trigger)', async () => { + const dir = makeProjectDir({ + last_seen_first_seen: '2026-07-03T00:20:00Z', + seen_ids: ['1001', '1002'], + }); + const r = await runPrecheck(dir, 'web'); + expect(r.code).toBe(0); + expect(r.verdict).toBe('SKIP|no new error groups'); + }); + + test('seen_ids covers only some groups → EVALUATE counts the fresh ones', async () => { + const dir = makeProjectDir({ + last_seen_first_seen: '2026-07-03T00:20:00Z', + seen_ids: ['1001'], + }); + const r = await runPrecheck(dir, 'web'); + expect(r.code).toBe(0); + expect(r.verdict).toBe('EVALUATE|1 new groups'); + }); + test('missing config → ERROR, exit 0 (verdict is the protocol)', async () => { const dir = makeProjectDir({ last_seen_first_seen: '2026-07-01T00:00:00Z' }); const r = await runPrecheck(dir, 'web', { ERROR_HERMIT_TOKEN: '' });