SLOPSHOPPER

guard

Turn coding rules into harness-enforced ones. comment-guard (each Edit/Write) and comment-sweep (at Stop, for writes that bypassed them) hand back the comment…

newguardtoastnetworktimer
★ 1v0.7.3MITupdated 2026-10-03FunnyQ/cc-plugins/packages/guard
A shopper browsing a rack in a slop shop
README

cc-plugins

A local Claude Code, Codex, and OpenCode plugin marketplace for a personal coding workflow. It ships five plugins: monitor turns local traces into useful dashboards — the usage-dashboard skill is the rear-view mirror for usage history, and the cockpit skill is the windshield for the session currently in flight; dispatch is interview-driven planning you can then execute — spec the work, write a blueprint to disk, and fly it with a quality loop, or map a whole project into milestone legs and plan each one just-in-time; relay delegates a task out to another harness's CLI (codex, opencode, or claude) — delegate work, request a review, or generate an image — then captures the result and reports back; chronicle authors your git history — commits (auto simple/atomic), reviewer-legible PRs/MRs, and config-first releases that bump versions, write the changelog, and cut the tag; herdr is reference plus a typed wrapper for driving agents across panes in the Herdr terminal workspace manager.

Development Workflow

This repository uses GitHub Flow. Create feature and fix branches from main, then open pull requests back into main. The existing develop branch is historical and does not indicate that this repository uses git-flow. Chronicle records this machine-readable PR policy in .chronicle/pr.json.

Plugins

monitor bundles three skills:

SkillDescription
usage-dashboardLocal usage dashboard for Claude Code, Codex, and OpenCode: sessions, tokens, cost, model mix, and project activity, plus live sessions for Claude Code and Codex
cockpitPer-project work cockpit for Claude Code, Codex, and OpenCode: goal capture, decision log, live transcript, needs-your-call bridge, and a send box for live sessions
installOne-stop prerequisite check and permission wiring for the whole plugin, command-triggered

dispatch bundles five skills:

SkillDescription
preflightShort interview that captures what you want as docs/<slug>/INTENT.md, before anyone decides how to build it — writes one file and stops, never executes
hopLightweight interviewer that gathers requirements into a single in-conversation plan to approve and execute
flightplanHeavyweight interviewer that writes a multi-file blueprint to disk — PLAN.md + a tasks/ tree of self-contained task files for sub-agents
autopilotExecutes a flightplan tree in parallel waves — a dev→verify→judge→score loop gated on each task's Eval rubric, an atomic commit between waves, then the closing final-review gate, leaving an audit trail
waypointsRolling-wave milestone-roadmap tier above flightplan — writes only docs/<proj>/WAYPOINTS.md plus a waypoints.ts CLI (active / leg-scaffold / advance) so each leg's flightplan is planned just-in-time after the previous leg lands

relay is a single portable skill:

SkillDescription
relayDelegate a task to another harness's CLI (codex / opencode / claude): delegate (do work), review (analysis only), or image (codex only) — capture the result, smart-apply when safe, and report back

chronicle bundles four skills:

SkillDescription
commitCraft git commit(s) for the current changes — auto-decides between one simple commit and an atomic split
prOpen a reviewer-legible PR/MR for the current branch, enriched by the cockpit decision trail when present
releaseCut a release — bump version files, write the CHANGELOG entry, then commit, merge, tag, and push; local stops before the push, prepare after the entry
installSet up chronicle's prerequisites — the named agent roles on Codex (Claude Code needs none)

herdr is a single skill:

SkillDescription
herdrReference for the Herdr terminal workspace manager (config, CLI, plugin dev) plus a typed herd wrapper to spawn and drive agents in sibling panes when running inside herdr

Claude Code Installation

CLI

claude plugins marketplace add FunnyQ/cc-plugins
claude plugins install monitor@q-lab-marketplace
# Any of these plugin ids works: monitor, dispatch, relay, chronicle, herdr

TUI

  1. Open Claude Code
  2. Type /plugins to open the plugin manager
  3. Select Add Marketplace → enter FunnyQ/cc-plugins
  4. Select Install Plugin → choose the plugin you want

The usage-dashboard skill runs a prerequisite check automatically before launching the dashboard, so there's no manual setup step. If something is missing, the hint is surfaced in the terminal. The most common case is stats-cache.json not existing yet; run /stats once in Claude Code to seed it.

If you want to run the precheck yourself:

bun $CLAUDE_PLUGIN_ROOT/skills/install/scripts/install.ts

Codex Installation

Codex reads this marketplace from .agents/plugins/marketplace.json. The registry lists all five plugins, and each is installed by id.

codex plugin marketplace add FunnyQ/cc-plugins
codex plugin add monitor@q-lab-marketplace
# Any of these plugin ids works: monitor, dispatch, relay, chronicle, herdr

Check the install:

codex plugin list | rg 'q-lab-marketplace|monitor'

After installing a Codex plugin, start a new Codex session so the skill list is refreshed.

OpenCode Installation

There is no plugin registry step. The repo is the single source of truth: opencode/install.ts symlinks skills, agents, commands, and the plugin module straight into ~/.config/opencode/, so an update to the checkout is live immediately — no reinstall, no rebuild.

Requires OpenCode 1.x. The plugin module targets OpenCode's V1 plugin API. OpenCode 2.x replaced that API, so on 2.x the module fails to load and none of its hooks run. The cockpit send and relay's opencode backend also support 1.x only.

bun opencode/install.ts --check     # report what is and is not wired
bun opencode/install.ts --apply     # symlink everything, raise subagent_depth
bun opencode/install.ts --unlink    # remove only what --apply created

--dry-run also exists — it prints the same plan as --apply without writing anything.

