SLOPSHOPPER

followups

Claude flags what it notices along the way, asks whether to keep each one, and keeps a to-do you can export to memory, docs or the README

newpanebandguardcommandtoast
★ 2v0.5.5MITupdated 2026-10-25Amitro123/claudemod/plugins/followups
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · followups
│ ┃ To-do ✕ › fix the failing auth test and add an audit log call │ ┃ ☑ To-do │ ┃ ⏺ Read(src/auth.ts) │ ┃ Nothing tracked yet. When Claude spots ⎿ Read 6 lines │ ┃ something, it asks above the prompt. ⏺ Update(src/auth.ts) │ ┃ /followup adds one by hand. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Export to [ Memory ] [ Docs ] [ README ] ⎿ 3 pass, 1 fail │ ┃ Memory → CLAUDE.md · Docs → │ ┃ docs/FOLLOW-UPS.md · README → README.md ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /followups │ ⎿ followups: Follow-ups pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · To-do
☑ To-do Nothing tracked yet. When Claude spots something, it asks above the prompt. /followup adds one by hand. Export to [ Memory ] [ Docs ] [ README ] Memory → CLAUDE.md · Docs → docs/FOLLOW-UPS.md · README → README.md
README

claudemod: mods for Claude Code

Seven mods, each installed on its own. They also work side by side: each draws its part of the band above the prompt, and the others' parts stay.

ModWhat it does
clawd-hudA pixel spark above the prompt, usage batteries, a prompt-cache timer and a context meter
task-hudA to-do progress bar, and a pane with a pixel creature for each sub-agent
followupsClaude flags what it notices, asks whether to track it, and keeps a to-do you can export
rtl-toggleFixes the reading order of lines that mix English and Hebrew
goal-runAsks for a goal when a session starts, has Claude plan it, and runs a pixel Claude to 100% as the tasks get done
brain-hudAgent memory with its skill built in: mistake-brain logs and routes lessons, a pixel Claude with a brain reacts to each entry, and a pane draws the memory as a graph
factory/factory <feature>: a spec, your approval of its plan, a build on its own branch, checks, parallel reviews and an approver, then Claude opens the PR

Add the marketplace once, then install the ones you want:

/plugin marketplace add Amitro123/claudemod
/plugin install <mod>@claudemod

The installer may say some options are "not yet set". That's fine: every option has a default, and the mods run on those until you change one with /plugin configure <mod>@claudemod.

If you added this marketplace before it was renamed from clawd-hud, remove it with /plugin marketplace remove clawd-hud, add it again, and reinstall your mods as <mod>@claudemod.

clawd-hud: a pixel spark

clawd-hud demo

A Claude Code plugin that puts a little animated spark above the prompt, with a pixel battery for each of your usage limits, and adds a prompt-cache timer and a context meter beside the model picker. It works on personal plans and on enterprise seats.

What it shows

Above the prompt (the AbovePrompt band)

  • The spark on the left. What it does follows what Claude is doing:
MoodWhenScenes
idlewaiting for youdrifting with a firefly, juggling, watering a sprout
workinga turn is runningat the terminal, stacking blocks, pondering, sending out sub-agents
working > 90sa hard taska smoke break on a crate joins the rotation
loopthe same tool with the same input 3× in one turnriding a coaster loop it can't get out of
donethe turn answeredfireworks and a hop (a lightbulb if the task was hard)
relax4 min after donestargazing on a hill, a pool float, cocoa by a snowy window
errorAPI error or refusalgoes gray under a little storm cloud, then idle
  • A battery per usage window, showing what is left: green above 30%, amber above 10%, red below. Each has a "resets in …" countdown.
Personal (Pro / Max)Enterprise
Batteries5h window, 7d weekly7d weekly, mo monthly spend cap ("over by …" past 100%)
Costnot shown$ spent this session
"resets in"the API's reset timethe API's reset time; for the spend cap without one, the 1st of next month
Cache timer (auto)1 hour5 minutes

Beside the model picker (the SessionMode slot)

  • Cache bolt: counts down the prompt-cache TTL from the last response. Green while warm, amber in the last minute, gray once cold.
  • Context capsule: how full the context window is, e.g. ctx 34% · 68k/200k.

