SLOPSHOPPER

harness-ops

Claude Code operations: audit-skill-visibility (skills the listing budget hides), inventory (all commands, skills, agents, tools, plugins), audit-install-state…

newprocesstimer
A shopper browsing a rack in a slop shop
README

harness-ops

Contents

A Claude Code plugin for running Claude Code well over time. It ships sixteen skills and a family of telemetry-emitter hooks, including a skill that diagnoses why most of an installed skill fleet never gets used. audit-native-overlap maps native Claude Code surfaces against the current repo's own components so a custom skill never silently duplicates what the product now ships. audit-install-state reports on the machine-scope ~/.claude install directory itself. audit-performance captures slowness evidence at the moment a machine or session feels slow so the cause is diagnosed instead of nuked, including the fan-out layer of hooks, statusline, spawn cost, and subagent concurrency. observability reads what your sessions actually did. known-issues tracks what upstream has broken, and changelog integration keeps your repo current with what upstream has shipped. The plugins skill keeps your own plugin fleet current, morning-brief prints your read-only operator morning view, and lanes launches and manages your loop lanes as background sessions. A re-runnable setup action settles where the known-issues registry lives, and the *-audit hooks feed observability with per-hook execution telemetry Claude Code's native OTEL cannot see.

Skills

SkillWhat it does
/harness-ops:audit-skill-visibilityAudits whether the model can actually see each installed skill, the question behind "why does most of my fleet never get used?", since a skill the model cannot see can never be chosen. Reports three independent things per skill: reachability (visible, user-only by design, hidden by an override or disabled plugin, or invisibly misconfigured), observation (what usage was actually recorded, always horizon-qualified), and starvation (whether it is losing the description-budget contest: Claude Code drops descriptions starting with the skills you invoke least, so an unused skill loses the keywords a request would match and stays unused). Whether the listing overflows is computed from documented settings; which particular skills lose their descriptions is a labeled likelihood band, never an exact cutoff. Withholds every cold verdict the data cannot support instead of reporting absence of data as absence of use. Read-only.
/harness-ops:audit-install-stateRead-only audit of the machine-scope Claude Code installation directory, the ~/.claude tree plus the home-root ~/.claude.json. Inventories every file (entries labeled as an authored surface or a rolled-up bulk tree, with the complete per-file rows in a CSV artifact), separates what Claude Code's own cleanupPeriodDays sweep already manages from what nothing manages, resolves what each number in a filename actually is before attempting any process-liveness lookup, and deny-lists any subtree holding a revert ledger before classifying anything as stale. Never deletes; hands off to claude purge and /disk-hygiene:clean.
/harness-ops:audit-native-overlapMaps native Claude Code surfaces, built-in CLI commands, bundled skills, plugin-backed built-ins, session-provided skills, against the current repo's plugin skills and agents. Bare invocation is a read-only overlap report (candidates with evidence, detection integrity floors carried through, and a shared-listing-budget exposure section); verdicts (prefer-native / prefer-ours / complementary / superseded / defer) are human-gated in a committed store (docs/native-surfaces/records.json) rendered into a generated registry (docs/native-surfaces.md) whose every row carries an observable recheck trigger; only an explicit apply step bakes presence-gated native references into component descriptions and Boundary sections.
/harness-ops:audit-performanceRead-only slowness-diagnostic capture, run at the moment the machine or a session feels slow, before restarting or deleting anything. One timed engine pass separates four documented suspects: accumulated install-tree state, version regression, component bloat, and the fan-out layer, plus on Windows a kernel-object census that names the host-level Token-object leak beneath all four. Each suspect's evidence and verdict routing is documented in the skill. Phase timings are first-class evidence; content reads are allowlisted to four non-secret config files (settings.json, .last-cleanup, hooks.json, installed_plugins.json), so ~/.claude.json and history.jsonl stay stat-only. Reports and routes; never mutates, never elevates, and never executes a discovered hook or statusline command.
/harness-ops:observabilityReads locally captured Claude Code telemetry, OTEL DuckDB store, machine-owned collector, optional Aspire dashboard, hook-event JSONL, ccusage, and renders cross-session trend reports (session/day/week/month/since:/all scopes). Read-only except the explicit clean action, which prunes the JSONL log and OTEL store by age.
/harness-ops:known-issuesSearches known Claude product GitHub bugs before you build on a feature, checks service health and model quality, and maintains a persistent registry of tracked issues (what they block, workarounds, follow-ups when fixed). Actions: status (default), search, check-all, scan, list, quality, create.
/harness-ops:changelogIngests Claude Code changelog entries and integrates them into the current repo: fetch (read-only display), diff (decision rows by owner surface and action lens over a release range, no edits), status (the read marker from the repo's Claude Code ledger, the default range to the newest release, and the replay cap), and apply (executes the decisions in scope one PR per owner plugin, hands larger ones off as work items, then runs a native-surface drift pass that files work items for new overlap candidates, fired store triggers, and a degraded or broken inventory; explicit user intent only).
/harness-ops:behavior-probesRuns live probes of Claude Code platform behavior (permission rules, auto mode, hooks, worktrees, subagent caps, sandbox) as data-driven cases, each negative case with a paired positive control, and gives each a pass, fail or inconclusive verdict stamped with the Claude Code version. --dry-run (the default) runs every scaffold against a fake claude and costs nothing; a live run is opt-in and capped per case and per suite. recheck lists the cases a changelog range touches. Outcomes are recorded in the skill's records.md.
/harness-ops:pluginsBrings a machine's plugin fleet current on demand: marketplace refresh, updates for the plugins that actually load (including in-repo project/local-scope installs), new-catalog-plugin install per policy, and scope-divergence detection. Actions: sync (default, CLI-mediated mutations only), audit (read-only dry run), converge (the one action that can touch a committed .claude/settings.json. Previews and confirms per plugin first).
/harness-ops:morning-briefPrints the read-only, gh-based operator morning view for the current repo in one pass: open counts per queue label (needs-triage, status: ready, status: needs-decision, needs-human), the gh-native merge-ready PR list (non-draft + mergeStateStatus=CLEAN, with a clean PR whose head is behind its base marked UNVERIFIED), parked status: needs-decision issues with their RECOMMENDED lines, and loop-lane telemetry freshness (per-lane last-cycle age + flags:). Never mutates anything; the authoritative PR merge gate stays /source-control:babysit-prs.
/harness-ops:lanesStarts, restarts, stops, and reports loop lanes as named background Claude Code sessions seeded from canonical prompt files. start (default) / restart pull the repo and refresh the plugin marketplace, then launch each configured lane (claude --bg -n <lane> --permission-mode auto, plus --permission-prompts none on CLI 2.1.259 or later) with its per-lane model/effort; status shows per-lane running state and live sessionId; stop ends a lane via claude stop; consume-restarts is the OS-schedulable restart-request consumer. It reads each configured lane's telemetry restart_request and relaunches the stopped lanes that asked, through the same launcher (#1653). Acts only on sessions whose name is a configured lane. Lanes come from a JSON config (--config, else $HARNESS_OPS_LANES_CONFIG, else <repo>/.work/lanes/lanes.json, with a temporary default-only fallback to the pre-move <repo>/.work/lanes.json under a deprecation warning); config and prompts live in the reserved lanes/ concern home under a hardcoded .work root, which is a sanctioned placement but still session-local, so a durable cross-machine home stays #480's job.
/harness-ops:checkRead-only check that node and jq resolve for the hooks, with the install route for a missing tool. Model-invocable; never installs.
/harness-ops:machine-profileDiscovers this machine's facts and identity domains (each tree's git include, gh directory and verdicts), stores them as a re-runnable profile that records the observation behind every value, and reports drift between the stored profile and the host now. Actions: profile (default), diff, explain <key>, apply --option <key>. Read-only unless the operator confirms a write: record --confirm writes the profile document, and apply --confirm prints what to hand to each setup and writes nothing. Never installs and never reapplies a stored value on its own. Design: machine-profile-design.
/harness-ops:setupcheck reports the effective known-issues-registry, skill-usage-log and hook-log-root destinations, their defaults, path containment, the hook log root's self-ignoring guard, and retired conventions (retirements.yaml), and prints the guidance for routing personal option changes through Claude Code's plugin configuration prompt; apply writes exactly one file, the guard inside the hook log root, and runs the gated retirement cleanup.

