SLOPSHOPPER

goal-run

Set a goal at the start of a session: Claude plans the tasks, you approve, and a pixel Claude in running shoes runs a 0-100% track as the tasks get done, with…

newpanebandguardcommandtoast
★ 2v0.4.5MITupdated 2026-10-25Amitro123/claudemod/plugins/goal-run
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · goal-run
│ ┃ Goal ✕ › fix the failing auth test and add an audit log call │ ┃ No goal yet. Type /session-goal and what you │ ┃ want to get done this session. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-goal │ ⎿ goal-run: No goal yet. /session-goal <what you want to get done> │ │ 🎯 Is there a goal you want to work on this session? e.g. ship the CSV export with tests ⏎ set goal [ No goal this time ] ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
🎯 Is there a goal you want to work on this session? e.g. ship the CSV export with tests ⏎ set goal [ No goal this time ] ⟨Claude Code's own drawing⟩
Pane · Goal
No goal yet. Type /session-goal and what you want to get done this session.
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 4 files
hooks/register.tsx 780 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Asking, Goal, Pace, Priority, Side, Task, TaskStatus } from '../types'
5import { TRACK_SIZE, trackSvg, trackText } from './art'
6import type { Pose } from './art'
7import { FACTORY_PLAN_APPROVED_ARG, FACTORY_SOURCE, GOAL_REACHED, goalClosedNote, goalPrompt, goalReachedToast, parseDoneResult, parseStartPrompt, PLAN_APPROVED_FOR_FACTORY, PLAN_KEPT_FOR_FACTORY, PLAN_WAITING_FOR_FACTORY, planChangesForFactory, toolText } from './texts'
8// The goal-run tests import the start-prompt parser from here, as before it was shared.
9export { parseStartPrompt }
10
11// ---------- state ----------
12
13const goal = atom({ plugin: 'goal-run', key: 'goal' } as const, null as Goal | null)
14// When the goal this session last loaded or saved was set: a newer one saved by another session wins.
15const heldAt = atom({ plugin: 'goal-run', key: 'heldAt' } as const, 0)
16// Set once this session was told its goal is no longer the one saved.
17const superseded = atom({ plugin: 'goal-run', key: 'superseded' } as const, false)
18const asking = atom({ plugin: 'goal-run', key: 'asking' } as const, null as Asking)
19const tick = atom({ plugin: 'goal-run', key: 'tick' } as const, 0)
20const side = atom({ plugin: 'goal-run', key: 'side' } as const, null as Side | null)
21
22const PANE = 'goal-run'
23const PANE_TITLE = 'Goal'
24const PLAN_TOOL = 'mcp__goal-run__plan'
25const UPDATE_TOOL = 'mcp__goal-run__update'
26const ADD_TOOL = 'mcp__goal-run__add'
27// The followups mod: its to-do Start submits a prompt under its name, and its done tool closes an item.
28const TODO_PLUGIN = 'followups'
29const TODO_DONE_TOOL = 'mcp__followups__done'
30// The runner's stride: four frames a second while a task is in progress.
31const FRAME_MS = 250
32const MAX_TASKS = 12
33
34const APPROVE_RUN = 'Approve, run through'
35const APPROVE_ASK = 'Approve, ask between tasks'
36const APPROVE_BUILD = 'Approve, start building'
37const CHANGE = 'Change it'
38const ADD_IT = 'Add it'
39const NOT_NOW = 'Not now'
40const GO_ON = 'Continue'
41const STOP_HERE = 'Stop here'
42export const paceOf = (g: Goal): Pace => g.pace ?? 'run'
43const PACE_TEXT: Record<Pace, string> = { run: 'runs through', ask: 'asks between tasks' }
44
45const timers: { cancel: () => void }[] = []
46
47// ---------- text ----------
48
49const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
50const tok = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
51const MARK: Record<TaskStatus, string> = { pending: '○', in_progress: '◐', done: '✓' }
52const COLOR: Record<TaskStatus, string> = { pending: '#8b8fa3', in_progress: '#d97757', done: '#1f9d6b' }
53
54export const fractionOf = (g: Goal) => (g.tasks.length ? g.tasks.filter(t => t.status === 'done').length / g.tasks.length : 0)
55export const percentOf = (g: Goal) => Math.round(fractionOf(g) * 100)
56const tokensOf = (g: Goal) => g.tasks.reduce((n, t) => n + t.tokens, g.other)
57
58export function poseOf(g: Goal | null): Pose {
59  if (!g) return 'stand'
60  if (g.status === 'done') return 'cheer'
61  return g.status === 'active' && g.tasks.some(t => t.status === 'in_progress') ? 'run' : 'stand'
62}
63
64// The plan as the person reads it in the approval dialog.
65// ---------- priorities ----------
66
67const RANK: Record<Priority, number> = { high: 0, medium: 1, low: 2 }
68const PRIORITY_MARK: Record<Priority, string> = { high: '▲ high', medium: '■ med', low: '▼ low' }
69const PRIORITY_COLOR: Record<Priority, string> = { high: '#e5484d', medium: '#c9a227', low: '#8b8fa3' }
70export const priorityOf = (t: Task): Priority => t.priority ?? 'medium'
71const isPriority = (p: unknown): p is Priority => p === 'high' || p === 'medium' || p === 'low'
72
73export type PlanItem = { title: string; priority: Priority; dependsOn: number[] }
74
75// The plan as the tool was given it: titles alone (medium), or { title, priority, dependsOn } with
76// dependsOn the 1-based places of other tasks in the same list.
77export function planItems(raw: unknown): PlanItem[] {
78  if (!Array.isArray(raw)) return []
79  return raw.slice(0, MAX_TASKS).map(r => {
80    const o = (r && typeof r === 'object' ? r : { title: r }) as { title?: unknown; priority?: unknown; dependsOn?: unknown }
81    return {
82      title: clip(String(o.title ?? '').trim(), 120),
83      priority: isPriority(o.priority) ? o.priority : 'medium',
84      dependsOn: Array.isArray(o.dependsOn) ? o.dependsOn.map(Number).filter(n => Number.isInteger(n)) : [],
85    }
86  }).filter(it => it.title)
87}
88
89// The order the work runs in: the highest priority whose dependencies are done goes next, ties in
90// the order given. Dependencies a cycle makes impossible are dropped, and those tasks keep their place.
91export function orderPlan(items: PlanItem[]): Task[] {
92  const deps = items.map((it, i) => new Set(it.dependsOn.map(n => n - 1).filter(j => j >= 0 && j < items.length && j !== i)))
93  const placed: number[] = []
94  const left = new Set(items.map((_, i) => i))
95  while (left.size) {
96    const ready = [...left].filter(i => [...deps[i]!].every(j => !left.has(j)))
97    const pool = ready.length ? ready : [Math.min(...left)]
98    const next = pool.sort((a, b) => RANK[items[a]!.priority] - RANK[items[b]!.priority] || a - b)[0]!
99    placed.push(next)
100    left.delete(next)
101  }
102  const idOf = new Map(placed.map((orig, k) => [orig, `t${k + 1}`]))
103  return placed.map((orig, k) => {
104    const it = items[orig]!
105    const needs = [...deps[orig]!].filter(j => placed.indexOf(j) < k).map(j => idOf.get(j)!)
106    return { id: `t${k + 1}`, title: it.title, status: 'pending', tokens: 0, priority: it.priority, ...(needs.length ? { needs } : {}) }
107  })
108}
109
110// What a request added: its output, the input it wrote to the cache, and uncached input. Cache reads
111// are the context earlier requests already counted; adding them would count it again on every step.
112export function stepTokens(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): number {
113  return u.input_tokens + u.output_tokens + u.cache_creation_input_tokens
114}
115
116// Everything a task needs, directly or through another task.
117function needsOf(g: Goal, id: string, seen = new Set<string>()): Set<string> {
118  for (const n of g.tasks.find(t => t.id === id)?.needs ?? []) {
119    if (!seen.has(n)) {
120      seen.add(n)
121      needsOf(g, n, seen)
122    }
123  }
124  return seen
125}
126
127// Moves a pending task one place up or down among the pending tasks; the person's order then stands.
128// Dependencies stay first: ↑ carries along the tasks it needs, ↓ the tasks that need it.
129export function movePending(g: Goal, id: string, by: -1 | 1): Goal {
130  const slots = g.tasks.map((t, i) => (t.status === 'pending' ? i : -1)).filter(i => i >= 0)
131  const order = slots.map(i => g.tasks[i]!)
132  let lo = order.findIndex(t => t.id === id)
133  if (lo < 0) return g
134  let hi = lo
135  const before = (a: Task, b: Task) => needsOf(g, b.id).has(a.id)
136  for (;;) {
137    const passed = order[by < 0 ? lo - 1 : hi + 1]
138    if (!passed) return g
139    const group = order.slice(lo, hi + 1)
140    if (!group.some(t => (by < 0 ? before(passed, t) : before(t, passed)))) break
141    if (by < 0) lo--
142    else hi++
143  }
144  const group = order.slice(lo, hi + 1)
145  const moved = by < 0
146    ? [...order.slice(0, lo - 1), ...group, order[lo - 1]!, ...order.slice(hi + 1)]
147    : [...order.slice(0, lo), order[hi + 1]!, ...group, ...order.slice(hi + 2)]
148  const tasks = [...g.tasks]
149  slots.forEach((s, k) => { tasks[s] = moved[k]! })
150  return { ...g, tasks }
151}
152
153// Where a new task goes: before the first pending task of lower priority, but never ahead of a task it
154// needs. Done and in-progress tasks keep their places.
155export function insertTask(g: Goal, item: { title: string; priority: Priority; needs: string[] }): { goal: Goal; task: Task; at: number } {
156  const id = `t${g.tasks.reduce((n, t) => Math.max(n, Number(t.id.slice(1)) || 0), 0) + 1}`
157  const needs = item.needs.filter(n => g.tasks.some(t => t.id === n))
158  const task: Task = { id, title: item.title, status: 'pending', tokens: 0, priority: item.priority, ...(needs.length ? { needs } : {}) }
159  const floor = Math.max(-1, ...needs.map(n => g.tasks.findIndex(t => t.id === n))) + 1
160  let at = g.tasks.findIndex((t, i) => i >= floor && t.status === 'pending' && RANK[priorityOf(t)] > RANK[item.priority])
161  if (at < 0) at = g.tasks.length
162  const tasks = [...g.tasks.slice(0, at), task, ...g.tasks.slice(at)]
163  return { goal: { ...g, tasks, status: g.status === 'done' ? 'active' : g.status }, task, at }
164}
165
166// A new plan for a goal already under way keeps the done tasks (their tokens too) ahead of it; the
167// tokens of tasks it drops move to "planning and between tasks", so the total stays right.
168export function replan(before: Goal | null, text: string, tasks: Task[]): Goal {
169  const kept = before && before.status !== 'planning' ? before.tasks.filter(t => t.status === 'done') : []
170  const dropped = before ? before.tasks.filter(t => !kept.includes(t)).reduce((n, t) => n + t.tokens, 0) : 0
171  const shift = kept.reduce((n, t) => Math.max(n, Number(t.id.slice(1)) || 0), 0)
172  const renamed = tasks.map(t => ({ ...t, id: `t${Number(t.id.slice(1)) + shift}`, ...(t.needs ? { needs: t.needs.map(n => `t${Number(n.slice(1)) + shift}`) } : {}) }))
173  return {
174    text,
175    status: 'proposed',
176    tasks: [...kept, ...renamed],
177    activeId: null,
178    other: (before?.other ?? 0) + dropped,
179    ...(before?.outside ? { outside: before.outside } : {}),
180    ...(before?.pace ? { pace: before.pace } : {}),
181    ...(before?.setAt ? { setAt: before.setAt } : {}),
182  }
183}
184
185const needsText = (t: Task, g: Goal) => (t.needs?.length ? ` (needs ${t.needs.map(id => g.tasks.findIndex(x => x.id === id) + 1).join(', ')})` : '')
186
187// The plan as the person reads it in the approval dialog: in order, each with its priority and what it waits on.
188export function planText(g: Goal): string {
189  return [
190    `Goal: ${g.text}`,
191    ...g.tasks.map((t, i) => `${i + 1}. [${priorityOf(t)}] ${t.title}${needsText(t, g)}`),
192    'Ordered by priority, dependencies first.',
193  ].join('\n')
194}
195
196// What the model reads beside each prompt while a goal is being worked: where it stands, and how to move it.
197export function reminder(g: Goal): string {
198  const done = g.tasks.filter(t => t.status === 'done').length
199  return [
200    `Session goal: ${g.text}. ${done}/${g.tasks.length} tasks done (${percentOf(g)}%).`,
201    ...g.tasks.map(t => `- [${t.status === 'done' ? 'done' : t.status === 'in_progress' ? 'in progress' : 'pending'}] ${t.id} (${priorityOf(t)} priority): ${t.title}${t.needs?.length ? `, needs ${t.needs.join(', ')}` : ''}`),
202    `The list is the order to work in: by priority, dependencies first, and as the person may have reordered it. Take the first pending task next, not by its id. When you start a task call ${UPDATE_TOOL} with its id and status "in_progress"; when it is finished, status "done". To add a task the person asks for, use ${ADD_TOOL}, not a new plan.`,
203    paceOf(g) === 'run'
204      ? 'The person chose to run through: after a task, go straight on to the next pending one. Stop only when you are blocked or need their decision.'
205      : 'The person chose to be asked between tasks: marking a task done asks them whether to go on.',
206  ].join('\n')
207}
208
209// ---------- updates ----------
210
211const storeKey = async ($: EngineInterface) => `goal:${await $.session.root()}`
212
213async function save($: EngineInterface) {
214  const key = await storeKey($)
215  const g = await read($, goal)
216  const saved = (await $.store.get(key)) as Goal | null | undefined
217  // Another session in this project set a newer goal: it stays saved, and this session's copy stays its own.
218  if (saved && typeof saved === 'object' && (saved.setAt ?? 0) > (g ? g.setAt ?? 0 : await read($, heldAt))) {
219    if (!(await read($, superseded))) {
220      await update($, superseded, () => true)
221      $.ui.toast("A newer goal was set in another session; progress here isn't saved")
222    }
223    return
224  }
225  await $.store.set(key, g)
226  await update($, heldAt, () => g?.setAt ?? 0)
227  if (await read($, superseded)) await update($, superseded, () => false)
228}
229
230async function setGoal($: EngineInterface, fn: (g: Goal | null) => Goal | null) {
231  await update($, goal, fn)
232  await save($)
233  await syncTicking($)
234}
235
236// The runner moves only while a task is in progress: the timer runs then, and stops otherwise.
237async function syncTicking($: EngineInterface) {
238  const isRunning = poseOf(await read($, goal)) === 'run'
239  if (isRunning && !timers.length) {
240    timers.push($.clock.every(FRAME_MS, async () => {
241      const t = await $.clock.now()
242      await update($, tick, () => t)
243    }))
244  }
245  if (!isRunning) for (const timer of timers.splice(0)) timer.cancel()
246}
247
248// A goal typed by the person: Claude is asked for a plan, which waits for approval before any work.
249async function startGoal($: EngineInterface, text: string) {
250  const g: Goal = { text: clip(text.trim(), 200), status: 'planning', tasks: [], activeId: null, other: 0, setAt: await $.clock.now() }
251  await update($, asking, () => null)
252  await setGoal($, () => g)
253  void $.ui.open({ id: PANE, title: PANE_TITLE })
254  void $.prompt.submit({
255    text: `${goalPrompt(g.text)}\n\nBreak it into 3 to 8 concrete tasks and propose them with ${PLAN_TOOL}. Don't start the work until I approve the plan.`,
256  }).catch(() => {})
257}
258
259async function approve($: EngineInterface, pace: Pace) {
260  await setGoal($, g => (g ? { ...g, status: 'active', pace } : g))
261  // A factory's plan: the factory starts its builder; the main loop is not asked to work.
262  if ((await read($, goal))?.source === FACTORY_SOURCE) {
263    void $.command.run({ command: 'factory', args: FACTORY_PLAN_APPROVED_ARG }).catch(() => {})
264    return
265  }
266  void $.prompt.submit({ text: `The plan is approved (${PACE_TEXT[pace]}). Start with the first pending task.` }).catch(() => {})
267}
268
269// The to-do items a followups Start prompt names, from its lines "- id <id>: <title>[ (bug)][ — detail][ [where: …]]".
270
271const norm = (s: string) => s.trim().toLowerCase().replace(/\s+/g, ' ')
272
273// Closes the goal task a done to-do item became, and notes it for a side job still waiting to be
274// added. Returns the goal's text when that closed the goal, else ''.
275async function closeFromTodo($: EngineInterface, ref: string): Promise<string> {
276  const g = await read($, goal)
277  const task = g?.tasks.find(t => t.followupId && (t.followupId === ref || norm(t.title) === norm(ref)))
278  let reached = ''
279  if (g && task && task.status !== 'done') {
280    await setGoal($, cur => (cur ? withStatus(cur, task.id, 'done') : cur))
281    const now = await read($, goal)
282    if (now?.status === 'done') {
283      $.ui.toast(goalReachedToast(now.text))
284      reached = now.text
285    }
286  }
287  await update($, side, cur => {
288    if (!cur) return cur
289    const item = cur.items.find(it => it.id === ref || norm(it.title) === norm(ref))
290    return item && !cur.doneIds.includes(item.id) ? { ...cur, doneIds: [...cur.doneIds, item.id] } : cur
291  })
292  return reached
293}
294
295// One task moved to a status, the goal's active task and done-ness kept in step.
296export function withStatus(g: Goal, id: string, status: TaskStatus): Goal {
297  const tasks = g.tasks.map(t => (t.id === id ? { ...t, status } : t))
298  const activeId = status === 'in_progress' ? id : g.activeId === id ? null : g.activeId
299  return { ...g, tasks, activeId, status: tasks.every(t => t.status === 'done') ? 'done' : 'active' }
300}
301
302// The side job's items as new tasks at the end of the plan: in progress (or done, if followups closed
303// them already), carrying the tokens they used so far, split evenly.
304export function addSide(g: Goal, sj: Side): { goal: Goal; taskIds: string[] } {
305  const next = g.tasks.reduce((n, t) => Math.max(n, Number(t.id.slice(1)) || 0), 0) + 1
306  const share = Math.floor(sj.tokens / Math.max(1, sj.items.length))
307  const added: Task[] = sj.items.map((it, i) => ({
308    id: `t${next + i}`,
309    title: it.title,
310    status: sj.doneIds.includes(it.id) ? 'done' : 'in_progress',
311    tokens: share + (i === 0 ? sj.tokens - share * sj.items.length : 0),
312    priority: it.isBug ? 'high' : 'medium',
313    followupId: it.id,
314  }))
315  const tasks = [...g.tasks, ...added]
316  const firstOpen = added.find(t => t.status === 'in_progress')
317  return {
318    goal: {
319      ...g,
320      tasks,
321      activeId: firstOpen ? firstOpen.id : g.activeId,
322      outside: Math.max(0, (g.outside ?? 0) - sj.tokens),
323      status: tasks.every(t => t.status === 'done') ? 'done' : 'active',
324    },
325    taskIds: added.map(t => t.id),
326  }
327}
328
329async function addSideToGoal($: EngineInterface) {
330  const sj = await read($, side)
331  const g = await read($, goal)
332  if (!sj || !g) return
333  const { goal: grown, taskIds } = addSide(g, sj)
334  await setGoal($, () => grown)
335  await update($, side, cur => (!cur ? cur : cur.isLive ? { ...cur, isAsking: false, taskIds } : null))
336}
337
338async function keepSideApart($: EngineInterface) {
339  await update($, side, cur => (!cur ? cur : cur.isLive ? { ...cur, isAsking: false } : null))
340}
341
342const isTaskStatus = (s: unknown): s is TaskStatus => s === 'pending' || s === 'in_progress' || s === 'done'
343
344export const register: Register = (on, options) => {
345  const askOnStart = String(options.askOnStart ?? 'on') !== 'off'
346  // Per turn: a person is at the prompt (so a stop can be sent back), and the run-through nudge was used.
347  let isInteractive = false
348  let isNudged = false
349
350  on('session.start', async ($, e, next) => {
351    isInteractive = e.isInteractive
352    for (const timer of timers.splice(0)) timer.cancel()
353    await update($, side, () => null)
354    await $.tool.register({
355      name: 'plan',
356      description: `Propose the plan for the person's session goal: 3 to 8 concrete tasks, each with a priority (how much it matters to the goal: high, medium or low) and the tasks it depends on (dependsOn: their 1-based places in this list). The plan is ordered by priority, dependencies first, and the person sees it that way. The person is asked to approve it; the result says whether they did or what to change. Call it when they set a goal (or ask to re-plan); don't start the work before it is approved.`,
357      inputSchema: {
358        type: 'object',
359        properties: {
360          goal: { type: 'string', description: 'The goal, as the person put it (or a short restatement)' },
361          source: { type: 'string', enum: [FACTORY_SOURCE], description: 'Only "factory", when a /factory run asks you to submit its spec\'s tasks: the factory\'s builder then does the work' },
362          tasks: {
363            type: 'array',
364            description: 'The tasks, in any order: the plan orders them',
365            items: {
366              type: 'object',
367              properties: {
368                title: { type: 'string', description: 'Short and imperative' },
369                priority: { type: 'string', enum: ['high', 'medium', 'low'] },
370                dependsOn: { type: 'array', items: { type: 'integer' }, description: '1-based places in this list of the tasks it needs done first' },
371              },
372              required: ['title', 'priority'],
373            },
374          },
375        },
376        required: ['tasks'],
377      },
378      isDeferred: false,
379    })
380    await $.tool.register({
381      name: 'update',
382      description: `Mark progress on the approved goal plan: status "in_progress" when you start a task, "done" when it is finished. Use the task id from the plan (t1, t2, ...). The goal's progress bar and runner follow it.`,
383      inputSchema: {
384        type: 'object',
385        properties: {
386          id: { type: 'string', description: 'The task id, e.g. t2' },
387          status: { type: 'string', enum: ['in_progress', 'done', 'pending'] },
388        },
389        required: ['id', 'status'],
390      },
391      isDeferred: false,
392    })
393    await $.tool.register({
394      name: 'add',
395      description: `Add one task to the approved goal plan, when the person asks for more work during the goal: give it a priority (high, medium or low) and the ids of tasks it needs first. It goes in among the pending tasks by priority, after what it needs, and the person confirms. Use it instead of proposing a new plan; done and in-progress tasks are untouched.`,
396      inputSchema: {
397        type: 'object',
398        properties: {
399          title: { type: 'string', description: 'Short and imperative' },
400          priority: { type: 'string', enum: ['high', 'medium', 'low'] },
401          needs: { type: 'array', items: { type: 'string' }, description: 'Ids of tasks it needs done first, e.g. ["t2"]' },
402        },
403        required: ['title', 'priority'],
404      },
405      isDeferred: false,
406    })
407    await $.command.register({ name: 'session-goal', description: 'Set a session goal, or show it', argumentHint: '[goal | clear]' })
408
409    // A goal kept for this project from an earlier session: offered to continue, or a new one asked for.
410    if (!(await read($, goal))) {
411      const saved = await $.store.get(await storeKey($))
412      if (saved && typeof saved === 'object') {
413        await update($, goal, () => saved as Goal)
414        await update($, heldAt, () => (saved as Goal).setAt ?? 0)
415      }
416    }
417    const g = await read($, goal)
418    if (g && g.status !== 'done') {
419      if (askOnStart) await update($, asking, () => 'resume')
420      void $.ui.open({ id: PANE, title: PANE_TITLE })
421    } else if (askOnStart) {
422      await update($, asking, () => 'new')
423    }
424    await syncTicking($)
425    return next(e)
426  })
427
428  on('session.end', async ($, e, next) => {
429    for (const timer of timers.splice(0)) timer.cancel()
430    return next(e)
431  }).catch(($, e, next) => next(e))
432
433  on('command.run', { command: 'session-goal' }, async ($, e) => {
434    const arg = e.args.trim()
435    if (arg === 'clear') {
436      await setGoal($, () => null)
437      return { text: 'Goal cleared.' }
438    }
439    if (arg) {
440      await startGoal($, arg)
441      return { text: `Goal set: ${arg}. Claude will propose a plan.` }
442    }
443    await $.ui.open({ id: PANE, title: PANE_TITLE })
444    const g = await read($, goal)
445    return { text: g ? `Goal: ${g.text} (${percentOf(g)}%)` : 'No goal yet. /session-goal <what you want to get done> sets one.' }
446  })
447
448  // The plan, put to the person: approved, sent back with their words, or left waiting in the pane.
449  on('tool.call', { tool: PLAN_TOOL }, async ($, e) => {
450    const input = e as unknown as { goal?: unknown; tasks?: unknown; source?: unknown }
451    const isFactory = input.source === FACTORY_SOURCE
452    const items = planItems(input.tasks)
453    if (!items.length) return { deny: 'A plan needs at least one task.' }
454    const before = await read($, goal)
455    // A factory plan never takes over the person's own goal, and doesn't inherit its done tasks.
456    if (isFactory && before && before.source !== FACTORY_SOURCE && before.status !== 'done') return { result: PLAN_KEPT_FOR_FACTORY }
457    const text = clip(String(input.goal ?? '').trim() || before?.text || 'Session goal', 200)
458    // A plan with no goal set before it is a new goal, set now.
459    const proposed = { setAt: await $.clock.now(), ...replan(isFactory ? null : before, text, orderPlan(items)), ...(isFactory ? { source: FACTORY_SOURCE } : {}) }
460    await update($, asking, () => null)
461    await setGoal($, () => proposed)
462    void $.ui.open({ id: PANE, title: PANE_TITLE })
463
464    let answer = ''
465    try {
466      answer = isFactory
467        ? await $.ui.ask(`${planText(proposed)}\n\nThe factory's spec for this goal. Approve it, and the builder starts?`, { header: 'Factory plan', options: [APPROVE_BUILD, CHANGE] })
468        : await $.ui.ask(`${planText(proposed)}\n\nApprove this plan? Run straight through the tasks, or be asked before each next one.`, { header: 'Goal plan', options: [APPROVE_RUN, APPROVE_ASK, CHANGE] })
469    } catch {
470      answer = ''
471    }
472    if (isFactory) {
473      if (answer === APPROVE_BUILD) {
474        await setGoal($, g => (g ? { ...g, status: 'active', pace: 'run' } : g))
475        return { result: PLAN_APPROVED_FOR_FACTORY }
476      }
477      if (!answer.trim()) return { result: PLAN_WAITING_FOR_FACTORY }
478      return { result: planChangesForFactory(answer === CHANGE ? '' : answer) }
479    }
480    if (answer === APPROVE_RUN || answer === APPROVE_ASK) {
481      const pace: Pace = answer === APPROVE_RUN ? 'run' : 'ask'
482      await setGoal($, g => (g ? { ...g, status: 'active', pace } : g))
483      return { result: `Approved. ${reminder({ ...proposed, status: 'active', pace })}` }
484    }
485    if (!answer.trim()) return { result: "The person hasn't answered; the plan waits for approval in the Goal pane. Don't start the work yet." }
486    if (answer === CHANGE) return { result: 'The person wants changes to the plan. Ask what to change, then propose it again with this tool.' }
487    return { result: `The person wants changes: ${answer}\nPropose the revised plan with this tool.` }
488  })
489
490  on('tool.call', { tool: UPDATE_TOOL }, async ($, e) => {
491    const input = e as unknown as { id?: unknown; status?: unknown }
492    const g = await read($, goal)
493    if (!g || g.status === 'planning' || g.status === 'proposed') return { deny: 'There is no approved plan to update yet.' }
494    const id = String(input.id ?? '').trim()
495    if (!g.tasks.some(t => t.id === id)) return { deny: `No task "${id}" in the plan; its ids are ${g.tasks.map(t => t.id).join(', ')}.` }
496    if (!isTaskStatus(input.status)) return { deny: 'status is "in_progress", "done" or "pending".' }
497    const status = input.status
498    await setGoal($, cur => (cur ? withStatus(cur, id, status) : cur))
499    const now = (await read($, goal))!
500    if (now.status === 'done') $.ui.toast(goalReachedToast(now.text))
501    const where = `${id} is ${status.replace('_', ' ')}. ${now.tasks.filter(t => t.status === 'done').length}/${now.tasks.length} tasks done (${percentOf(now)}%).`
502    const next = now.tasks.find(t => t.status === 'pending')
503    if (now.status === 'done') return { result: `${where} ${GOAL_REACHED}` }
504    // In a factory run the builder works through the tasks on its own: nothing to ask, nowhere to send it.
505    if (status !== 'done' || !next || now.source === FACTORY_SOURCE) return { result: where }
506    if (paceOf(now) === 'run') return { result: `${where} Go straight on to ${next.id}: ${next.title}.` }
507    // Asked between tasks: the person decides whether the next one starts now.
508    const done = now.tasks.find(t => t.id === id)!
509    let answer = ''
510    try {
511      answer = await $.ui.ask(`"${done.title}" is done (${percentOf(now)}%). Go on to "${next.title}"?`, { header: 'Goal', options: [GO_ON, STOP_HERE] })
512    } catch {
513      answer = ''
514    }
515    if (answer === GO_ON) return { result: `${where} The person says go on: start ${next.id}: ${next.title}.` }
516    if (answer && answer !== STOP_HERE) return { result: `${where} The person says: ${answer}` }
517    return { result: `${where} The person wants to stop here. Don't start ${next.id}; sum up where the goal stands and wait for them.` }
518  })
519
520  // A task the person asks for mid-goal: put in by its priority among the pending tasks, with their OK.
521  on('tool.call', { tool: ADD_TOOL }, async ($, e) => {
522    const input = e as unknown as { title?: unknown; priority?: unknown; needs?: unknown }
523    const g = await read($, goal)
524    if (!g || g.status === 'planning' || g.status === 'proposed') return { deny: `There is no approved plan to add to; propose one with ${PLAN_TOOL}.` }
525    const title = clip(String(input.title ?? '').trim(), 120)
526    if (!title) return { deny: 'A task needs a title.' }
527    const priority: Priority = isPriority(input.priority) ? input.priority : 'medium'
528    const needs = Array.isArray(input.needs) ? input.needs.map(n => String(n).trim()) : []
529    const { goal: grown, task, at } = insertTask(g, { title, priority, needs })
530    const before = grown.tasks[at + 1]
531    const place = `#${at + 1}${before ? `, before "${before.title}"` : ', last'}`
532    let answer = ''
533    try {
534      answer = await $.ui.ask(`Add to the goal: [${priority}] ${title}${task.needs ? ` (needs ${task.needs.join(', ')})` : ''}. It goes in as ${place}. Progress ${percentOf(g)}% → ${percentOf(grown)}%.`, { header: 'Goal', options: [ADD_IT, NOT_NOW] })
535    } catch {
536      answer = ''
537    }
538    if (answer === ADD_IT) {
539      await setGoal($, () => grown)
540      return { result: `Added ${task.id} (${priority} priority) as ${place}. ${reminder(grown)}` }
541    }
542    if (answer && answer !== NOT_NOW) return { result: `Not added yet. The person says: ${answer}` }
543    return { result: answer ? 'The person chose not to add it.' : "The person hasn't answered; it wasn't added." }
544  })
545
546  // Every request's new tokens, the sub-agents' too, are counted against the task in progress (else "other").
547  on('turn.step', async function* ($, e, next) {
548    const r = yield* next(e)
549    const u = r.usage
550    const g = await read($, goal)
551    const sj = await read($, side)
552    if (u && g && sj?.isLive) {
553      // Work started from the to-do list: its tasks' once added to the goal, else counted outside it.
554      const used = stepTokens(u)
555      const into = g.tasks.find(t => sj.taskIds.includes(t.id) && t.status === 'in_progress')
556      if (into) {
557        await setGoal($, cur => (cur ? { ...cur, tasks: cur.tasks.map(t => (t.id === into.id ? { ...t, tokens: t.tokens + used } : t)) } : cur))
558      } else {
559        await update($, side, cur => (cur ? { ...cur, tokens: cur.tokens + used } : cur))
560        await setGoal($, cur => (cur ? { ...cur, outside: (cur.outside ?? 0) + used } : cur))
561      }
562    } else if (u && g && g.status !== 'done') {
563      const used = stepTokens(u)
564      await setGoal($, cur => {
565        if (!cur) return cur
566        const active = cur.tasks.find(t => t.id === cur.activeId && t.status === 'in_progress')
567        return active
568          ? { ...cur, tasks: cur.tasks.map(t => (t.id === active.id ? { ...t, tokens: t.tokens + used } : t)) }
569          : { ...cur, other: cur.other + used }
570      })
571    }
572    return r
573  }).catch(async function* ($, e, next) { return yield* next(e) })
574
575  // While a goal is worked, each of the person's prompts carries where it stands.
576  // A followups Start is a side job: no goal reminder beside it, its tokens counted apart, and the
577  // person asked (in the band) whether its items join the goal. The person's own next prompt ends it.
578  on('prompt.submit', async ($, e, next) => {
579    if (e.command) return next(e)
580    const g = await read($, goal)
581    if (e.origin.kind === 'plugin' && e.origin.name === TODO_PLUGIN) {
582      const items = parseStartPrompt(e.text)
583      if (g && items.length) {
584        await update($, side, () => ({ items, tokens: 0, isAsking: g.status === 'active', taskIds: [], doneIds: [], isLive: true }))
585      }
586      return next(e)
587    }
588    if (e.origin.kind === 'composer') isNudged = false
589    if (e.origin.kind === 'composer') await update($, side, cur => (!cur ? cur : cur.isAsking ? { ...cur, isLive: false } : null))
590    if (!g || g.status !== 'active' || g.source === FACTORY_SOURCE) return next(e)
591    return next({ ...e, context: [...(e.context ?? []), reminder(g)] })
592  }).catch(($, e, next) => next(e))
593
594  // A to-do item followups marks done closes the goal task it became (or will become, if added later).
595  // This hook runs only while goal-run sits above followups, whose own hook answers the tool; the
596  // PostToolUse hook below sees the call whatever the order. Closing twice changes nothing.
597  on('tool.call', { tool: TODO_DONE_TOOL }, async ($, e, next) => {
598    const r = await next(e)
599    if (('deny' in r && r.deny) || !parseDoneResult(String(r.result ?? ''))) return r
600    const reached = await closeFromTodo($, String((e as unknown as { id?: unknown }).id ?? '').trim())
601    // Claude (and brain-hud) learn the goal is reached on this path too.
602    if (!reached || 'deny' in r) return r
603    return { ...r, context: [...(r.context ?? []), goalClosedNote(reached)] }
604  }).catch(($, e, next) => next(e))
605
606  on('classic.PostToolUse', async ($, e, next) => {
607    const r = await next(e)
608    if (e.tool_name === TODO_DONE_TOOL && parseDoneResult(toolText(e.tool_response))) {
609      await closeFromTodo($, String((e.tool_input as { id?: unknown } | null)?.id ?? '').trim())
610    }
611    return r
612  }).catch(($, e, next) => next(e))
613
614  // Running through: a turn that ends with pending tasks left is sent on once, to the next one. Claude
615  // may still stop when it is blocked or needs the person; the second stop of a turn always passes.
616  on('classic.Stop', async ($, e, next) => {
617    const r = await next(e)
618    const g = await read($, goal)
619    const next1 = g?.tasks.find(t => t.status === 'pending')
620    if (!isInteractive || isNudged || e.stop_hook_active || r.block || !g || g.status !== 'active' || g.source === FACTORY_SOURCE || paceOf(g) !== 'run' || !next1) return r
621    if ((await read($, side))?.isLive) return r
622    isNudged = true
623    const left = g.tasks.filter(t => t.status !== 'done').length
624    return { ...r, block: `The goal still has ${left} task${left === 1 ? '' : 's'} to do, and the person chose to run through. Go on with ${next1.id}: ${next1.title}. If you are blocked or need their decision, say so plainly and stop.` }
625  }).catch(($, e, next) => (next.called ? next(e) : {}))
626
627  // ---------- the band: the question at the start of a session ----------
628
629  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
630    const ask = await read($, asking)
631    const sj = await read($, side)
632    const g = await read($, goal)
633    if (e.props.hasSurvey || (!ask && !(sj?.isAsking && g))) return next(e)
634    const { Box, Button, Input: Field, Text } = $.ui.resolve(e as typeof e & { surface: 'desktop' })
635    // The mobile app draws no field yet (should the band reach it): there the question points at the command.
636    const surface: string = e.surface
637    const Input = surface === 'mobile' ? null : Field
638    const below = await next(e)
639    if (sj?.isAsking && g) {
640      const grown = addSide(g, sj).goal
641      const names = sj.items.map(it => `"${it.title}"`).join(', ')
642      return (
643        <Box flexDirection="column">
644          <Box flexDirection="column" paddingX={1}>
645            <Text bold color="#d97757" wrap="truncate-end">{`➕ Add to your goal? ${names}`}</Text>
646            <Text dimColor wrap="truncate-end">{`The plan grows to ${grown.tasks.length} tasks: ${percentOf(g)}% → ${percentOf(grown)}%. Kept apart, its tokens show as outside the goal.`}</Text>
647            <Box flexDirection="row" gap={1}>
648              <Button key="side-add" label="Add to goal" hotkey="g" variant="primary" onPress={() => addSideToGoal($)} />
649              <Button key="side-apart" label="Keep it separate" hotkey="k" onPress={() => keepSideApart($)} />
650            </Box>
651          </Box>
652          {below}
653        </Box>
654      )
655    }
656    const dismiss = () => update($, asking, () => null)
657    return (
658      <Box flexDirection="column">
659        <Box flexDirection="column" paddingX={1}>
660          {ask === 'resume' && g ? (
661            <Box flexDirection="column">
662              <Text bold color="#d97757">{`🎯 Keep going on your goal? ${g.text} (${percentOf(g)}%)`}</Text>
663              <Box flexDirection="row" gap={1}>
664                <Button key="resume" label="Continue" hotkey="c" variant="primary" onPress={async () => {
665                  await dismiss()
666                  await $.ui.open({ id: PANE, title: PANE_TITLE })
667                }} />
668                <Button key="new-goal" label="New goal" hotkey="n" onPress={async () => {
669                  await setGoal($, () => null)
670                  await update($, asking, () => 'new')
671                }} />
672                <Button key="not-now" label="Not now" onPress={dismiss} />
673              </Box>
674            </Box>
675          ) : (
676            <Box flexDirection="column">
677              <Text bold color="#d97757">🎯 Is there a goal you want to work on this session?</Text>
678              {Input ? (
679                <Input key="goal" placeholder="e.g. ship the CSV export with tests" submitLabel="set goal" onSubmit={async v => {
680                  if (v.trim()) await startGoal($, v)
681                }} />
682              ) : (
683                <Text dimColor>Type /session-goal and your goal to set one.</Text>
684              )}
685              <Box flexDirection="row" gap={1}>
686                <Button key="no-goal" label="No goal this time" onPress={dismiss} />
687              </Box>
688            </Box>
689          )}
690        </Box>
691        {below}
692      </Box>
693    )
694  })
695
696  // ---------- the pane: the goal, the runner and the tasks ----------
697
698  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
699    const { Box, Button, Text } = $.ui.resolve(e)
700    const g = await read($, goal)
701    const frame = Math.floor((await read($, tick)) / FRAME_MS)
702    const Svg = e.surface === 'terminal' ? null : $.ui.resolve(e as typeof e & { surface: 'desktop' }).Svg
703
704    if (!g) {
705      return (
706        <Box flexDirection="column" gap={1}>
707          <Text dimColor>No goal yet. Type /session-goal and what you want to get done this session.</Text>
708        </Box>
709      )
710    }
711    const pose = poseOf(g)
712    const done = g.tasks.filter(t => t.status === 'done').length
713    const cells = Math.max(16, Math.min(48, e.props.bodyColumns - 4))
714    // The track scales down to a narrow pane (about 7 px a cell) rather than running past its edge.
715    const trackWidth = Math.min(TRACK_SIZE.width, Math.max(180, e.props.bodyColumns * 7))
716
717    return (
718      <Box flexDirection="column" gap={1}>
719        <Text bold color="#d97757" wrap="truncate-end">{`🎯 ${g.text}`}</Text>
720        {Svg
721          ? <Svg source={trackSvg(fractionOf(g), pose, frame)} alt={`${percentOf(g)}% of the way to the goal`} width={trackWidth} height={Math.round((trackWidth * TRACK_SIZE.height) / TRACK_SIZE.width)} />
722          : <Text color="#d97757">{trackText(fractionOf(g), pose, frame, cells)}</Text>}
723        <Box flexDirection="row" gap={2}>
724          <Text bold>{`${percentOf(g)}%`}</Text>
725          <Text dimColor>{g.tasks.length ? `${done}/${g.tasks.length} tasks` : ''}</Text>
726          <Text dimColor>{`${tok(tokensOf(g))} tokens`}</Text>
727        </Box>
728        {g.status === 'planning' ? <Text dimColor>Claude is drafting the plan…</Text> : null}
729        {g.status === 'proposed' ? (
730          <Box flexDirection="row" gap={1}>
731            <Text color="#c9a227" bold>Proposed plan: approve it to start</Text>
732            <Button key="approve" label="Approve, run through" variant="primary" onPress={() => approve($, 'run')} />
733            <Button key="approve-ask" label="Approve, ask between" onPress={() => approve($, 'ask')} />
734          </Box>
735        ) : null}
736        {g.status === 'done' ? <Text color="#1f9d6b" bold>Goal reached!</Text> : null}
737        {g.tasks.length ? (
738          <Box flexDirection="column">
739            {g.tasks.map(t => (
740              <Box key={`row-${t.id}`} flexDirection="column">
741                <Box flexDirection="row" justifyContent="space-between" gap={1}>
742                  <Box flexDirection="row" gap={1}>
743                    <Text color={COLOR[t.status]}>{MARK[t.status]}</Text>
744                    <Text color={PRIORITY_COLOR[priorityOf(t)]}>{PRIORITY_MARK[priorityOf(t)]}</Text>
745                    <Text color={t.status === 'pending' ? undefined : COLOR[t.status]} bold={t.status === 'in_progress'} dimColor={t.status === 'done'} wrap="truncate-end">{t.title}</Text>
746                  </Box>
747                  <Box flexDirection="row" gap={1}>
748                    {t.status === 'pending' ? <Button key={`up-${t.id}`} label="↑" plain onPress={() => setGoal($, cur => (cur ? movePending(cur, t.id, -1) : cur))} /> : null}
749                    {t.status === 'pending' ? <Button key={`down-${t.id}`} label="↓" plain onPress={() => setGoal($, cur => (cur ? movePending(cur, t.id, 1) : cur))} /> : null}
750                    <Text dimColor>{t.tokens ? `${tok(t.tokens)} tokens` : '–'}</Text>
751                  </Box>
752                </Box>
753                {t.needs?.length && t.status !== 'done' ? <Text dimColor wrap="truncate-end">{`   ${needsText(t, g).trim()}`}</Text> : null}
754              </Box>
755            ))}
756            {g.other ? (
757              <Box flexDirection="row" justifyContent="space-between" gap={1}>
758                <Text dimColor>Planning and between tasks</Text>
759                <Text dimColor>{`${tok(g.other)} tokens`}</Text>
760              </Box>
761            ) : null}
762            {g.outside ? (
763              <Box flexDirection="row" justifyContent="space-between" gap={1}>
764                <Text dimColor>Outside the goal (from the to-do list)</Text>
765                <Text dimColor>{`${tok(g.outside)} tokens`}</Text>
766              </Box>
767            ) : null}
768          </Box>
769        ) : null}
770        <Box flexDirection="row" gap={1}>
771          {g.status === 'active' ? (
772            <Button key="pace" label={paceOf(g) === 'run' ? '⏩ Runs through · ask between' : '⏸ Asks between · run through'} plain onPress={() => setGoal($, cur => (cur ? { ...cur, pace: paceOf(cur) === 'run' ? 'ask' : 'run' } : cur))} />
773          ) : null}
774          <Button key="clear" label="Clear goal" plain onPress={() => setGoal($, () => null)} />
775        </Box>
776      </Box>
777    )
778  })
779}
780
hooks/art.ts 99 lines
1// The pixel art: Claude in running shoes on a track to a finish flag, as one SVG string, and the
2// terminal's text version of the same track.
3
4const BODY = '#d97757'
5const EYE = '#1d1f2b'
6const SHOE = '#3b7dd8'
7const STRIPE = '#ffffff'
8const SOLE = '#3a3d4d'
9const TRACK = '#8b8fa3'
10const CELL = 4
11
12export type Pose = 'run' | 'stand' | 'cheer'
13
14// 14×12 cells: two rows of headroom for raised arms, then the body (`o`, `e` an eye); arms, legs
15// and the sneakers are placed per frame.
16const TOP = 2
17const HEAD = ['..ooooooooo..', '..ooooooooo..', '..ooeooooeo..', '..ooeooooeo..', '..ooooooooo..', '..ooooooooo..']
18
19type Px = [x: number, y: number, fill: string]
20
21// One frame of the sprite as cells: running cycles four strides, standing and cheering hold still.
22export function spriteCells(pose: Pose, frame: number): Px[] {
23  const px: Px[] = []
24  const bob = pose === 'run' && frame % 2 === 1 ? 1 : 0
25  HEAD.forEach((row, y) => {
26    for (let x = 0; x < row.length; x++) {
27      if (row[x] === 'o') px.push([x, y + TOP + bob, BODY])
28      if (row[x] === 'e') px.push([x, y + TOP + bob, EYE])
29    }
30  })
31  // Arms: out at the sides, swinging up and down while running, raised when cheering.
32  if (pose === 'cheer') {
33    for (const [x, y] of [[1, 2], [0, 1], [0, 0], [11, 2], [12, 1], [12, 0]] as const) px.push([x, y, BODY])
34  } else {
35    const swing = pose === 'run' ? (frame % 4 < 2 ? -1 : 1) : 0
36    const armY = TOP + 3 + bob
37    for (const [x, dy] of [[0, swing], [1, swing], [11, -swing], [12, -swing]] as const) px.push([x, armY + dy, BODY])
38  }
39  // Legs and shoes: the front and back foot trade places through the stride.
40  const stride = pose === 'run' ? [[-1, 1], [0, 0], [1, -1], [0, 0]][frame % 4]! : [0, 0]
41  const feet = [
42    { x: 4 + stride[0]!, lift: pose === 'run' && stride[0]! > 0 ? 1 : 0 },
43    { x: 8 + stride[1]!, lift: pose === 'run' && stride[1]! > 0 ? 1 : 0 },
44  ]
45  for (const { x, lift } of feet) {
46    const y = TOP + 6 + bob - lift
47    px.push([x, y, BODY], [x, y + 1, BODY])
48    // A chunky sneaker pointing ahead: blue with a white stripe, on a dark sole.
49    px.push([x - 1, y + 2, SHOE], [x, y + 2, STRIPE], [x + 1, y + 2, SHOE], [x + 2, y + 2, SHOE])
50    px.push([x - 1, y + 3, SOLE], [x, y + 3, SOLE], [x + 1, y + 3, SOLE], [x + 2, y + 3, SOLE])
51  }
52  return px
53}
54
55export const TRACK_SIZE = { width: 360, height: 74 }
56const SPRITE_W = 14 * CELL
57const SPRITE_H = 12 * CELL
58const LEFT = 6
59const FLAG_X = TRACK_SIZE.width - 22
60const GROUND = 66
61
62// Where the runner stands for a fraction of the goal: the start at 0, the flag at 1.
63export const runnerX = (fraction: number) => LEFT + Math.round(Math.max(0, Math.min(1, fraction)) * (FLAG_X - SPRITE_W - LEFT - 2))
64
65export function trackSvg(fraction: number, pose: Pose, frame: number): string {
66  const { width: w, height: h } = TRACK_SIZE
67  const x0 = runnerX(fraction)
68  const y0 = GROUND - SPRITE_H + 1
69  let s = ''
70  // The track: run so far in the body's color, the rest dashed, a checkered flag at the end.
71  s += `<line x1="${LEFT}" y1="${GROUND + 1}" x2="${FLAG_X}" y2="${GROUND + 1}" stroke="${TRACK}" stroke-opacity="0.45" stroke-width="2" stroke-dasharray="4 4"/>`
72  s += `<line x1="${LEFT}" y1="${GROUND + 1}" x2="${x0 + SPRITE_W / 2}" y2="${GROUND + 1}" stroke="${BODY}" stroke-width="3" stroke-linecap="round"/>`
73  s += `<rect x="${FLAG_X}" y="${GROUND - 34}" width="2" height="36" fill="${SOLE}"/>`
74  for (let r = 0; r < 3; r++) for (let c = 0; c < 4; c++) {
75    s += `<rect x="${FLAG_X + 2 + c * 4}" y="${GROUND - 34 + r * 4}" width="4" height="4" fill="${(r + c) % 2 ? '#ffffff' : '#1d1f2b'}" stroke="#1d1f2b" stroke-width="0.3"/>`
76  }
77  // Dust behind the shoes while running; confetti at the flag once there.
78  if (pose === 'run') {
79    const puff = frame % 2 ? 2 : 3
80    s += `<circle cx="${x0 - 4}" cy="${GROUND - 2}" r="${puff}" fill="${TRACK}" fill-opacity="0.35"/><circle cx="${x0 - 10}" cy="${GROUND - 1}" r="${puff - 1}" fill="${TRACK}" fill-opacity="0.2"/>`
81  }
82  if (pose === 'cheer') {
83    const bits = [[-14, 6, STRIPE], [-6, 0, '#3b7dd8'], [4, 4, '#1f9d6b'], [SPRITE_W + 2, 2, '#c9a227'], [SPRITE_W + 8, 9, '#8a5cf0'], [-10, 14, '#c9a227']] as const
84    for (const [dx, dy, c] of bits) s += `<rect x="${x0 + dx}" y="${y0 - 6 + dy + (frame % 2)}" width="3" height="3" fill="${c}" transform="rotate(${(dx * 7) % 45} ${x0 + dx} ${y0 + dy})"/>`
85  }
86  for (const [x, y, fill] of spriteCells(pose, frame)) {
87    s += `<rect x="${x0 + x * CELL}" y="${y0 + y * CELL}" width="${CELL}" height="${CELL}" fill="${fill}"/>`
88  }
89  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" shape-rendering="crispEdges">${s}</svg>`
90}
91
92// The terminal's track: the runner's place along a row of cells, the flag at the end.
93export function trackText(fraction: number, pose: Pose, frame: number, cells: number): string {
94  const runner = pose === 'cheer' ? '\\(^o^)/' : pose === 'run' ? (frame % 2 ? '=(•_•)»' : '-(•_•)»') : ' (•_•) '
95  const room = Math.max(4, cells - runner.length - 2)
96  const at = Math.round(Math.max(0, Math.min(1, fraction)) * room)
97  return `${'━'.repeat(at)}${runner}${'┄'.repeat(room - at)} ⚑`
98}
99
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 68 lines
1export type TaskStatus = 'pending' | 'in_progress' | 'done'
2export type Priority = 'high' | 'medium' | 'low'
3export type Task = {
4  id: string
5  title: string
6  status: TaskStatus
7  tokens: number
8  // How much it matters to the goal; the plan runs highest first. Absent on a goal saved before
9  // priorities: read as medium.
10  priority?: Priority
11  // The tasks it can't start before, by id: they come first whatever their priority.
12  needs?: string[]
13  // Set for a task that came from a followups to-do item: that item's id, so its "done" carries over.
14  followupId?: string
15}
16// How Claude moves between tasks: straight on to the next (run), or asking the person after each (ask).
17export type Pace = 'run' | 'ask'
18// planning: Claude is writing the plan; proposed: it waits for the person; active: being worked; done: reached.
19export type GoalStatus = 'planning' | 'proposed' | 'active' | 'done'
20export type Goal = {
21  // 'factory' when a /factory run submitted the plan: its builder does the work, not the main loop.
22  source?: 'factory'
23  text: string
24  status: GoalStatus
25  tasks: Task[]
26  // The task tokens are counted against: the last one started and not yet done.
27  activeId: string | null
28  // Tokens spent while no task was in progress: planning, and work between tasks.
29  other: number
30  // Tokens spent on work started from the to-do list and kept apart from the goal.
31  outside?: number
32  // Chosen at approval; absent on a goal saved before it: read as run.
33  pace?: Pace
34  // When the person set it; absent on a goal saved before it. A newer goal from another session wins.
35  setAt?: number
36}
37
38// Work started from the followups to-do list (its Start button) while a goal is open: counted apart,
39// and offered to the goal as tasks until the person answers.
40export type Side = {
41  // A bug (followups' kind) joins the goal as high priority, anything else as medium.
42  items: { id: string; title: string; isBug?: boolean }[]
43  tokens: number
44  // Unanswered: the band asks whether to add the items to the goal.
45  isAsking: boolean
46  // Once added to the goal: the task ids the items became, which their tokens go to from then on.
47  taskIds: string[]
48  // The to-do items followups already marked done, so a late "add" brings them in as done.
49  doneIds: string[]
50  // Its turn's work is still the side job's: false once the person sends a prompt of their own.
51  isLive: boolean
52}
53// What the band above the prompt asks: for a new goal, or whether to go on with an unfinished one.
54export type Asking = 'new' | 'resume' | null
55
56declare module 'claude-code' {
57  interface PluginState {
58    'goal-run': {
59      goal: Goal | null
60      heldAt: number
61      superseded: boolean
62      asking: Asking
63      side: Side | null
64      tick: number
65    }
66  }
67}
68