--apply installs:

InstallsCountTarget
Skills15~/.config/opencode/skills/<name>/
Plugin module1~/.config/opencode/plugin/q-lab.ts
Chronicle agents6~/.config/opencode/agents/<name>.md
Commands2~/.config/opencode/commands/<name>.md
subagent_depth1 edit~/.config/opencode/opencode.json

The subagent_depth prerequisite. --apply raises subagent_depth to at least 2 in ~/.config/opencode/opencode.json, the one config file it touches outside symlinks. The edit is raise-only — every other key in that file is preserved, and a value already ≥ 2 is left alone. OpenCode ships defaulting subagent_depth to 1, which blocks nesting outright, so this isn't a hypothetical edge case — it's the shipped default. Chronicle no longer needs it — none of its agents spawns a child — but dispatch's autopilot does: below the required depth its driver simply stops mid-run with no error naming the config, and the failure reads exactly like a plugin bug. Anyone hand-editing opencode.json instead of running --apply needs to set this key to 2 themselves.

Which config file gets the edit. OpenCode merges three global config names in order — config.json, then opencode.json, then opencode.jsonc — so the last one present wins. The installer patches whichever of those already exists, highest precedence first, and creates opencode.json only when none does. Two consequences: an existing opencode.jsonc is the file that gets raised, and a config carrying comments is read but never rewritten, because re-serializing it would delete them — --check and --apply report manual and print the line to add by hand.

Works under OpenCode:

  • All 15 skills, discovered from ~/.config/opencode/skills/.
  • usage-dashboard reads OpenCode data natively — the shared reader already handles ~/.local/share/opencode/opencode.db.
  • cockpit reads and sends for OpenCode sessions. Precondition: the OpenCode TUI must have been started with a port (opencode --port <n>), or OPENCODE_TUI_SERVER_URL must be set before cockpit starts.
  • relay delegate and review.
  • The five ported hook behaviors: decision-log start, the scribe nudge, the chronicle branch guard, the dispatch flightplan lint, and the guard comment check.
  • Both monitor commands.
  • Chronicle's subagents, spawned through the task tool.

Does not work under OpenCode:

  • The statusline. It is a Claude-only concept with no OpenCode equivalent, and there is no plan for one.
  • relay image — codex-only, and it fails at the capability gate before any CLI runs.
  • Workflow-driven autopilot. There is no Workflow tool, so autopilot runs as a manual task-tool wave loop instead: behaviorally close, but with no automatic parallel-wave scheduling.
  • monitor's setup.ts --session-check. It is deliberately not ported because it is inert outside Claude Code — it returns immediately without CLAUDE_PLUGIN_DATA, and its actual work is statusline-path migration and reaping orphaned Claude processes.

usage-dashboard

A single-page dashboard that reads local ~/.claude/ and ~/.codex/ data and visualizes usage in a browser. No telemetry, no cloud; everything stays on your machine.

Token Atlas dashboard preview

Features

  • Live now (Claude + Codex) — a panel of your currently-active Claude and Codex sessions with live status; click one to open it in cockpit's live transcript view (token-atlas links out rather than rendering transcripts itself). When cockpit's daemon isn't running the panel says so and the rows stay inert, so a click never opens a dead tab
  • Cost + usage overview — sessions, interactions, tokens, estimated spend, daily burn, and monthly budget projection
  • Persistent history — Claude usage is rolled into a local SQLite store (~/.local/share/q-lab/token-atlas/), so your token/cost/model history survives Claude Code's automatic transcript cleanup (cleanupPeriodDays) instead of disappearing as old sessions age out
  • Model analysis — daily trend, model distribution, and per-model token/cost breakdown
  • Project insights — project rankings with drilldown details for model mix and cost
  • Session ledger — recent Claude and Codex sessions side by side
  • Anomaly detection — flags days that break from your recent baseline
  • Token composition — input, output, cache-read, cache-write, and reasoning token shares
  • Activity timeline — hourly and daily activity patterns from local session data
  • Data health diagnostics — non-fatal source-read failures and record counts
  • Filters + export — provider/range filters, persisted preferences, and JSON/CSV export

Prerequisites

  • At least one Claude Code session (the cockpit shim fetches its release binary on first run)
  • For Claude usage totals, run /stats once to seed stats-cache.json

Quick Start

packages/monitor/skills/cockpit/bin/cockpit atlas serve   # [--port N] [--no-open]

As JSON instead: cockpit atlas stats (dashboard data), cockpit atlas live (active sessions), cockpit atlas rollup-update [--rebuild] (rollup DB).

Opens http://localhost:5938 in your default browser.

Options

--port <n>    Use a different port (default: 5938)
--no-open     Don't auto-open browser

Pricing

Token costs are estimated using bundled defaults (references/pricing-defaults.json). On startup, live prices are fetched from OpenRouter (3s timeout, silent fail). You can override with a custom file:

~/.config/cc-dashboard/pricing.json
{
  "models": {
    "claude-opus-4-7": { "input": 5.00, "output": 25.00, "cacheRead": 0.50, "cacheWrite": 6.25 }
  }
}

cockpit

Cockpit is a per-project dashboard and skill for active work. Start with a session goal, keep a distilled decision log, stream the current Claude Code or Codex transcript, and park on needs_your_call so a button click in the dashboard wakes the session.

Cockpit dashboard preview

Quick Start

In Claude Code or Codex, invoke the cockpit skill and confirm the proposed goals. From a development checkout, the dashboard can also be started directly:

packages/monitor/skills/cockpit/bin/cockpit server

Opens http://localhost:5858 in your default browser.