The audit hooks

Eight advisory *-audit hooks, registered as nine rows because skill-usage-audit has two producers, emit the marketplace hook-telemetry envelope. One JSON event per run carrying that hook's own duration_ms, outcome, and a privacy-safe subject. Each is independently toggleable via its own userConfig boolean (default on; see Per-hook kill switches).

Three scripts serve those nine rows. hooks/audit-event-emitter.sh carries seven of them and picks the row from the payload's hook_event_name, the way session-event-log.sh next to it serves 28 events from one file; the seven events are distinct, so the event alone selects the row. Each row still reads its own <name>_enabled switch and emits the same telemetry hook id, hook_event, status and data fields it emitted as a standalone script, so no downstream reader can tell the difference. The two rows with earned behavior of their own keep their files: skill-usage-audit.sh, whose kill switch is inlined above its library source because it sits on the hot PostToolUse path, and hook-failure-audit.sh (below).

The hook budget is accounted per hooks.json entry, and the collapse changes no entry: the same nine audit rows, on the same events, with the same matchers, and one event still spawns exactly one process. Per-entry cost is unchanged for an enabled row (the same library, the same jq passes, plus one bash pattern match to read the event) and lower when every switch is off, because the emitter reads all seven switches before it parses the library. A row that is off on its own pays one buffered payload it did not pay before, because the row is only known once the event has been read; it parsed the same library then as now, and no extra process runs either way.

