wt-pack: every wt-* skill for running herdr agents on a ticket pipeline, plus wt-memory's hooks (preferences, pkill guard) and MCP server, and the wt MCP…

<img alt="wt-pack — Claude Code agents on a worktree ticket pipeline" src="docs/assets/banner-light.svg" width="100%">
<b>Plan, build, review and ship tickets with a team of Claude Code agents — each in its own git worktree, all in one control room.</b>
<img alt="Claude Code skills" src="https://img.shields.io/badge/Claude%20Code-skills-d97757"> <img alt="herdr" src="https://img.shields.io/badge/agents-herdr-408cff"> <img alt="Node 22.13+" src="https://img.shields.io/badge/node-%E2%89%A5%2022.13-339933?logo=node.js&logoColor=white"> <img alt="macOS first" src="https://img.shields.io/badge/macOS-first-000000?logo=apple&logoColor=white"> <img alt="Linux works" src="https://img.shields.io/badge/Linux-works-FCC624?logo=linux&logoColor=black"> <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue"></a>
<a href="#quick-start">Quick start</a> · <a href="#the-dashboard">Dashboard</a> · <a href="#skills">Skills</a> · <a href="#architecture">Architecture</a> · <a href="docs/features.md">Feature reference</a> · <a href="#contributing">Contributing</a>
wt-pack is a set of Claude Code skills plus a local web dashboard. Together they turn a handful of Claude Code sessions, run by herdr, into a small team with defined roles:
Each ticket follows the same loop: research → plan → work → simplify → review → ship → babysit → compound. You watch and steer the whole thing from wt-dashboard: the agents, their conversations, the board, shared rooms, and the moments that need you.
| 🧭 Ticket pipeline | wt-plan → wt-work → wt-ship: evidence-backed plans, unit-by-unit implementation, then simplify, review and ship from one read of the diff. | ||
| 🌳 One worktree per ticket | Every ticket runs on its own branch and checkout, so agents never step on each other. wt-finish retires the worktree once it has merged. | ||
| 🤝 Named agent pools | `wt-agents spawn planner\ | worker\ | auditor`. Handoffs label the target pane with its task, and both ends know how to reach each other. |
| 🗂️ Local kanban board | wt-ticket keeps per-project tickets (AS-12) on disk. Optional Dispatch sends Ready cards to free agents on its own. | ||
| 💬 Rooms | Chat rooms shared by you and your agents. @mention an agent to deliver a message into its session. | ||
| 🔔 Needs you | An inbox and native notifications when an agent is blocked on a question, stalled, or done. | ||
| 🧠 Shared memory | wt-memory keeps global, per-role and per-project preferences, which a Claude Code plugin injects into every session. | ||
| 📈 Observability | Call stats, outcomes, the server log and housekeeping for the optional judgment layer. | ||
| 🖥️ Mac app | A Tauri shell around the same dashboard UI shown below, with a tray menu that shows how many agents need you. |
<table> <tr> <td width="50%"><img alt="Board: per-project kanban with ticket badges and assignees" src="docs/assets/screenshots/board.png"><br><sub><b>Board</b>: per-project tickets, sizes, priorities and assignees.</sub></td> <td width="50%"><img alt="Agent chat: an agent's transcript with tool calls and the linked ticket" src="docs/assets/screenshots/chat.png"><br><sub><b>Agent chat</b>: the live transcript, tool calls, and the ticket and worktree it is on.</sub></td> </tr> <tr> <td width="50%"><img alt="Rooms: agents and the user discussing work in #acme-shop" src="docs/assets/screenshots/rooms.png"><br><sub><b>Rooms</b>: you and your agents in one thread. An @mention delivers into the agent's session.</sub></td> <td width="50%"><img alt="Observability: calls by feature, latency percentiles and recent calls" src="docs/assets/screenshots/observability.png"><br><sub><b>Observability</b>: calls, cache hits, error rates and p50/p95 per feature.</sub></td> </tr> </table>
<sub>The screenshots use a throwaway demo project (acme-shop) with simulated agents.</sub>
Pick one of these. Don't combine 1 and 2: both load wt-memory's hooks, so every hook would run twice (./setup doctor warns about it).
/plugin marketplace add rephol/wt-pack
/plugin install wt-pack@wt-pack
You get every wt-* skill, plus wt-memory's hooks (standing preferences and the pkill guard) and its MCP server. Agents still need herdr. There is no board, rooms or dashboard: wt-ticket and room say so when you call them. Plugin skills are namespaced, so type /wt-pack:wt-plan instead of /wt-plan. A prompt like "Use wt-plan …" works either way. Add the marketplace from GitHub or a clean clone, never from a working checkout. A local-path install copies everything in the directory, including node_modules and .claude/worktrees, which hold other tickets' unmerged code.
./setup: skills, board, rooms and the dashboard. See Quick start.You need macOS or Linux, Node ≥ 22.13, Git, gh, herdr and Claude Code.
git clone https://github.com/rephol/wt-pack.git ~/wt-pack
~/wt-pack/setup
setup does four things:
CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json, and removes the older per-skill links and plugins;It asks once before installing missing Homebrew packages (--yes skips the question). It never repoints an install that belongs to another checkout, and running it again changes nothing.
./setup doctor # what is missing, one line each; exit 1 while a required check fails
./setup secrets # optional TypeSafe key (Linear: dashboard Settings › Integrations)
./setup uninstall # service, plugin, settings entry, links; keeps data and keys (--purge deletes dashboard data/config)
Inside Claude Code, "set up wt-pack" runs the wt-setup skill, which drives the same script.
On Linux there is no launchd, Keychain or Tauri app; doctor lists what to do by hand. In short:
sudo apt-get install -y jq gh # drop sudo when you are root
curl -fsSL https://herdr.dev/install.sh | sh # then add ~/.local/bin to PATH
gh auth login
herdr # the herdr server must be running
npm --prefix ~/wt-pack/skills/wt-dashboard start # the dashboard, instead of launchd
test/docker/ runs this install in a clean node:22 container. To keep it running on a server, see Running on a Linux VM: a systemd unit, and access over Tailscale or an SSH tunnel.
Each directory under skills/ is one skill, shipped in the one wt-pack plugin (a full ./setup loads the checkout as that plugin).
| Skill | What it does |
|---|---|
wt-plan | Ticket → worktree → sharded research → a reviewed, committed plan → handoff |
wt-research | Evidence about a ticket or change, as structured findings with quoted sources |
wt-work | Implements a plan unit by unit, choosing an evidence strategy for each unit |
wt-ship | Simplify → review → record learnings → open or merge, in that order |
wt-simplify · wt-review · wt-compound · wt-pr | The steps wt-ship runs, each also usable on its own |
wt-babysit | Watches a PR until it is merge-ready |
wt-finish | Retires a merged worktree and its branch |
wt-agents | Spawns, lists and removes named herdr agents (planners, workers, auditors) |
wt-handoff | Hands a prompt to an agent as a /goal, labelling both ends |
wt-audit | The auditor's loop: use the product, file findings, rank what to do next |
wt-ticket | The local kanban board CLI |
wt-room | The rooms CLI |
wt-memory | Durable preferences for every agent, per role and per project |
wt-roles | Per-repo role instructions and named personas (.wt-pack/roles/): a guide and a CLI to create and check them |
wt-dashboard | The control room: a Node server, the web UI and the Mac app |
wt-setup | Runs ./setup |
wt-shared | Not a skill: scripts the other skills call by path |
skills/wt-dashboard/server.mjs) with a node:sqlite store for tickets, rooms and the inbox. It reads herdr, git and gh to show state, and Claude Code's own JSONL transcripts for the chat view.skills/wt-shared/scripts/wt-judge.mjs) turns decisions the pack otherwise eyeballs into typed, logged judgments through the TypeSafe API. Unconfigured is the normal case: every caller treats exit 3 (no TYPESAFE_API_KEY) as "do what the pack always did". Its log (~/.claude/wt-judge-log.jsonl) and any calibrated thresholds stay on the machine that made them. Two subcommands ship ADVISORY: they rank what to read, but never shrink what gets read.Everything else, including what every feature does, where it lives and its defaults, is in docs/features.md. Operator detail (the service, data layout, rollback) is in skills/wt-dashboard/README.md.
Issues and pull requests are welcome. CONTRIBUTING.md covers setup, the repo layout, running the tests, and the rules a change follows (one commit per skill, backward-compatible CLIs, docs/features.md in the same change).
MIT © 2026 rephol
hooks/register.ts 40 lines1// The one hooks module of the wt-pack plugin (hooks.json `modules` takes a single entry, so every mod registers
2// from here): the /wt command (WP-212), per-request model routing (WP-211), AskUserQuestion capture (WP-206) and
3// wt-message delivery (WP-210) and Bash convention guards (WP-208). The plugin root is the repo root, so the other skills are at `<root>/skills`.
4// An event several mods hook (session.start, turn.start, turn.complete, prompt.submit) is registered once, below,
5// running each mod's handler in turn (compose.ts). The loader follows `$` only into functions declared in this
6// file and wants a function literal as the hook, hence `ctx($)` here and the thin arrows.
7import type { EngineInterface, Register } from 'claude-code'
8import { runShared, type Ctx } from '../skills/wt-mods/hooks/compose'
9import { commandsHooks, registerCommands } from '../skills/wt-mods/hooks/commands'
10import { routingHooks, routingState, registerRouting } from '../skills/wt-mods/hooks/routing'
11import { registerGuards } from '../skills/wt-mods/hooks/guards'
12import { registerAsk } from '../skills/wt-ask/hooks/register'
13import { deliverHooks } from '../skills/wt-room/mod/hooks/register'
14
15const skills = (root: string) => `${root}/skills`
16
17function ctx($: EngineInterface): Ctx {
18 return {
19 root: $.plugin.root,
20 run: (argv, init) => $.process.run(argv, init),
21 submit: (text) => $.prompt.submit({ text }),
22 every: (ms, fn) => $.clock.every(ms, fn),
23 registerCommand: (c) => $.command.register(c),
24 }
25}
26
27export const register: Register = (on, options) => {
28 const routing = routingState()
29 // deliver before routing: routing's turn.complete awaits a subprocess, which must not delay deliver's next pull
30 const mods = [commandsHooks(), deliverHooks(skills), routingHooks(routing, skills)]
31 registerCommands(on, skills)
32 registerRouting(on, routing, skills)
33 registerGuards(on)
34 registerAsk(on, options, skills)
35 on('session.start', ($, e, next) => runShared(ctx($), mods, 'session.start', e, next))
36 on('turn.start', ($, e, next) => runShared(ctx($), mods, 'turn.start', e, next))
37 on('turn.complete', ($, e, next) => runShared(ctx($), mods, 'turn.complete', e, next))
38 on('prompt.submit', ($, e, next) => runShared(ctx($), mods, 'prompt.submit', e, next))
39}
40skills/wt-mods/hooks/compose.ts 30 lines1// WP-213: one plugin, one hooks module — but the engine refuses a second hook on the same event without a matcher,
2// and several mods want session.start / turn.start / turn.complete / prompt.submit. So a mod gives `Hooks` (its
3// handlers for those four shared events, plain functions) and a `registerX(on, …)` function for everything else
4// (matcher hooks, turn.step). The one module registers each shared event once, as the `chain` of every mod's handler.
5// (The loader reads `on(...)` call sites statically: `on` is only ever passed to a plain function of the plugin.)
6import type { EngineInterface, Register } from 'claude-code'
7
8export type On = Parameters<Register>[0]
9// The loader follows `$` only into functions declared in the module's own file, so a shared-event handler (which
10// lives in a mod's file) never sees `$`: it gets this narrow ctx, built in the module from literal `$.noun.event(...)` calls.
11export type Ctx = {
12 root: string
13 run: (argv: readonly string[], init?: Parameters<EngineInterface['process']['run']>[1]) => ReturnType<EngineInterface['process']['run']>
14 submit: (text: string) => Promise<unknown>
15 every: (ms: number, fn: () => void) => unknown
16 registerCommand: (c: Parameters<EngineInterface['command']['register']>[0]) => Promise<unknown>
17}
18export type Hook = (ctx: Ctx, e: any, next: (e: any) => any) => any // eslint-disable-line @typescript-eslint/no-explicit-any
19export type Shared = 'session.start' | 'turn.start' | 'turn.complete' | 'prompt.submit'
20export type Hooks = Partial<Record<Shared, Hook>>
21
22// First is outermost: h0 runs, and its next() runs h1, … then the engine's own (the final `next`).
23export const chain = (hooks: readonly Hook[]): Hook => (ctx, e, next) =>
24 hooks.reduceRight<(e2: unknown) => any>((n, h) => (e2) => h(ctx, e2, n as never), (e2) => next(e2))(e)
25
26export const handlers = (mods: readonly Hooks[], event: Shared): Hook => chain(mods.flatMap(m => (m[event] ? [m[event]] : [])))
27
28// What the module's one hook per shared event calls: `($, e, next) => runShared(ctx($), mods, 'turn.complete', e, next)`.
29export const runShared = (ctx: Ctx, mods: readonly Hooks[], event: Shared, e: unknown, next: (e: any) => any) => handlers(mods, event)(ctx, e, next) // eslint-disable-line @typescript-eslint/no-explicit-any
30skills/wt-mods/hooks/commands.ts 68 lines1// WP-207/212 — one instant slash command, `/wt <sub> …`: wrappers that run existing wt-pack scripts directly, with
2// no Claude turn, and `immediate` so they also run while the agent is mid-turn. No logic of their own: argv in,
3// stdout out. One namespaced command (not /room, /ticket, …) so it cannot clash with another plugin's commands.
4import type { Register } from 'claude-code'
5import type { Hooks } from './compose'
6
7type Ctx = Parameters<Parameters<Parameters<Register>[0]>[2]>[0]
8// Where the skills dir is, from the plugin root: the repo's root plugin keeps it at `<root>/skills`, the wt-mods
9// skill plugin sits inside it (`<root>/..`). Passed in by whichever entry registers this.
10export type SkillsDir = (root: string) => string
11
12// Shell-style split (quotes, no expansion): the scripts are run by argv, never through a shell.
13export const split = (s: string): string[] => {
14 const out: string[] = []
15 const re = /"((?:[^"\\]|\\.)*)"|'([^']*)'|(\S+)/g
16 for (let m = re.exec(s); m; m = re.exec(s)) out.push(m[1] !== undefined ? m[1].replace(/\\(.)/g, '$1') : (m[2] ?? m[3]))
17 return out
18}
19
20type Cmd = { description: string; hint: string; script: string; argv: (a: string[], pane: string) => string[] | string }
21
22// script is relative to the skills dir; argv returns the script's args, or a usage string.
23export const COMMANDS: Record<string, Cmd> = {
24 room: { description: 'wt-room: list | read <slug> | post <slug> "text"', hint: '<list|read|post> …', script: 'wt-room/scripts/room', argv: a => (a.length ? a : ['list']) },
25 ticket: { description: 'wt-ticket: show|move|list|comment … (e.g. show WP-12)', hint: '<show|move|list|…> …', script: 'wt-ticket/scripts/wt-ticket', argv: a => (a.length ? a : ['list', '--mine']) },
26 dnd: { description: 'Do-not-disturb for this agent: on [--for 2h] | off | (status)', hint: '[on|off]', script: 'wt-agents/scripts/agents.sh', argv: (a, pane) => ['dnd', pane, ...a] },
27 herd: { description: 'List the herdr agents (wt-agents list; /agents is built in)', hint: '[role] [--json]', script: 'wt-agents/scripts/agents.sh', argv: a => ['list', ...a] },
28 watch: { description: 'PR poller status (wt-watch-prs poller-status)', hint: 'status', script: 'wt-watch-prs/scripts/watch-prs.sh', argv: a => (a.length === 0 || a[0] === 'status' ? ['poller-status', ...a.slice(1)] : 'usage: /wt watch status') },
29}
30
31export const usage = `usage: /wt <${Object.keys(COMMANDS).join('|')}> …\n` + Object.entries(COMMANDS).map(([n, c]) => ` /wt ${n} ${c.hint} — ${c.description}`).join('\n')
32
33// `/wt room post dev "hi"` → name 'room', rest 'post dev "hi"'; no/unknown sub → the usage text.
34export const subOf = (args: string): [string, string] => {
35 const m = args.trim().match(/^(\S+)\s*([\s\S]*)$/)
36 return m && Object.hasOwn(COMMANDS, m[1]) ? [m[1], m[2]] : ['', '']
37}
38
39async function run($: Ctx, skills: SkillsDir, name: string, args: string) {
40 const c = COMMANDS[name]
41 let pane = (await $.env.get('HERDR_PANE_ID')) ?? ''
42 if (name === 'dnd') {
43 if (!pane) return { text: 'dnd: not inside a herdr pane (HERDR_PANE_ID is unset)' }
44 // HERDR_PANE_ID may be the stable id; agents.sh matches the canonical pane_id (CLAUDE.md trap).
45 const got = await $.process.run(['herdr', 'pane', 'get', pane]).catch(() => undefined)
46 try { pane = JSON.parse(got?.stdout ?? '').result.pane.pane_id || pane } catch { return { text: 'dnd: cannot resolve this pane (herdr pane get failed)' } }
47 }
48 const argv = c.argv(split(args), pane)
49 if (typeof argv === 'string') return { text: argv }
50 const r = await $.process.run([`${skills($.plugin.root)}/${c.script}`, ...argv], { timeoutMs: 20000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: String(err) }))
51 const text = [r.stdout.trimEnd(), r.stderr.trimEnd()].filter(Boolean).join('\n')
52 return { text: (text || `/${name}: no output`) + (r.exitCode ? `\n(exit ${r.exitCode})` : '') }
53}
54
55export const commandsHooks = (): Hooks => ({
56 'session.start': async (ctx, e, next) => {
57 await ctx.registerCommand({ name: 'wt', description: 'wt-pack: room | ticket | dnd | herd | watch (runs the script, no model turn)', argumentHint: '<room|ticket|dnd|herd|watch> …', immediate: true }).catch(() => null) // one mod failing must not skip the others' session.start
58 return next(e)
59 },
60})
61
62export const registerCommands = (on: Parameters<Register>[0], skills: SkillsDir) => {
63 on('command.run', { command: 'wt' }, ($, e) => {
64 const [name, rest] = subOf(e.args)
65 return name ? run($, skills, name, rest) : { text: usage }
66 })
67}
68skills/wt-mods/hooks/routing.ts 91 lines1// WP-211 — per-request model routing: the first model request of each main-loop turn is routed through
2// model-route.mjs (Jev + the mode gate + sessionFloor), and the turn's remaining steps reuse that pick. In
3// off/shadow `pick` applies nothing (shadow only logs the decision), so the request goes through untouched;
4// only `live` rewrites model/effort. No routing logic here: the script owns the gate, floors and the log.
5import type { Register } from 'claude-code'
6import type { SkillsDir } from './commands'
7import type { Hooks } from './compose'
8
9const ROUTE = 'wt-shared/scripts/model-route.mjs'
10
11type Pick = { apply: string | null; applyEffort: string | null; ref: string | null; source: string }
12type Efforts = 'low' | 'medium' | 'high' | 'xhigh' | 'max'
13const EFFORTS: readonly string[] = ['low', 'medium', 'high', 'xhigh', 'max']
14
15// `pick --json` stdout → the decision, or null when it is not one (a failed script never blocks a request).
16export const parsePick = (stdout: string): Pick | null => {
17 try {
18 const d = JSON.parse(stdout)
19 return { apply: typeof d.apply === 'string' ? d.apply : null, applyEffort: typeof d.applyEffort === 'string' ? d.applyEffort : null, ref: typeof d.ref === 'string' ? d.ref : null, source: String(d.source ?? '') }
20 } catch { return null }
21}
22
23// The routing state, shared by the shared-event hooks (routingHooks) and the turn.step hook (registerRouting).
24export const routingState = () => ({
25 prompt: '', // the last submitted prompt: the task text for the next turn's first request
26 turn: undefined as { id: string; pick: Pick | null; from?: string; model?: string } | undefined,
27 modelIds: new Map<string, string>(),
28})
29type State = ReturnType<typeof routingState>
30
31export const routingHooks = (st: State, skills: SkillsDir): Hooks => ({
32 'prompt.submit': (_$, e, next) => {
33 // Only a person's own prompt is a task: not a slash command, a background notification or a peer's message.
34 const o = e.origin as { kind?: string; asUser?: boolean } | undefined // absent: the user's own
35 const person = !o?.kind || o.kind === 'composer' || o.kind === 'bridge' || o.kind === 'sdk' || (o.kind === 'plugin' && o.asUser === true)
36 if (person && !e.text.trimStart().startsWith('/')) st.prompt = e.text
37 return next(e)
38 },
39
40 // WP-159's outcomes, per turn, deliberately thin: a routed turn that answered is 'ok', one the model refused is
41 // 'returned' (the tier could not do it). An interruption or an API error says nothing about the pick. A shadow
42 // pick applied nothing, so it records 'shadow-ok'/'shadow-returned' with the model that actually ran (WP-215):
43 // the eval reads those apart from real outcomes, never as a verdict on the pick. An answer is a weak signal next to WP-159's "ticket
44 // reached Done"; the per-turn log is for the tuning's volume, not a verdict.
45 'turn.complete': async (ctx, e, next) => {
46 const t = st.turn
47 if (t && !e.agentId && t.id === e.turnId) {
48 st.turn = undefined
49 const pick = t.pick
50 if (pick?.ref && !pick.source.startsWith('jev-failopen') && (e.reason === 'answer' || e.reason === 'refusal')) {
51 const what = (pick.apply ? '' : 'shadow-') + (e.reason === 'answer' ? 'ok' : 'returned')
52 await ctx.run(['node', `${skills(ctx.root)}/${ROUTE}`, 'outcome', pick.ref, what, `turn ${e.reason}${pick.apply ? '' : ` on ${t.from ?? '?'}`}`], { timeoutMs: 8000 }).catch(() => null)
53 }
54 }
55 return next(e)
56 },
57})
58
59export const registerRouting = (on: Parameters<Register>[0], st: State, skills: SkillsDir) => {
60 on('turn.step', async function* ($, e, next) {
61 if (e.agentId) return yield* next(e) // a subagent's tier is its Agent call's; this routes the main loop
62 const script = `${skills($.plugin.root)}/${ROUTE}`
63 if (e.index === 0 && st.prompt) {
64 const text = st.prompt
65 st.prompt = '' // a turn with no prompt of its own (a background wake-up) is not routed on a stale one
66 const r = await $.process.run(['node', script, 'pick', '--skill', 'turn-step', '--session', '--json'], { stdin: text, timeoutMs: 8000 }).catch(() => null)
67 st.turn = { id: e.turnId, pick: r?.exitCode === 0 ? parsePick(r.stdout) : null }
68 }
69 const turn = st.turn
70 const pick = turn?.id === e.turnId ? turn.pick : null
71 if (turn && pick) turn.from ??= e.model // the model that ran: a shadow outcome names it
72 // Nothing to apply: off/shadow, a failed pick, an unrouted turn — or Jev being down (fail-open lands on
73 // sonnet, which would silently downgrade a session that is on opus).
74 if (!turn || !pick?.apply || pick.source.startsWith('jev-failopen')) return yield* next(e)
75 turn.from ??= e.model
76 if (e.model !== turn.from) return yield* next(e) // the engine switched model itself (a fallback): leave it
77 if (!turn.model) {
78 let id = st.modelIds.get(pick.apply)
79 if (!id) {
80 const m = await $.process.run(['node', script, 'model-id', pick.apply], { timeoutMs: 5000 }).catch(() => null)
81 id = m?.exitCode === 0 ? m.stdout.trim() : ''
82 if (id) st.modelIds.set(pick.apply, id)
83 }
84 if (!id) return yield* next(e) // no pinned id for the tier: an alias is not known to be valid here
85 turn.model = id + (e.model.match(/\[[^\]]+\]$/)?.[0] ?? '') // keep a [1m]-style variant suffix
86 }
87 const effort = pick.applyEffort && EFFORTS.includes(pick.applyEffort) ? (pick.applyEffort as Efforts) : e.effort
88 return yield* next({ ...e, model: turn.model, effort })
89 })
90}
91skills/wt-mods/hooks/guards.ts 75 lines1// WP-208/WP-279 — Bash tool.call guards for wt-pack conventions: a one-line refusal naming the rule and the fix. Loose
2// shell parsing on purpose (like wt-memory's pkill-guard.mjs, which stays): it stops accidents, not obfuscation.
3import type { Register } from 'claude-code'
4
5const SENDS = /handoff\.sh|wt-room|\broom\s+post|\bherdr\b|wt-ticket|wt-ask/ // commands whose strings reach another agent or a card
6const words = (seg: string) => [...seg.matchAll(/"[^"]*"|'[^']*'|\S+/g)].map((m) => m[0])
7const TAKES_VALUE = new Set('FGgPstUucJjMNd'.split('')) // pkill/pgrep options that consume the next word
8const SKILLS_PATH = /(~|\$HOME|\$\{HOME\}|\/Users\/[^/\s]+|\/home\/[^/\s]+)\/\.claude\/skills\//
9
10// WP-279: `git push --force`/`-f`/`+ref` (--force-with-lease passes), and `pkill|pgrep -f` with a pattern under 6
11// characters or starting with "-" (it matches unrelated processes, WP-120).
12const forcePush = (w: string[]) => {
13 const i = w.findIndex((t) => t === 'push')
14 return w[0] === 'git' && i > 0 && w.slice(i + 1).some((t) => t === '--force' || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(t) || /^\+\S/.test(t))
15}
16const broadKill = (w: string[]) => {
17 const i = w.findIndex((t) => /^(?:\S*\/)?p(?:kill|grep)$/.test(t))
18 if (i < 0) return false
19 let full = false
20 const ops: string[] = []
21 for (let j = i + 1; j < w.length; j++) {
22 const t = w[j]
23 if (/^-[a-zA-Z]+$/.test(t)) { if (t.includes('f')) full = true; if (TAKES_VALUE.has(t[t.length - 1])) j++ } else if (!/^-\d+$/.test(t)) ops.push(t.replace(/^["']|["']$/g, ''))
24 }
25 return full && ops.some((p) => p.length < 6 || p.startsWith('-'))
26}
27// In the main checkout a branch change moves every other agent's base: only a worktree may switch branches.
28const branchSwitch = (w: string[]) => {
29 const i = w.findIndex((t) => t === 'checkout' || t === 'switch')
30 if (w[0] !== 'git' || i < 1 || w.includes('--')) return false
31 const rest = w.slice(i + 1)
32 if (w[i] === 'switch') return !rest.some((t) => t === '--help' || t === '-h')
33 return rest.some((t) => /^(-[bBc]|--orphan|--detach)$/.test(t)) || rest.filter((t) => !t.startsWith('-')).length === 1 // ponytail: `git checkout <file>` is refused too (use `git restore`)
34}
35
36// The refusal for a command, or null. Each segment of `a && b ; c` is judged alone. `here`: the session is in a
37// wt-pack checkout, where the repo's own rules (no drafts, own paths, repo-local identity) apply; the skills-path
38// rule is pack-wide. `main`: that checkout is the main one, not a worktree. Other projects keep wt-ship's draft PRs
39// and their own commit habits.
40export const guard = (cmd: string, here = true, main = here): string | null => {
41 for (const seg of cmd.split(/&&|\|\||[;\n]/)) {
42 const bare = seg.replace(/"[^"]*"|'[^']*'/g, '""') // flags inside a message are not flags
43 const gh = /\bgh\s+pr\s+create\b/.test(bare) && /(^|\s)(--draft|-d)(\s|=|$)/.test(bare)
44 if (gh && here) return 'wt-pack: no draft PRs. Drop --draft: review, merge to main, push.'
45 if (here && /\bgit\s+(?:-c\s+\S+\s+|-\S+\s+)*commit\b/.test(seg)) {
46 if (/\bcommit\b[^|]*\s-[a-zA-Z]*a[a-zA-Z]*(\s|$)|--all\b/.test(bare)) return 'wt-pack: never `git commit -a`. Commit only your own paths: `git commit <paths>`.'
47 if (/--author\b|\s-c\s+user\.(name|email)|GIT_(AUTHOR|COMMITTER)_(NAME|EMAIL)=/.test(bare)) return 'wt-pack: commits use the repo-local git identity. Drop --author / -c user.* / GIT_AUTHOR_*; fix it with `./setup doctor`.'
48 }
49 const w = words(bare)
50 if (here && forcePush(w)) return 'wt-pack: no force-push. Push normally (the orchestrator merges and pushes); --force-with-lease only if you must.'
51 if (here && broadKill(words(seg))) return 'wt-pack: pkill/pgrep -f needs a specific pattern (6+ characters, not a flag) — a short one matches every agent and Chrome (WP-120). Kill by recorded pid.'
52 if (main && branchSwitch(w)) return 'wt-pack: never change branch in the main checkout. Work in a worktree: `git worktree add .claude/worktrees/<slug> -b <branch> main`.'
53 if (here && w.some((t, k) => /(^|\/)wt-ticket$/.test(t) && w[k + 1] === 'new' && /^(--help|-h|help)$/.test(w[k + 2] ?? ''))) return 'wt-pack: `wt-ticket new --help` creates a ticket titled "--help". Run `wt-ticket` with no arguments for usage.'
54 if (SENDS.test(seg) && SKILLS_PATH.test(seg)) return 'wt-pack: never write ~/.claude/skills/… in sent strings. Refer to the skill by name or a repo-relative path.'
55 }
56 return null
57}
58
59// From the session's cwd: exit 1 = not a wt-pack checkout (the marker setup uses; worktrees carry it too),
60// 10 = a wt-pack worktree, 0 = the main checkout.
61const WHERE = 'test -f "$(git rev-parse --show-toplevel 2>/dev/null)/.claude-plugin/marketplace.json" || exit 1; [ "$(git rev-parse --git-dir)" = "$(git rev-parse --git-common-dir)" ] && exit 0; exit 10'
62
63export const registerGuards = (on: Parameters<Register>[0]) => {
64 let where: Promise<number> | undefined // ponytail: once per session, keyed on the session's cwd, not a `cd` in the command
65 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
66 let deny = guard(e.command, false, false)
67 if (!deny && guard(e.command, true, true)) { // only a command a repo rule would refuse pays for the check
68 where ??= $.process.run(['sh', '-c', WHERE], { timeoutMs: 3000 }).then((r) => r.exitCode, () => 1)
69 const at = await where
70 if (at !== 1) deny = guard(e.command, true, at === 0)
71 }
72 return deny ? { deny } : next(e)
73 })
74}
75skills/wt-ask/hooks/register.ts 72 lines1import type { Register } from 'claude-code'
2import type { SkillsDir } from '../../wt-mods/hooks/commands'
3
4// WP-206: in a wt-pack herdr agent pane, answer AskUserQuestion through wt-dashboard (wt-ask posts the chip and
5// Inbox card, --wait blocks for the answer) and return it as the tool's result. Anything off the happy path
6// (no pane, dashboard down, a text/number question, a wt-ask error) calls next(e): the native dialog.
7const SLICE_S = 300 // one --wait call; $.process.run caps a command at ten minutes
8
9type Q = { question: string; header: string; options: { label: string; description?: string }[]; multiSelect: boolean; kind?: string }
10type Answer = { selected: string[][]; other?: string[]; text?: string; chat?: boolean }
11
12export const registerAsk = (on: Parameters<Register>[0], options: Parameters<Register>[1], skills: SkillsDir) => {
13 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
14 const qs = e.questions as Q[]
15 if (qs.some((q) => q.kind && q.kind !== 'choice')) return next(e) // wt-ask only carries option questions
16 const wt = `${skills($.plugin.root)}/wt-ask/scripts/wt-ask`
17 const run = (argv: string[], stdin?: string, timeoutMs = 15000) => $.process.run([wt, ...argv], { stdin, timeoutMs }).catch(() => null)
18
19 if ((await run(['--ping']))?.exitCode !== 0) return next(e) // not a known agent pane, or dashboard down
20 const body = JSON.stringify({ questions: qs.map(({ question, header, options, multiSelect }) => ({ question, header, options: options.map(({ label, description }) => ({ label, description })), multiSelect })) })
21 const posted = await run(['--json', '-', '--no-deliver'], body)
22 const id = posted?.exitCode === 0 ? posted.stdout.trim() : ''
23 if (!id) return next(e)
24
25 const timeoutMin = Number(options.timeoutMin) > 0 ? Number(options.timeoutMin) : 30
26 const deadline = (await $.clock.now()) + timeoutMin * 60_000
27 // Core's record: answers maps question -> label(s), `response` is freeform text typed instead of selecting.
28 // WP-233: 'Chat about this' drops the question like the native picker does; `other[i]` is the typed answer.
29 const CHAT = { deny: 'The user chose "Chat about this" instead of answering: stop, and discuss the question with them in chat (or the room) before continuing.' }
30 const answered = (a: Answer) => a.chat ? CHAT : ({
31 result: {
32 questions: e.questions,
33 answers: Object.fromEntries(qs.map((q, i) => [q.question, [...(a.selected[i] ?? []), a.other?.[i]].filter(Boolean).join(', ') || (i === 0 && a.text ? a.text : '')])),
34 ...(a.text ? { response: a.text } : {}),
35 },
36 })
37 // WP-231: the pane shows what is asked (a status line alone left a phone terminal blank). Esc interrupts the
38 // whole turn (live-checked: Claude records 'User declined', no picker), so it is not an answer path: it closes
39 // the dashboard ask at once ($.process.run takes no signal; the --wait slice would hold it open up to 5 min).
40 const ESC = { deny: 'The user pressed Esc to answer in the terminal: ask the same question again as plain text with numbered options and wait for their reply.' }
41 next.signal.addEventListener('abort', () => { void run(['--resolve', id]) })
42 $.ui.log('Asked in wt-dashboard (answer there; Esc cancels):')
43 for (const q of qs) $.ui.log(`${q.question} — ${q.options.map((o) => o.label).join(' / ')}`)
44 $.ui.status('asked in wt-dashboard — answer there; Esc cancels')
45 try {
46 for (;;) {
47 const left = Math.ceil((deadline - (await $.clock.now())) / 1000)
48 if (left <= 0) break
49 if (next.signal.aborted) return ESC
50 const slice = Math.min(SLICE_S, left)
51 const w = await run(['--wait', id, '--timeout', String(slice)], undefined, (slice + 15) * 1000)
52 if (w?.exitCode === 0) {
53 let a: Answer
54 try { a = JSON.parse(w.stdout) as Answer } catch { return next(e) }
55 return answered(a)
56 }
57 if (w?.exitCode === 3 && w.stderr.includes('resolved')) return { deny: 'The question was closed in wt-dashboard without an answer; continue without it or ask again.' }
58 if (w?.exitCode !== 3) { await run(['--resolve', id]); return next(e) } // dashboard went away: ask natively
59 }
60 if (next.signal.aborted) return ESC
61 // --resolve is a 409 when the user answered in the last moment: take that answer rather than deny it.
62 if ((await run(['--resolve', id]))?.exitCode !== 0) {
63 const late = await run(['--wait', id, '--timeout', '1'], undefined, 20000)
64 if (late?.exitCode === 0) return answered(JSON.parse(late.stdout) as Answer)
65 }
66 return { deny: `No answer from the user within ${timeoutMin} min (asked via wt-dashboard); continue without it or ask again.` }
67 } finally {
68 $.ui.status(undefined)
69 }
70 })
71}
72skills/wt-room/mod/hooks/register.ts 58 lines1import type { SkillsDir } from '../../../wt-mods/hooks/commands'
2import type { Hook, Hooks } from '../../../wt-mods/hooks/compose'
3
4// WP-210: pull wt-pack traffic from wt-dashboard's per-pane queue and submit it as a plugin-origin prompt, one at a
5// time and only while no turn runs (order kept, nothing interleaved into a turn). The 20 s hello tells the dashboard
6// to queue for this pane; without it (mod off, crashed, dashboard down) senders paste keystrokes as before.
7const HELLO_MS = 20_000
8const PULL_MS = 3_000
9
10export const deliverHooks = (skills: SkillsDir): Hooks => {
11 let turnRunning = false
12 let pulling = false
13 let lastSubmitted = ''
14 let pullNow: () => Promise<void> = async () => {}
15 let runCli: (argv: string[]) => Promise<{ exitCode?: number; stdout: string } | null> = async () => null
16 let checked = false // WP-272: the finish check runs at most once per turn
17
18 const onStart: Hook = async (ctx, e, next) => {
19 const wt = `${skills(ctx.root)}/wt-room/mod/scripts/wt-deliver`
20 const run = (argv: string[]) => ctx.run([wt, ...argv], { timeoutMs: 6000 }).catch(() => null)
21 runCli = run
22 const pull = async () => {
23 if (turnRunning || pulling) return
24 pulling = true
25 try {
26 const r = await run(['next'])
27 if (r?.exitCode !== 0) return
28 let item: { id?: string; text?: string }
29 try { item = JSON.parse(r.stdout) } catch { return }
30 if (!item.id || typeof item.text !== 'string') return // {} : nothing queued
31 if (item.id !== lastSubmitted) { // a failed ack leaves the row queued: retry only the ack, never the submit
32 turnRunning = true // a submitted prompt starts a turn; do not wait for turn.start to say so
33 try { await ctx.submit(item.text) } catch (err) { turnRunning = false; throw err }
34 lastSubmitted = item.id
35 }
36 for (let i = 0; i < 3 && (await run(['ack', item.id, 'delivered']))?.exitCode !== 0; i++);
37 } catch { /* retry next tick */ } finally { pulling = false }
38 }
39 pullNow = pull
40 await run(['hello'])
41 ctx.every(HELLO_MS, () => { void run(['hello']) })
42 ctx.every(PULL_MS, () => { void pull() })
43 return next(e)
44 }
45 const onTurnStart: Hook = (_ctx, e, next) => { turnRunning = true; checked = false; return next(e) }
46 const onComplete: Hook = async (_ctx, e, next) => {
47 const r = await next(e)
48 turnRunning = false
49 void pullNow()
50 // WP-272: a handed-off agent that ends its turn without reporting gets one reminder (the server decides: it knows the
51 // card and the message). The reminder is queued for this pane, so pull it right away. Fails open: errors are ignored.
52 if (!checked) { checked = true; void runCli(['check-finish']).then(() => pullNow()) }
53 return r
54 }
55
56 return { 'session.start': onStart, 'turn.start': onTurnStart, 'turn.complete': onComplete }
57}
58