Provider Support

  • Claude Code transcripts resolve from ~/.claude/projects/**/<session>.jsonl.
  • Codex transcripts resolve from ~/.codex/state_5.sqlite thread rows and rollout files under ~/.codex/sessions.
  • OpenCode transcripts resolve via the opencode provider in packages/monitor/cockpit-rs/src/server/transcript.rs, reading the same ~/.local/share/opencode/opencode.db the usage-dashboard uses.
  • Decision logs live per-project under .cockpit/; the registry and wait/send bridge are shared through ~/.local/share/q-lab/cockpit/.

Send box

The send box at the bottom of the Decision Log column can send text into a running session.

  • Claude Code uses the monitor mod's inbox poll, described below. The agent's answer comes back through the live transcript.
  • Codex uses the managed Codex remote-control daemon. Cockpit connects to the local app-server control socket, resumes the selected thread, and submits or steers a turn. Direct app-server is only a fallback when remote-control is unavailable.
  • OpenCode uses the TUI HTTP bridge, not the mod: it discovers the running TUI from OPENCODE_TUI_SERVER_URL (or a ps scan for opencode --port <n>) and delivers through /tui/append-prompt + /tui/submit-prompt. Same port precondition as the OpenCode Installation section above — the TUI must have been started with --port; a serve process is not discovered, and a successful send is not a delivery receipt.

A monitor mod (hooks/register.ts) delivers Claude Code sends. On session start it long-polls the cockpit daemon's inbox over HTTP and submits each message as a new turn once the session is idle. The same mod relays permission dialogs to the dashboard. It needs a Claude Code build with function-hook mods, and a plain claude session gets both:

claude

If an older version left a hand-wired cockpit-channel entry in ~/.claude.json, /monitor:install removes it. See the cockpit skill for the full setup.

For Codex send support, install and enable the managed standalone Codex remote-control daemon:

curl -fsSL https://chatgpt.com/codex/install.sh | sh
codex app-server daemon enable-remote-control

Cockpit checks /api/codex-control/status before enabling the Codex send box, so stale or non-resumable threads stay disabled instead of failing only after send.

dispatch

Interview-driven planning you can execute. Four skills form one arc — capture what you want, gather the spec, commit a blueprint to disk, then fly it with a multi-agent quality loop — and a fifth, waypoints, sits above it for whole-project rolling-wave planning.