The six pure emitter rows are a no-op until a consumer wires a sink (below); skill-usage-audit is one exception. Both its producers also write the shared skill-usage.jsonl second store unconditionally (disable the whole feature with skill_usage_audit_enabled=false; pick the store's home with skill_usage_scope and skill_usage_dir). In the default repo scope the store dir is kept out of git status via an idempotent machine-local .git/info/exclude entry (skill_usage_git_exclude=false opts out for teams that commit the telemetry).

One row per Skill call, with these fields:

FieldPresentWhat it holds
tsalwaysUTC timestamp of the call, YYYY-MM-DDTHH:MM:SSZ
eventalwaysSkillUse, the key readers filter this store on
skillalwaysthe skill name, leading slash stripped
branchalwaysthe checked-out branch, unknown outside a git work tree
projectalwaysthe project root's basename, for display
project_idalwaysbasename plus a digest, collision-resistant across checkouts in the user and data-dir scopes
hookalwaysskill-usage-audit
sourcealwaystool (the Skill tool) or expansion (a user-typed slash command)
expansion_typeexpansion path onlyslash_command or mcp_prompt

hook-failure-audit is the other exception. Its user-facing systemMessage warning fires regardless of sink wiring (only its envelope needs a sink), because its whole subject is failures nothing else surfaces: a hook that fails to launch is a non-blocking error, the guarded tool call proceeds as if approved, and the only durable trace is a transcript attachment no human reads (#2577). It runs once per Stop, tails a bounded window of the session transcript for hook_non_blocking_error attachments (structural match on the attachment type, never substring), and warns once per session per distinct failing hook registration, keyed on hookName plus the registered command because several plugins share an event and matcher, re-warning when a new registration starts failing. It lives in this plugin, not in the plugin it might report on, deliberately: an in-plugin detector shares its plugin's registration form and dies with it, which is exactly how disk-hygiene's guard monitor missed the #1416 incident class.

Each failure record is classed one of four ways, and the warning gives each class its own diagnosis and remedy:

  • launch failure: stderr carries an exec-failure signature; the hook never ran.
  • stale config: exit 127 and the whole stderr is bash's missing-script line (bash: <path>: No such file or directory). The session still runs hook config it loaded before a plugin update or an in-place edit deleted that script; the remedy is /reload-plugins, or a restart.
  • ambiguous: exit 126 or 127 with neither of the above, so the registered command and the hook's own commands are both suspects.
  • completed non-zero exit: the hook ran and failed.

Its budget share is stated as a process count, not a duration, and the host is the reason. The hook-budget convention states each always-on hook's budget as k x S plus measured work (k = fewest spawns, S = one no-op spawn's time), not a fixed millisecond figure, and on the host in #3508 one process creation costs 180-2,841 ms (median 1,108 ms at 501 concurrent processes), so the count is what decides whether the set fits and a duration measured anywhere else does not transfer. On a turn with no hook failure recorded, the common case, the hook costs 9 process creations and 4 program execs: one wc for the tail-cap decision, one grep pre-filter that opens the transcript directly, and two jq passes inside the synced hook-utils.sh. The creations above that are subshell forks inside the same shared library. It was 18 creations and 6 execs before #3512. Counts are measured with strace -ff -e trace=clone,clone3,fork,vfork,execve, not bash -x: a command substitution whose command carries its own redirection forks a subshell that xtrace cannot see, and those forks were most of the cost. hook-failure-audit.test.sh asserts both ceilings. Windows Git Bash, the host the convention binds to, measured S (the bash spawn floor) at 33 ms on 2026-09-30, through the bash the launcher spawns (20 ms on the PATH bash), but the hook-failure-audit wall there is still unmeasured for this row, so its spawn-equivalents (hook wall divided by the same-run S) are still owed.

skill-usage-audit is captured by two disjoint producers so both invocation paths are measured: the model-invoked Skill tool (PostToolUse) and the user-typed slash command (UserPromptExpansion, which bypasses the Skill tool). Events carry a source field (tool vs expansion) so consumers can tell the paths apart; both share the same telemetry hook id and second store.

Its share of the budget is stated as a git process count, the part of the row's cost that varies with the store's contents. The store write spawns 3 git processes: the repo-root read, the .git/info/exclude hygiene read (repo scope only), and one git rev-parse --abbrev-ref HEAD for the branch. Measured with strace -ff -e trace=execve on a throwaway fixture repository, the same method the hook-failure-audit counts above use. This row is not in the always-on per-tool-call set: its matcher is Skill, so an ordinary tool call spawns nothing.

HookEventEmitsWhy it earns its spawn
api-error-auditStopFailureAPI turn-failure error_type (never the message body)The event only fires when a turn fails at the API, so the hook costs nothing on a healthy session
config-change-auditConfigChangethe mutated config_sourceMatcher-scoped to the four sources that can change behavior, and a settings or skills change is rare
instructions-loaded-auditInstructionsLoaded<repo-relative-file>:<load_reason> (absolute prefix stripped; session_start filtered by default)The highest-volume row here, and the only one whose cost is worth watching. It is advisory: InstructionsLoaded ignores the exit code, so the hook never gates a load, and the session_start write-time filter keeps the noisiest reason out of the store by default
permission-denied-auditPermissionDeniedclassifier denials, Bash:<first-token> subjectFires only on an actual denial, and it is the only durable record of what the classifier refused
pre-compact-auditPreCompactcompaction trigger (manual/auto)Once per compaction, which is the rarest event this plugin observes
skill-usage-audit (tool path)PostToolUse (Skill)model-invoked skill; source: "tool"; also writes the skill-usage.jsonl second storeMatcher-scoped to Skill, so it is skipped on every other tool call rather than firing per PostToolUse
skill-usage-audit (expansion path)UserPromptExpansionuser-typed /command (slash_command/mcp_prompt); source: "expansion" + expansion_type; same second storeFires per expanded slash command, not per prompt, and it is the only path that sees a user-typed invocation (the Skill tool never runs)
tool-failure-auditPostToolUseFailureWrite/Edit/Bash failures, privacy-safe subjectMatcher-scoped to the three tools whose failures are actionable, and the event fires only on failure
hook-failure-auditStopunsurfaced hook_non_blocking_error attachments; envelope subjects are hook names only; also warns via systemMessageThe only per-turn row, and it is the sole surface for a hook that failed to launch while its guarded tool call proceeded

None captures a command body, absolute path, error message, or argument body, only category labels, privacy-safe subjects, and (for instructions-loaded-audit) the repo-relative path of the loaded rule file.

The InstructionsLoaded row carries no matcher on purpose. That event's matcher selects on load reason, and the row passes every reason through verbatim into its subject, so scoping to the full documented set would skip nothing and would silently drop any reason a later release adds. Scoping below that set is worse: the only reason worth excluding for cost is session_start, which the row already drops at write time, and it drops it behind the instructions_loaded_audit_log_session_start option. A matcher that excluded session_start would stop the hook from ever spawning on it, leaving that option switched on but unable to log anything. The row therefore stays unscoped until the option is retired.

Per-hook kill switches

Each audit hook is toggled by its own userConfig boolean (default on; set to false for a clean no-op). Disable one hook without touching the others. The hooks read them through the native CLAUDE_PLUGIN_OPTION_<KEY> hook-process mirror. Seven rows share audit-event-emitter.sh, which reads the switch of the row the event selected: sharing a script does not share a switch, and turning one row off leaves the other six emitting.

HookOption
api-error-auditapi_error_audit_enabled
config-change-auditconfig_change_audit_enabled
instructions-loaded-auditinstructions_loaded_audit_enabled
permission-denied-auditpermission_denied_audit_enabled
pre-compact-auditpre_compact_audit_enabled
skill-usage-audit (both paths)skill_usage_audit_enabled
tool-failure-audittool_failure_audit_enabled
hook-failure-audithook_failure_audit_enabled

instructions-loaded-audit drops deterministic, high-volume session_start loads by default; set instructions_loaded_audit_log_session_start=true to opt back into logging them. A stdin_read_timeout option (seconds, default 2) is an idle bound on reading each hook's payload: any byte arriving resets it, so a large or slowly-delivered payload is never cut off while it is still coming, and it fires only once the pipe has gone silent for that long, at which point these audit hooks fail open (skip). On a shell whose read -t accepts fractional values the bound is read in four slices, so the stall is detected within a quarter of the configured interval of it; where fractional timeouts are unavailable (Bash 3.2, the macOS system shell) it is read as one window and a producer that sends bytes then goes silent can take up to two intervals. A producer that keeps emitting is bounded by Claude Code's own hook timeout, not by this value. A setting this shell's read -t will not accept, or 0, falls back to the default.

Set them interactively with /plugin configure harness-ops@<marketplace>, or headless on the install command:

claude plugin install harness-ops@<marketplace> --config skill_usage_audit_enabled=false

Option scoping (user vs project settings, and the per-repository escape hatch) per "How to set these" below.

Wiring the reference sink

A migrated emitter is inert without a consumer. hooks/hook-telemetry-sink.sh is a reference sink: it reads an envelope on stdin and appends one line under the hook log root, <project-root>/.observability/claude by default (the session_event_log_dir option moves it). An envelope carrying data.session_id lands in sessions/<session_id>.jsonl, beside the per-session event log; one without lands in the shared hook-events.jsonl in the legacy shape; that file rotates to hook-events.jsonl.1 at hook_events_max_bytes whatever the session-log switch is set

Source 1 files
hooks/register.ts 104 lines
1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3// The per-session hook event log, off by default. Off, the module hooks nothing, so no event starts
4// a process. On, each observed classic event hands its payload to session-event-log.sh, which
5// writes the record the settings row wrote, and SessionEnd also runs session-retention.sh. A mod
6// cannot append to a file, so each write is one process.
7
8type ClassicEvent = Readonly<{ hook_event_name: string }>
9type Next = (e: never) => Promise<unknown>
10
11const TIMEOUT_MS = 5_000
12
13let config: PluginOptions = {}
14const logged = new Set<string>()
15
16// What a settings hook had in its environment: CLAUDE_PLUGIN_OPTION_<KEY> for each option and the
17// project root. CLAUDE_EFFORT and TRACEPARENT are cleared, so a value the Claude Code process itself
18// holds never stands in for one the event did not carry.
19export const scriptEnv = (options: PluginOptions, root: string): Record<string, string> => ({
20  ...Object.fromEntries(
21    Object.entries(options).map(([key, value]) => [`CLAUDE_PLUGIN_OPTION_${key.toUpperCase()}`, String(value)]),
22  ),
23  CLAUDE_PROJECT_DIR: root,
24  CLAUDE_EFFORT: '',
25  TRACEPARENT: '',
26})
27
28const logOnce = ($: EngineInterface, key: string, text: string) => {
29  if (logged.has(key)) return
30  logged.add(key)
31  $.ui.log(`harness-ops: ${text}`, { to: 'debug' })
32}
33
34async function run($: EngineInterface, script: string, stdin?: string) {
35  try {
36    // A settings hook got the project dir Claude Code exports, which stays put when the session's
37    // root moves (EnterWorktree); the session root stands in only where none is exported.
38    const root = (await $.env.get('CLAUDE_PROJECT_DIR')) || (await $.session.root())
39    const result = await $.process.run(['node', `${$.plugin.root}/hooks/exec-bash.mjs`, `${$.plugin.root}/hooks/${script}`], {
40      cwd: root,
41      env: scriptEnv(config, root),
42      ...(stdin === undefined ? {} : { stdin }),
43      timeoutMs: TIMEOUT_MS,
44    })
45    if (result.exitCode !== 0) logOnce($, `${script}-exit`, `${script} exited ${result.exitCode}: ${result.stderr.trim()}`)
46  } catch (error) {
47    logOnce($, `${script}-threw`, `${script} did not run: ${error instanceof Error ? error.message : String(error)}`)
48  }
49}
50
51// The write only records, so no event waits on it: it runs on a timer, outside the event, as the
52// async settings rows did. SessionEnd's stays awaited, as its row stayed synchronous: its hooks
53// share the 1.5-second teardown budget.
54async function record($: EngineInterface, e: ClassicEvent, next: Next) {
55  const payload = JSON.stringify(e)
56  if (e.hook_event_name !== 'SessionEnd') {
57    $.clock.after(0, () => void run($, 'session-event-log.sh', payload))
58    return next(e as never)
59  }
60  const [result] = await Promise.all([next(e as never), run($, 'session-event-log.sh', payload), run($, 'session-retention.sh')])
61  return result
62}
63
64function passThrough($: EngineInterface, e: ClassicEvent, next: Next) {
65  return next(e as never)
66}
67
68export const register: Register = (on, options) => {
69  if (options.session_event_log_enabled !== true) return
70  config = options
71  // scripts/gen-hook-event-registry.sh writes the block below from hook-events.registry.json, one
72  // hook per event marked observe; Claude Code reads each event name from a string literal.
73  // BEGIN GENERATED: observed events
74  on('classic.ConfigChange', record).catch(passThrough)
75  on('classic.CwdChanged', record).catch(passThrough)
76  on('classic.DirectoryAdded', record).catch(passThrough)
77  on('classic.Elicitation', record).catch(passThrough)
78  on('classic.ElicitationResult', record).catch(passThrough)
79  on('classic.InstructionsLoaded', record).catch(passThrough)
80  on('classic.Notification', record).catch(passThrough)
81  on('classic.PermissionDenied', record).catch(passThrough)
82  on('classic.PermissionRequest', record).catch(passThrough)
83  on('classic.PostCompact', record).catch(passThrough)
84  on('classic.PostModelSwitch', record).catch(passThrough)
85  on('classic.PostToolBatch', record).catch(passThrough)
86  on('classic.PostToolUseFailure', record).catch(passThrough)
87  on('classic.PreCompact', record).catch(passThrough)
88  on('classic.PreModelSwitch', record).catch(passThrough)
89  on('classic.SessionEnd', record).catch(passThrough)
90  on('classic.SessionStart', record).catch(passThrough)
91  on('classic.Setup', record).catch(passThrough)
92  on('classic.Stop', record).catch(passThrough)
93  on('classic.StopFailure', record).catch(passThrough)
94  on('classic.SubagentStart', record).catch(passThrough)
95  on('classic.SubagentStop', record).catch(passThrough)
96  on('classic.TaskCompleted', record).catch(passThrough)
97  on('classic.TaskCreated', record).catch(passThrough)
98  on('classic.TeammateIdle', record).catch(passThrough)
99  on('classic.UserPromptExpansion', record).catch(passThrough)
100  on('classic.UserPromptSubmit', record).catch(passThrough)
101  on('classic.WorktreeRemove', record).catch(passThrough)
102  // END GENERATED: observed events
103}
104