SLOPSHOPPER

loopd-probe

Throwaway probe: which mod events fire in a non-interactive session, and what they carry

newguardcommand
v0.0.1MITupdated 2026-10-08cbmono/loopd/docs/spikes/mods-probe
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · loopd-probe
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /probe-dump ⎿ loopd-probe: probe.preview-session.001.session.start {"keys":["cwd","surface","isInteractive"],"surface":"terminal","isInte ⎿ loopd-probe: probe.preview-session.002.turn.start {"keys":["turnId"],"at":1791474831309} ⎿ loopd-probe: probe.preview-session.003.tool.call {"keys":["tool_use_id","tool","file_path"],"tool":"Read","at":179147483130 ⎿ loopd-probe: probe.preview-session.004.tool.call {"keys":["tool_use_id","tool","pattern","path"],"tool":"Grep","at":1791474 ⎿ loopd-probe: probe.preview-session.005.tool.call {"keys":["tool_use_id","tool","file_path","old_string","new_string"],"tool ⎿ loopd-probe: probe.preview-session.006.tool.call {"keys":["tool_use_id","tool","file_path","content"],"tool":"Write","at":1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README
   ▄▄▄▄
◀━▐    ▌   loopd — the loop, running
  ▝▄▄▄▄▘

You steer. They build. Two gates stay yours.

A control panel for running a small team of AI agents on your repositories.

You describe the work. A project-manager agent breaks it into tasks. You approve. Engineer agents build it in the background, open pull requests, and get reviewed. You merge.

You act like an engineering manager, not a pair programmer.

This repo is the template. You stamp out one instance per group of repos (work, a side project, a client). Each instance is its own small git repo that sits beside those repos and holds only the state of the work — never application code.

Two colours, wherever loopd renders. Blue #5ea2ff is the machine's — agents, code, refs, running state. Pink #ff7ac2 is yours — gates, decisions, anything waiting on a person. No third accent, no status rainbow.

NeedsClaude Code, git, gh, bash. python3 only for the optional board (all three renderers).
Time to first loopabout 10 minutes
Storage formatplain markdown (OKF Knowledge Bundle) — the commands write the files for you
LicenseMIT
VersionVERSION — one line, no extension, the only copy. A core change proposes the bump in its PR; the owner approves it by merging (versioning)

Contents

DocRead it when
This pagesetting up, or looking up a command or a config key
docs/onboarding.mdsomeone is joining a bundle and will run two commands and no more — install, /loopd:init and its one question, what the banner shows, what never needs them
docs/first-hour.mdyour first hour — install, the seven skills of week one, the two gates that stay yours
docs/first-hour.md § Plugins that pair wellyou are deciding which other plugins to install alongside loopd — four to install, two to skip, and why superpowers must not be installed on a machine that runs the loop
docs/schema.mdyou need to know what a document type holds
docs/autonomy.mdyou want the loop to promote or merge without you
docs/operations.mdinstalling and upgrading (the plugin half and the bundle half), the board's three renderers, worktrees, editor setup
docs/migrating.mdyou already run a pre-plugin install — upgrade it in place, or re-home it into a fresh folder without losing projects/ or knowledge/
docs/sharing.mdseveral people will share one bundle — the org's repo, --org, and who sets what
docs/conventions.mdyou are changing this repo — every design invariant and why it exists
docs/releases/v1.0.0.mdwhat 1.0.0 changed — 91 merged PRs since the last tag, grouped: install, commands, agents, gates, docs
The config layeryou want this repo's agents, commands and hooks in ~/.claude too

Normative contracts live in the machinery itself: plugin/seed/SCHEMA.md (document types, the verification predicate) and plugin-yolo/companion/AUTONOMY.md (the delegated-autonomy modes, shipped by the loopd-yolo companion plugin).


Install

Two halves, on two clocks. The plugin carries every slash command, the two enforcement hooks and the eight role agents, and is installed once per machine; the bundle carries the scripts, the SessionStart hook and the root documents, and is stamped once per instance. A machine with only the plugin has commands and nothing to read; a bundle with only the stamp has the data and no way to drive it, and every /loopd:… reports unknown command. Do the plugin first — it is one line, and it is what step 7 needs. (docs/operations.md § 1)

1. Install the plugin — once per machine

This repo is its own marketplace. In any Claude Code session:

/plugin marketplace add cbmono/loopd
/plugin install loopd@loopd

That is the minimal install — core alone. For core plus the autonomy companion and the mods in one command, install the bundle instead: /plugin install loopd-all@loopd (plugin-all/README.md — it ships nothing of its own, so turning a mod off later never touches loopd).

Or add a mod on its own — a hooks module Claude Code runs inside every session that loads it, each doing one thing and nothing else, each off by uninstalling it:

ModWhat it does
loopd-mod-usagerecords what a session spent, so session-usage.sh can read it back →
loopd-mod-paneadds /board: opens the board in a pane beside the transcript →
loopd-mod-signallets a role agent's session tell the PM's session its turn is over, the moment it is →
/plugin install loopd-mod-usage@loopd

Every command is namespaced: /loopd:dispatch, /loopd:new-project, and the rest of the table below; so is every role agent — loopd:software-engineer and the rest — because a bare agent name does not resolve. See plugin/README.md.

Installed as ai-bridge@ai-bridge before the rename? MIGRATION.md moves the machine and each bundle over, in order.

Already on ai-bridge-v2? That name is gone in 1.0.0 — the swap.

2. Make the bundle directory

Name it _loopd-<group>, inside the group folder, beside that group's repos.

mkdir -p ~/workspace/<group>/_loopd-<group>
  • The leading underscore pins it to the top of the group folder and keeps it visible (unlike a dotfile).
  • The -<group> suffix distinguishes it from other groups' bundles.
  • The group folder itself is not a repo — just a plain directory holding this bundle plus the group's repos, side by side, each its own repo.
  • A bundle created before the rename keeps its _ai-bridge-<group> name: that prefix is still recognised wherever the directory name is read (plugin/scripts/bundle-paths.sh), so nothing has to move.

No clone of this repo is needed. The installer ships in the plugin. /loopd:init creates the directory too, so this step is optional — it is here because naming it right is the part worth doing deliberately.

3. Stamp it

/loopd:init ~/workspace/<group>/_loopd-<group>

It does three things, and none of them is a symlink into a checkout:

#ActionDetail
1Copies plugin/seed/ content — only if absentnever clobbers bundle data
2Converts a bundle stamped by the retired install.shremoves its machinery links and the managed .gitignore block; the data is untouched
3Links the group's repos into <bundle>/repos/skipped while reposRoot is the seeded placeholder. The only symlinks a stamped bundle holds.

It is idempotent. It backs up any conflicting real file as <name>.bak.<epoch>. --refresh-seeds additionally 3-way merges a seed change this repo has made since the bundle was stamped; without it that drift is reported and nothing is written.

This replaced install.sh, and the reason is structural. A plugin-shipped installer cannot stamp absolute symlinks into a plugin cache whose path changes on every update — every one of them would dangle. The symlinks existed so a git pull of this repo propagated into every bundle; claude plugin update gives that property for the whole tree, so they lost their reason to exist. The replaced install.sh and upgrade.sh ship for one version as stubs that print the command to run instead.

It also asks who the team is — once

On a first stamp, at a terminal, it offers to collect the roster: one line per person, <github-login> <commit-email>, yourself first. That fills in the tracked people map and defaultOwner, plus this clone's gitignored instance.config.local.json — the three values a shared bundle needs, which used to be hand-edited afterwards. See docs/sharing.md.

  • Nothing is written until you confirm it. ctrl-C, ctrl-D and an empty first line all write nothing at all, and say so — a half-answered roster is never left behind.
  • It never asks on a refresh, and never when stdin is not a terminal (a script, a background agent). It prints what to edit by hand instead.
  • It never overwrites a value that is already there.
  • Skipping costs nothing: fill the same three values in by hand whenever you like.

4. Configure it

cd ~/workspace/<group>/_loopd-<group>
$EDITOR instance.config.json      # org, reposRoot, worktreeRoot, authorEmail

5. Give it a remote

git init && git add -A && git commit -m "chore: bootstrap control panel"
gh repo create <user>/_loopd-<group> --private --source=. --push

Keep the leading underscore in the repo name, so a fresh git clone lands a _loopd-<group>/ directory that matches the convention.

6. Run your first loop

cd ~/workspace/<group>/_loopd-<group>   # this matters — see below
claude

Then, inside the session:

/loopd:new-project add rate limiting to the public API

Answer its questions. Review the draft tasks. Promote the ones you want (draft → ready). Then:

/loop 10m /loopd:dispatch

/loop is Claude Code's own repeat-a-slash-command primitive, and it is the standard way to run the cadence: one pass every ten minutes, in the session you are already in, with nothing installed to drive it. Omit the interval (/loop /loopd:dispatch) on a quiet bundle and the model paces itself. A pass that fires while a tick is still running prints one line and skips — the dispatch lock refuses it, so a clock can never start a second orchestrator. docs/operations.md → "Running the loop on a cadence" has the reasoning.

Always launch Claude from inside the instance directory. The bundle's linked role agents, its SessionStart banner and this panel's CLAUDE.md load from the instance's .claude/, and that is chosen by the working directory — not by what your editor has open. Everything the plugin carries is per machine and resolves anywhere.


The core loop

/loopd:new-project  →  you promote draft → ready  →  /loopd:dispatch  →  you merge the PR

/loopd:dispatch is serial and completion-gated — one tick at a time. Run one per instance. The launcher takes a per-clone lock (.tick-lock) immediately before each dispatch, and the tick runs the same check on entry — a resumed tick never passes through the launcher, so one that finds no lock is refused rather than allowed to run. That guarantee survives a compaction instead of resting on the session remembering it dispatched — →.

Two gates stay yours by default:

  1. Promote a task from draft to ready.
  2. Merge the PR (build projects) or approve the deliverable (research projects).

The idea is to steer, not watch. Role agents run in the background and bubble up results and questions, not every step.

Both gates can be delegated — see docs/autonomy.md. That capability is off unless installed, literally: it lives entirely in the separate loopd-yolo companion plugin, and uninstalling that plugin makes every project gated again with no other edits.

Who runs what, end to end

One kind: build project, from /loopd:new-project to merge. Every step links to the document that owns its rule; nothing here restates one. Tiers are the seed defaults — each instance sets its own in roleTiers/models (model routing).

#StepWho runs it
1Scaffold — slug, objective, phases, seed draft tasksyou and the main session, interactively →
2Commit the scaffold and its registration as one changemain session, via commit-as.sh
3Scaffold review, three stages: validate-bundle.sh, then an external reviewer, then the qa-reviewer fallback. Stage 1 gates the rest; stages 2 and 3 are advisory, and stage 2 is dispatched rather than waited onmain session; qa-reviewer (deep) on the fallback → step 8
4Refine each draft — fill acceptance_criteria, raise open_questionsproject-manager (deep) →
5Approach critique — mandatory on its trigger (a complex or heavily-inferred kind: build draft), advisory in what it may decide; its concerns land in advisor_notes and gate nothingplan-architect (apex), dispatched by the PM →

HUMAN GATE 1 — you promote the task draft → ready

Nothing is dispatched until you do. The PM refines and critiques a draft but never sets ready (two human authorities).

#StepWho runs it
6Dispatch the ready task — its own worktree and branch, both recorded on the task before the agent spawnsproject-manager → the assignee →
7Build it, then self-review your own diff — a pre-filter, never the gatesoftware-engineer / devops-engineer (deep) →
8Open the PR carrying the task's acceptance_criteria as a ✓/✗ table — the artifact pr-body-clearance.sh reads. The agent never mergesthe same agent
9Independent review at the PR's current head: the external reviewer where one is configured, else the qa-reviewer fallback. The PM reads that verdict with review-clearance.sh, and review-rounds.sh stops it at two roundsexternal reviewer, else qa-reviewer (deep) →

HUMAN GATE 2 — you merge the PR

One ✗ in that criteria table blocks it, however green CI is (SCHEMA.md).

The next tick reflects the merge — status: done, and the task's worktree is reclaimed.

Commands

Run these inside an instance.

CommandWhat it does
/loopd:new-project <description>(plugin) scaffolds a project: phases, draft tasks, acceptance criteria. Asks for the capability flags you didn't pass
/loopd:dispatch [gap](plugin) the serial background loop: dispatch, track, report. /loopd:dispatch 10m ticks every ten minutes
/loopd:answer(plugin) answer the PM's open questions from inside the session
/loopd:board(plugin) serve — the default, so a bare call serves — the board on a local URL, one process per bundle; publish — the same page as a private artifact, at the same URL every run
/loopd:pr-review-request <pr>(plugin) ask for an independent review of a PR
/loopd:audit(plugin) the slow counter-metric — is the throughput moving the real goals? Read-only, never acts
/loopd:fanout <task>(plugin) parallel work across several repos
/loopd:close-project [<slug>](plugin) close a project and fold its conclusions into knowledge/, then remove its folder — or freeze and keep it, on retain: true. No slug opens a picker of the projects, multi-select, and asks about --force. →
`/loopd:welcome [check\fix]`(plugin) reprint the SessionStart banner; check reports state that could be wrong, fix repairs only the idempotent tier. →
/loopd:brief-me [project](plugin) a since-you-last-looked digest, or a meeting-ready brief for one project. Read-only
/loopd:capture <notes>(plugin) turn a decision or meeting notes into drafted projects and tasks, with provenance — never promoted
/loopd:work <task>(plugin) work one task in this session, ledger kept for you — the solo alternative to dispatching an agent
/loopd:handoff <path> <login>(plugin) transfer a task or project to another human, with the context that makes the transfer real
/loopd:kb-apply <report>(plugin) apply one knowledge/ reflection report after reading it — the only path that writes a proposal. The scheduled kb-propose.sh only ever proposes
/loopd:prune-wt(plugin) remove the worktrees prune-worktrees.sh reports REMOVABLE, after one yes — each re-checked at removal time, skipped on any doubt; refuses while a tick holds the lock. →

Flags /loopd:new-project accepts: kind=research, autonomy=<mode>, clis="…", browser=off|claude-for-chrome (default off), /yolo, /cli …, /claudeforchrome, --no-commit.

The team

RoleDoes
project-managerruns the loop: refines drafts, dispatches, tracks, reports
software-engineerwrites code in a target repo and opens the PR
devops-engineerinfrastructure, CI, deploys
qa-reviewerthe independent verification gate — fresh context, real signals
cataloguerfolds conclusions into knowledge/
auditorread-only drift check for /loopd:audit
failure-analystdiagnoses a failing check or a broken build

All eight ship in the plugin and are dispatched namespaced — loopd:software-engineer, loopd:qa-reviewer, and so on. A bare agent name does not resolve (measured 2026-09-02). A task's assignee: field stays bare; the PM adds the namespace when it spawns.

Role dispatches are routed to a cost-appropriate model per tier (docs/operations.md § model routing).

Where the work lives

_loopd-<group>/
├── objectives/        OPTIONAL — goals that outlive one project (`/loopd:init <dir> --with-objectives`)
├── projects/<slug>/
│   ├── project.md     kind, status, autonomy, owner, target_repo
│   ├── phases/        ordered stages
│   ├── tasks/         the unit an agent is dispatched on
│   └── deliverables/  research output (no repo, no PR)
├── knowledge/         services, findings, teams, runbooks, references
├── repos/             symlinks to the group's repos (gitignored)
├── AWAITING.md        what needs you (derived, gitignored)
├── SNAPSHOT.json      board input (derived, gitignored)
└── instance.config.json

Document types and their fields: docs/schema.md.

Two kinds of project

build (default)research
OutputPRs to a target_repodeliverables inside the bundle (projects/<slug>/deliverables/)
Who executesdispatched role agentsthe human, in-session — the PM tracks but never dispatches
target_reporequirednot asked
clis promptasked, pre-filled from detected CLIs/MCPsnot asked (recorded if you pass clis=)
browseraskedasked — web research is its clearest case
Scaffold reviewthree-stage chainskipped
Gateyou merge the PRyou approve the deliverable

A research project is asked less on purpose. Each dropped question describes machinery a research project never runs, so offering it would ask you to authorise tools nothing will use. Don't restore a question for symmetry — docs/conventions.md invariant 5.

Answering the PM's questions

When a draft is blocked it lists numbered open_questions (Q1:, Q2:, …).

  1. Open the task doc.
  2. Append --- <answer> to the question line: `` Q1: which region should we default to? --- eu-central-1 ``
  3. The next tick treats everything after the --- as your answer, folds it into the task, and clears the question.
  4. The draft becomes promotable once the list empties.

Answering in chat during a session works too (/loopd:answer).

The cleared entry is moved, not deleted — it lands in answered_questions as one flat line, <ISO 8601> by <login> · <the entry verbatim>. It is a human audit record: nothing reads it and no gate consults it. No customer PII in an answer — unlike the question you clear, this list persists for the life of the repo.

What needs you

AWAITING.md is the instance's one status artifact: a queue of just the items a human decision unblocks.

MarkerMeans
✅approve
❓answer
🔀merge
⛔unblock
🏁close
⏳continue — only where continueAfterTasks/continueAfterDays is set (absent ⇒ off)

Each item carries a real link. Every /loopd:dispatch tick rewrites the file, and a SessionStart hook injects its items at launch.

In-flight and upcoming work is deliberately excluded — it needs no decision, and a queue you scroll past is a queue you stop reading. There is no /status command and no full board; don't reintroduce one.

On by default, off by deletion.

ActionEffect
rm AWAITING.mdthe queue is off for good — an installer re-run will not resurrect it
touch AWAITING.mdback on

Derived and gitignored; never hand-edit it. Reasoning: docs/conventions.md invariant 3.

A cross-instance board is available too, on the same off-by-deletion rule (docs/operations.md § the board). Read the field list before you carry one off the machine — nothing publishes it, but a rendered file is still a file.

Three ways to see the board

One snapshot, four renderers. scripts/write-snapshot.sh derives each instance's SNAPSHOT.json; all four read it and none of them reads the bundle. Pick by what you are doing, not by which is newest.

You wantRunCosts
a look right now, in the terminal you are inscripts/print-board.shnothing
a page to open locally — the one each tick rendersscripts/build-board.sh --standalone .a re-run, or a looping instance
a live page in the browser, on a fixed local URL/loopd:board servea process you keep running
a page that updates itself as you work, no browserscripts/watch-board.sha process you keep running
scripts/print-board.sh                      # columns: instance, project, phases, tasks, awaiting
scripts/build-board.sh --standalone .       # ./board.html — THIS instance only, openable in a browser
scripts/build-board.sh --standalone         # the same, but every instance in boardInstances (see below)
scripts/build-board.sh                      # the same page as a BODY — no <html> wrapper, for embedding
scripts/watch-board.sh                      # ./.board-live/board.html, re-rendered on every change
scripts/board-serve.sh                      # http://localhost:<boardPort> — the same page, served and auto-reloading

The page keeps itself current, locally. Every /loopd:dispatch tick re-renders it to .board-live/board.html — gitignored, on this machine — and reports the path; a SessionStart hook prints the same path when a session starts. board: false in instance.config.json turns that off; absent or true leaves it on, which is the seeded default ([docs/operations.md § rendering it from each tick](docs/operations.

Source 1 files
hooks/register.js 82 lines
1// Throwaway probe. Records, for every event it hooks, the event's field names and the
2// fields that matter to a usage mod, under a store key that names the event and the
3// session. Nothing else.
4let seq = 0
5
6function summary(e) {
7  const out = { keys: Object.keys(e || {}) }
8  if (e && e.usage !== undefined) out.usage = e.usage
9  if (e && e.model !== undefined) out.model = e.model
10  if (e && e.durationMs !== undefined) out.durationMs = e.durationMs
11  if (e && e.isAborted !== undefined) out.isAborted = e.isAborted
12  if (e && e.reason !== undefined) out.reason = e.reason
13  if (e && e.tool !== undefined) out.tool = e.tool
14  if (e && e.agentId !== undefined) out.agentId = e.agentId
15  if (e && e.surface !== undefined) out.surface = e.surface
16  if (e && e.isInteractive !== undefined) out.isInteractive = e.isInteractive
17  return out
18}
19
20async function record($, name, e, extra) {
21  let sid = 'nosid'
22  try { sid = await $.session.id() } catch (err) { sid = 'err:' + String(err && err.message) }
23  seq += 1
24  const key = 'probe.' + sid + '.' + String(seq).padStart(3, '0') + '.' + name
25  const value = { ...summary(e), at: Date.now(), ...(extra || {}) }
26  await $.store.set(key, value)
27}
28
29export function register(on) {
30  on('session.start', async ($, e, next) => {
31    let surfaces = null
32    try { surfaces = await $.session.surfaces() } catch (err) { surfaces = 'err:' + String(err && err.message) }
33    await record($, 'session.start', e, { surfaces })
34    try {
35      await $.command.register({ name: 'probe-dump', description: 'dump the probe store', immediate: true })
36    } catch (err) {}
37    return next(e)
38  })
39
40  on('command.run', { command: 'probe-dump' }, async ($) => {
41    const keys = await $.store.keys()
42    const lines = []
43    for (const k of keys) {
44      const v = await $.store.get(k)
45      lines.push(k + ' ' + JSON.stringify(v))
46    }
47    return { text: lines.join('\n') || '(store empty)' }
48  })
49
50  on('turn.start', async ($, e, next) => {
51    await record($, 'turn.start', e)
52    return next(e)
53  })
54
55  on('turn.step', async function* ($, e, next) {
56    const result = yield* next(e)
57    await record($, 'turn.step', e, { resultKeys: Object.keys(result || {}), resultUsage: result ? result.usage : null, resultModel: result ? result.model : null, stopReason: result ? result.stopReason : null })
58    return result
59  })
60
61  on('tool.call', async ($, e, next) => {
62    const result = await next(e)
63    await record($, 'tool.call', e, { resultKeys: Object.keys(result || {}), isError: result ? result.isError : null, deny: result ? result.deny : null })
64    return result
65  })
66
67  on('turn.complete', async ($, e, next) => {
68    await record($, 'turn.complete', e)
69    return next(e)
70  })
71
72  on('session.measure', async ($, e, next) => {
73    await record($, 'session.measure', e)
74    return next(e)
75  })
76
77  on('session.end', async ($, e, next) => {
78    await record($, 'session.end', e)
79    return next(e)
80  })
81}
82