Dispatch flow: preflight, hop, waypoints, flightplan, autopilot, final review, ship

  • preflight — a short interview that captures what you want as docs/<slug>/INTENT.md, before anyone decides how to build it. Writes one file and stops; never executes.
  • hop — a lightweight interview that produces a single in-conversation plan and executes it. Best when you'll execute now, in one session. Formerly named preflight.
  • flightplan — a thorough interview that writes docs/<slug>/PLAN.md plus a tasks/ tree of self-contained task files (each with its own ## Eval rubric). Best when the work spans sessions or hands off to sub-agents.
  • autopilot — executes that tree in waves. Each wave re-scouts the ready set (next-ready) and runs those tasks in parallel; for each task it runs Dev → an independent binary gate (re-runs the task's Verification) → a rubric judge → a deterministic score gate, retrying until the task passes its rubric. Between waves it makes an atomic commit of the completed work, so the run leaves a clean per-wave history rather than one giant diff. The Final review task depends transitively on every other task, so the wave loop naturally schedules it last as the whole-tree gate — a closing multi-lens review round (cross-vendor codex + four /simplify lenses → an Opus fixer), followed by a final commit of its fixes. Every verdict lands in a self-gitignored docs/<slug>/.flightlog/ audit trail (RUNLOG.md).
  • waypoints — the tier above flightplan for large builds. It writes only a milestone roadmap (docs/<proj>/WAYPOINTS.md, legs tracked with [x]/[~]/[ ]); each leg's detailed flightplan is generated just-in-time after the previous leg lands, so every plan starts from what actually shipped rather than one oversized up-front guess. A waypoints.ts CLI collapses the lifecycle into three verbs — active (rolling-wave digest), leg-scaffold (nest a leg's tree under docs/<proj>/legs/NN-slug/), and advance (land the active leg; writing requires --outcome as the confirmation gate). flightplan gains a narrow waypoint mode that plans one leg at a time. Human-in-loop by design — one leg lands before the next is planned.

Installation

# Claude Code
claude plugins install dispatch@q-lab-marketplace

# Codex
codex plugin add dispatch@q-lab-marketplace

# OpenCode
bun opencode/install.ts --apply

(Add the marketplace first if you haven't — see the monitor install steps above.)

relay

One portable skill that delegates a task out to another harness's CLI, then captures the output and reports back — a multi-backend generalization of the codex-only odin-codex skill.

/relay <codex|opencode|claude> delegate <task>
/relay <codex|opencode|claude> review [task]
/relay codex image <prompt> --out <path>
  • delegate — ask a backend to do something (implement, refactor, debug); smart-applied when safe.
  • review — analysis only, no edits. No task reviews uncommitted changes; a provided task is followed as written.
  • image — generate an image via codex (gpt-image-2). codex-only; opencode/claude fail fast at the capability gate.

A capability gate rejects unsupported (backend, mode) pairs before any CLI runs. Every run captures full output to /tmp/relay/<ts>/last.md and prints it. Models resolve by precedence: --model flag > config file (~/.config/q-lab/cc-plugins/relay/config.json) > built-in defaults. Per-CLI invocation details, headless output handling, and the OpenCode symlink install live in the backend reference.

Live-pane mode — inside herdr (HERDR_ENV=1), delegate/review automatically run the backend's interactive TUI in a visible, take-over-able pane opened in its own new tab (so your working pane keeps its full size), via the herdr plugin's herd.ts, dynamically imported — no hard dependency. The answer is captured through a result-file contract; stdout stays the clean answer, live metadata rides stderr. --dangerous makes it a YOLO / unattended run (auto-approves permissions: codex/claude bypass flags, opencode --auto); without it, approval prompts surface in the pane for a human to answer. A run that outlives --wait-timeout (default 10 min) exits 0 with a "still running" report and leaves the pane alive. --headless opts out; outside herdr the classic headless flow is unchanged.

Installation

# Claude Code
claude plugins install relay@q-lab-marketplace

# Codex
codex plugin add relay@q-lab-marketplace

# OpenCode
bun opencode/install.ts --apply

If you previously ran the old manual symlink (ln -s .../packages/relay/skills/relay ~/.claude/skills/relay), remove it. opencode/install.ts --apply installs relay under ~/.config/opencode/skills/, and OpenCode scans ~/.claude/skills/ as well — leaving the old symlink in place surfaces two skills both named relay. --check warns about it.

chronicle

Chronicle authors your git history — what a commit says, what a pull request argues, and what a release ships. A thin SKILL.md sends the bulky reading to an agent — a diff, a branch, a range of commits — and keeps the decisions where you can see them. Diff-reading and git output stay out of your conversation.

  • commit — one agent reads the changeset, cuts it into commits that each build on their own, and writes the messages; a script decides whether the split is worth keeping and does the staging. Pass simple to force one commit.
  • pr — reads the branch, harvests the cockpit decision trail when one exists, and opens a GitHub PR or GitLab MR with a title, a four-section body, and an optional Mermaid overview diagram. The trail is an enrichment, not a requirement; an unrecognized remote stops the flow rather than guessing.
  • release — config-first: detects whole-repo vs per-component layout, remembers it in a committed .chronicle/release.json, then bumps the version files and writes the CHANGELOG entry, commits, tags, and pushes. Pass local to stop before the push, or prepare to stop after the entry.
  • install — sets up each harness's prerequisite: on Claude Code the nested-subagent spawn-depth setting described in the Plugins section above, on Codex the named agent roles the orchestrators are addressed by.

Installation

# Claude Code
claude plugins install chronicle@q-lab-marketplace

# Codex
codex plugin add chronicle@q-lab-marketplace

# OpenCode
bun opencode/install.ts --apply

herdr

Reference and in-session agent orchestration for Herdr, a terminal workspace manager with workspaces, tabs, split panes, and agent detection. Two halves:

  • Reference — a knowledge skill that answers questions about Herdr's config.toml, CLI, keybindings, and plugin development. Detail lives in references/; the skill reads only the relevant file.
  • herd wrapper — a typed Bun wrapper (scripts/herd.ts) over the raw herdr CLI, for when you (an agent) are running inside a herdr pane (HERDR_ENV=1) and want to spawn and drive other agents in sibling panes or their own tabs (spawn --new-tab). It collapses herdr's multi-step recipes into seven verbs and handles the sharp edges: it addresses agents by a collision-resistant generated name (pane ids renumber), its send writes the prompt and presses Enter (raw agent send only writes literal text), its keys verb sends bare key chords (submit / clear the input box), and its read defaults to the visible screen (agent TUIs leave scrollback empty).
HERD="$CLAUDE_PLUGIN_ROOT/skills/herdr/scripts/herd.ts"   # or the skill's load-time base dir

bun "$HERD" spawn reviewer --agent codex --task "review the diff in src/api/"
bun "$HERD" send reviewer-a3f9 "now check error handling"
bun "$HERD" wait reviewer-a3f9 --status idle --time
Source 4 files
hooks/register.ts 75 lines
1/**
2 * comment-guard as a Claude Code mod: the same check as comment-guard.ts, run
3 * in-process on `tool.call` instead of spawning bun per Edit or Write. The
4 * question rides back as the result's `context`, which the model reads the way
5 * it read the command hook's exit-2 stderr. Codex and OpenCode keep spawning
6 * comment-guard.ts, since neither has function hooks.
7 */
8
9import type { Register } from "claude-code";
10import {
11  ASK,
12  baseName,
13  blocksFor,
14  formatReason,
15  type ToolResponse,
16} from "./comment-core.ts";
17import {
18  screenBlocks,
19  screenNote,
20  TIMEOUT_MS,
21  type Post,
22} from "./jev-screen.ts";
23import { DEFAULT_STATE_DIR, reportedFile, reportedLine } from "./ledger.ts";
24
25export const register: Register = (on) => {
26  on("tool.call", { tool: ["Edit", "Write"] }, async ($, e, next) => {
27    const ran = await next(e);
28    if (ran.deny !== undefined || ran.isError) return ran;
29    if (e.tool !== "Edit" && e.tool !== "Write") return ran;
30
31    const filePath = e.file_path;
32    const { blocks, asked } = await blocksFor(
33      e.tool,
34      e,
35      ran.result as ToolResponse,
36      (path) => $.fs.read(path),
37    );
38    if (blocks.length === 0) return ran;
39
40    // Read-then-write, not an append: two parallel writes can drop one entry, which costs one re-asked block at Stop.
41    const dir = (await $.env.get("GUARD_STATE_DIR")) ?? DEFAULT_STATE_DIR;
42    const ledger = reportedFile(dir, await $.session.id());
43    const prior = (await $.fs.exists(ledger)) ? await $.fs.read(ledger) : "";
44    await $.fs.write(ledger, prior + reportedLine(filePath, asked));
45
46    const post: Post = (url, init) =>
47      new Promise((resolve, reject) => {
48        // `$.http.fetch` takes no signal, so the timeout abandons the request rather than cancelling it.
49        const timer = $.clock.after(TIMEOUT_MS, () =>
50          reject(new Error("jev timeout")),
51        );
52        $.http.fetch(url, init).then((r) => {
53          timer.cancel();
54          resolve({ ok: r.ok, json: async () => JSON.parse(r.text) });
55        }, reject);
56      });
57
58    const fileName = baseName(filePath);
59    const screen = await screenBlocks(fileName, blocks, {
60      apiKey: await $.env.get("TYPESAFE_API_KEY"),
61      fetch: post,
62    });
63    const note = screenNote("💬 comment-guard", screen);
64    if (screen.kept.length === 0) {
65      if (note) $.ui.toast(note);
66      return ran;
67    }
68    const reason = `${formatReason(fileName, screen.kept)}\n${ASK}`;
69    return {
70      ...ran,
71      context: [...(ran.context ?? []), note ? `${reason}\n${note}` : reason],
72    };
73  });
74};
75
hooks/comment-core.ts 469 lines
1/**
2 * The Node-free half of comment-guard: syntax tables, block scan, diff
3 * resolution and the report text. The Claude Code mod imports it, and a mod
4 * runs with no Node, so nothing here may import a `node:` module.
5 */
6
7// node:path is out of a mod's reach; these copy its posix answers, pinned by a test against it.
8export function baseName(filePath: string): string {
9  return filePath.replace(/\/+$/, "").split("/").pop() ?? "";
10}
11
12export function extName(filePath: string): string {
13  const name = baseName(filePath);
14  const dot = name.lastIndexOf(".");
15  return dot <= 0 || name === ".." ? "" : name.slice(dot);
16}
17
18export type ToolInput = {
19  file_path?: string;
20  old_string?: string;
21  new_string?: string;
22  content?: string;
23};
24
25/** One unified-diff hunk as Claude Code reports it on `tool_response`. */
26export type Hunk = {
27  newStart?: number;
28  lines?: string[];
29};
30
31export type ToolResponse = {
32  type?: string;
33  structuredPatch?: Hunk[];
34  /** Claude Code held the edit for review: the file on disk is unchanged. */
35  staged?: boolean;
36};
37
38/** A language's comment forms: line markers, plus open/close block pairs. */
39export type Syntax = {
40  line: readonly string[];
41  block: readonly (readonly [string, string])[];
42};
43
44/** A contiguous run of comment lines, with the ones this edit added marked. */
45export type CommentBlock = {
46  start: number;
47  lines: string[];
48  added: boolean[];
49  /** Comment lines, not counting blanks bridged in from between paragraphs. */
50  height: number;
51};
52
53const MIN_BLOCK_LINES = 3;
54
55const SKIP_EXTS = new Set([".md", ".mdx", ".txt", ".json"]);
56
57// Third-party and generated trees. Their comments are someone else's to justify.
58const SKIP_SEGMENTS = new Set(["docs", "vendor", "node_modules"]);
59
60const HASH: Syntax = { line: ["#"], block: [] };
61const C: Syntax = { line: ["//"], block: [["/*", "*/"]] };
62const CSS: Syntax = { line: [], block: [["/*", "*/"]] };
63const MARKUP: Syntax = { line: [], block: [["<!--", "-->"]] };
64const PHP: Syntax = { line: ["//", "#"], block: [["/*", "*/"]] };
65const SQL: Syntax = { line: ["--"], block: [["/*", "*/"]] };
66const LUA: Syntax = { line: ["--"], block: [["--[[", "]]"]] };
67const HASKELL: Syntax = { line: ["--"], block: [["{-", "-}"]] };
68const ERB: Syntax = {
69  line: [],
70  block: [
71    ["<%#", "%>"],
72    ["<!--", "-->"],
73  ],
74};
75// Haml/Slim scope by indentation and the scan sees trimmed lines, so a multi-line `-#` block reports as one and never reaches MIN_BLOCK_LINES.
76const HAML: Syntax = { line: ["-#", "/"], block: [["<!--", "-->"]] };
77// A single-file component mixes a markup template with a script and a style
78// block, so it needs every form its three sections can carry.
79const COMPONENT: Syntax = {
80  line: ["//"],
81  block: [
82    ["/*", "*/"],
83    ["<!--", "-->"],
84  ],
85};
86
87export const BY_EXT: Record<string, Syntax> = {
88  ".rb": HASH,
89  ".rake": HASH,
90  ".gemspec": HASH,
91  ".py": HASH,
92  ".sh": HASH,
93  ".bash": HASH,
94  ".zsh": HASH,
95  ".fish": HASH,
96  ".yaml": HASH,
97  ".yml": HASH,
98  ".toml": HASH,
99  ".ex": HASH,
100  ".exs": HASH,
101  ".pl": HASH,
102  ".pm": HASH,
103  ".r": HASH,
104  ".ini": HASH,
105  ".conf": HASH,
106  ".properties": HASH,
107  ".graphql": HASH,
108  ".gql": HASH,
109  ".tf": HASH,
110  ".hcl": HASH,
111
112  ".js": C,
113  ".mjs": C,
114  ".cjs": C,
115  ".jsx": C,
116  ".ts": C,
117  ".mts": C,
118  ".cts": C,
119  ".tsx": C,
120  ".jsonc": C,
121  ".json5": C,
122  ".go": C,
123  ".rs": C,
124  ".c": C,
125  ".h": C,
126  ".cpp": C,
127  ".cc": C,
128  ".cxx": C,
129  ".hpp": C,
130  ".hh": C,
131  ".hxx": C,
132  ".m": C,
133  ".mm": C,
134  ".cs": C,
135  ".java": C,
136  ".kt": C,
137  ".kts": C,
138  ".scala": C,
139  ".swift": C,
140  ".dart": C,
141  ".zig": C,
142  ".proto": C,
143  ".scss": C,
144  ".sass": C,
145  ".less": C,
146  ".styl": C,
147
148  ".css": CSS,
149  ".html": MARKUP,
150  ".htm": MARKUP,
151  ".xml": MARKUP,
152  ".svg": MARKUP,
153
154  ".vue": COMPONENT,
155  ".svelte": COMPONENT,
156  ".astro": COMPONENT,
157
158  ".erb": ERB,
159  ".haml": HAML,
160  ".slim": HAML,
161  ".php": PHP,
162  ".sql": SQL,
163  ".lua": LUA,
164  ".hs": HASKELL,
165};
166
167/** Build files carry no extension, so they are matched on the name instead. */
168export const BY_NAME: Record<string, Syntax> = {
169  ".env": HASH,
170  rakefile: HASH,
171  gemfile: HASH,
172  guardfile: HASH,
173  capfile: HASH,
174  brewfile: HASH,
175  procfile: HASH,
176  makefile: HASH,
177  dockerfile: HASH,
178  justfile: HASH,
179};
180
181// Policy, not lookup: `COMMENT_GUARDED` in opencode/plugin.ts mirrors syntaxFor's half alone, so folding these checks in would leave it compared against a set it cannot encode.
182export function isGuardedPath(filePath: string): boolean {
183  if (/\.min\.(js|css)$/.test(filePath)) return false;
184  // Segments, not a substring: a relative `docs/gen.py` has no leading slash.
185  if (filePath.split("/").some((part) => SKIP_SEGMENTS.has(part))) return false;
186  return !SKIP_EXTS.has(extName(filePath));
187}
188
189export function syntaxFor(filePath: string): Syntax | null {
190  const ext = extName(filePath).toLowerCase();
191  const known = BY_EXT[ext];
192  if (known) return known;
193
194  // `Dockerfile.DEV` is still a Dockerfile, and basename's own suffix match is case-sensitive, so both halves are lowered before the extension comes off.
195  const name = baseName(filePath).toLowerCase();
196  const stem = ext ? name.slice(0, name.length - ext.length) : name;
197  return BY_NAME[stem] ?? null;
198}
199
200/**
201 * Stateful across every open/close pair the language has, so a docblock, an
202 * HTML comment and a Lua long comment all count their full height rather than
203 * just the opening line. Tracking the open pair is also what lets a `*`
204 * continuation count without becoming a marker of its own — as a marker it
205 * would read `*ptr = 0` and a wrapped multiplication as comments.
206 *
207 * Block openers are tested before line markers because several overlap: Lua's
208 * `--[[` also starts with its line marker `--`.
209 */
210export function commentFlags(lines: string[], syntax: Syntax): boolean[] {
211  const flags: boolean[] = [];
212  let closer: string | null = null;
213
214  for (const line of lines) {
215    if (closer !== null) {
216      flags.push(true);
217      if (line.includes(closer)) closer = null;
218      continue;
219    }
220
221    const pair = syntax.block.find(([open]) => line.startsWith(open));
222    if (pair) {
223      flags.push(true);
224      if (!line.slice(pair[0].length).includes(pair[1])) closer = pair[1];
225      continue;
226    }
227
228    // Leading marker only. A trailing `#` or `//` is usually inside a string —
229    // matching those flags every `url = "http://..."` as a comment.
230    flags.push(syntax.line.some((m) => line.startsWith(m)));
231  }
232  return flags;
233}
234
235function commentLines(text: string, syntax: Syntax): string[] {
236  const lines = text.split("\n").map((l) => l.trim());
237  const flags = commentFlags(lines, syntax);
238  return lines.filter((_, i) => flags[i]);
239}
240
241/**
242 * A multiset difference, not a line diff: we only care about comment lines, and
243 * on those the multiset answer is the better one — moving an existing comment
244 * is not an addition, while rewording one is a new claim to justify.
245 */
246export function addedCommentLines(
247  toolName: string,
248  input: ToolInput,
249  syntax: Syntax,
250): string[] {
251  if (toolName === "Write") return commentLines(input.content ?? "", syntax);
252
253  const before = new Map<string, number>();
254  for (const line of commentLines(input.old_string ?? "", syntax)) {
255    before.set(line, (before.get(line) ?? 0) + 1);
256  }
257
258  const added: string[] = [];
259  for (const line of commentLines(input.new_string ?? "", syntax)) {
260    const seen = before.get(line) ?? 0;
261    if (seen > 0) before.set(line, seen - 1);
262    else added.push(line);
263  }
264  return added;
265}
266
267/**
268 * A `-` line consumes no line in the new file, so only context and additions
269 * advance the counter. `\ No newline at end of file` is a diff annotation
270 * rather than a line and is skipped the same way. Text is trimmed on both
271 * sides, because the block scan compares trimmed lines too.
272 */
273export function patchLines(patch: Hunk[]): {
274  added: { n: number; text: string }[];
275  removed: string[];
276} {
277  const added: { n: number; text: string }[] = [];
278  const removed: string[] = [];
279  for (const hunk of patch) {
280    let n = hunk.newStart ?? 1;
281    for (const line of hunk.lines ?? []) {
282      if (line.startsWith("\\")) continue;
283      if (line.startsWith("-")) {
284        removed.push(line.slice(1).trim());
285        continue;
286      }
287      if (line.startsWith("+")) added.push({ n, text: line.slice(1).trim() });
288      n++;
289    }
290  }
291  return { added, removed };
292}
293
294/**
295 * A line the diff also removed is not new — re-indenting a block, or moving one
296 * between two points of the same edit, rewrites every line it touches and would
297 * otherwise re-ask for a comment nobody wrote. Subtracting the removed texts as
298 * a multiset is what keeps the diff path agreeing with `addedCommentLines`:
299 * moving a comment is not an addition, rewording one is.
300 *
301 * A formatter running as a second PostToolUse hook rewrites the file in
302 * parallel with this one, and every line the diff named then shifts — marks
303 * land on the wrong lines, or the block falls silent because they land on code.
304 * Text is the weaker answer but it cannot be shifted, so a lost race degrades
305 * instead of lying.
306 */
307export function resolveAdded(
308  patch: Hunk[],
309  lines: string[],
310): Set<number> | string[] {
311  const { added, removed } = patchLines(patch);
312
313  const pool = new Map<string, number>();
314  for (const text of removed) pool.set(text, (pool.get(text) ?? 0) + 1);
315
316  const net = added.filter(({ text }) => {
317    const left = pool.get(text) ?? 0;
318    if (left === 0) return true;
319    pool.set(text, left - 1);
320    return false;
321  });
322
323  const intact = net.every((a) => lines[a.n - 1] === a.text);
324  return intact ? new Set(net.map((a) => a.n)) : net.map((a) => a.text);
325}
326
327/**
328 * Blocks come from disk rather than from `new_string` because an Edit fragment
329 * truncates any block that continues past its edges, which would both mis-size
330 * the run and hide whether it sits at the top of the file.
331 *
332 * `added` is line numbers when the harness handed us a diff, and comment text
333 * otherwise. Text is the weaker answer — an added line whose wording repeats an
334 * untouched one marks whichever comes first in the file — so it is the fallback
335 * for harnesses that report no diff, not the preferred path.
336 */
337export function flaggedBlocks(
338  fileText: string,
339  syntax: Syntax,
340  added: string[] | Set<number>,
341): CommentBlock[] {
342  const lines = fileText.split("\n").map((l) => l.trim());
343  const flags = commentFlags(lines, syntax);
344
345  const byNumber = added instanceof Set ? added : null;
346  const pending = new Map<string, number>();
347  if (!(added instanceof Set)) {
348    for (const line of added) pending.set(line, (pending.get(line) ?? 0) + 1);
349  }
350
351  const blocks: CommentBlock[] = [];
352  let sawCode = false;
353  let i = 0;
354
355  while (i < lines.length) {
356    const line = lines[i]!;
357    if (!flags[i]) {
358      if (line !== "" && !line.startsWith("#!")) sawCode = true;
359      i++;
360      continue;
361    }
362
363    const header = !sawCode;
364    const start = i + 1;
365    const body: string[] = [];
366    const marked: boolean[] = [];
367    let bridged = 0;
368
369    while (i < lines.length) {
370      if (!flags[i]) {
371        // One blank keeps the run open so a paragraph break cannot split a block below the threshold; two blanks read as a real separation.
372        if (lines[i] !== "" || !flags[i + 1]) break;
373        body.push("");
374        marked.push(false);
375        bridged++;
376        i++;
377        continue;
378      }
379
380      const text = lines[i]!;
381      let hit: boolean;
382      if (byNumber) {
383        hit = byNumber.has(i + 1);
384      } else {
385        const left = pending.get(text) ?? 0;
386        // Consume in file order so each added occurrence claims one line, and so a
387        // hit landing in an exempt or short block still spends itself.
388        if (left > 0) pending.set(text, left - 1);
389        hit = left > 0;
390      }
391      body.push(text);
392      marked.push(hit);
393      i++;
394    }
395
396    // Counted, not filtered on empty text: a blank line inside a `/* */` is a comment line and still counts, while a bridged one is not and does not.
397    const height = body.length - bridged;
398    if (!header && height >= MIN_BLOCK_LINES && marked.some(Boolean)) {
399      blocks.push({ start, lines: body, added: marked, height });
400    }
401  }
402  return blocks;
403}
404
405// The read is injected rather than done up front so a skipped edit never touches the disk.
406export async function blocksFor(
407  toolName: string,
408  input: ToolInput,
409  response: ToolResponse,
410  read: (filePath: string) => Promise<string>,
411): Promise<{ blocks: CommentBlock[]; asked: string[] }> {
412  const none = { blocks: [], asked: [] };
413  const filePath = input.file_path ?? "";
414  if (!filePath || !isGuardedPath(filePath)) return none;
415  const syntax = syntaxFor(filePath);
416  if (!syntax) return none;
417
418  if (response.staged) return none;
419  const patch = response.structuredPatch;
420  // A Write that creates a file reports no hunks at all (78 of 78 measured), so
421  // an empty patch means "nothing changed" only when the file already existed.
422  if (response.type === "update" && patch?.length === 0) return none;
423
424  // PostToolUse runs after the write landed, so the file on disk is the shape
425  // being judged. Without it there is no block sizing worth reporting.
426  let fileText: string;
427  try {
428    fileText = await read(filePath);
429  } catch {
430    return none;
431  }
432
433  // Live on both harnesses, not dead code: a create Write sends an empty patch, and OpenCode's `commentPayload` sends none at all.
434  const added = patch?.length
435    ? resolveAdded(
436        patch,
437        fileText.split("\n").map((l) => l.trim()),
438      )
439    : addedCommentLines(toolName, input, syntax);
440
441  const blocks = flaggedBlocks(fileText, syntax, added);
442  const asked = blocks.flatMap((b) => b.lines.filter((_, k) => b.added[k]));
443  return { blocks, asked };
444}
445
446// Kept out of formatReason so the sweep asks once however many files it reports.
447export const ASK =
448  "Answer for every line marked +: does it say why, or what? Delete the ones that say what.";
449
450export function formatReason(
451  fileName: string,
452  blocks: CommentBlock[],
453  label = "💬 comment-guard",
454): string {
455  const total = blocks.reduce((n, b) => n + b.height, 0);
456  const out = [
457    `${label}: ${blocks.length} comment block(s), ${total} lines, in ${fileName}.`,
458  ];
459
460  for (const block of blocks) {
461    const end = block.start + block.lines.length - 1;
462    out.push(`  ${fileName}:${block.start}-${end}`);
463    block.lines.forEach((line, k) => {
464      out.push(`  ${block.added[k] ? "+" : " "} ${block.start + k}  ${line}`);
465    });
466  }
467  return out.join("\n");
468}
469
hooks/jev-screen.ts 100 lines
1/**
2 * Asks TypeSafe's Jev whether each added comment line says why or what, and
3 * drops the blocks it is sure are all why, so the model is not stopped to
4 * re-answer a question with an obvious answer. Measured over 708 reported lines
5 * (160 labelled by opus): at 0.8 it spares 84 of 222 blocks and passes 4 of 36
6 * what-lines, at p50 220 ms per block. Every failure keeps every block, so the
7 * hook degrades to asking the model, never to staying silent.
8 *
9 * A port of chronicle's `askJev`, not an import: plugins never import each other.
10 */
11
12import type { CommentBlock } from "./comment-core.ts";
13
14const ENDPOINT = "https://api.typesafe.ai/v1/systemone";
15const MODEL = "jev-latest";
16// Measured max 344 ms; the whole hook has 10 s and a network stall must not eat it.
17export const TIMEOUT_MS = 2_000;
18const PASS_WHY = 0.8;
19
20const CRITERIA = {
21  why: "Carries a reason, constraint, invariant, history, measured fact, rejected alternative, or consequence the code alone cannot show; a line continuing such a sentence counts",
22  what: "Restates what the code does, narrates steps, labels a section, describes the edit itself, or names what a thing is without saying why it matters",
23};
24
25type Answer = { probabilities?: Record<string, number> };
26
27// The caller hands in its transport, and the transport enforces TIMEOUT_MS: the mod has neither a global `fetch` nor `AbortSignal.timeout`.
28export type Post = (
29  url: string,
30  init: { method: string; headers: Record<string, string>; body: string },
31) => Promise<{ ok: boolean; json(): Promise<unknown> }>;
32
33async function allWhy(
34  file: string,
35  block: CommentBlock,
36  opts: { apiKey: string; fetch: Post },
37): Promise<boolean> {
38  const questions: Record<string, unknown> = {};
39  block.added.forEach((added, k) => {
40    if (!added) return;
41    questions[`l:${k}`] = {
42      type: "choice",
43      instructions: `Under the rule "comment why, never what", does \`comment.lines[${k}]\` say why or what? Read the rest of the block as context.`,
44      criteria: CRITERIA,
45    };
46  });
47  try {
48    const response = await opts.fetch(ENDPOINT, {
49      method: "POST",
50      headers: {
51        Authorization: `Bearer ${opts.apiKey}`,
52        "Content-Type": "application/json",
53      },
54      body: JSON.stringify({
55        model: MODEL,
56        state: { comment: { file, lines: block.lines } },
57        questions,
58      }),
59    });
60    if (!response.ok) return false;
61    const { answers } = (await response.json()) as {
62      answers?: Record<string, Answer>;
63    };
64    return Object.keys(questions).every(
65      (id) => (answers?.[id]?.probabilities?.why ?? 0) >= PASS_WHY,
66    );
67  } catch {
68    return false;
69  }
70}
71
72/** `ms` is the slowest request, since they run in parallel; absent when nothing was sent. */
73export type Screen = { kept: CommentBlock[]; withdrawn: number; ms?: number };
74
75export async function screenBlocks(
76  file: string,
77  blocks: CommentBlock[],
78  opts: { apiKey: string | undefined; fetch: Post },
79): Promise<Screen> {
80  const { apiKey } = opts;
81  if (!apiKey) return { kept: blocks, withdrawn: 0 };
82  const started = performance.now();
83  const passed = await Promise.all(
84    blocks.map((block) => allWhy(file, block, { apiKey, fetch: opts.fetch })),
85  );
86  const kept = blocks.filter((_, i) => !passed[i]);
87  return {
88    kept,
89    withdrawn: blocks.length - kept.length,
90    ms: Math.round(performance.now() - started),
91  };
92}
93
94// Silent when nothing was withdrawn: a withdrawal is the one outcome nobody would otherwise see.
95export function screenNote(label: string, screen: Screen): string | null {
96  if (screen.withdrawn === 0) return null;
97  const total = screen.kept.length + screen.withdrawn;
98  return `${label}: Jev withdrew ${screen.withdrawn} of ${total} comment block(s) as why (${screen.ms} ms)`;
99}
100
hooks/ledger.ts 17 lines
1/**
2 * Where and how comment-guard records the lines it asked, so comment-sweep can
3 * subtract them. Node-free because the Claude Code mod writes this ledger
4 * through `$.fs` while the bun hooks write it through `node:fs`.
5 */
6
7export const DEFAULT_STATE_DIR = "/tmp/q-lab/guard";
8
9// Session ids come off stdin and become file names.
10export const safe = (sessionId: string) => sessionId.replace(/[^\w.-]/g, "_");
11
12export const reportedFile = (dir: string, sessionId: string) =>
13  `${dir}/${safe(sessionId)}.reported.jsonl`;
14
15export const reportedLine = (file: string, texts: string[]) =>
16  JSON.stringify({ file, texts }) + "\n";
17