In a terminal (which can't draw SVG) the same readings show up as a text spark ✦(•_•) and ▰▰▰▱▱ batteries. A one-line summary also goes to the status bar.

The spark shares both places with the other mods: what they draw in the band shows under its row, and whatever sits beside the model picker stays next to the bolt and capsule.

Install

/plugin install clawd-hud@claudemod

Options

Set with claude plugin configure clawd-hud@claudemod, or in the plugin settings:

OptionValuesDefaultWhat it does
planauto, personal, enterpriseautoWhich limits to show. auto switches to enterprise as soon as the account reports a monthly spend limit (spend_limit).
cacheTtlauto, 5m, 1hautoThe cache timer's length. auto is 1h on personal plans and 5m on enterprise.

How it works

  • hooks/register.tsx hooks session.start, session.measure, turn.start, tool.call, turn.complete and ui.render, keeps its state in atoms, and redraws the spark 8 times a second while it moves (working, the loop, fireworks, the storm) and less often while it idles or rests: 4 times a second for the SVG scenes, once a second for the terminal's text face.
  • hooks/scene.ts paints one frame of a mood on an 88×20 pixel stage. Every motion is a plain function of the time since the mood began, so a redraw lands on the same frame and never restarts an animation.
  • hooks/gauges.ts draws the batteries, the bolt and the capsule as plain SVG, so the demo renderer uses the exact same art.
  • tests/plan.test.tsx checks the personal and enterprise views, the spend-cap overflow and both cache timers. Run it with claude plugin test plugins/clawd-hud.

Changelog

0.6.2: the idle and resting SVG scenes redraw 4 times a second instead of once, so they move smoothly; the terminal stays at once a second. The terminal face's idle blink shows again (every 4 seconds).

0.6.1: plays well with the other mods: their bands, and other changes beside the model picker, are no longer hidden. Idle redraws drop from 8 a second to 1. No "clawd-hud loaded" toast on every start.

0.6.0: a new look. The spark mascot, its scenes, the stage and the batteries, bolt and capsule are all new, drawn by a new animation engine. Everything else carries over: plans, timers, loop detection, the hard-task and eureka moments.

0.5.0: renamed from pixel-buddy to clawd-hud (install again; old settings don't carry over). Enterprise mode. The loop detector's tool.call hook got a registration .catch, so it can never block a tool call.

0.4.1: only a real answer celebrates (a refusal gets the error mood). The cache countdown flips to "cold" even after the machine slept. Timers no longer stack up when session.start fires again. No more "resets in NaNm".

0.4.0: the hard-task smoke break, the eureka lightbulb and the loop coaster. 0.3.0: the error mood.

task-hud: to-dos and sub-agents

task-hud: the to-do bar above the prompt and the Sub-agents pane

<sub>The creatures, the task bar and the progress lines are drawn by the mod's own art.ts; the window around them is a mock-up of the desktop app.</sub>

/plugin install task-hud@claudemod
  • A progress bar above the prompt once Claude writes a to-do list (TodoWrite, or TaskCreate/TaskUpdate): it's titled with the prompt that started the work, fills as each to-do gets ticked off (Tasks 4/5 · 80%), and shows the one in progress (Now: Write the script). The title survives a reload of the mod. When it shares the band with clawd-hud, it draws above the spark. A finished list clears on your next prompt.
  • A Sub-agents pane that opens the first time Claude sends out a sub-agent, and reopens with /task-hud. Each agent gets a pixel creature in its own color that walks while it works. Its card shows the task's description, the agent type and model (general-purpose · Opus 5.5), its tokens and run time, the current step (Step 3 · Reading pre-outline.md), the steps before it and a progress line. Cards are grouped into Running and Finished. A finished agent closes its eyes and lists what it did (Wrote script.md). A failed one turns gray. An agent sent back to work gets its card back, or a new one if its old card was already dropped. Clear drops the finished cards.

Nobody knows ahead of time how many steps a sub-agent will take, so a running agent's line creeps toward 90% as it works and fills only when it finishes. The token count is the size of the agent's context on its latest request.

Tests: claude plugin test plugins/task-hud.

followups: don't lose what Claude notices

followups: Claude asks about a bug it noticed in the question dialog; the To-do pane shows open, in-progress and done items with Start, Done and Export to

<sub>A mock-up of version 0.5.0, not a screenshot. The dialog is Claude Code's own question dialog, so it looks like the one in your app rather than exactly like this. The pane follows the mod's drawing: orange open, blue in progress, green done, 🐞 for a bug, and an item offered from a sub-agent's open questions. A dialog closed without an answer leaves the item in a band above the prompt (Add to to-do / Skip / Add all), which isn't shown here.</sub>

While Claude works, it often notices things outside the task: a flaky test, a stale doc, a bug in nearby code. Those get lost unless somebody writes them down. This mod writes them down.

/plugin install followups@claudemod
  • Claude flags what it notices. The mod gives Claude a flag tool and a short system-prompt section on when to use it: things outside the current task that need attention later. Claude doesn't fix them unless you ask.
  • A second look after each answer. Claude doesn't always remember to flag. After each of its answers (in the main conversation, 200 characters or longer), a small model (haiku) reads the answer, together with the reports any sub-agents handed back since the last answer (where the work, and its loose ends, often is), for problems mentioned but left open: something flaky, unverified, skipped or put off, or an open question or decision that wasn't settled. If it can't run (haiku not allowed on the account, an API error), one line in the transcript says so instead of it failing quietly. Up to 3 of those wait in the band above the prompt. Anything already tracked, or that you said isn't needed (remembered per project, across sessions), isn't offered again. It costs one small call per answer and never delays the end of a turn. Turn it off with the autoDetect option (claude plugin configure followups@claudemod).
  • A sub-agent's open questions, read directly. When a sub-agent's report has a section for what it left open (Unsure-about, Open questions or Open items, as the model router's routed-worker writes), each line in it is offered in the band as soon as the report comes back. This needs no model, so it works even where the after-answer look can't run. A section that says none offers nothing, and the turn's end-of-turn check doesn't stop Claude again for items already offered.
  • A check when the turn ends. If Claude's final reply says something is unverified, untested or left open (a short English and Hebrew phrase list: not verified, untested, known gap, open questions, לא נבדק, פער, שאלות פתוחות, ...; Open questions: none doesn't count; not follow-up, this mod's own word) and it never called flag that turn, the turn is stopped once with a nudge to flag each item or say there are none. It reads the reply whatever its source, so findings relayed from a subagent's report count; phrases in code blocks and inline code don't. It stops at most once per turn, never when flag was called (even if you turned the item down), and only in interactive sessions (not -p or the SDK). Limits: it is a phrase match, so a reply that leaves something open in other words is missed (the second look above still reads it), and a phrase used in passing can stop a turn needlessly. Add phrases to STOP_PHRASES in hooks/register.tsx; turn it off with the stopCheck option.
  • Claude stops and asks you about each one in the question dialog: Claude noticed: "Fix the flaky upload test" (Fails 1 in 5 runs · tests/upload.test.ts:42). Add it to your follow-ups? You answer Add to to-do or Not needed, or type your own wording, which is kept as the title. Claude gets your answer back and carries on. If you close the dialog without answering, the item waits in a band above the prompt with Add to to-do (a), Skip (s) and Add all, so it isn't lost.
  • A To-do pane lists what you kept, colored by status: orange ○ open, blue ◐ in progress, green struck-through ✓ done. A red 🐞 marks bugs (Claude passes kind: "bug" to flag for broken behavior; /followup adds a plain task). ✓ Done / ↺ Reopen toggles an item, ✕ removes it, and /followup <text> adds one by hand. The header counts open, in progress and done. The list is saved per project and comes back in the next session (lists saved by older versions are migrated). Two sessions open in the same project don't overwrite each other: each save merges with what the other saved, so items added, changed or removed in either one are kept that way. When the project has open or in-progress items, the pane opens by itself as the session starts; the openOnStart option turns that off. The list is saved on the machine running Claude Code, so it carries over between local sessions but not between cloud sessions, whose container is gone when they end: use Export to → Memory for that.
  • ▶ Start (per open item, or Start all open) marks it in progress and submits a message to Claude with the item's id, title, detail and location, asking it to do the work. When it finishes, Claude calls the done tool with that id and the item turns green. done is idempotent, rejects unknown ids, and the prompt tells Claude not to use it for unrelated work. If the message can't be sent, the items go back to open, so you can start them again. Pressing Add to to-do twice keeps the item once.
  • Export to:
ButtonWrites toWhy
MemoryCLAUDE.mdClaude reads it at the start of every session
Docsdocs/FOLLOW-UPS.mda page of its own
READMEREADME.md, under Known issues & follow-upsvisible to everyone

The export is a markdown checklist ([ ] open, [x] done, in-progress items tagged _(in progress)_, bugs _(bug)_) between <!-- followups:start --> and <!-- followups:end --> markers. Exporting again replaces that section in place, and the rest of the file is left alone.

Tests: claude plugin test plugins/followups.

rtl-toggle: Hebrew and English on one line

Toggle offToggle on
Toggle off: embedded Hebrew renders out of orderToggle on: embedded Hebrew renders in correct order

A line that starts in English but is mostly Hebrew (or Arabic, or another right-to-left script) can render out of order, with the Hebrew words shifted. This mod fixes those lines in your messages and Claude's.

/plugin install rtl-toggle@claudemod
  • A button above the prompt (עברית: פעיל / עברית: כבוי) turns the fix on or off for the session. /rtl does the same on every surface, and /rtl on, /rtl off and /rtl status set or report it.
  • What it changes: a line that starts with an English letter but has at least as many RTL words as LTR words gets one invisible leading RLM (U+200F). That gives the line a right-to-left base direction and right alignment, like a line that starts in Hebrew. Nothing else in the text changes.
  • What it leaves alone: code blocks (fenced and indented), lines that already start in Hebrew, lines with no RTL text, and inline code, links and URLs, which aren't counted. Table rows are fixed cell by cell, except in the terminal.
  • Limits: the mod API has no dir or alignment prop, so alignment comes only from the RLM. That works where the surface picks the direction per block (the desktop app). In a terminal it depends on the terminal's own bidi support. Messages already on screen keep their layout until they're drawn again.

It also lives in its own repo, Amitro123/RTL-claude-mod, which has the full rules and the performance notes.

Tests: claude plugin test plugins/rtl-toggle.

goal-run: a goal for the session

goal-run: the question a session opens with, and the Goal pane at 40%, with each task's priority and tokens

<sub>Mock-ups, not screenshots: the track and the runner are drawn by the mod's own art.ts, and the band, the pane and the dialogs follow its layout. The dialogs are Claude Code's own, so yours will look like your app's.</sub>

/plugin install goal-run@claudemod

How a goal runs

goal-run in four steps: the start-of-session question; the plan by priority in the approval dialog, with the pane waiting at 0%; the runner at 40% with each task's tokens and a to-do item offered to the goal; the runner cheering at 100%

  1. The question. When a session starts, a band above the prompt asks Is there a goal you want to work on this session?, with a field to type it. No goal this time dismisses it. If an earlier session in this project left a goal unfinished, the band offers Continue, New goal or Not now instead.
  2. The plan, by priority. Your goal goes to Claude, which breaks it into 3 to 8 tasks with the plan tool. Each task has a priority (high, medium or low) and the tasks it depends on. The plan is ordered highest priority first, except that a task comes after what it needs: tests come after the code they test, and say so (needs 1).
  3. Your approval, and the pace. The plan is put to you in a dialog, each task with its priority. Pick Approve, run through, Approve, ask between tasks, or Change it (Claude revises and asks again). Nothing starts before you approve. If you close the dialog, the plan waits in the pane with both approve buttons.
  4. Run through: Claude goes from task to task on its own. If it tries to stop with tasks left, it's sent on once; it can still stop when it's blocked or needs your decision.
  5. Ask between tasks: after each task, a dialog asks "Add tests" is done (40%). Go on to "Document the endpoint"?, with Continue or Stop here.
  6. A button in the pane switches between the two while you work.
  7. The run. The Goal pane shows the goal, a track from 0 to 100%, and a pixel Claude in blue sneakers. It stands still between tasks, runs while one is in progress, and cheers at the flag at 100%. Claude marks each task in progress and done with the update tool, and takes the first pending task in the list's current order. Each of your prompts while the goal is open carries the plan's state, so Claude keeps track of where it is.
  8. What each task cost. Every model request, sub-agents' included, is counted against the task in progress, and the pane shows each task's tokens. Planning and work between tasks have a line of their own. The count is the new tokens of each request (output, input written to the cache, uncached input). Cache reads are left out: they are the context earlier requests already counted, and counting them again on every step would grow the number far past what the task added. It is not a price.

Steering it while it runs

goal-run while it runs: the plan by priority with the pace choice; a task added mid-goal, placed by priority; the question between tasks; running through, sent on to the next task

  • Reorder. ↑ ↓ in the pane move tasks that haven't started. A task moved up takes the tasks it needs along with it, and one moved down takes the tasks that need it, so dependencies stay first. Claude works the list as you leave it.
  • Add a task. Ask Claude for more work during the goal, and it uses the add tool with a priority and what the task needs. The task goes in among the tasks that haven't started, by priority and after anything it needs. A dialog shows where it lands and what that does to the percentage (It goes in as #4, before "Document the endpoint". Progress 50% → 40%), and you choose Add it or Not now. Done and in-progress tasks stay where they are. The goal's text doesn't change, but the plan grows, so the percentage drops to match the work added.
  • Re-plan. A new plan in the middle of a goal keeps the done tasks and their tokens.
  • Work from followups' to-do list. Pressing ▶ Start on a to-do item while a goal is open is a side job. Claude gets no goal reminder with it, so the two don't compete, and its tokens show on a line of their own, Outside the goal. The band above the prompt asks whether to Add to goal, showing what that does to the plan (grows to 6 tasks: 40% → 33%), or Keep it separate. Added, the item becomes a task in progress, carrying the tokens it used so far, and a bug comes in as high priority. It turns done when followups marks the item done. The side job ends when you send a prompt of your own.

Commands and options

  • /session-goal <text> sets a goal at any time, /session-goal shows it, and /session-goal clear drops it. Claude Code has its own /goal, so the mod's command has a different name.
  • The goal is saved per project on the machine running Claude Code, like followups' list, so it carries over between local sessions in the same folder. If two sessions in the folder each set a goal, the one set later is the one saved. The other session is told once, with a toast, that its progress isn't saved.
  • askOnStart (on / off) turns the start-of-session question off.
  • A plan from factory. When the factory's spec is put to you as the goal's plan, the dialog is titled Factory plan and offers Approve, start building or Change it. Approved, the factory's builder does the tasks and marks them, and the main Claude is told not to: there's no goal reminder on your prompts, no nudge at a stop and no question between tasks. Changes go back to the factory's spec-writer. Approving later in the pane runs /factory plan-approved. If you already have a goal of your own that isn't done, it stays: the factory's plan isn't set as the goal, and the factory asks for approval in its own band. A factory plan starts fresh, without an earlier goal's done tasks.

Tests: claude plugin test plugins/goal-run.

brain-hud: the agent's memory, made visible

brain-hud: three of the scenes above the prompt, an offer from followups, and the Brain pane with the memory drawn as a knowledge graph

<sub>The scenes and the brain map are the mod's own SVG output; the windows around them are mock-ups. The data is a real project's memory, with one new lesson just routed into Projects (✦).</sub>

brain-hud gives Claude a memory that lasts between sessions, and shows it. The mistake-brain skill ships inside it, with its hooks. Claude logs what happened to MISTAKES.md (a mistake, a success, a decision or a handoff). It routes those entries into PARA folders under .agent-brain/shared/ (Projects, Areas, Resources, Archives), promotes a lesson that repeats into a rule (with your approval), and now and then reorganizes the memory ("dream"). The mod shows that memory as it grows. Nothing else needs installing.

/plugin install brain-hud@claudemod

Then, in each project that should remember: /brain-hud:mistake-brain init, or Set up memory in the Brain pane (/brain). It creates MISTAKES.md, the PARA folders, the index .agent-brain/shared/MEMORY.md and the standing rules. A CLAUDE.md tracked in git is left as it is; the instruction goes into .claude/rules/mistake-brain.md.

The skill, built in

  • The command is /brain-hud:mistake-brain w
Source 3 files
hooks/register.tsx 650 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Flag, Item, Status } from '../types'
5import { doneResult, startLine } from './texts'
6
7// ---------- state ----------
8
9const pending = atom({ plugin: 'followups', key: 'pending' } as const, [] as Flag[])
10const items = atom({ plugin: 'followups', key: 'items' } as const, [] as Item[])
11const exported = atom({ plugin: 'followups', key: 'exported' } as const, '')
12const skipped = atom({ plugin: 'followups', key: 'skipped' } as const, [] as string[])
13// The list as this session last loaded or saved it, to merge with another session's saves.
14const base = atom({ plugin: 'followups', key: 'base' } as const, [] as Item[])
15
16const PANE = 'followups'
17const PANE_TITLE = 'To-do'
18const TOOL = 'mcp__followups__flag'
19const DONE_TOOL = 'mcp__followups__done'
20
21// One accent for the header; status colors for the rows (all read on light and dark themes).
22const ACCENT = '#4f7cff'
23const COLOR = { open: '#e08a1e', in_progress: '#2a9fd6', done: '#1f9d6b', bug: '#e5484d' } as const
24const MARK = { open: '○', in_progress: '◐', done: '✓' } as const
25
26// The model reads this in every request: when to flag, and when not to.
27const GUIDANCE = `# Follow-ups
28While you work you may notice things outside the task at hand that need attention later: a bug or broken behavior in nearby code, a failing or flaky test you didn't cause, a missing or stale doc, a TODO/FIXME, a security or performance concern, a config that looks wrong, or a step you skipped. The person tends to lose these if they aren't written down.
29Before you end a turn, look back over it: anything you noticed, worked around, left unverified or put off for later that the person would want to come back to gets flagged now, even when you also mention it in your reply. Findings in a subagent's or worker's report count too: flag them before your final reply.
30When you notice one, call ${TOOL} once for it, with a short imperative title ("Fix the flaky upload test") and a one-line detail; add \`where\` (file:line, command, URL) when there is one. Set kind to "bug" when the thing is broken behavior (a bug, a failing test, a crash, wrong output); leave kind out for ordinary tasks. The tool stops and asks the person in a dialog whether to keep it and returns their answer, so don't ask them yourself. Don't fix it unless they asked, and keep going with the task. Don't flag the task you were asked to do, its normal next steps, or something you are about to fix anyway.
31When a message says it started a to-do item and gives its id, do that work, and when it is finished call ${DONE_TOOL} with that id (and a short note of what you did). Call ${DONE_TOOL} only for items started that way; never for unrelated work, and not before the work is actually done.`
32
33// Words that mark a reply as leaving something open. Narrow on purpose (a false stop costs a turn),
34// so extend it one phrase at a time. Left out as too common: "בהמשך" ("later", but also "following
35// that") and "follow-up" / "follow up" (this mod's own word, in nearly every reply about it).
36export const STOP_PHRASES = [
37  'not verified', 'unverified', 'not tested', 'untested', 'no coverage', 'left open', 'still open', 'known gap',
38  'open question', 'open questions', 'unsure about', 'unsure-about',
39  'לא אומת', 'לא נבדק', 'לא נבדקו', 'פער', 'פריט המשך', 'שאלה פתוחה', 'שאלות פתוחות',
40]
41export const STOP_MESSAGE = 'You mentioned items that need follow-up. Call mcp__followups__flag for each, or say there are none.'
42
43// What the after-answer check asks a small model: what in this answer was left open.
44export const SWEEP_RULES = `You read the final reply an AI coding assistant just gave, with any reports its sub-agents handed it this turn, and list the follow-ups in them: problems or loose ends mentioned but left open, which the person could lose track of.
45Count: a bug or broken behavior, a failing or flaky test, a missing or stale doc, a TODO or FIXME, a security or performance concern, a config that looks wrong, a step that was skipped, something left unverified or untested, something put off for later, or an open question or decision that came up and wasn't settled.
46Don't count: the work that was done, a yes/no question about going ahead with the very next step, an offer to do the next step now, or anything in the already-tracked list.
47Answer with a JSON array only, at most 3 entries, each {"title": "short imperative, under 80 characters", "detail": "one line, under 160 characters", "where": "file:line, command or URL, or empty", "kind": "bug" or "task"}. Answer [] when there is nothing.`
48// Answers shorter than this rarely leave anything open; skipping them saves a call per short turn.
49const SWEEP_MIN_CHARS = 200
50
51// Where each export goes, and the heading its section gets there.
52const TARGETS = {
53  memory: { label: 'Memory', path: 'CLAUDE.md', heading: '## Open follow-ups', note: 'Claude reads this at the start of every session.' },
54  docs: { label: 'Docs', path: 'docs/FOLLOW-UPS.md', heading: '# Follow-ups', note: '' },
55  readme: { label: 'README', path: 'README.md', heading: '## Known issues & follow-ups', note: '' },
56} as const
57type Target = keyof typeof TARGETS
58
59const START = '<!-- followups:start -->'
60const END = '<!-- followups:end -->'
61
62// ---------- helpers ----------
63
64const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
65const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ')
66export const isStatus = (s: unknown): s is Status => s === 'open' || s === 'in_progress' || s === 'done'
67
68// Items saved before statuses existed carry isDone; map them to a status.
69export function migrate(saved: unknown): Item[] {
70  if (!Array.isArray(saved)) return []
71  return saved.map((raw: Record<string, unknown>) => {
72    const { isDone, ...rest } = raw
73    const status: Status = isStatus(raw.status) ? raw.status : isDone === true ? 'done' : 'open'
74    return { ...rest, status, kind: raw.kind === 'bug' ? 'bug' : 'task' } as Item
75  })
76}
77
78// The reply without fenced code blocks (an unclosed fence runs to the end) and inline code.
79export function prose(text: string): string {
80  return outsideFences(text).join('\n').replace(/``[^\n]*?``/g, ' ').replace(/`[^`\n]*`/g, ' ')
81}
82
83// The lines of a reply outside fenced code blocks (an unclosed fence runs to the end).
84function outsideFences(text: string): string[] {
85  const out: string[] = []
86  let fence = ''
87  for (const line of text.split('\n')) {
88    const m = /^\s*(`{3,}|~{3,})/.exec(line)
89    if (fence) {
90      if (m && m[1]!.startsWith(fence[0]!) && m[1]!.length >= fence.length) fence = ''
91    } else if (m) fence = m[1]!
92    else out.push(line)
93  }
94  return out
95}
96
97// The names a report gives what it leaves open (the model router's routed-worker writes Unsure-about,
98// with its Open questions there), and the other sections a label heading may name.
99const OPEN_SECTIONS = ['open questions', 'open question', 'open items', 'unsure-about', 'unsure about', 'שאלות פתוחות']
100const LABEL_SECTIONS = [...OPEN_SECTIONS, 'findings', 'files', 'changes', 'summary', 'notes', 'next steps', 'deliverable']
101const NONE = /^(?:none|nothing|n\/?a|no|אין|ללא)\.?$/iu
102// "Open questions: none" says nothing is open: the stop check reads past it.
103const OPEN_NONE = /(?:open questions?|open items|unsure[- ]about|שאלות פתוחות|שאלה פתוחה)\**\s*[:—-]?\s*\**\s*(?:none|nothing|n\/?a|אין|ללא)(?![\p{L}\p{N}])\.?/giu
104
105// A section heading: "## Name" (any name), or a label of a known section ("**Name:**", "Name:", "3. Name:"),
106// with whatever follows its colon on the same line.
107function headingOf(line: string): { name: string; rest: string } | null {
108  const hash = /^\s*#{1,6}\s+\**([^*:]+?)\**\s*:?\s*$/u.exec(line)
109  if (hash) return { name: hash[1]!.trim().toLowerCase(), rest: '' }
110  const label = /^\s*(?:\d+\.\s+)?\**([\p{L}][\p{L} -]{1,30}?)\**\s*:\**\s*(.*)$/u.exec(line)
111  if (label && LABEL_SECTIONS.includes(label[1]!.trim().toLowerCase())) {
112    return { name: label[1]!.trim().toLowerCase(), rest: label[2]!.replace(/\*+$/, '').trim() }
113  }
114  return null
115}
116
117// The items under a report's open-questions section, one per bullet or numbered line (or the text
118// after its label); "none" and its kin give nothing. At most 5.
119export function openItems(text: string): string[] {
120  const out: string[] = []
121  const add = (raw: string) => {
122    const item = raw.replace(/^\s*(?:[-*•]|\d+[.)])\s+/, '').replace(/\*\*/g, '').trim()
123    if (item && !NONE.test(item)) out.push(clip(item, 200))
124  }
125  let isOpen = false
126  for (const line of outsideFences(text)) {
127    const h = headingOf(line)
128    if (h) {
129      isOpen = OPEN_SECTIONS.includes(h.name)
130      if (isOpen && h.rest) add(h.rest)
131    } else if (isOpen && line.trim()) add(line)
132  }
133  return out.slice(0, 5)
134}
135
136// Phrases as whole words in the prose of a reply, any case. Subagent reports relayed in it count too.
137export function leftOpen(text: string, phrases: string[] = STOP_PHRASES): string[] {
138  const body = prose(text).replace(OPEN_NONE, ' ')
139  return phrases.filter(p => {
140    const src = p.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+')
141    return !!src && new RegExp(`(?<![\\p{L}\\p{N}_])${src}(?![\\p{L}\\p{N}_])`, 'iu').test(body)
142  })
143}
144
145export function sweepPrompt(answer: string, known: string[], reports: string[] = []): string {
146  return [
147    'Already tracked (skip these):',
148    ...(known.length ? known.map(t => `- ${t}`) : ['- (none)']),
149    '',
150    ...reports.flatMap(r => ['A sub-agent\'s report:', '<report>', r.slice(-6_000), '</report>', '']),
151    'The reply:',
152    '<reply>',
153    answer.slice(-12_000),
154    '</reply>',
155  ].join('\n')
156}
157
158// The small model's answer, read leniently: the first JSON array in it, only well-formed entries.
159export function parseFindings(text: string): Omit<Flag, 'id' | 'at'>[] {
160  const a = text.indexOf('[')
161  const b = text.lastIndexOf(']')
162  if (a === -1 || b < a) return []
163  let raw: unknown
164  try {
165    raw = JSON.parse(text.slice(a, b + 1))
166  } catch {
167    return []
168  }
169  if (!Array.isArray(raw)) return []
170  return raw
171    .filter((f): f is Record<string, unknown> => !!f && typeof f === 'object' && typeof (f as { title?: unknown }).title === 'string')
172    .map(f => ({
173      title: clip(String(f.title).trim(), 120),
174      detail: clip(String(f.detail ?? '').trim(), 300),
175      where: clip(String(f.where ?? '').trim(), 160),
176      kind: f.kind === 'bug' ? ('bug' as const) : ('task' as const),
177    }))
178    .filter(f => f.title)
179    .slice(0, 3)
180}
181
182// Everything already flagged, kept or turned down, normalised for comparing titles.
183async function knownTitles($: EngineInterface): Promise<Set<string>> {
184  const all = [...(await read($, pending)), ...(await read($, items))].map(f => norm(f.title))
185  return new Set([...all, ...(await read($, skipped))])
186}
187
188// Reads a finished answer for loose ends Claude mentioned but didn't flag, and offers them in the band.
189// The look runs quietly; when it can't run at all, the person is told once per session.
190let isWarned = false
191function warn($: EngineInterface, why: string) {
192  if (isWarned) return
193  isWarned = true
194  $.ui.log(`followups: the look for loose ends after each answer couldn't run (${why}), so they aren't offered above the prompt. Claude's own flags and the end-of-turn check still work.`)
195}
196
197async function sweep($: EngineInterface, answer: string, reports: string[]) {
198  const known = await knownTitles($)
199  let r
200  try {
201    r = await $.model.complete({ model: 'haiku', system: SWEEP_RULES, prompt: sweepPrompt(answer, [...known], reports), maxTokens: 600, effort: 'low', timeoutMs: 20_000 })
202  } catch (err) {
203    return warn($, err instanceof Error ? err.message : String(err))
204  }
205  if (!r.isAnswered) {
206    // An interrupted look (the person moved on) isn't a fault worth a line.
207    if (r.reason !== 'aborted') warn($, r.reason === 'api-error' ? `api-error ${r.status ?? ''}`.trim() : r.reason)
208    return
209  }
210  const fresh = await knownTitles($)
211  const found = parseFindings(r.text).filter(f => !fresh.has(norm(f.title)))
212  if (!found.length) return
213  const at = await $.clock.now()
214  await update($, pending, l => [...l, ...found.map(f => ({ ...f, id: newId(at), at }))])
215  $.ui.toast(found.length === 1 ? 'Claude noticed a possible follow-up: see above the prompt' : `Claude noticed ${found.length} possible follow-ups: see above the prompt`)
216}
217
218// A sub-agent's report that names open questions has them offered in the band at once: read
219// straight from its section, no model, so it works where the after-answer look can't run.
220// Resolves how many it offered.
221async function offerOpenItems($: EngineInterface, report: string): Promise<number> {
222  const known = await knownTitles($)
223  const fresh = openItems(report).filter(t => !known.has(norm(clip(t, 120))))
224  if (!fresh.length) return 0
225  const at = await $.clock.now()
226  const flags: Flag[] = fresh.map(t => ({ id: newId(at), kind: 'task', title: clip(t, 120), detail: "From a sub-agent's open questions", where: '', at }))
227  await update($, pending, l => [...l, ...flags])
228  $.ui.toast(fresh.length === 1 ? 'A sub-agent left an open question: see above the prompt' : `A sub-agent left ${fresh.length} open questions: see above the prompt`)
229  return fresh.length
230}
231
232const newId = (at: number) => `${at.toString(36)}-${Math.random().toString(36).slice(2, 7)}`
233
234// Exports and the saved list belong to the project, so both key on its root.
235const storeKey = async ($: EngineInterface) => `items:${await $.session.root()}`
236
237// A three-way merge with what another session in the same project saved meanwhile. `base` is the
238// list as this session last loaded or saved it: what changed here since wins, what changed only
239// there is taken, and an item either side deleted stays deleted.
240export function mergeLists<T extends { id: string }>(base: T[], mine: T[], stored: T[]): T[] {
241  const was = new Map(base.map(i => [i.id, JSON.stringify(i)]))
242  const there = new Map(stored.map(i => [i.id, i]))
243  const held = new Set(mine.map(i => i.id))
244  const kept = mine.flatMap(i => {
245    if (was.get(i.id) !== JSON.stringify(i)) return [i]
246    const other = there.get(i.id)
247    return other ? [other] : []
248  })
249  return [...kept, ...stored.filter(i => !held.has(i.id) && !was.has(i.id))]
250}
251
252async function save($: EngineInterface) {
253  const key = await storeKey($)
254  const saved = await $.store.get(key)
255  const from = await read($, base)
256  const stored = Array.isArray(saved) ? migrate(saved) : from
257  const merged = await update($, items, mine => mergeLists(from, mine, stored))
258  await $.store.set(key, merged)
259  await update($, base, () => merged)
260}
261
262// What the person turned down, kept per project like the list, so it isn't offered again later.
263const skippedKey = async ($: EngineInterface) => `skipped:${await $.session.root()}`
264
265async function turnDown($: EngineInterface, title: string) {
266  const t = norm(title)
267  // Another session in the folder may have turned things down meanwhile: the list only grows, so join them.
268  const key = await skippedKey($)
269  const saved = await $.store.get(key)
270  const there = Array.isArray(saved) ? saved.filter((s): s is string => typeof s === 'string') : []
271  const list = await update($, skipped, l => [...new Set([...there, ...l, t])])
272  await $.store.set(key, list)
273}
274
275const ADD = 'Add to to-do'
276const SKIP = 'Not needed'
277
278// Stops the turn on the engine's own question dialog and answers the tool with what the person
279// chose. Typing under "Other" keeps it under their wording. A dismissed dialog (or a run with
280// no one to ask) leaves the flag in the band above the prompt, so it is never lost.
281async function ask($: EngineInterface, flag: Flag): Promise<string> {
282  const extra = [flag.detail, flag.where].filter(Boolean).join(' · ')
283  let answer: string
284  try {
285    answer = await $.ui.ask(`Claude noticed${flag.kind === 'bug' ? ' a bug' : ''}: "${flag.title}"${extra ? ` (${extra})` : ''}. Add it to your follow-ups?`, {
286      header: 'Follow-up',
287      options: [ADD, SKIP],
288    })
289  } catch {
290    answer = ''
291  }
292  if (!answer.trim()) {
293    await update($, pending, list => [...list, flag])
294    return `The person didn't answer; "${flag.title}" waits above the prompt for them. Carry on with the task.`
295  }
296  if (answer === SKIP) {
297    await turnDown($, flag.title)
298    return `The person skipped "${flag.title}". Don't flag or raise it again. Carry on with the task.`
299  }
300  const kept = answer === ADD ? flag : { ...flag, title: clip(answer.trim(), 120) }
301  await keep($, [kept])
302  return `The person added "${kept.title}" to their follow-ups. Don't fix it now unless they ask. Carry on with the task.`
303}
304
305async function keep($: EngineInterface, flags: Flag[]) {
306  if (!flags.length) return
307  const ids = new Set(flags.map(f => f.id))
308  await update($, pending, list => list.filter(f => !ids.has(f.id)))
309  // By id, inside the update: two presses before a redraw both carry the same flag.
310  await update($, items, list => {
311    const have = new Set(list.map(i => i.id))
312    return [...list, ...flags.filter(f => !have.has(f.id)).map(f => ({ ...f, kind: f.kind ?? 'task', status: 'open' as const }))]
313  })
314  await save($)
315  void $.ui.open({ id: PANE, title: PANE_TITLE })
316}
317
318// The message that starts an item: names it, carries the id, and says how to report back.
319export function startPrompt(list: Item[]): string {
320  return [
321    list.length === 1 ? 'Start this item from my to-do list:' : `Start these ${list.length} items from my to-do list, one after the other:`,
322    ...list.map(startLine),
323    `Do the work. When an item is finished, call ${DONE_TOOL} with its id (and a short note). Don't call it for anything else.`,
324  ].join('\n')
325}
326
327async function setStatus($: EngineInterface, ids: Set<string>, status: Status) {
328  await update($, items, l => l.map(i => (ids.has(i.id) ? { ...i, status } : i)))
329  await save($)
330}
331
332async function start($: EngineInterface, which: Item[]) {
333  if (!which.length) return
334  await setStatus($, new Set(which.map(i => i.id)), 'in_progress')
335  // A turn of its own once the session is idle; it may not resolve until then, so not awaited. If
336  // it can't be sent, the items go back to open, where their Start button is.
337  $.prompt.submit({ text: startPrompt(which) }).catch(() => setStatus($, new Set(which.map(i => i.id)), 'open'))
338}
339
340// The markdown an export writes: the items as a checklist between two markers.
341export function section(target: Target, list: Item[]): string {
342  const t = TARGETS[target]
343  const lines = list.map(i => {
344    const extra = [i.detail, i.where && `\`${i.where}\``].filter(Boolean).join(' · ')
345    const tags = `${i.kind === 'bug' ? ' _(bug)_' : ''}${i.status === 'in_progress' ? ' _(in progress)_' : ''}`
346    return `- [${i.status === 'done' ? 'x' : ' '}] ${i.title}${tags}${extra ? ` — ${extra}` : ''}`
347  })
348  return [START, t.heading, '', ...(t.note ? [`_${t.note}_`, ''] : []), ...lines, END].join('\n')
349}
350
351// Puts the section in the file: in place of an earlier export, else at the end. Only a START…END
352// pair counts (END after START); a stray END before it is ignored, so a re-export stays one block.
353export function upsert(text: string, block: string): string {
354  const first = text.indexOf(START)
355  const b = first === -1 ? -1 : text.indexOf(END, first + START.length)
356  const a = b === -1 ? -1 : text.lastIndexOf(START, b)
357  if (a !== -1 && b !== -1) return text.slice(0, a) + block + text.slice(b + END.length)
358  return text.trim() ? `${text.replace(/\s*$/, '')}\n\n${block}\n` : `${block}\n`
359}
360
361async function exportTo($: EngineInterface, target: Target) {
362  const list = await read($, items)
363  const t = TARGETS[target]
364  if (!list.length) return update($, exported, () => 'Nothing to export yet.')
365  try {
366    const path = `${(await $.session.root()).replace(/[\\/]$/, '')}/${t.path}`
367    const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
368    await $.fs.write(path, upsert(typeof before === 'string' ? before : '', section(target, list)))
369    await update($, exported, () => `Exported ${list.length} to ${t.path}`)
370    $.ui.toast(`Follow-ups exported to ${t.path}`)
371  } catch (err) {
372    await update($, exported, () => `Couldn't write ${t.path}: ${err instanceof Error ? err.message : String(err)}`)
373  }
374}
375
376export const register: Register = (on, options) => {
377  const autoDetect = String(options.autoDetect ?? 'on') !== 'off'
378  const openOnStart = String(options.openOnStart ?? 'on') !== 'off'
379  const stopCheck = String(options.stopCheck ?? 'on') !== 'off'
380  // The module's own, per turn: a person is at the prompt, flag was called, the check already stopped once.
381  let isInteractive = false
382  let isFlagged = false
383  let isBlocked = false
384  // Sub-agents' final reports since the main loop's last answer: the work, and its loose ends, is often there.
385  let reports: string[] = []
386
387  on('session.start', async ($, e, next) => {
388    isInteractive = e.isInteractive
389    await $.tool.register({
390      name: 'flag',
391      description: 'Flag something you noticed that needs attention later, outside the current task: a bug or broken behavior, a failing or flaky test, a missing or stale doc, a TODO/FIXME, a security or performance concern, a step you skipped, or something you left unverified or put off. Call it as soon as you notice one (once per item), and before ending a turn for any you mention in your reply; the person tends to lose these otherwise. Not for the task itself or its normal next steps. The person is asked in a dialog whether to add it to their follow-up to-do list; the result is their answer.',
392      inputSchema: {
393        type: 'object',
394        properties: {
395          title: { type: 'string', description: 'Short imperative title, e.g. "Fix the flaky upload test"' },
396          detail: { type: 'string', description: 'One line: what is wrong and why it matters' },
397          where: { type: 'string', description: 'Optional file:line, command or URL' },
398          kind: { type: 'string', enum: ['bug', 'task'], description: 'bug for broken behavior; task (default) for anything else' },
399        },
400        required: ['title'],
401      },
402      isDeferred: false,
403    })
404    await $.tool.register({
405      name: 'done',
406      description: 'Mark a to-do item finished. Call it when you finish an item the person started from their to-do list (the message gave its id), never for unrelated work.',
407      inputSchema: {
408        type: 'object',
409        properties: {
410          id: { type: 'string', description: 'The item id from the start message' },
411          note: { type: 'string', description: 'Optional: what you did' },
412        },
413        required: ['id'],
414      },
415      isDeferred: false,
416    })
417    await $.command.register({ name: 'followups', description: 'Show the follow-up to-do list' })
418    await $.command.register({ name: 'followup', description: 'Add a follow-up to the to-do list', argumentHint: '<what to do>' })
419    // $.state outlives a reload, so a list an older version held is migrated in place too.
420    const held = await read($, items)
421    if (held.some(i => !isStatus((i as { status?: unknown }).status))) await update($, items, l => migrate(l))
422    // Items kept in this folder before carry over to a new session.
423    if (!held.length) {
424      const saved = await $.store.get(await storeKey($))
425      if (Array.isArray(saved) && saved.length) {
426        const list = migrate(saved)
427        await update($, items, () => list)
428        await update($, base, () => list)
429      }
430    }
431    const turnedDown = await $.store.get(await skippedKey($))
432    if (Array.isArray(turnedDown)) {
433      const fromStore = turnedDown.filter((t): t is string => typeof t === 'string')
434      await update($, skipped, l => [...new Set([...fromStore, ...l])])
435    }
436    // Open work left from an earlier session is shown, so it isn't forgotten.
437    if (openOnStart && (await read($, items)).some(i => i.status !== 'done')) void $.ui.open({ id: PANE, title: PANE_TITLE })
438    return next(e)
439  })
440
441  on('prompt.compose', async ($, e, next) => {
442    const r = await next(e)
443    if (e.traits.includes('bare')) return r
444    return { sections: [...r.sections, { id: 'followups:guidance', text: GUIDANCE, scope: 'session' as const }] }
445  })
446
447  // A new turn from the person (a continuation after a stop has no text) starts the check afresh.
448  on('turn.start', async ($, e, next) => {
449    if (e.agentId === undefined && e.text !== '') {
450      isFlagged = false
451      isBlocked = false
452    }
453    return next(e)
454  })
455
456  on('tool.call', { tool: TOOL }, async ($, e) => {
457    isFlagged = true // any call counts, even one the person turns down
458    const input = e as unknown as { title?: unknown; detail?: unknown; where?: unknown; kind?: unknown }
459    const title = clip(String(input.title ?? '').trim(), 120)
460    if (!title) return { deny: 'A follow-up needs a title.' }
461    if ((await read($, skipped)).includes(norm(title))) {
462      return { result: `The person already said "${title}" isn't needed. Don't raise it again.` }
463    }
464    const known = [...(await read($, pending)), ...(await read($, items))]
465    if (known.some(f => norm(f.title) === norm(title))) {
466      return { result: `Already flagged: "${title}". No need to flag it again.` }
467    }
468    const at = await $.clock.now()
469    const flag: Flag = {
470      id: newId(at),
471      kind: input.kind === 'bug' ? 'bug' : 'task',
472      title,
473      detail: clip(String(input.detail ?? '').trim(), 300),
474      where: clip(String(input.where ?? '').trim(), 160),
475      at,
476    }
477    return { result: await ask($, flag) }
478  })
479
480  on('tool.call', { tool: DONE_TOOL }, async ($, e) => {
481    const input = e as unknown as { id?: unknown; note?: unknown }
482    const ref = String(input.id ?? '').trim()
483    const list = await read($, items)
484    const item = list.find(i => i.id === ref) ?? list.find(i => ref && norm(i.title) === norm(ref))
485    if (!item) return { deny: `No to-do item with id "${ref}". Use the id from the start message.` }
486    if (item.status === 'done') return { result: `"${item.title}" is already done. Nothing to change.` }
487    await setStatus($, new Set([item.id]), 'done')
488    const note = clip(String(input.note ?? '').trim(), 200)
489    $.ui.toast(`Done: ${item.title}`)
490    return { result: doneResult(item.title, note) }
491  })
492
493  // After a main-loop answer, look it over for loose ends Claude didn't flag. Run from a timer so
494  // the end of the turn never waits on the extra model call.
495  on('turn.complete', async ($, e, next) => {
496    const r = await next(e)
497    if (e.agentId !== undefined) {
498      if (e.reason === 'answer' && e.answer.trim()) {
499        reports = [...reports, e.answer.trim()].slice(-4)
500        // Offered in the band: the stop check needn't send Claude back to flag them again.
501        if (await offerOpenItems($, e.answer)) isFlagged = true
502      }
503      return r
504    }
505    const taken = reports
506    reports = []
507    // A turn the Stop check sent back has had Claude go over its own reply: a second reading would
508    // only offer the same loose ends again under other titles.
509    const chars = [e.answer, ...taken].join('\n').trim().length
510    if (autoDetect && !isBlocked && e.reason === 'answer' && chars >= SWEEP_MIN_CHARS) {
511      const answer = e.answer
512      $.clock.after(0, () => sweep($, answer, taken).catch(err => warn($, err instanceof Error ? err.message : String(err))))
513    }
514    return r
515  })
516
517  // The end of the main loop's turn: when the reply names something left open and flag wasn't called,
518  // stop it once with a nudge. classic.Stop is the one event that can send Claude back to work
519  // (`block`); turn.complete only reads. Without a person at the prompt, nobody could answer, so no check.
520  on('classic.Stop', async ($, e, next) => {
521    const r = await next(e)
522    if (!stopCheck || !isInteractive || isFlagged || isBlocked || e.stop_hook_active || r.block) return r
523    if (!leftOpen(e.last_assistant_message ?? '').length) return r
524    isBlocked = true
525    return { ...r, block: STOP_MESSAGE }
526  }).catch(($, e, next) => (next.called ? next(e) : {}))
527
528  on('command.run', { command: 'followups' }, async $ => {
529    await $.ui.open({ id: PANE, title: PANE_TITLE })
530    return { text: 'Follow-ups pane opened.' }
531  })
532
533  on('command.run', { command: 'followup' }, async ($, e) => {
534    const title = clip(e.args.trim(), 120)
535    if (!title) return { text: 'Usage: /followup <what to do>' }
536    const tracked = (await read($, items)).find(i => norm(i.title) === norm(title))
537    if (tracked) return { text: `Already in your follow-ups: "${tracked.title}".` }
538    // A flag still waiting in the band is what the person means: keep it, detail and all.
539    const waiting = (await read($, pending)).find(f => norm(f.title) === norm(title))
540    if (waiting) {
541      await keep($, [waiting])
542      return { text: `Added to follow-ups: ${waiting.title}` }
543    }
544    const at = await $.clock.now()
545    await keep($, [{ id: newId(at), kind: 'task', title, detail: '', where: '', at }])
546    return { text: `Added to follow-ups: ${title}` }
547  })
548
549  // ---------- the band: Claude asks ----------
550
551  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
552    const list = await read($, pending)
553    const flag = list[0]
554    if (e.props.hasSurvey || !flag) return next(e)
555    const { Box, Button, Text } = $.ui.resolve(e)
556    const below = await next(e)
557    const more = list.length - 1
558    return (
559      <Box flexDirection="column">
560        <Box flexDirection="column" paddingX={1}>
561          <Box flexDirection="row" gap={1}>
562            <Text color="#e08a1e" bold>◆ Claude noticed something. Track it?</Text>
563            {more ? <Text dimColor>{`(+${more} more)`}</Text> : null}
564          </Box>
565          <Text bold wrap="truncate-end">{`  ${flag.title}`}</Text>
566          {flag.detail ? <Text dimColor wrap="truncate-end">{`  ${flag.detail}`}</Text> : null}
567          {flag.where ? <Text dimColor wrap="truncate-end">{`  ${flag.where}`}</Text> : null}
568          <Box flexDirection="row" gap={1}>
569            <Button key="add" label="Add to to-do" hotkey="a" variant="primary" onPress={() => keep($, [flag])} />
570            <Button key="skip" label="Skip" hotkey="s" onPress={async () => {
571              await update($, pending, l => l.filter(f => f.id !== flag.id))
572              await turnDown($, flag.title)
573            }} />
574            {more ? <Button key="add-all" label={`Add all ${list.length}`} onPress={async () => keep($, await read($, pending))} /> : null}
575          </Box>
576        </Box>
577        {below}
578      </Box>
579    )
580  })
581
582  // ---------- the pane: the to-do and its export ----------
583
584  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
585    const { Box, Button, Text } = $.ui.resolve(e)
586    const list = await read($, items)
587    const note = await read($, exported)
588    const count = (st: Status) => list.filter(i => i.status === st).length
589    const [open, doing, done] = [count('open'), count('in_progress'), count('done')]
590    const toggle = (id: string) => async () => {
591      await update($, items, l => l.map(i => (i.id === id ? { ...i, status: i.status === 'done' ? ('open' as const) : ('done' as const) } : i)))
592      await save($)
593    }
594    const remove = (id: string) => async () => {
595      await update($, items, l => l.filter(i => i.id !== id))
596      await save($)
597    }
598
599    return (
600      <Box flexDirection="column" gap={1}>
601        <Box flexDirection="row" gap={2}>
602          <Text bold color={ACCENT}>☑ To-do</Text>
603          <Text dimColor>{list.length ? `${open} open · ${doing} in progress · ${done} done` : ''}</Text>
604          {open > 1 ? <Button key="start-all" label="▶ Start all open" plain onPress={() => start($, list.filter(i => i.status === 'open'))} /> : null}
605        </Box>
606        {list.length ? (
607          <Box flexDirection="column">
608            {list.map(i => (
609              <Box key={`row-${i.id}`} flexDirection="column">
610                <Box flexDirection="row" justifyContent="space-between" gap={1}>
611                  <Box flexDirection="row" gap={1}>
612                    {i.kind === 'bug' ? <Text color={COLOR.bug} bold>🐞</Text> : null}
613                    <Text color={COLOR[i.status]}>{MARK[i.status]}</Text>
614                    <Text color={COLOR[i.status]} dimColor={i.status === 'done'} strikethrough={i.status === 'done'} wrap="truncate-end">{i.title}</Text>
615                  </Box>
616                  <Box flexDirection="row" gap={1}>
617                    {i.status === 'open' ? <Button key={`s-${i.id}`} label="▶ Start" plain onPress={() => start($, [i])} /> : null}
618                    <Button key={`t-${i.id}`} label={i.status === 'done' ? '↺ Reopen' : '✓ Done'} plain onPress={toggle(i.id)} />
619                    <Button key={`x-${i.id}`} label="✕" plain onPress={remove(i.id)} />
620                  </Box>
621                </Box>
622                {i.status !== 'done' && (i.detail || i.where) ? (
623                  <Text dimColor wrap="truncate-end">{`   ${[i.detail, i.where].filter(Boolean).join(' · ')}`}</Text>
624                ) : null}
625              </Box>
626            ))}
627          </Box>
628        ) : (
629          <Text dimColor>Nothing tracked yet. When Claude spots something, it asks above the prompt. /followup adds one by hand.</Text>
630        )}
631        <Box flexDirection="column">
632          <Box flexDirection="row" gap={1} flexWrap="wrap">
633            <Text bold>Export to</Text>
634            {(Object.keys(TARGETS) as Target[]).map(t => (
635              <Button key={`export-${t}`} label={TARGETS[t].label} onPress={() => exportTo($, t)} />
636            ))}
637            {list.some(i => i.status === 'done') ? (
638              <Button key="clear-done" label="Clear done" plain onPress={async () => {
639                await update($, items, l => l.filter(i => i.status !== 'done'))
640                await save($)
641              }} />
642            ) : null}
643          </Box>
644          <Text dimColor>{note || 'Memory → CLAUDE.md · Docs → docs/FOLLOW-UPS.md · README → README.md'}</Text>
645        </Box>
646      </Box>
647    )
648  })
649}
650
hooks/texts.ts 68 lines
1// GENERATED from shared/texts.ts by tools/sync-texts.mjs: edit that file, not this one.
2// Wording one mod writes and another reads: followups' Start prompt and done result, goal-run's
3// start prompt, reached-goal toast and result. Edit it here, then run `node tools/sync-texts.mjs`:
4// a mod can import nothing outside its own folder, so each one keeps a copy in hooks/texts.ts.
5
6export type StartItem = { id: string; title: string; isBug?: boolean }
7
8// followups: one line per item it hands Claude when the person presses Start.
9export const startLine = (i: { id: string; title: string; kind?: string; detail?: string; where?: string }) =>
10  `- id ${i.id}: ${i.title}${i.kind === 'bug' ? ' (bug)' : ''}${i.detail ? ` — ${i.detail}` : ''}${i.where ? ` [where: ${i.where}]` : ''}`
11
12export function parseStartPrompt(text: string): StartItem[] {
13  const out: StartItem[] = []
14  for (const line of text.split('\n')) {
15    const m = /^- id (\S+): (.+?)( \(bug\))?(?: — .*?)?(?: \[where: .*\])?$/.exec(line.trim())
16    if (m) out.push({ id: m[1]!, title: m[2]!.trim().slice(0, 120), ...(m[3] ? { isBug: true } : {}) })
17  }
18  return out
19}
20
21// A tool's answer as text, however it arrives: a string, MCP content blocks, or `{ content }`
22// (what a classic PostToolUse hook gets as tool_response).
23export function toolText(response: unknown): string {
24  if (typeof response === 'string') return response
25  const blocks = Array.isArray(response) ? response : (response as { content?: unknown } | null)?.content
26  if (Array.isArray(blocks)) return blocks.map(b => String((b as { text?: unknown })?.text ?? '')).join('\n')
27  return response == null ? '' : JSON.stringify(response)
28}
29
30// followups: what its done tool answers.
31export const doneResult = (title: string, note: string) => `Marked "${title}" done${note ? ` (${note})` : ''}.`
32
33export function parseDoneResult(text: string): { title: string; note: string } | null {
34  const m = /^Marked "(.+)" done(?: \((.*)\))?\.$/.exec(text)
35  return m ? { title: m[1]!, note: m[2] ?? '' } : null
36}
37
38// goal-run: the prompt that hands Claude the goal, the toast when it is reached (on every path), and
39// the update tool's words for it.
40export const goalPrompt = (text: string) => `My goal for this session: ${text}`
41export const parseGoalPrompt = (text: string) => /^My goal for this session: (.+)$/m.exec(text)?.[1]?.trim() ?? null
42export const goalReachedToast = (text: string) => `Goal reached: ${text}`
43export const GOAL_REACHED = 'The goal is reached.'
44// goal-run: the note it adds to followups' done result when that to-do item closed the goal's last task.
45export const goalClosedNote = (text: string) => `That closed the last task of the session goal "${text}". ${GOAL_REACHED}`
46export const parseGoalClosedNote = (text: string) => /^That closed the last task of the session goal "(.+)"\. /.exec(text)?.[1] ?? null
47
48// goal-run ↔ factory: a plan the factory's spec-writer wrote is submitted with source "factory".
49// goal-run then lets the factory's builder do the work, and answers the plan tool in these words;
50// the factory reads them to go on to the build or back to the spec. A plan approved later in the
51// Goal pane is passed on with the command /factory plan-approved.
52export const FACTORY_SOURCE = 'factory' as const
53export const FACTORY_PLAN_APPROVED_ARG = 'plan-approved'
54export const PLAN_APPROVED_FOR_FACTORY = "Approved. The factory's builder does the work: don't start the tasks yourself, and don't propose another plan."
55export const planChangesForFactory = (said: string) => `The person wants changes: ${said}\nThe factory's spec-writer revises the spec; don't propose a plan yourself.`
56export const PLAN_WAITING_FOR_FACTORY = "The person hasn't answered; the plan waits for approval in the Goal pane, and the factory waits with it."
57// The person has a goal of their own: goal-run keeps it, and the factory asks for approval itself.
58export const PLAN_KEPT_FOR_FACTORY = "The person has a session goal of their own, so the factory's plan is not set as the goal. The factory asks the person to approve it in its own band; don't propose a plan or start the tasks."
59
60// Only the factory's own wording counts: a regular plan's "Approved. …" or "The person wants changes
61// to the plan" is the main loop's, not the factory's.
62export function parsePlanOutcome(text: string): { approved: true } | { changes: string } | { kept: true } | null {
63  if (text.startsWith(PLAN_APPROVED_FOR_FACTORY)) return { approved: true }
64  if (text.startsWith(PLAN_KEPT_FOR_FACTORY)) return { kept: true }
65  const m = /^The person wants changes: ([\s\S]*?)\nThe factory's spec-writer revises the spec/.exec(text)
66  return m ? { changes: m[1]!.trim() } : null
67}
68
types/index.d.ts 22 lines
1export type Status = 'open' | 'in_progress' | 'done'
2export type Kind = 'bug' | 'task'
3export type Flag = { id: string; title: string; detail: string; where: string; at: number; kind?: Kind }
4export type Item = Flag & { status: Status }
5
6declare module 'claude-code' {
7  interface PluginState {
8    followups: {
9      // What Claude flagged and the person hasn't answered yet.
10      pending: Flag[]
11      // What the person chose to keep.
12      items: Item[]
13      // The last export's outcome, shown under the buttons.
14      exported: string
15      // Titles (normalised) the person said aren't needed, so they aren't suggested again.
16      skipped: string[]
17      // The list as this session last loaded or saved it, to merge with another session's saves.
18      base: Item[]
19    }
20  }
21}
22