Plan and track tasks inside Claude Code, kept as plain Markdown files in your project, with weekly sprints, a sprint board and agent teammates.

A better way to manage tasks in Claude Code.
<img src="docs/screenshots/board.svg" alt="Claude Code with the sprint board docked on the right">
A Claude Code plugin. Your tasks are Markdown files in your project; a team of Claude agents works on them; you only review the result. Above: you ask for a fix, the lead files it as T-007 and hands it to the teammate that owns the shop.
.claude/tasks/, committed with your code. Labels group related tasks; a task can wait on other tasks, or on every task with a label (dependsOn), and the lead starts it once they are done.demoVideos setting. A release can come with one video of all its tasks' videos: bin/release-video.sh (setting releaseVideos).liveReview.dev branch, or straight to main. Asked once per project./better-tasks shows sprints, goals, the backlog and search, grouped by label on g./away: screens off, the Mac keeps working; the virtual test displays stay on.claude plugin marketplace add iosifnicolae2/better-tasks
claude plugin install better-tasks@better-tasks
Restart Claude Code. Optional tools: brew install ffmpeg uv for videos, gh for pull requests.
New release out? better-tasks asks at startup: "better-tasks vX is out. Update?" Yes updates it; restart Claude Code. No: not asked again for that release. Keep marketplace auto-update off: you pick each release.
Run inside the project folder, vX.Y.Z being the latest release:
claude plugin marketplace add iosifnicolae2/better-tasks#vX.Y.Z --scope project
claude plugin install better-tasks@better-tasks --scope project
This is saved in .claude/settings.json, pinned to that release ("ref", no auto-update): commit it. Each teammate still runs the install once. Answering "everyone on this project" at setup does the same for you. A project set up unpinned or with "autoUpdate": true is pinned at its next start. --scope local is only you, only this project (not committed).
/better-tasks config (or c on the board), Claude Code's /config, or per project in .claude/tasks/config.json. Git flow, teammate models, videos, your PR template, and more. Every setting, its choices and default: skills/settings/SKILL.md. Or just ask Claude what you can configure.
Project rules for every task: ask "set up better-tasks for this project", then edit the files it makes. To change what the lead or the teammates are told, put a file of the same name as one of better-tasks' instructions in your project's .claude/better-tasks/: it is added after ours, or replaces it with replace: true at its top.
claude plugin marketplace update better-tasks
claude plugin update better-tasks@better-tasks
Restart Claude Code. Updates bring the latest release, not every commit on main. In a project that pins better-tasks, saying Yes to the startup question also moves the pin.
Your Claude Code settings pin better-tasks to an old release, and better-tasks before v0.11.8 can't move that pin. Fix it once, in the project folder:
curl -fsSL https://raw.githubusercontent.com/iosifnicolae2/better-tasks/main/bin/fix-update.sh | sh
It moves the pin to the latest release (a copy of your old settings: ~/.claude/settings.json.bak-better-tasks), updates better-tasks and moves the project's pin: bin/fix-update.sh. Restart Claude Code. From then on, Yes to the startup question updates it.
claude plugin uninstall better-tasks@better-tasks
claude plugin marketplace remove better-tasks
Installed per project? Add --scope project to update and uninstall.
better-tasks has no server and collects no data: nothing is sent to its author or to any analytics. It works through Claude Code's plugin hooks (hooks/) and the scripts in bin/, all in this repo.
.claude/tasks/, its settings in .claude/tasks/config.json, the finished-task log docs/tasks.md, sprint goals in .claude/tasks/sprints.md and before/after videos in .claude/tasks_videos/, all in your project. It also writes files other tools obey:.gitignore: adds .claude/worktrees/, so teammates' worktrees stay out of git..idea/: marks .claude/worktrees/ excluded in IntelliJ, only in a project that has .idea/..claude/settings.json (the project's): adds better-tasks, pinned to a release, when you answer "everyone on this project", and moves that pin when you say Yes to an update. It commits only that file.~/.claude/settings.json: moves your pin of better-tasks to the new release when you say Yes to an update (back as it was if the update fails).~/.claude/CLAUDE.md and ~/.claude/settings.json through Claude, with your approval: see the prompts below.~/.claude/projects/, which only its successor is pointed at.CLAUDE_CODE_PROMPT_CACHE_TTL and CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL to 1h (setting longCache), unless you set a TTL yourself. Its settings page writes only its own /config rows (better-tasks.*) or the project's .claude/tasks/config.json. When you turn a setting on, it sets up what that setting needs (the Kokoro voice for videos, gh for pull requests)./config, only when you press "All Claude Code settings" on its settings page. It adds /better-tasks and /away.~/.claude/CLAUDE.md pointing at its team rules. You approve the edit."CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1" to the env block of ~/.claude/settings.json (you approve the edit) and then to ask you to restart.statusEvery quiet minutes (10 by default, 0 turns it off) while a task is running (status doing) and nothing is in your prompt box: a status check asking the lead to move the open tasks forward (.claude/better-tasks/status-check.md).bun scripts/instructions-doc.ts --open in a clone.mcp__better-tasks__*) are answered by the plugin itself. It never answers a permission question: you do. Its teammate agent types run in default permission mode, so a teammate asks you as Claude Code would (a lead in bypass, accept-edits or auto mode gives its teammates that mode, as Claude Code does).git; gh for pull requests, with your own GitHub login; claude plugin marketplace and claude plugin update when you say Yes to an update; your editor or the default app (open), to open a task or a video; on macOS caffeinate (setting keepAwake), and for /away pmset, osascript and a small virtual-display helper compiled from bin/. With videos on: ffmpeg, uv and the Kokoro voice (bin/demo-video.sh). With live review on: node, screencapture and security (bin/live-review.sh).gh uses your GitHub login. Before it opens a PR for you to approve, bin/open-pr.sh reads gh auth token to check that the PR's video loads, and sends it only to GitHub's own hosts.git ls-remote on this GitHub repo at startup, to see if a release is out. With "PR per task", gh talks to GitHub for your repo, and better-tasks installs or updates gh with Homebrew if it is missing or too old. With before/after videos on, a one-time setup installs the Kokoro voice (uv fetches packages from PyPI and the voice model from Hugging Face) into ~/.local/share/better-tasks/kokoro, and each video goes to its PR on GitHub (attached by gh, as when you drop a file in; it opens for whoever can see the repo; without gh 2.99+, on a better-tasks-videos branch of your repo). With live review on (setting liveReview, off by default), npm installs Google's @google/genai once into ~/.cache/better-tasks/, and while a teammate tests, the test display's frames that changed (never your own screens; the boxes it masks blacked out) go to Google's Gemini Live API under your key, about $0.02 a minute. Use a key of an AI Studio project with billing on: on the free tier Google may use what is sent to improve its products, and people may review it (the Gemini API terms, "Unpaid Services"); on either tier Google keeps requests 55 days to detect abuse. Nothing else.Questions, bugs or a security report: open an issue or write to iosif@bringes.io.
Ask Claude for the change: it forks this repo, tries it as a linked install, then offers to open a PR here. Details and dev commands: CONTRIBUTING.md.
hooks/register.tsx 948 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, ToolCallInput } from 'claude-code'
3
4import type { Activity, CacheStep, StatusCheck, Task, Teammate, TurnFacts } from '../types'
5import { activityOf } from './activity'
6import { realigned, rollOver } from './boundary'
7import { subagentTtl } from './cache'
8import { OFF_LINE } from './projectsetup'
9import { addSource, hasTeamInstall, maySelfCommit, needsPin, pinCommit, pinnedTag, repoUrl, SHARED_SETTINGS, TEAM_COMMIT, TEAM_NO, TEAM_QUESTION, TEAM_SETTING, TEAM_YES, TEAMMATE_INSTALL, updateCommit, withTeamInstall } from './teaminstall'
10import { activeInstall, declaresMarketplace, DECLINED_KEY, listArgv, lsRemoteArgv, MANAGED_SETTINGS, offeredRelease, pinTarget, refreshArgv, releaseTags, repinArgv, RESTART_HEADER, RESTART_LATER, RESTART_SKIP, restartLaterLine, restartQuestion, UPDATE_HEADER, UPDATE_NO, UPDATE_YES, updateArgv, updatedLine, updateQuestion, versionAt, withDeclaredTag } from './updatecheck'
11import type { Install } from './updatecheck'
12import { excludeWorktrees, IDE_SETTING } from './intellij'
13import { migrateFolder, migrateRules } from './migrate'
14import { DEFAULT_TYPE, teammateTypes, typeOf } from './models'
15import { readUpstreamPr, saveUpstreamPr, UPSTREAM_PR_TOOL } from './contribute'
16import { contextBlock, footerText, isPerson, isQuestion, resolvedIn, unclosedLine, unfiledLine, withRules } from './coordinator'
17import { ENABLE_OPTION, QUESTION, SETTING_KEY, SETUP_TOAST, setupArgv, setupVerdict, voiceDir } from './demovideo'
18import { FLOW_ASK_HEADER, flowOfAnswer, flowOptions, flowQuestion, hasPrs, lookAt, recommend, usesWorktree } from './gitflow'
19import type { GitFlow, Probe } from './gitflow'
20import { findPrTemplate } from './prtemplate'
21import { GH_UPDATE_TOAST, ghProblem, ghUpdateArgv, ghUpdateVerdict, hasGitHub } from './pullrequest'
22import type { Io } from './io'
23import { PANE_COMMANDS, registerPane } from './pane'
24import { registerScreen, SCREEN_COMMANDS, SCREEN_TOOLS } from './screen'
25import { hasPointer, pointerPrompt, RESTART_TEXT, SETUP_PROMPT, teamsState, waitingLine } from './setup'
26import { projectSettings, readOverrides, saveProjectValue } from './settings'
27import type { Settings } from './settings'
28import { sprintStart } from './sprints'
29import { isOpen, listTasks, saveTask, today, whenOf } from './tasks'
30import { contextTokens, isActive, outsideTeam, overLimit, predecessorOf, refreshTeam } from './team'
31import { NO_FACTS, renderRule, rulesChangedNote, TEMPLATES_DIR, varsOf } from './rules'
32import type { Facts, RuleFile, RulesSent } from './rules'
33import { ourSkill } from './skills'
34import { changedSections } from './template'
35import type { Sources, Vars } from './template'
36import { IGNORE_COMMIT, isNotIgnored, withIgnoreLine, WORKTREES_FOLDER } from './ignoreworktrees'
37import { addArgs, baseOf, includedFiles, ownWorktreeOf, worktreePaths } from './ownworktree'
38import type { OwnWorktree } from './ownworktree'
39import { spawnTask, withSummary } from './spawn'
40import { fingerprintOf, isRunning, NO_CHECK, statusDecision } from './status'
41import { startupTips } from './tips'
42import { runTool, TOOLS } from './tools'
43
44// Wires the parts to the engine. `$` and the state refs stay in this file (the
45// validator follows neither across an import); the parts get `ioOf($)`.
46// Settings are read per call (settingsNow): the plugin's options with the project's config.json over them.
47
48/** The tools that file a user's message: as a new task, or on an existing one. */
49const FILING_TOOLS = ['task_create', 'task_update', 'task_note']
50
51let pluginOptions: PluginOptions = {}
52let loggedProblems = ''
53let loggedMissingTemplate = ''
54let voiceSetup: Promise<void> | undefined
55let ghUpdate: Promise<void> | undefined
56
57const NO_OPTION = 'No'
58/** A setup question waits for an empty prompt box this long (the user is not typing), checked every POLL_MS. */
59const QUIET_MS = 2000
60const POLL_MS = 250
61/** Still typing after this long: the questions wait for the next session. */
62const GIVE_UP_MS = 10 * 60_000
63
64const tasksState = atom({ plugin: 'better-tasks', key: 'tasks' } as const, [] as Task[])
65const teamState = atom({ plugin: 'better-tasks', key: 'team' } as const, [] as Teammate[])
66const tokensState = atom({ plugin: 'better-tasks', key: 'tokens' } as const, {} as Record<string, number>)
67const activityState = atom({ plugin: 'better-tasks', key: 'activity' } as const, {} as Record<string, Activity>)
68const cacheStepsState = atom({ plugin: 'better-tasks', key: 'cacheSteps' } as const, {} as Record<string, CacheStep>)
69const statusState = atom({ plugin: 'better-tasks', key: 'statusCheck' } as const, NO_CHECK as StatusCheck)
70const noticeState = atom({ plugin: 'better-tasks', key: 'notice' } as const, '')
71const footerState = atom({ plugin: 'better-tasks', key: 'footer' } as const, '')
72const resolvedState = atom({ plugin: 'better-tasks', key: 'resolved' } as const, [] as string[])
73/** The teammate agent types as last registered (JSON of their specs); '' until they are (models.ts). */
74const typesState = atom({ plugin: 'better-tasks', key: 'teammateTypes' } as const, '')
75const rulesState = atom({ plugin: 'better-tasks', key: 'rulesSent' } as const, { shown: '', lead: '', teammate: '' } as RulesSent)
76/** This project has no GitHub remote (seen at session start): no PRs, so if it chose no flow, straight to main. */
77const noGitHubState = atom({ plugin: 'better-tasks', key: 'noGitHub' } as const, false)
78const turnState = atom({ plugin: 'better-tasks', key: 'turn' } as const, { asked: false, filed: false, question: false, prompted: false } as TurnFacts)
79
80export const register: Register = (on, options) => {
81 pluginOptions = options
82 registerPane(on, options)
83 registerScreen(on, options)
84
85 on('session.start', async ($, e, next) => {
86 const started = await next(e)
87 if (await isOffHere($)) {
88 await declareCommands($)
89 $.ui.log(OFF_LINE)
90 return started
91 }
92 await declareTools($)
93 await declareCommands($)
94 await update($, rulesState, () => ({ shown: '', lead: '', teammate: '' }))
95 const noGitHub = !(await hasGitHubRemote($))
96 await update($, noGitHubState, () => noGitHub)
97 await useLongCache($).catch(error => logFailure($, 'the 1-hour cache', error))
98 const moved = await migrateFolder(ioOf($)).catch(error => `better-tasks: moving the old task folder failed: ${error}`)
99 if (moved) $.ui.log(moved)
100 const rulesMoved = await migrateRules(ioOf($)).catch(error => `better-tasks: moving the old instruction overrides failed: ${error}`)
101 if (rulesMoved) $.ui.log(rulesMoved)
102 await keepWorktreesFromIde($).catch(error => logFailure($, 'excluding the worktrees in IntelliJ', error))
103 if (!(await setUpTeams($))) return started
104 await update($, typesState, () => '')
105 await syncTeammateTypes($, await settingsNow($)).catch(error => logFailure($, 'the teammate agent types', error))
106 if (e.isInteractive) await pointUserRules($).catch(error => logFailure($, 'the CLAUDE.md pointer', error))
107 if (e.isInteractive) void startQuestions($).catch(error => logFailure($, 'the startup questions', error))
108 const startedAt = await $.clock.now()
109 await update($, statusState, () => ({ ...NO_CHECK, activeAt: startedAt }))
110 await tick($).catch(error => logFailure($, 'the first refresh', error))
111 $.clock.every(60_000, () => tick($))
112 const tips = await startupTips(ioOf($), await settingsNow($)).catch(() => [])
113 for (const line of tips) $.ui.log(line)
114 return started
115 })
116
117 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
118 const footer = await read($, footerState)
119 return footer ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, footer] } }) : next(e)
120 })
121
122 on('prompt.compose', async ($, e, next) => {
123 const composed = await next(e)
124 if (!(await teamsOn($)) || (await isOffHere($))) return composed
125 const rules = (await read($, rulesState)).shown || (await rememberRules($)).shown
126 return { sections: withRules(composed.sections, e.traits, e.tools, rules) }
127 })
128
129 // Our skills follow the settings in force: their path filled in, their settings under their title (skills.ts).
130 on('skill.prompt', async ($, e, next) => {
131 const computed = await next(e)
132 const skill = ourSkill(e.skill)
133 if (!skill) return computed
134 return { text: await ruleNow($, `${skill}.md`) }
135 })
136
137 // A setting turned on in /config (composer) or on our settings page (turnedOn): set up what it needs.
138 on('config.set', { key: /^better-tasks\./ }, async ($, e, next) => {
139 const written = await next(e)
140 if (written.value === true) void setUpTurnedOn($, e.key)
141 return written
142 })
143 on('state.set', { plugin: 'better-tasks', key: 'turnedOn' }, async ($, e, next) => {
144 const written = await next(e)
145 void setUpTurnedOn($, `better-tasks.${(e.value as { field: string }).field}`)
146 return written
147 })
148
149 // The footer follows the tasks as the board does: any write of them (a tool, a file edit, the board) recounts it.
150 on('state.set', { plugin: 'better-tasks', key: 'tasks' }, async ($, e, next) => {
151 const written = await next(e)
152 await showStatus($, await settingsNow($)).catch(error => logFailure($, 'the footer', error))
153 return written
154 })
155
156 on('prompt.submit', async ($, e, next) => {
157 const isOwnCheck = e.origin.kind === 'plugin' && e.origin.name === 'better-tasks'
158 if (isOwnCheck) {
159 const unclosed = unclosedLine(await unclosedIds($))
160 return next({ ...e, context: [...(e.context ?? []), await contextBlock(ioOf($), await settingsNow($), unclosed)] })
161 }
162 if (!isPerson(e.origin) || (await isOffHere($))) return next(e)
163 const now = await $.clock.now()
164 await update($, statusState, check => ({ ...check, activeAt: now, quiet: 0 }))
165 const state = teamsState(await $.env.get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'), (await $.settings.read()).env)
166 if (state !== 'on') return next({ ...e, context: [...(e.context ?? []), waitingLine(state)] })
167 const reminder = unfiledLine(await read($, turnState))
168 await update($, turnState, () => ({ asked: false, filed: false, question: isQuestion(e.text), prompted: true, userText: e.text }))
169 const settings = await settingsNow($)
170 await syncTeammateTypes($, settings).catch(() => undefined)
171 const notices = [await changedRulesNote($), await read($, noticeState), unclosedLine(await unclosedIds($)), reminder].filter(Boolean).join('\n')
172 const block = await contextBlock(ioOf($), settings, notices)
173 await update($, noticeState, () => '')
174 await showStatus($, settings)
175 return next({ ...e, context: [...(e.context ?? []), block] })
176 })
177
178 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
179 const answered = await next(e)
180 if (e.agentId !== undefined) return answered
181 const answers = (answered.result as { answers?: unknown } | undefined)?.answers
182 await update($, turnState, facts => ({ ...facts, asked: true, userText: [facts.userText, ...answerTexts(answers)].join('\n') }))
183 const resolved = resolvedIn(answers, (await settingsNow($)).tasks.prefix)
184 if (resolved.length > 0) await update($, resolvedState, ids => [...new Set([...ids, ...resolved])])
185 return answered
186 })
187
188 on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
189 if (!e.name || !(await teamsOn($)) || (await isOffHere($))) return next(e)
190 const settings = await settingsNow($)
191 const tasks = await listTasks(ioOf($))
192 const full = overLimit(await refreshTeam(ioOf($)), e.name, settings.maxTeammates, tasks.filter(isOpen))
193 if (full) return { deny: full }
194 const task = spawnTask(tasks, e.prompt, e.name, settings.tasks.prefix)
195 const named = task ? withSummary(e, task) : { description: e.description, prompt: e.prompt }
196 const handover = await handoverOf($, e.name)
197 const hasTypes = (await read($, typesState)) !== ''
198 const type = e.subagent_type ?? (hasTypes ? DEFAULT_TYPE : undefined)
199 const wantsWorktree = (usesWorktree(settings.gitFlow, settings.worktree) && !e.isolation) || e.isolation === 'worktree'
200 const own = wantsWorktree && !settings.worktreeSandbox ? await ownWorktree($, e.name) : undefined
201 const isIsolated = wantsWorktree && own === undefined
202 const teammate = await ruleNow($, 'teammate.md', { isWorktree: wantsWorktree, isIsolated, ownWorktree: own, isHard: type === typeOf('hard') })
203 const prompt = [named.prompt, handover, teammate].filter(Boolean).join('\n\n')
204 const { isolation: asked, ...spawn } = e
205 const isolation = isIsolated ? 'worktree' : asked === 'worktree' ? undefined : asked
206 return next({
207 ...spawn,
208 description: named.description,
209 prompt,
210 ...(type === undefined ? {} : { subagent_type: type }),
211 ...(isolation === undefined ? {} : { isolation }),
212 })
213 })
214
215 on('tool.call', async ($, e, next) => {
216 const agentId = e.agentId
217 if (agentId !== undefined) {
218 const now = await $.clock.now()
219 const text = activityOf(String(e.tool), e)
220 await update($, activityState, all => ({ ...all, [agentId]: { text, at: now } }))
221 await refreshTeam(ioOf($))
222 }
223 return next(e)
224 })
225
226 on('turn.start', async ($, e, next) => {
227 const now = await $.clock.now()
228 await update($, statusState, check => ({ ...check, busy: true, activeAt: now }))
229 return next(e)
230 })
231
232 on('turn.complete', async ($, e, next) => {
233 const agentId = e.agentId
234 if (agentId === undefined) {
235 const now = await $.clock.now()
236 await update($, statusState, check => ({ ...check, busy: false, activeAt: now }))
237 await update($, turnState, facts => ({ ...facts, userText: '' }))
238 }
239 if (agentId !== undefined) {
240 await update($, activityState, ({ [agentId]: _ended, ...rest }) => rest)
241 await refreshTeam(ioOf($))
242 }
243 return next(e)
244 })
245
246 on('turn.step', async function* ($, e, next) {
247 const result = yield* next(e)
248 const agentId = e.agentId
249 if (agentId !== undefined && result.usage) {
250 const tokens = contextTokens(result.usage)
251 const step = { at: await $.clock.now(), read: result.usage.cache_read_input_tokens, created: result.usage.cache_creation_input_tokens }
252 await update($, tokensState, all => ({ ...all, [agentId]: tokens }))
253 await update($, cacheStepsState, all => ({ ...all, [agentId]: step }))
254 await refreshTeam(ioOf($))
255 }
256 return result
257 })
258
259 // Messages and wake-ups stay on this project's team: another session's agent only when the user names it (team.ts outsideTeam).
260 on('tool.call', { tool: 'SendMessage' }, async ($, e, next) => {
261 if (!(await teamsOn($)) || (await isOffHere($))) return next(e)
262 const userText = (await read($, turnState)).userText ?? ''
263 const outside = outsideTeam(await $.agent.list(), String(e.to ?? ''), e.agentId !== undefined, userText)
264 return outside ? { deny: outside } : next(e)
265 })
266
267 on('tool.call', { tool: 'mcp__better-tasks__task_create' }, ($, e) => serveTool($, e, 'task_create'))
268 on('tool.call', { tool: 'mcp__better-tasks__task_update' }, ($, e) => serveTool($, e, 'task_update'))
269 on('tool.call', { tool: 'mcp__better-tasks__task_list' }, ($, e) => serveTool($, e, 'task_list'))
270 on('tool.call', { tool: 'mcp__better-tasks__sprint_goal' }, ($, e) => serveTool($, e, 'sprint_goal'))
271 on('tool.call', { tool: 'mcp__better-tasks__team_status' }, ($, e) => serveTool($, e, 'team_status'))
272 on('tool.call', { tool: 'mcp__better-tasks__project_init' }, ($, e) => serveTool($, e, 'project_init'))
273 on('tool.call', { tool: 'mcp__better-tasks__task_note' }, ($, e) => serveTool($, e, 'task_note'))
274 on('tool.call', { tool: 'mcp__better-tasks__task_search' }, ($, e) => serveTool($, e, 'task_search'))
275 on('tool.call', { tool: 'mcp__better-tasks__upstream_pr' }, async ($, e) => {
276 const answer = String((e as { answer?: unknown }).answer ?? 'ask')
277 return { result: await saveUpstreamPr(ioOf($), await claudeDirOf($), answer) }
278 })
279}
280
281/** The teammate agent types follow the settings: registered again when they change, in force from the next turn (models.ts). */
282async function syncTeammateTypes($: EngineInterface, settings: Settings): Promise<void> {
283 const types = teammateTypes(settings.models)
284 const key = JSON.stringify(types)
285 if (key === (await read($, typesState))) return
286 // permissionMode 'default' asks the user; a lead in bypass, acceptEdits or auto mode still hands its teammates its own mode.
287 for (const type of types) {
288 await $.agent.register({ name: type.name, description: type.description, prompt: type.prompt, model: type.model, effort: type.effort, permissionMode: 'default' })
289 }
290 await update($, typesState, () => key)
291}
292
293/** Registers every tool and command on its own: one refusal (a taken name) leaves the rest working. */
294async function declareTools($: EngineInterface): Promise<void> {
295 for (const tool of [...TOOLS, ...SCREEN_TOOLS, UPSTREAM_PR_TOOL]) {
296 await $.tool.register(tool).catch(error => logFailure($, `tool ${tool.name}`, error))
297 }
298}
299
300/** The commands stay even where better-tasks is off: /better-tasks config is how to look and turn it on. */
301async function declareCommands($: EngineInterface): Promise<void> {
302 for (const command of [...PANE_COMMANDS, ...SCREEN_COMMANDS]) {
303 await $.command.register(command).catch(error => logFailure($, `/${command.name}`, error))
304 }
305}
306
307/** From the first run, whatever the git flow, IntelliJ skips .claude/worktrees/ (intellij.ts; on by default); a project without .idea/ is left as it is. */
308async function keepWorktreesFromIde($: EngineInterface): Promise<void> {
309 if (!(await settingsNow($)).excludeWorktreesFromIde) return
310 const line = await excludeWorktrees(ioOf($), await ideaFilesOf($))
311 if (line) $.ui.log(line)
312}
313
314/** The names in the project's .idea/; undefined when it has none (no JetBrains IDE). */
315async function ideaFilesOf($: EngineInterface): Promise<string[] | undefined> {
316 return $.fs.list(`${await $.session.root()}/.idea`).then(entries => entries.map(entry => entry.name), () => undefined)
317}
318
319function logFailure($: EngineInterface, what: string, error: unknown): void {
320 const reason = error instanceof Error ? error.message : String(error)
321 $.ui.log(`better-tasks: ${what} failed: ${reason}`)
322}
323
324/**
325 * The 1-hour prompt cache for teammates and the main conversation (setting `longCache`), unless the user
326 * chose a TTL already. Set in this process's environment, which Claude Code reads per request.
327 */
328async function useLongCache($: EngineInterface): Promise<void> {
329 if (!(await settingsNow($)).longCache) return
330 const chosen = await $.settings.read()
331 if ((await $.env.get('CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL')) === undefined && chosen.subagentPromptCacheTtl === undefined) {
332 await $.env.set('CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL', '1h')
333 }
334 if ((await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL')) === undefined && chosen.promptCacheTtl === undefined) {
335 await $.env.set('CLAUDE_CODE_PROMPT_CACHE_TTL', '1h')
336 }
337}
338
339/** For a successor ("login-2"): where its predecessor's transcript is. */
340async function handoverOf($: EngineInterface, name: string): Promise<string> {
341 const predecessor = predecessorOf(await refreshTeam(ioOf($)), name)
342 return predecessor ? `${predecessor.name}'s transcript, to search: ${await transcriptOf($, predecessor.id)}` : ''
343}
344
345/** Where a teammate's transcript is: ~/.claude/projects/<project>/<session>/subagents/, the file named for its id. */
346async function transcriptOf($: EngineInterface, agentId: string): Promise<string> {
347 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
348 const project = (await $.session.root()).replace(/[^A-Za-z0-9]/g, '-')
349 const folder = `${home}/projects/${project}/${await $.session.id()}/subagents`
350 const files = await $.fs.list(folder).catch(() => [])
351 const file = files.find(entry => entry.name.includes(agentId) && entry.name.endsWith('.jsonl'))
352 return file ? `${folder}/${file.name}` : `${folder}/ (the .jsonl file whose name holds ${agentId})`
353}
354
355/** The user's Claude Code folder: CLAUDE_CONFIG_DIR, else ~/.claude. */
356async function claudeDirOf($: EngineInterface): Promise<string> {
357 return (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
358}
359
360async function teamsOn($: EngineInterface): Promise<boolean> {
361 return (await $.env.get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS')) === '1'
362}
363
364/** Once per session: true when agent teams are on; else asks Claude to set them up, or says to restart. */
365/** Once per machine: ask Claude to point the user's global CLAUDE.md at our team rules (the user approves the edit). */
366async function pointUserRules($: EngineInterface): Promise<void> {
367 if (await $.store.get('claudeMdAsked')) return
368 const home = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? '~'}/.claude`
369 const path = `${home}/CLAUDE.md`
370 const text = await $.fs.read(path).catch(() => undefined)
371 if (text !== undefined && hasPointer(text)) return
372 await $.store.set('claudeMdAsked', true)
373 void $.prompt.submit({ text: pointerPrompt(path, text !== undefined) }).catch(() => undefined)
374}
375
376/**
377 * At startup, one after the other: the team question (or pinning a shared install), a newer release, then
378 * asks once whether to turn on before/after videos, then which git flow
379 * (only in a project with a GitHub remote, until one is saved in its config.json), then, with worktrees in an
380 * IntelliJ project, whether IntelliJ may skip them. A setting already on gets what it needs.
381 */
382async function startQuestions($: EngineInterface): Promise<void> {
383 if (await isOffHere($)) return
384 await ignoreWorktrees($).catch(error => logFailure($, 'adding .claude/worktrees/ to .gitignore', error))
385 await askTeamInstall($)
386 await checkForUpdate($).catch(error => logFailure($, 'the update check', error))
387 const settings = await settingsNow($)
388 if (settings.demoVideos) {
389 if (!(await isVoiceReady($))) await setUpVoice($)
390 } else {
391 await askToTurnOn($, { field: 'demoVideos', question: QUESTION, header: 'Videos' })
392 }
393 const { values } = await readOverrides(ioOf($))
394 const isChosen = FLOW_KEYS.some(key => key in values) || pluginOptions.pullRequests === true
395 if (!isChosen) await askGitFlow($)
396 else if (hasPrs(settings.gitFlow)) await checkGh($)
397}
398
399/** True when better-tasks is off in this project (the user said no to it here). */
400async function isOffHere($: EngineInterface): Promise<boolean> {
401 return !(await settingsNow($)).useBetterTasks
402}
403
404async function isGitRepo($: EngineInterface): Promise<boolean> {
405 return $.process.run(['git', 'rev-parse', '--is-inside-work-tree']).then(done => done.exitCode === 0, () => false)
406}
407
408/**
409 * In a git project whose shared settings don't have better-tasks yet: only me, or everyone on the project?
410 * Shared unpinned (before pinning, or with autoUpdate): pinned to a release now, without asking.
411 */
412async function askTeamInstall($: EngineInterface): Promise<void> {
413 const shared = await readShared($)
414 if (needsPin(shared)) return pinShared($)
415 if (hasTeamInstall(shared)) return
416 if (await isGitRepo($)) await askToTurnOn($, { field: TEAM_SETTING, question: TEAM_QUESTION, header: 'Team', answers: [TEAM_YES, TEAM_NO] })
417}
418
419/** Adds better-tasks, pinned to a release, to the project's shared settings and commits only that file (teaminstall.ts). */
420async function shareWithTeam($: EngineInterface): Promise<void> {
421 const shared = await readShared($)
422 if (needsPin(shared)) return pinShared($)
423 if (hasTeamInstall(shared)) return
424 const tag = await pinTagOf($, shared)
425 if (tag === undefined) return $.ui.log(`better-tasks: could not find its latest release (offline?); not added to ${SHARED_SETTINGS} yet`)
426 const changed = withTeamInstall(shared, tag)
427 if (changed === undefined) return $.ui.log(`better-tasks: ${SHARED_SETTINGS} is not one JSON object; better-tasks was not added to it`)
428 await $.fs.write(await sharedPath($), changed)
429 const committed = await commitShared($, TEAM_COMMIT)
430 if (committed.exitCode === 0) $.ui.log(`better-tasks: added to ${SHARED_SETTINGS}, pinned to ${tag}, and committed it. Push it; each teammate then installs it once: ${TEAMMATE_INSTALL}`)
431 else $.ui.log(`better-tasks: added to ${SHARED_SETTINGS}, pinned to ${tag}, but committing it failed: ${committed.stderr.trim()}. Commit it yourself.`)
432}
433
434/** Shared unpinned: the marketplace pinned to the installed release, autoUpdate gone; committed when the git flow allows. */
435async function pinShared($: EngineInterface): Promise<void> {
436 const shared = await readShared($)
437 const tag = await pinTagOf($, shared)
438 const changed = tag === undefined ? undefined : withTeamInstall(shared, tag)
439 if (tag === undefined || changed === undefined) return
440 const hadEdits = await hasSharedEdits($)
441 await $.fs.write(await sharedPath($), changed)
442 const outcome = await selfCommit($, pinCommit(tag), hadEdits)
443 $.ui.log(`better-tasks: pinned to ${tag} in ${SHARED_SETTINGS}, no auto-update (it took every new release unchecked). ${outcome}`)
444}
445
446/**
447 * Startup: a release newer than the installed one or the project's pin is offered once per version (updatecheck.ts).
448 * Yes moves the pin (when the project has one), updates the plugin and asks to restart; No is not asked again.
449 */
450async function checkForUpdate($: EngineInterface): Promise<void> {
451 const install = await installOf($)
452 if (install === undefined) return
453 const shared = await readShared($)
454 const pinned = hasTeamInstall(shared) ? pinnedTag(shared) : undefined
455 const offered = offeredRelease(await releasesOf($, shared), install.version, pinned, await $.store.get(DECLINED_KEY))
456 if (offered === undefined) return
457 const answer = await askSetup($, updateQuestion(offered), [UPDATE_YES, UPDATE_NO], UPDATE_HEADER)
458 if (answer === UPDATE_NO) await $.store.set(DECLINED_KEY, offered)
459 if (answer === UPDATE_YES) await updateTo($, offered, install)
460}
461
462async function updateTo($: EngineInterface, tag: string, install: Install): Promise<void> {
463 const shared = await readShared($)
464 const isPinnedHere = hasTeamInstall(shared) && pinnedTag(shared) !== undefined
465 const movesPin = isPinnedHere && pinnedTag(shared) !== tag
466 const hadEdits = await hasSharedEdits($)
467 const run = async (argv: string[]) => $.process.run(argv, { cwd: await $.session.root(), timeoutMs: 180_000 })
468 const isDeclared = await isDeclaredByUser($)
469 const userPath = `${await claudeDirOf($)}/settings.json`
470 const user = (await $.fs.read(userPath).catch(() => undefined)) ?? ''
471 const userRepinned = withDeclaredTag(user, tag)
472 const fetchArgv = userRepinned !== undefined ? repinArgv(addSource(user), tag, 'user') : isPinnedHere && !isDeclared ? repinArgv(addSource(shared), tag) : refreshArgv
473 if (fetchArgv === undefined) return $.ui.log(`better-tasks: the better-tasks source in ${userRepinned !== undefined ? userPath : SHARED_SETTINGS} is not an owner/repo or https URL; update it yourself`)
474 // A user pin lets only an add from that same source#tag through: the pin moves first, back when the add fails.
475 if (userRepinned !== undefined) await $.fs.write(userPath, userRepinned)
476 const moved = await run(fetchArgv)
477 if (moved.exitCode !== 0 && userRepinned !== undefined) await $.fs.write(userPath, user)
478 if (moved.exitCode !== 0) return $.ui.log(`better-tasks: could not fetch ${tag}: ${moved.stderr.trim()}`)
479 if (movesPin && isDeclared) await pinSharedTo($, shared, tag)
480 const pinNote = movesPin ? ` ${SHARED_SETTINGS} now pins ${tag}. ${await selfCommit($, updateCommit(tag), hadEdits)}` : ''
481 const updated = await run(updateArgv(install.scope))
482 if (updated.exitCode !== 0 || (await versionNow($, install.scope)) !== tag.slice(1)) {
483 return $.ui.log(`better-tasks: could not update to ${tag}: ${(updated.stderr || updated.stdout).trim()}. Try: ${updateArgv(install.scope).join(' ')}`)
484 }
485 $.ui.log(`${updatedLine(tag)}${pinNote}`)
486 const answer = await askSetup($, restartQuestion(tag), [RESTART_LATER, RESTART_SKIP], RESTART_HEADER)
487 if (answer === RESTART_LATER) $.ui.log(restartLaterLine(tag))
488}
489
490const sharedPath = async ($: EngineInterface) => `${await $.session.root()}/${SHARED_SETTINGS}`
491const readShared = async ($: EngineInterface) => $.fs.read(await sharedPath($)).catch(() => undefined)
492
493/** Writes the project's pin itself: `marketplace add` can't, when the user's settings declare the marketplace. */
494async function pinSharedTo($: EngineInterface, shared: string | undefined, tag: string): Promise<void> {
495 const pinned = withTeamInstall(shared, tag)
496 if (pinned !== undefined) await $.fs.write(await sharedPath($), pinned)
497}
498
499/** True when the user's or managed settings declare the better-tasks marketplace (updatecheck.ts). */
500async function isDeclaredByUser($: EngineInterface): Promise<boolean> {
501 const files = [`${await claudeDirOf($)}/settings.json`, ...MANAGED_SETTINGS]
502 const texts = await Promise.all(files.map(file => $.fs.read(file).catch(() => undefined)))
503 return texts.some(declaresMarketplace)
504}
505
506/** The release to pin to: the installed one, or the repo's newest for a linked install (updatecheck.ts). */
507async function pinTagOf($: EngineInterface, shared: string | undefined): Promise<string | undefined> {
508 return pinTarget(await releasesOf($, shared), (await installOf($))?.version)
509}
510
511/** The release tags of the better-tasks repo the project uses (a fork's, when it names one); none offline. */
512async function releasesOf($: EngineInterface, shared: string | undefined): Promise<string[]> {
513 const listed = await $.process.run(lsRemoteArgv(repoUrl(shared)), { timeoutMs: 20_000 }).catch(() => undefined)
514 return listed?.exitCode === 0 ? releaseTags(listed.stdout) : []
515}
516
517/** The version installed at `scope` now (updatecheck.ts). */
518async function versionNow($: EngineInterface, scope: string): Promise<string | undefined> {
519 const listed = await $.process.run(listArgv, { timeoutMs: 30_000 }).catch(() => undefined)
520 return listed?.exitCode === 0 ? versionAt(listed.stdout, scope, await $.session.root()) : undefined
521}
522
523/** The install this session runs; undefined for a linked install (the user's own checkout). */
524async function installOf($: EngineInterface): Promise<Install | undefined> {
525 const listed = await $.process.run(listArgv, { timeoutMs: 30_000 }).catch(() => undefined)
526 return listed?.exitCode === 0 ? activeInstall(listed.stdout, $.plugin.root, await $.session.root()) : undefined
527}
528
529async function hasSharedEdits($: EngineInterface): Promise<boolean> {
530 const diff = await $.process.run(['git', '-C', await $.session.root(), 'diff', '--quiet', 'HEAD', '--', SHARED_SETTINGS])
531 return diff.exitCode !== 0
532}
533
534/** Commits the shared settings alone. */
535async function commitShared($: EngineInterface, message: string): Promise<{ exitCode: number; stderr: string }> {
536 const git = async (...args: string[]) => $.process.run(['git', '-C', await $.session.root(), ...args])
537 const added = await git('add', '--', SHARED_SETTINGS)
538 return added.exitCode === 0 ? git('commit', '--quiet', '-m', message, '--only', '--', SHARED_SETTINGS) : added
539}
540
541/** A change better-tasks made on its own: committed when the git flow allows and the file had no other edits; returns the sentence saying which. */
542async function selfCommit($: EngineInterface, message: string, hadEdits: boolean): Promise<string> {
543 const settings = await settingsNow($)
544 const branch = (await $.process.run(['git', '-C', await $.session.root(), 'symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim()
545 if (hadEdits || !maySelfCommit(settings.gitFlow, branch, settings.devBranch)) return 'Commit it yourself.'
546 const committed = await commitShared($, message)
547 return committed.exitCode === 0 ? 'Committed; push it for your teammates.' : `Committing it failed: ${committed.stderr.trim()}. Commit it yourself.`
548}
549
550/**
551 * The teammate's own worktree, made with git as Claude Code would (ownworktree.ts), or the one it left
552 * before; undefined when git can't make it (not a repository, say), which spawns it isolated instead.
553 */
554async function ownWorktree($: EngineInterface, name: string): Promise<OwnWorktree | undefined> {
555 const root = await $.session.root()
556 const git = (...args: string[]) => $.process.run(['git', '-C', root, ...args])
557 const worktree = ownWorktreeOf(root, name)
558 if (worktreePaths((await git('worktree', 'list', '--porcelain')).stdout).includes(worktree.path)) return worktree
559 const hasBranch = (await git('rev-parse', '--verify', '--quiet', `refs/heads/${worktree.branch}`)).exitCode === 0
560 const remote = await git('symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD')
561 const base = baseOf(((await $.settings.read()).worktree as { baseRef?: unknown } | undefined)?.baseRef, remote.exitCode === 0 ? remote.stdout : '')
562 const added = await git(...addArgs(worktree, base, hasBranch))
563 if (added.exitCode !== 0) {
564 $.ui.log(`better-tasks: making ${name}'s worktree failed (${added.stderr.trim()}); it runs in Claude Code's isolated worktree instead.`)
565 return undefined
566 }
567 await copyIncluded($, root, worktree.path)
568 return worktree
569}
570
571/** The gitignored files .worktreeinclude names, copied into a new worktree, as Claude Code does for its own. */
572async function copyIncluded($: EngineInterface, root: string, worktree: string): Promise<void> {
573 if ((await $.fs.read(`${root}/.worktreeinclude`).catch(() => undefined)) === undefined) return
574 const listed = (...args: string[]) => $.process.run(['git', '-C', root, 'ls-files', '--others', '--ignored', ...args]).then(done => done.stdout)
575 const files = includedFiles(await listed('--exclude-standard'), await listed(`--exclude-from=${root}/.worktreeinclude`))
576 for (const file of files) {
577 await $.process.run(['mkdir', '-p', `${worktree}/${file}`.replace(/\/[^/]*$/, '')])
578 await $.process.run(['cp', '-p', `${root}/${file}`, `${worktree}/${file}`])
579 }
580}
581
582/** A git project's .gitignore gets .claude/worktrees/ once (ignoreworktrees.ts), committed alone; not from inside a worktree. */
583async function ignoreWorktrees($: EngineInterface): Promise<void> {
584 const root = await $.session.root()
585 if (root.includes(`/${WORKTREES_FOLDER}`)) return
586 const git = (...args: string[]) => $.process.run(['git', '-C', root, ...args])
587 if (!isNotIgnored((await git('check-ignore', '-n', '-v', WORKTREES_FOLDER)).stdout)) return
588 const hadEdits = (await git('diff', '--quiet', 'HEAD', '--', '.gitignore')).exitCode !== 0
589 const path = `${root}/.gitignore`
590 await $.fs.write(path, withIgnoreLine(await $.fs.read(path).catch(() => undefined), WORKTREES_FOLDER))
591 if (hadEdits) return $.ui.log(`better-tasks: added ${WORKTREES_FOLDER} to .gitignore; it has your other edits too, so commit it yourself.`)
592 const added = await git('add', '--', '.gitignore')
593 const committed = added.exitCode === 0 ? await git('commit', '--quiet', '-m', IGNORE_COMMIT, '--only', '--', '.gitignore') : added
594 if (committed.exitCode === 0) $.ui.log(`better-tasks: added ${WORKTREES_FOLDER} to .gitignore and committed it, so git status stops listing teammates' worktrees.`)
595 else $.ui.log(`better-tasks: added ${WORKTREES_FOLDER} to .gitignore, but committing it failed: ${committed.stderr.trim()}. Commit it yourself.`)
596}
597
598/** The git flow question: the project looked at once for the recommendation, the answer saved in its config.json. */
599/** A project that set any of these chose its flow already (the last two are the old switches). */
600const FLOW_KEYS = ['gitFlow', 'pullRequests', 'worktree']
601
602async function askGitFlow($: EngineInterface): Promise<void> {
603 if (!(await hasGitHubRemote($))) return
604 const recommended = recommend(await lookAt(await probeOf($)))
605 const answer = await askSetup($, flowQuestion(recommended), flowOptions(recommended.flow), FLOW_ASK_HEADER)
606 const flow = answer === undefined ? undefined : flowOfAnswer(answer)
607 if (flow === undefined) return
608 const problem = await saveProjectValue(ioOf($), 'gitFlow', flow)
609 if (problem) $.ui.log(`better-tasks: ${problem}`)
610 else await setUpFlow($, flow)
611}
612
613/** What a flow needs: gh for the PRs; for dev-prs, its branch, checked out here when made from this commit. */
614async function setUpFlow($: EngineInterface, flow: GitFlow): Promise<void> {
615 if (flow === 'dev-prs') await useDevBranch($, (await settingsNow($)).devBranch).catch(error => logFailure($, 'making the dev branch', error))
616 if (hasPrs(flow)) await checkGh($)
617 await keepWorktreesFromIde($)
618}
619
620/** Makes the dev branch at this commit and switches to it: the same commit, so no file changes. One that exists is left alone. */
621async function useDevBranch($: EngineInterface, dev: string): Promise<void> {
622 if (dev.startsWith('-')) return $.ui.log(`better-tasks: "${dev}" is not a branch name (devBranch in config.json)`)
623 const git = (...args: string[]) => $.process.run(['git', ...args])
624 if ((await git('branch', '--list', '--', dev)).stdout.trim()) {
625 const current = (await git('symbolic-ref', '--quiet', '--short', 'HEAD')).stdout.trim()
626 if (current !== dev) $.ui.log(`better-tasks: teammates land on ${dev}; this checkout is on ${current || 'no branch'}: git switch ${dev} when you're ready`)
627 return
628 }
629 const switched = await git('switch', '--quiet', '--create', dev)
630 if (switched.exitCode === 0) $.ui.log(`better-tasks: made the ${dev} branch here, from this commit: teammates land on it, PRs go to main`)
631 else $.ui.log(`better-tasks: could not make the ${dev} branch: ${switched.stderr.trim()}`)
632}
633
634/** What looking at the project for the recommendation needs (gitflow.ts). */
635async function probeOf($: EngineInterface): Promise<Probe> {
636 return {
637 root: await $.session.root(),
638 run: (argv, timeoutMs) => $.process.run(argv, timeoutMs ? { timeoutMs } : undefined).then(done => done.stdout, () => undefined),
639 list: path => $.fs.list(path),
640 read: path => $.fs.read(path).catch(() => undefined),
641 }
642}
643
644// ---- The instruction templates (rules.ts, template.ts) ----
645
646/** Where the templates come from: the plugin's .claude/better-tasks/, and the project's files for its overrides. */
647function sourcesOf($: EngineInterface): Sources {
648 return {
649 plugin: name => $.fs.read(`${$.plugin.root}/${TEMPLATES_DIR}/${name}`).catch(() => undefined),
650 project: async path => $.fs.read(`${await $.session.root()}/${path}`).catch(() => undefined),
651 }
652}
653
654/** The values the templates read, from the settings in force and these facts. A PR template path that isn't there is one log line, once. */
655async function varsNow($: EngineInterface, facts: Partial<Facts> = {}): Promise<Vars> {
656 const settings = await settingsNow($)
657 const template = await findPrTemplate(ioOf($), await $.session.root(), settings.prTemplate, $.plugin.root)
658 const missing = template.missing ?? ''
659 if (missing && missing !== loggedMissingTemplate) $.ui.log(`better-tasks: PR template not found: ${missing}; using ${template.path}`)
660 loggedMissingTemplate = missing
661 const known = {
662 pluginRoot: $.plugin.root,
663 hasTypes: (await read($, typesState)) !== '',
664 upstreamPr: await readUpstreamPr(ioOf($), await claudeDirOf($)),
665 prTemplate: template.path,
666 hasOwnPrTemplate: template.source !== 'shipped',
667 }
668 return varsOf(settings, { ...NO_FACTS, ...known, ...facts })
669}
670
671/** One instruction, rendered now. A project override that doesn't render (a typo in a tag) is logged and left out. */
672async function ruleNow($: EngineInterface, name: RuleFile, facts: Partial<Facts> = {}): Promise<string> {
673 const vars = await varsNow($, facts)
674 const sources = sourcesOf($)
675 try {
676 return await renderRule(name, sources, vars)
677 } catch (error) {
678 logFailure($, `the project's ${TEMPLATES_DIR}/${name}`, error)
679 return renderRule(name, { ...sources, project: async () => undefined }, vars)
680 }
681}
682
683/** The lead's rules as the session started with them (its system prompt keeps them), and both texts as last sent. */
684async function rememberRules($: EngineInterface): Promise<RulesSent> {
685 const sent = { shown: await ruleNow($, 'lead.md'), lead: '', teammate: await ruleNow($, 'teammate.md') }
686 sent.lead = sent.shown
687 await update($, rulesState, () => sent)
688 return sent
689}
690
691/** A setting changed mid-session: the changed sections, once, as replacing the old ones (the system prompt stays as it started). */
692async function changedRulesNote($: EngineInterface): Promise<string> {
693 const before = await read($, rulesState)
694 if (!before.shown) return ''
695 const now = { lead: await ruleNow($, 'lead.md'), teammate: await ruleNow($, 'teammate.md') }
696 if (now.lead === before.lead && now.teammate === before.teammate) return ''
697 await update($, rulesState, sent => ({ ...sent, ...now }))
698 return rulesChangedNote(changedSections(before.lead, now.lead), changedSections(before.teammate, now.teammate))
699}
700
701
702/** A setting's on/off question: `field` is its key in the project's config.json. */
703/** `answers`: the yes and no labels, when "Enable (recommended)" and "No" don't say what happens. */
704type TurnOnQuestion = { field: string; question: string; header: string; answers?: [yes: string, no: string] }
705
706/**
707 * Asks once per project: the answer, on or off, is saved in its config.json, so another project is asked
708 * at its own first start. Dismissed or answered in free text, it is asked again next session.
709 * Enable also sets the setting up. Returns the answer just saved; undefined when none was.
710 */
711async function askToTurnOn($: EngineInterface, ask: TurnOnQuestion): Promise<boolean | undefined> {
712 if (ask.field in (await readOverrides(ioOf($))).values) return undefined
713 const [yes, no] = ask.answers ?? [ENABLE_OPTION, NO_OPTION]
714 const answer = await askSetup($, ask.question, [yes, no], ask.header)
715 if (answer !== yes && answer !== no) return undefined
716 const problem = await saveProjectValue(ioOf($), ask.field, answer === yes)
717 if (problem) {
718 $.ui.log(`better-tasks: ${problem}`)
719 return undefined
720 }
721 if (answer === yes) await setUpTurnedOn($, `better-tasks.${ask.field}`)
722 return answer === yes
723}
724
725/**
726 * A setup question pops up on its own, so a key meant for the prompt box could answer it: Enter picks the
727 * highlighted option (the recommended one, first). So it waits until the user is not typing. Returns the
728 * answer; undefined for a dismissal (asked again next session) or a user who never stopped typing.
729 */
730async function askSetup($: EngineInterface, question: string, options: string[], header: string): Promise<string | undefined> {
731 if (!(await untilNotTyping($))) return undefined
732 return $.ui.ask(question, { options, header }).catch(() => undefined)
733}
734
735/** True once the prompt box has stayed empty for QUIET_MS; false when that never happens within GIVE_UP_MS. */
736async function untilNotTyping($: EngineInterface): Promise<boolean> {
737 const start = await $.clock.now()
738 let quietSince = start
739 for (let now = start; now - start < GIVE_UP_MS; now = await $.clock.now()) {
740 if ((await $.prompt.read()).text !== '') quietSince = now
741 else if (now - quietSince >= QUIET_MS) return true
742 await $.clock.sleep(POLL_MS)
743 }
744 return false
745}
746
747async function hasGitHubRemote($: EngineInterface): Promise<boolean> {
748 return $.process.run(['git', 'remote', '-v']).then(done => hasGitHub(done.stdout), () => false)
749}
750
751/**
752 * What a setting needs once turned on: the voice for videos; for a git flow chosen on the settings page, its setup;
753 * worktrees in an IntelliJ project, the IntelliJ question; a yes to it, the exclusion now.
754 */
755async function setUpTurnedOn($: EngineInterface, key: string): Promise<void> {
756 if (key === SETTING_KEY) await setUpVoice($)
757 if (key === 'better-tasks.gitFlow') await setUpFlow($, (await settingsNow($)).gitFlow)
758 if (['better-tasks.gitFlow', 'better-tasks.worktree', `better-tasks.${IDE_SETTING}`].includes(key)) await keepWorktreesFromIde($)
759 if (key === `better-tasks.${TEAM_SETTING}`) await shareWithTeam($)
760}
761
762/** PR per task needs gh, 2.99 or newer for the video: updates it when it is missing or older (bin/gh-update.sh). */
763async function checkGh($: EngineInterface): Promise<void> {
764 const version = await $.process.run(['gh', '--version']).then(done => done.stdout, () => undefined)
765 if (ghProblem(version)) await updateGh($)
766}
767
768/** One gh update at a time: turning the setting on while startup's runs joins it. */
769function updateGh($: EngineInterface): Promise<void> {
770 ghUpdate ??= runGhUpdate($)
771 .catch(error => logFailure($, 'the gh update', error))
772 .finally(() => (ghUpdate = undefined))
773 return ghUpdate
774}
775
776async function runGhUpdate($: EngineInterface): Promise<void> {
777 $.ui.toast(GH_UPDATE_TOAST, { timeoutMs: 8000 })
778 let output = ''
779 for await (const piece of $.process.spawn({ argv: ghUpdateArgv($.plugin.root) })) {
780 if ('stream' in piece && piece.stream === 'stdout') output += piece.text
781 }
782 const verdict = ghUpdateVerdict(output)
783 $.ui.toast(verdict.text, { timeoutMs: 8000 })
784 if (!verdict.isReady) $.ui.log(verdict.text)
785}
786
787/** One Kokoro setup at a time (bin/kokoro-setup.sh): turning videos on twice joins the running one. */
788function setUpVoice($: EngineInterface): Promise<void> {
789 voiceSetup ??= runVoiceSetup($)
790 .catch(error => logFailure($, 'the Kokoro voice setup', error))
791 .finally(() => (voiceSetup = undefined))
792 return voiceSetup
793}
794
795async function runVoiceSetup($: EngineInterface): Promise<void> {
796 $.ui.toast(SETUP_TOAST, { timeoutMs: 8000 })
797 let output = ''
798 for await (const piece of $.process.spawn({ argv: setupArgv($.plugin.root) })) {
799 if ('stream' in piece && piece.stream === 'stdout') output += piece.text
800 }
801 const verdict = setupVerdict(output)
802 $.ui.toast(verdict.text, { timeoutMs: 8000 })
803 if (!verdict.isReady) $.ui.log(verdict.text)
804}
805
806async function isVoiceReady($: EngineInterface): Promise<boolean> {
807 const dir = voiceDir({
808 custom: await $.env.get('BETTER_TASKS_KOKORO'),
809 dataHome: await $.env.get('XDG_DATA_HOME'),
810 home: await $.env.get('HOME'),
811 })
812 return $.fs.read(`${dir}/.ready`).then(() => true, () => false)
813}
814
815async function setUpTeams($: EngineInterface): Promise<boolean> {
816 const state = teamsState(await $.env.get('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS'), (await $.settings.read()).env)
817 if (state === 'restart') {
818 $.ui.toast(RESTART_TEXT)
819 $.ui.status(RESTART_TEXT)
820 }
821 if (state === 'missing') {
822 $.ui.status('better-tasks: agent teams are off')
823 void $.prompt.submit({ text: SETUP_PROMPT })
824 }
825 return state === 'on'
826}
827
828function ioOf($: EngineInterface): Io {
829 const io: Io = {
830 root: () => $.session.root(),
831 now: () => $.clock.now(),
832 sessionId: () => $.session.id(),
833 read: path => $.fs.read(path),
834 write: (path, text) => $.fs.write(path, text),
835 list: path => $.fs.list(path),
836 publishTasks: tasks => update($, tasksState, () => tasks),
837 agents: () => $.agent.list(),
838 window: async () => (await $.session.usage()).context.window,
839 tokens: () => read($, tokensState),
840 activities: () => read($, activityState),
841 publishTeam: team => update($, teamState, () => team),
842 cacheSteps: () => read($, cacheStepsState),
843 cacheTtl: async () =>
844 subagentTtl({
845 subagentEnv: await $.env.get('CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL'),
846 subagentSetting: (await $.settings.read().catch(() => ({}) as Record<string, unknown>)).subagentPromptCacheTtl,
847 oneHourEnv: await $.env.get('ENABLE_PROMPT_CACHING_1H'),
848 force5mEnv: await $.env.get('FORCE_PROMPT_CACHING_5M'),
849 }),
850 run: argv => $.process.run(argv),
851 config: () => settingsNow($),
852 }
853 return io
854}
855
856async function settingsNow($: EngineInterface): Promise<Settings> {
857 const hasGitHub = !(await read($, noGitHubState))
858 return projectSettings(ioOf($), hasGitHub ? pluginOptions : { ...pluginOptions, hasGitHub })
859}
860
861/** One dim line when the project's config.json has problems (each bad key is skipped, the rest still applies). */
862async function logConfigProblems($: EngineInterface): Promise<void> {
863 const { problems } = await readOverrides(ioOf($))
864 const text = problems.join('; ')
865 if (text === loggedProblems) return
866 loggedProblems = text
867 if (text) $.ui.log(`better-tasks: config.json: ${text}. Using the other settings.`)
868}
869
870async function serveTool($: EngineInterface, e: ToolCallInput, name: string) {
871 const facts = await read($, turnState)
872 const answer = await runTool(ioOf($), { name, input: e as never, facts, agentId: e.agentId }, await settingsNow($))
873 const isFiling = FILING_TOOLS.includes(name) && e.agentId === undefined && !('deny' in answer)
874 if (isFiling) await update($, turnState, turn => ({ ...turn, filed: true }))
875 return answer
876}
877
878/** Sprint progress in the footer; the status line stays free for what needs attention. */
879async function showStatus($: EngineInterface, settings: Settings): Promise<void> {
880 const start = sprintStart(await today(ioOf($)), settings.sprint)
881 const tasks = await read($, tasksState)
882 await update($, footerState, () => footerText(tasks, start, settings.sprint))
883}
884
885/** Once a minute: a new sprint? then fresh tasks, team and status line. */
886async function tick($: EngineInterface): Promise<void> {
887 const settings = await settingsNow($)
888 await logConfigProblems($)
889 await checkSprint($, settings)
890 await realign($, settings)
891 await refreshTeam(ioOf($))
892 await showStatus($, settings)
893 await checkStatus($, settings).catch(error => logFailure($, 'the status check', error))
894}
895
896/** After a quiet spell with a task running, one prompt asks the coordinator to move it forward (see status.ts). */
897async function checkStatus($: EngineInterface, settings: Settings): Promise<void> {
898 const now = await $.clock.now()
899 const day = await today(ioOf($))
900 const isNear = (task: Task) => isOpen(task) && ['now', 'this-sprint'].includes(whenOf(task, day, settings.sprint))
901 const tasks = (await read($, tasksState)).filter(isNear)
902 const team = (await read($, teamState)).filter(isActive)
903 const check = await read($, statusState)
904 const { fire, check: next } = statusDecision({
905 now,
906 every: settings.statusEvery,
907 check,
908 hasWork: isRunning(tasks),
909 composerText: (await $.prompt.read().catch(() => ({ text: '' }))).text,
910 fingerprint: fingerprintOf(tasks, team, await unclosedIds($)),
911 })
912 await update($, statusState, () => next)
913 if (!fire) return
914 const text = await ruleNow($, 'status-check.md', { idleMinutes: Math.round((now - check.activeAt) / 60_000) })
915 void $.prompt.submit({ text }).catch(() => undefined)
916}
917
918/** The user's own answers (not the questions, which the model wrote). */
919const answerTexts = (answers: unknown) =>
920 answers && typeof answers === 'object' ? Object.values(answers).filter((value): value is string => typeof value === 'string') : []
921
922/** The tasks the user resolved that are still open; closed ones leave the list, so a reopened task is not closed again. */
923async function unclosedIds($: EngineInterface): Promise<string[]> {
924 const open = new Set((await listTasks(ioOf($))).filter(isOpen).map(task => task.id))
925 const ids = (await read($, resolvedState)).filter(id => open.has(id))
926 await update($, resolvedState, () => ids)
927 return ids
928}
929
930/** Keeps every task on a sprint boundary when the sprint length or start day changes. */
931async function realign($: EngineInterface, settings: Settings): Promise<void> {
932 const current = sprintStart(await today(ioOf($)), settings.sprint)
933 const tasks = await listTasks(ioOf($))
934 for (const task of realigned(tasks, current, settings.sprint)) await saveTask(ioOf($), task)
935}
936
937async function checkSprint($: EngineInterface, settings: Settings): Promise<void> {
938 const key = `sprint:${await $.session.root()}`
939 const current = sprintStart(await today(ioOf($)), settings.sprint)
940 const seen = (await $.store.get(key)) as string | undefined
941 if (seen !== undefined && seen < current) {
942 const rolled = await rollOver(ioOf($), seen, current, settings.sprint)
943 $.ui.toast(rolled.toast)
944 await update($, noticeState, () => rolled.notice)
945 }
946 if (seen !== current) await $.store.set(key, current)
947}
948hooks/activity.ts 47 lines1// What a teammate is doing, in a few words, from the tool it just called. Pure: register.tsx
2// stores it on the teammate at tool.call; the Sprint pane shows it under the task.
3
4const MAX = 40
5
6const fileName = (path: unknown) => (typeof path === 'string' ? path.split('/').at(-1) ?? path : '')
7
8const clip = (text: string) => (text.length > MAX ? `${text.slice(0, MAX - 1)}…` : text)
9
10function field(input: unknown, name: string): unknown {
11 return typeof input === 'object' && input !== null ? (input as Record<string, unknown>)[name] : undefined
12}
13
14export function activityOf(tool: string, input: unknown): string {
15 switch (tool) {
16 case 'Edit':
17 case 'MultiEdit':
18 case 'Write':
19 case 'NotebookEdit':
20 return clip(`editing ${fileName(field(input, 'file_path') ?? field(input, 'notebook_path'))}`)
21 case 'Read':
22 return clip(`reading ${fileName(field(input, 'file_path'))}`)
23 case 'Bash':
24 return clip(`running ${String(field(input, 'command') ?? '').split('\n')[0]?.trim()}`)
25 case 'Grep':
26 case 'Glob':
27 return clip(`searching ${String(field(input, 'pattern') ?? '')}`)
28 case 'WebFetch':
29 case 'WebSearch':
30 return 'browsing the web'
31 case 'AskUserQuestion':
32 return 'waiting for your answer'
33 case 'SendMessage':
34 return clip(`messaging ${String(field(input, 'to') ?? '')}`)
35 case 'Agent':
36 return 'starting a helper'
37 default:
38 return clip(tool.replace(/^mcp__[^_]+__/, '').replace(/_/g, ' '))
39 }
40}
41
42/** The teammate's state in plain words. */
43export function stateWord(status: string): string {
44 const words: Record<string, string> = { running: 'working', completed: 'done', killed: 'stopped', failed: 'failed' }
45 return words[status] ?? status
46}
47hooks/boundary.ts 56 lines1import type { Task } from '../types'
2import type { Files } from './io'
3import { goalOf, readSprints, withReview, writeSprints } from './sprintlog'
4import { sprintLabel, sprintStart } from './sprints'
5import type { SprintConfig } from './sprints'
6import { isOpen, listTasks, saveTask } from './tasks'
7
8// The sprint boundary: unfinished work rolls into the new sprint, the old one gets a review.
9
10/** The open tasks of sprints before `current`, moved into it. */
11export function rolledOver(tasks: readonly Task[], current: string): Task[] {
12 return tasks
13 .filter(task => isOpen(task) && task.sprint !== 'backlog' && task.sprint < current)
14 .map(task => ({ ...task, sprint: current, rolled: task.rolled + 1 }))
15}
16
17/**
18 * Tasks whose sprint is no boundary under `config` (its length or start day changed), put on one:
19 * the sprint holding their date, or `current` for open work that would land in the past.
20 */
21export function realigned(tasks: readonly Task[], current: string, config: SprintConfig): Task[] {
22 return tasks.flatMap(task => {
23 if (task.sprint === 'backlog') return []
24 const aligned = sprintStart(task.sprint, config)
25 const sprint = isOpen(task) && aligned < current ? current : aligned
26 return sprint === task.sprint ? [] : [{ ...task, sprint }]
27 })
28}
29
30export function shippedIn(tasks: readonly Task[], sprint: string): Task[] {
31 return tasks.filter(task => task.status === 'done' && task.sprint === sprint)
32}
33
34export type RollOver = { toast: string; notice: string }
35
36/** Moves unfinished work into `current` and writes the review of `old`. */
37export async function rollOver(files: Files, old: string, current: string, config: SprintConfig): Promise<RollOver> {
38 const tasks = await listTasks(files)
39 const moved = rolledOver(tasks, current)
40 const shipped = shippedIn(tasks, old)
41 for (const task of moved) await saveTask(files, task)
42
43 const oldLabel = sprintLabel(old, config)
44 const sprints = withReview(await readSprints(files), old, oldLabel, { shipped, rolled: moved })
45 await writeSprints(files, sprints)
46
47 const label = sprintLabel(current, config)
48 const summary = `${oldLabel} shipped ${shipped.length}, rolled over ${moved.length}.`
49 const goal = goalOf(sprints, current)
50 const ask = goal ? `Its goal is "${goal}". ` : 'Ask the user for its goal (then call sprint_goal). '
51 return {
52 toast: `${label} started. ${summary}`,
53 notice: `${label} just started. ${summary} ${ask}Ask which backlog tasks to pull in. Start nothing on your own.`,
54 }
55}
56hooks/cache.ts 45 lines1import type { CacheStep } from '../types'
2
3// Prompt-cache warmth of a teammate, measured from its last model request.
4// The cache lives TTL minutes after its last read or write; a request in time reads it and starts the clock again.
5
6export type CacheTtl = '5m' | '1h'
7
8type TtlSources = {
9 /** CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL */
10 subagentEnv?: string
11 /** The subagentPromptCacheTtl setting. */
12 subagentSetting?: unknown
13 /** ENABLE_PROMPT_CACHING_1H */
14 oneHourEnv?: string
15 /** FORCE_PROMPT_CACHING_5M */
16 force5mEnv?: string
17}
18
19const isTtl = (value: unknown): value is CacheTtl => value === '5m' || value === '1h'
20
21/** The TTL Claude Code uses for requests outside the main conversation (teammates, subagents), in its own order. */
22export function subagentTtl(sources: TtlSources): CacheTtl {
23 if (sources.force5mEnv === '1') return '5m'
24 if (isTtl(sources.subagentEnv)) return sources.subagentEnv
25 if (isTtl(sources.subagentSetting)) return sources.subagentSetting
26 return sources.oneHourEnv === '1' ? '1h' : '5m'
27}
28
29const MINUTE = 60_000
30
31/** Minutes a cache is trusted: the TTL less a margin, so a request planned now still lands in time. */
32export function trustedMinutes(ttl: CacheTtl): number {
33 return ttl === '1h' ? 55 : 4
34}
35
36export type Warmth = { cache: 'warm' | 'cold'; minutesLeft: number }
37
38/** Warm while the last request is younger than the trusted minutes and it used the cache; undefined before any request. */
39export function warmthOf(step: CacheStep | undefined, now: number, ttl: CacheTtl): Warmth | undefined {
40 if (step === undefined) return undefined
41 const left = Math.floor(trustedMinutes(ttl) - (now - step.at) / MINUTE)
42 const usedCache = step.read + step.created > 0
43 return left > 0 && usedCache ? { cache: 'warm', minutesLeft: left } : { cache: 'cold', minutesLeft: 0 }
44}
45hooks/projectsetup.ts 5 lines1// Per-project switch (config.json): useBetterTasks is not asked; false keeps better-tasks quiet here.
2
3export const USE_SETTING = 'useBetterTasks'
4export const OFF_LINE = `better-tasks is off in this project ("${USE_SETTING}": false in .claude/tasks/config.json; set it to true and restart to turn it on)`
5hooks/teaminstall.ts 102 lines1// Sharing better-tasks with a project's team: asked once per project at setup (setting shareWithTeam).
2// "Everyone" adds the marketplace and the plugin to the project's shared .claude/settings.json and commits
3// that one file. Teammates who open the project in Claude Code get the marketplace and see the plugin enabled
4// for the project; each installs it once (TEAMMATE_INSTALL). The marketplace is pinned to a release tag
5// (source.ref) with no autoUpdate: nobody gets a release the project didn't pick. Newer releases: updatecheck.ts.
6
7import type { GitFlow } from './gitflow'
8import { isGithubRepo, isHttpsUrl, isReleaseTag } from './updatecheck'
9
10export const TEAM_SETTING = 'shareWithTeam'
11export const SHARED_SETTINGS = '.claude/settings.json'
12export const TEAM_YES = 'Yes, everyone on this project (recommended)'
13export const TEAM_NO = 'No, only me'
14
15const MARKETPLACE = 'better-tasks'
16const PLUGIN = `better-tasks@${MARKETPLACE}`
17const UPSTREAM = { source: 'github', repo: 'iosifnicolae2/better-tasks' }
18
19export const TEAM_QUESTION = 'Should we set up better-tasks in this project for other team members?'
20
21export const TEAMMATE_INSTALL = `claude plugin install ${PLUGIN} --scope project`
22export const TEAM_COMMIT = 'Share better-tasks with everyone on this project: Claude Code turns it on here; each teammate installs it once'
23export const pinCommit = (tag: string) => `Pin better-tasks to ${tag} in the shared settings, no auto-update`
24export const updateCommit = (tag: string) => `Update better-tasks to ${tag}`
25
26type Source = { source?: unknown; repo?: unknown; url?: unknown; ref?: unknown }
27type Marketplace = { source?: Source; autoUpdate?: unknown }
28type SharedSettings = { enabledPlugins?: Record<string, unknown>; extraKnownMarketplaces?: Record<string, Marketplace> }
29
30/** True when the project's shared settings already enable better-tasks. */
31export function hasTeamInstall(settingsText: string | undefined): boolean {
32 const settings = parse(settingsText)
33 return settings !== undefined && settings.enabledPlugins?.[PLUGIN] === true
34}
35
36/** The release tag the shared settings pin better-tasks to; undefined when not pinned to one. */
37export function pinnedTag(settingsText: string | undefined): string | undefined {
38 const entry = marketplaceOf(settingsText)
39 return isReleaseTag(entry?.source?.ref) && entry?.autoUpdate !== true ? entry.source.ref : undefined
40}
41
42/** True when the shared settings enable better-tasks unpinned: no release tag, or autoUpdate on (set up before pinning). */
43export function needsPin(settingsText: string | undefined): boolean {
44 return hasTeamInstall(settingsText) && pinnedTag(settingsText) === undefined
45}
46
47/**
48 * The git URL of the marketplace's repo (a fork's, when the entry names one), for `git ls-remote`. A cloned repo
49 * writes this file: only an owner/repo or a plain https URL is used, else upstream's (updatecheck.ts).
50 */
51export function repoUrl(settingsText: string | undefined): string {
52 const source = marketplaceOf(settingsText)?.source
53 if (source?.source === 'git' && isHttpsUrl(source.url)) return source.url
54 const repo = source?.source === 'github' && isGithubRepo(source.repo) ? source.repo : UPSTREAM.repo
55 return `https://github.com/${repo}.git`
56}
57
58/** The source `claude plugin marketplace add` takes for that repo (owner/repo, or the git URL). */
59export function addSource(settingsText: string | undefined): string {
60 const url = repoUrl(settingsText)
61 return url.replace(/^https:\/\/github\.com\/(.+)\.git$/, '$1')
62}
63
64/**
65 * The shared settings with the marketplace pinned to `tag` and the plugin enabled, the rest kept; a marketplace
66 * entry already there keeps its source (a fork) and loses autoUpdate. Undefined when not one JSON object.
67 */
68export function withTeamInstall(settingsText: string | undefined, tag: string): string | undefined {
69 const settings = parse(settingsText)
70 if (settings === undefined) return undefined
71 const { autoUpdate: _dropped, ...existing } = settings.extraKnownMarketplaces?.[MARKETPLACE] ?? {}
72 const marketplace = { ...existing, source: { ...UPSTREAM, ...existing.source, ref: tag } }
73 const shared = {
74 ...settings,
75 extraKnownMarketplaces: { ...settings.extraKnownMarketplaces, [MARKETPLACE]: marketplace },
76 enabledPlugins: { ...settings.enabledPlugins, [PLUGIN]: true },
77 }
78 return `${JSON.stringify(shared, null, 2)}\n`
79}
80
81/**
82 * Whether better-tasks may commit a settings change it made on its own (a pin, an update) here: straight to main
83 * commits on the current branch; the shared dev branch only on it; a worktree per task keeps this checkout as it is.
84 */
85export function maySelfCommit(flow: GitFlow, branch: string, devBranch: string): boolean {
86 if (flow === 'direct') return true
87 return flow === 'dev-prs' && branch === devBranch
88}
89
90function marketplaceOf(settingsText: string | undefined): Marketplace | undefined {
91 return parse(settingsText)?.extraKnownMarketplaces?.[MARKETPLACE]
92}
93
94function parse(text: string | undefined): SharedSettings | undefined {
95 try {
96 const json: unknown = JSON.parse(text ?? '{}')
97 return typeof json === 'object' && json !== null && !Array.isArray(json) ? (json as SharedSettings) : undefined
98 } catch {
99 return undefined
100 }
101}
102hooks/updatecheck.ts 167 lines1// The startup update check: the newest better-tasks release (a vX.Y.Z tag of its repo) against the one this
2// project pins and the one installed. Newer: asked once per version; Yes moves the pin, updates the plugin,
3// then asks the user to restart Claude Code (I'll restart / Skip). Only for an install from the marketplace
4// (a linked install is the user's own).
5
6export const PLUGIN_ID = 'better-tasks@better-tasks'
7export const MARKETPLACE_NAME = 'better-tasks'
8export const UPDATE_YES = 'Yes'
9export const UPDATE_NO = 'No'
10export const UPDATE_HEADER = 'Update'
11/** The store key holding the release the user said No to: not asked again for it. */
12export const DECLINED_KEY = 'updateDeclined'
13
14export const updateQuestion = (tag: string) => `better-tasks ${tag} is out. Update?`
15export const updatedLine = (tag: string) => `better-tasks: updated to ${tag}.`
16
17/** After the update, the restart is a question: the user restarts on their own, or skips it. */
18export const RESTART_LATER = "I'll restart"
19export const RESTART_SKIP = 'Skip'
20export const RESTART_HEADER = 'Restart'
21export const restartQuestion = (tag: string) => `better-tasks ${tag} is installed. Restart Claude Code to use it: quit, then run claude --continue.`
22export const restartLaterLine = (tag: string) => `better-tasks: ${tag} runs once you restart Claude Code.`
23
24/** An install Claude Code knows: its version and the scope `claude plugin update` needs. */
25export type Install = { version: string; scope: string }
26
27const TAG = /^v(\d+)\.(\d+)\.(\d+)$/
28
29/** True for a release tag: vX.Y.Z. */
30export const isReleaseTag = (ref: unknown): ref is string => typeof ref === 'string' && TAG.test(ref)
31
32// Values from a project's shared settings reach git and claude as arguments; a cloned repo writes that file.
33// Only these shapes pass, so none can read as an option (--upload-pack=…) or a transport (ext::…).
34const GITHUB_REPO = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9_.][A-Za-z0-9_.-]*$/
35const HTTPS_URL = /^https:\/\/[A-Za-z0-9][A-Za-z0-9.-]*(:\d+)?\/[^\s#]*$/
36const SCOPES = ['user', 'project', 'local', 'managed']
37
38/** True for a GitHub `owner/repo`. */
39export const isGithubRepo = (repo: unknown): repo is string => typeof repo === 'string' && GITHUB_REPO.test(repo)
40/** True for a plain https:// git URL: no spaces, no fragment, nothing an option or another transport could hide in. */
41export const isHttpsUrl = (url: unknown): url is string => typeof url === 'string' && HTTPS_URL.test(url)
42/** True for a marketplace source `claude plugin marketplace add` may get: owner/repo or a plain https URL. */
43export const isSafeSource = (source: string) => isGithubRepo(source) || isHttpsUrl(source)
44
45/** The release tags in `git ls-remote --tags --refs` output. */
46export function releaseTags(lsRemote: string): string[] {
47 return lsRemote.split('\n').map(line => line.split('refs/tags/')[1]?.trim() ?? '').filter(isReleaseTag)
48}
49
50/** True when tag `a` is a later release than `b`. */
51export function isNewer(a: string, b: string): boolean {
52 const [x, y] = [numbers(a), numbers(b)]
53 const differing = x.findIndex((part, i) => part !== y[i])
54 return differing !== -1 && (x[differing] ?? 0) > (y[differing] ?? 0)
55}
56
57export function newestTag(tags: string[]): string | undefined {
58 return tags.reduce<string | undefined>((best, tag) => (best === undefined || isNewer(tag, best) ? tag : best), undefined)
59}
60
61/**
62 * The install this session runs, from `claude plugin list --json`: the entry at the plugin's own folder,
63 * this project's when both scopes share it. Undefined for a linked install or output that isn't the list.
64 */
65export function activeInstall(listJson: string, pluginRoot: string, projectRoot: string): Install | undefined {
66 const entries = parseList(listJson).filter(entry => entry.id === PLUGIN_ID && trimSlash(entry.installPath) === trimSlash(pluginRoot))
67 const entry = entries.find(each => each.scope !== 'user' && each.projectPath === projectRoot) ?? entries.find(each => each.scope === 'user')
68 return entry && typeof entry.version === 'string' && isReleaseTag(`v${entry.version}`) ? { version: entry.version, scope: SCOPES.includes(entry.scope ?? 'user') ? (entry.scope ?? 'user') : 'user' } : undefined
69}
70
71/**
72 * The version installed at `scope` (this project's entry, for project or local), from `claude plugin list --json`.
73 * After an update the entry lives in the new release's folder, so it is found by scope, not by the plugin's folder.
74 */
75export function versionAt(listJson: string, scope: string, projectRoot: string): string | undefined {
76 const entry = parseList(listJson).find(each => each.id === PLUGIN_ID && (each.scope ?? 'user') === scope && (scope === 'user' || each.projectPath === projectRoot))
77 return typeof entry?.version === 'string' ? entry.version : undefined
78}
79
80/**
81 * The release to offer, or undefined when there is nothing to ask: none newer than both the installed
82 * release and the project's pin, or the user already said No to it.
83 */
84export function offeredRelease(tags: string[], installed: string, pinned: string | undefined, declined: unknown): string | undefined {
85 const newest = newestTag(tags)
86 if (newest === undefined || newest === declined) return undefined
87 const behind = isNewer(newest, `v${installed}`) || (pinned !== undefined && isNewer(newest, pinned))
88 return behind ? newest : undefined
89}
90
91/**
92 * The release to pin a project to now: the one installed when its repo has that tag (or the repo can't be
93 * reached), else the repo's newest. Undefined for a linked install offline.
94 */
95export function pinTarget(tags: string[], installed: string | undefined): string | undefined {
96 const current = installed === undefined ? undefined : `v${installed}`
97 if (current !== undefined && (tags.length === 0 || tags.includes(current))) return current
98 return newestTag(tags)
99}
100
101export const lsRemoteArgv = (url: string) => ['git', 'ls-remote', '--tags', '--refs', '--', url]
102export const listArgv = ['claude', 'plugin', 'list', '--json']
103/**
104 * Moves the marketplace pinned in the project's (or the user's) settings to `tag`: those settings and Claude Code's
105 * copy of the marketplace. Undefined for a source or tag of another shape.
106 */
107export function repinArgv(source: string, tag: string, scope: 'project' | 'user' = 'project'): string[] | undefined {
108 if (!isSafeSource(source) || !isReleaseTag(tag)) return undefined
109 return ['claude', 'plugin', 'marketplace', 'add', '--scope', scope, '--', `${source}#${tag}`]
110}
111export const refreshArgv = ['claude', 'plugin', 'marketplace', 'update', MARKETPLACE_NAME]
112
113/** Managed settings, per OS; a file that isn't there is skipped. */
114export const MANAGED_SETTINGS = ['/Library/Application Support/ClaudeCode/managed-settings.json', '/etc/claude-code/managed-settings.json']
115
116/**
117 * True when user or managed settings declare the better-tasks marketplace (extraKnownMarketplaces). Claude Code
118 * then takes its source from there alone and refuses `marketplace add` from any other, a project's pin included.
119 */
120export function declaresMarketplace(settingsText: string | undefined): boolean {
121 return declarationOf(settingsText) !== undefined
122}
123
124/**
125 * The release tag the user's settings pin better-tasks to; undefined when they pin none. `claude plugin marketplace
126 * add owner/repo#tag` writes such a pin, and then only an add from that same source#tag is let through.
127 */
128export function declaredTag(settingsText: string | undefined): string | undefined {
129 const ref = declarationOf(settingsText)?.source?.ref
130 return isReleaseTag(ref) ? ref : undefined
131}
132
133/** The user's settings with their better-tasks pin moved to `tag`, the rest kept; undefined when they pin no release. */
134export function withDeclaredTag(settingsText: string | undefined, tag: string): string | undefined {
135 if (declaredTag(settingsText) === undefined || !isReleaseTag(tag)) return undefined
136 const settings = JSON.parse(settingsText ?? '{}')
137 settings.extraKnownMarketplaces[MARKETPLACE_NAME].source.ref = tag
138 return `${JSON.stringify(settings, null, 2)}\n`
139}
140
141type Declaration = { source?: { ref?: unknown } }
142
143function declarationOf(settingsText: string | undefined): Declaration | undefined {
144 try {
145 const declared = JSON.parse(settingsText ?? '{}')?.extraKnownMarketplaces
146 const isObject = typeof declared === 'object' && declared !== null
147 return isObject && Object.hasOwn(declared, MARKETPLACE_NAME) ? (declared[MARKETPLACE_NAME] ?? {}) : undefined
148 } catch {
149 return undefined
150 }
151}
152export const updateArgv = (scope: string) => ['claude', 'plugin', 'update', '--scope', SCOPES.includes(scope) ? scope : 'user', '--', PLUGIN_ID]
153
154type ListEntry = { id?: unknown; version?: unknown; scope?: string; installPath?: unknown; projectPath?: unknown }
155
156function parseList(text: string): ListEntry[] {
157 try {
158 const json: unknown = JSON.parse(text)
159 return Array.isArray(json) ? (json as ListEntry[]) : []
160 } catch {
161 return []
162 }
163}
164
165const numbers = (tag: string) => (TAG.exec(tag) ?? []).slice(1).map(Number)
166const trimSlash = (path: unknown) => (typeof path === 'string' ? path.replace(/\/+$/, '') : '')
167hooks/intellij.ts 113 lines1// Keeps JetBrains IDEs (IntelliJ, WebStorm, PyCharm...) from indexing teammate worktrees.
2// Claude Code puts each worktree, a full copy of the repo, in <project>/.claude/worktrees/, so the IDE
3// sees it as project content and re-indexes a whole copy each time one is made, merged or removed.
4// Done by default, not asked (project setting excludeWorktreesFromIde, on unless set false): with worktrees on and a
5// .idea folder, each session start marks that folder as excluded in the .idea module file.
6
7export const WORKTREES_DIR = '.claude/worktrees'
8
9export const IDE_SETTING = 'excludeWorktreesFromIde'
10
11const EXCLUDE_LINE = `<excludeFolder url="file://$MODULE_DIR$/${WORKTREES_DIR}" />`
12
13export type IdeFiles = {
14 root: () => Promise<string>
15 read: (path: string) => Promise<string>
16 write: (path: string, text: string) => Promise<void>
17}
18
19/** The module files .idea/modules.xml lists, as absolute paths. */
20export function modulePaths(modulesXml: string, root: string): string[] {
21 const paths = [...modulesXml.matchAll(/filepath="([^"]+)"/g)].map(match => match[1]!)
22 return paths.map(path => path.replaceAll('$PROJECT_DIR$', root))
23}
24
25/** Where `$MODULE_DIR$` points for a module file: its folder, or the project for one inside .idea/. */
26export function moduleDir(imlPath: string): string {
27 const dir = imlPath.slice(0, imlPath.lastIndexOf('/'))
28 return dir.endsWith('/.idea') ? dir.slice(0, -'/.idea'.length) : dir
29}
30
31/**
32 * The module file with the worktrees folder excluded under its content root, when that root is
33 * the project itself; undefined when there is nothing to change.
34 */
35export function withWorktreesExcluded(iml: string, isProjectModule: boolean): string | undefined {
36 if (!isProjectModule || iml.includes(`$MODULE_DIR$/${WORKTREES_DIR}"`)) return undefined
37 const open = /(<content url="file:\/\/\$MODULE_DIR\$")\s*>\n?/
38 const closed = /(<content url="file:\/\/\$MODULE_DIR\$")\s*\/>/
39 const indent = (iml.match(/^([ \t]*)<content url="file:\/\/\$MODULE_DIR\$"/m)?.[1] ?? ' ') + ' '
40 if (open.test(iml)) return iml.replace(open, (_, start: string) => `${start}>\n${indent}${EXCLUDE_LINE}\n`)
41 if (closed.test(iml)) return iml.replace(closed, (_, start: string) => `${start}>\n${indent}${EXCLUDE_LINE}\n${indent.slice(2)}</content>`)
42 return undefined
43}
44
45/** The module file IntelliJ IDEA itself writes for a plain folder, with the worktrees excluded. */
46export function folderModule(): string {
47 return `<?xml version="1.0" encoding="UTF-8"?>
48<module type="JAVA_MODULE" version="4">
49 <component name="NewModuleRootManager" inherit-compiler-output="true">
50 <exclude-output />
51 <content url="file://$MODULE_DIR$">
52 ${EXCLUDE_LINE}
53 </content>
54 <orderEntry type="inheritedJdk" />
55 <orderEntry type="sourceFolder" forTests="false" />
56 </component>
57</module>
58`
59}
60
61export function modulesXml(imlName: string): string {
62 return `<?xml version="1.0" encoding="UTF-8"?>
63<project version="4">
64 <component name="ProjectModuleManager">
65 <modules>
66 <module fileurl="file://$PROJECT_DIR$/.idea/${imlName}" filepath="$PROJECT_DIR$/.idea/${imlName}" />
67 </modules>
68 </component>
69</project>
70`
71}
72
73/**
74 * A project whose modules come from a build tool (Gradle, Maven, sbt...) keeps them out of .idea;
75 * writing a module file there would add a second one, so such projects are left alone.
76 */
77export function isBuildToolProject(ideaFiles: string[], miscXml: string): boolean {
78 const toolFiles = ['gradle.xml', 'sbt.xml', 'bsp.xml', 'kotlinc.xml']
79 const isTool = ideaFiles.some(name => toolFiles.includes(name))
80 return isTool || /ExternalStorageConfigurationManager" enabled="true"|MavenProjectsManager/.test(miscXml)
81}
82
83/**
84 * Marks .claude/worktrees as excluded in the project's IntelliJ module; returns the line to log when it
85 * changed something. A project with no .idea/ is left as it is.
86 */
87export async function excludeWorktrees(files: IdeFiles, ideaFiles: string[] | undefined): Promise<string | undefined> {
88 if (ideaFiles === undefined) return undefined
89 const root = await files.root()
90 const idea = `${root}/.idea`
91 const modules = await files.read(`${idea}/modules.xml`).catch(() => undefined)
92 if (modules === undefined) return excludeInNewModule(files, root, ideaFiles)
93 for (const path of modulePaths(modules, root)) {
94 const iml = await files.read(path).catch(() => undefined)
95 const changed = iml === undefined ? undefined : withWorktreesExcluded(iml, moduleDir(path) === root)
96 if (changed === undefined) continue
97 await files.write(path, changed)
98 return `better-tasks: IntelliJ now skips ${WORKTREES_DIR}/ (teammate worktrees), so they are not indexed`
99 }
100 return undefined
101}
102
103/** IntelliJ keeps a plain folder's module only in its cache until it is changed; this writes it, excluded. */
104async function excludeInNewModule(files: IdeFiles, root: string, ideaFiles: string[]): Promise<string | undefined> {
105 const misc = await files.read(`${root}/.idea/misc.xml`).catch(() => '')
106 if (isBuildToolProject(ideaFiles, misc)) return undefined
107 const imlName = `${root.slice(root.lastIndexOf('/') + 1)}.iml`
108 if (ideaFiles.includes(imlName)) return undefined
109 await files.write(`${root}/.idea/${imlName}`, folderModule())
110 await files.write(`${root}/.idea/modules.xml`, modulesXml(imlName))
111 return `better-tasks: IntelliJ now skips ${WORKTREES_DIR}/ (teammate worktrees), so they are not indexed`
112}
113hooks/migrate.ts 95 lines1import type { FsEntry } from 'claude-code'
2
3// One-time move of a project's files from the old folder (.claude/manager/, tasks in its tasks/)
4// to .claude/tasks/ (tasks, sprints.md, config.json and the overrides side by side).
5
6export const OLD_DIR = '.claude/manager'
7export const NEW_DIR = '.claude/tasks'
8
9export type Mover = {
10 root: () => Promise<string>
11 list: (path: string) => Promise<FsEntry[]>
12 read: (path: string) => Promise<string>
13 write: (path: string, text: string) => Promise<void>
14 run: (argv: string[]) => Promise<{ exitCode: number }>
15}
16
17/** Old paths in a config.json, pointed at the new folder. */
18export function movedConfig(text: string): string {
19 return text.replaceAll(`${OLD_DIR}/tasks`, NEW_DIR).replaceAll(OLD_DIR, NEW_DIR)
20}
21
22/** Moves the old folder when the project has it and not the new one; returns the line to log, if it moved. */
23export async function migrateFolder(io: Mover): Promise<string | undefined> {
24 const root = await io.root()
25 const isDir = (path: string) => io.list(path).then(() => true, () => false)
26 const [from, to] = [`${root}/${OLD_DIR}`, `${root}/${NEW_DIR}`]
27 if (!(await isDir(from)) || (await isDir(to))) return undefined
28
29 if ((await io.run(['mv', from, to])).exitCode !== 0) return `better-tasks: could not move ${OLD_DIR}/ to ${NEW_DIR}/`
30 if (await isDir(`${to}/tasks`)) {
31 for (const entry of await io.list(`${to}/tasks`)) await io.run(['mv', '-n', `${to}/tasks/${entry.name}`, `${to}/${entry.name}`])
32 await io.run(['rmdir', `${to}/tasks`])
33 }
34 const config = await io.read(`${to}/config.json`).catch(() => undefined)
35 if (config !== undefined && movedConfig(config) !== config) await io.write(`${to}/config.json`, movedConfig(config))
36 return `better-tasks: moved ${OLD_DIR}/ to ${NEW_DIR}/ (tasks, sprints, settings)`
37}
38
39// One-time move of the old instruction overrides to .claude/better-tasks/ (template.ts): coordinator.md becomes
40// lead.md, teammate.md stays teammate.md; the "instructions" setting (paths) becomes a "Project rules" section.
41
42const EXTEND_MARK = '<!-- extend -->'
43const RULE_MOVES = [['coordinator.md', 'lead.md'], ['teammate.md', 'teammate.md']] as const
44
45/** An old override as a new one: the extend marker is the new default; a plain one replaced, so it says so. */
46export function movedRule(old: string): string {
47 const text = old.trimStart()
48 return text.startsWith(EXTEND_MARK) ? text.slice(EXTEND_MARK.length).trimStart() : `---\nreplace: true\n---\n${text}`
49}
50
51/** The "instructions" setting's paths, as a section both the lead's and the teammates' rules end with. */
52export function projectRulesSection(paths: string): string {
53 return `\n## Project rules\nRead before you work: ${paths.split(',').map(path => path.trim()).filter(Boolean).join(', ')}.\n`
54}
55
56/** Moves the old overrides and the "instructions" setting into .claude/better-tasks/; returns the line to log, if anything moved. */
57export async function migrateRules(io: Mover): Promise<string | undefined> {
58 const root = await io.root()
59 const read = (path: string) => io.read(`${root}/${path}`).catch(() => undefined)
60 const moved: string[] = []
61 for (const [from, to] of RULE_MOVES) {
62 const old = await read(`${NEW_DIR}/${from}`)
63 if (old === undefined) continue
64 if ((await read(`${RULES_DIR}/${to}`)) === undefined) await io.write(`${root}/${RULES_DIR}/${to}`, movedRule(old))
65 await io.run(['rm', `${root}/${NEW_DIR}/${from}`])
66 moved.push(`${from} → ${RULES_DIR}/${to}`)
67 }
68 const configText = await read(`${NEW_DIR}/config.json`)
69 const config = parsed(configText)
70 const paths = typeof config?.instructions === 'string' ? config.instructions.trim() : ''
71 if (config && 'instructions' in config) {
72 for (const to of ['lead.md', 'teammate.md']) {
73 if (!paths) break
74 const own = (await read(`${RULES_DIR}/${to}`)) ?? ''
75 await io.write(`${root}/${RULES_DIR}/${to}`, `${own.trimEnd()}\n${projectRulesSection(paths)}`.trimStart())
76 }
77 const { instructions: _dropped, ...rest } = config
78 await io.write(`${root}/${NEW_DIR}/config.json`, `${JSON.stringify(rest, null, 2)}\n`)
79 if (paths) moved.push(`the "instructions" setting → a "Project rules" section in ${RULES_DIR}/lead.md and teammate.md`)
80 }
81 return moved.length > 0 ? `better-tasks: moved ${moved.join('; ')}` : undefined
82}
83
84const RULES_DIR = '.claude/better-tasks'
85
86function parsed(text: string | undefined): Record<string, unknown> | undefined {
87 if (text === undefined) return undefined
88 try {
89 const json = JSON.parse(text) as unknown
90 return json && typeof json === 'object' && !Array.isArray(json) ? (json as Record<string, unknown>) : undefined
91 } catch {
92 return undefined
93 }
94}
95hooks/models.ts 43 lines1import { LEVELS } from './settings'
2import type { Level, ModelChoice, TeammateModels } from './settings'
3
4// Which model and effort a teammate runs on: one per level of task, from the settings (easyModel, easyEffort,
5// normalModel, …, escalate). A spawn can set a model but not an effort, so the mod registers one agent type per
6// level, each with its model and effort (register.tsx does it); the lead picks the type, never the model.
7
8const PLUGIN = 'better-tasks'
9
10const nameOf = (level: Level) => `teammate-${level}`
11
12/** The agent type of a level: "better-tasks:teammate-easy". */
13export const typeOf = (level: Level) => `${PLUGIN}:${nameOf(level)}`
14
15/** The type of a named spawn the lead gave none. */
16export const DEFAULT_TYPE = typeOf('normal')
17
18/** What `$.agent.register` takes; the type is `better-tasks:<name>`. */
19export type TeammateType = { name: string; description: string; prompt: string; model: string; effort: string }
20
21const PROMPT = 'You are a better-tasks teammate. Your prompt names your task file and your area: follow it and the rules below it.'
22
23const ABOUT: Record<Level, { tasks: string; examples: string }> = {
24 easy: { tasks: 'Easy tasks', examples: 'quick, clear work: a typo, a text, a small fix' },
25 normal: { tasks: 'Normal tasks', examples: 'an ordinary feature or bug fix' },
26 hard: { tasks: 'Hard tasks', examples: 'deep debugging, security work, changes across several areas' },
27}
28
29/** "sonnet at xhigh effort"; inherit reads as the lead's own model. */
30export function describeChoice({ model, effort }: ModelChoice): string {
31 return `${model === 'inherit' ? "the lead's own model" : model} at ${effort} effort`
32}
33
34/** One agent type per level, with its model and effort. */
35export function teammateTypes(models: TeammateModels): TeammateType[] {
36 return LEVELS.map(level => ({
37 name: nameOf(level),
38 description: `A better-tasks teammate (${describeChoice(models[level])}) for ${ABOUT[level].tasks.toLowerCase()}: ${ABOUT[level].examples}.`,
39 prompt: PROMPT,
40 ...models[level],
41 }))
42}
43hooks/contribute.ts 71 lines1import type { ToolSpec } from 'claude-code'
2
3import type { Files } from './io'
4import { skillCall } from './skills'
5
6// Changes to better-tasks itself: made in the user's fork, run as a linked install, offered upstream as a PR.
7// The user's answer to "open a PR upstream?" is kept per user, in <claude config dir>/better-tasks/user.json,
8// outside the plugin's options: the linked fork runs under another plugin id, and the answer must reach both.
9
10export const UPSTREAM_REPO = 'iosifnicolae2/better-tasks'
11
12/** ask: offer the PR when a change is done; never: the user chose "Never". */
13export type UpstreamPr = 'ask' | 'never'
14
15/** The answers to the question, as the tool takes them. */
16export const UPSTREAM_ANSWERS = ['yes', 'not-now', 'never', 'ask'] as const
17export type UpstreamAnswer = (typeof UPSTREAM_ANSWERS)[number]
18
19export function userFile(claudeDir: string): string {
20 return `${claudeDir}/better-tasks/user.json`
21}
22
23function parseUser(text: string | undefined): Record<string, unknown> {
24 try {
25 const json: unknown = JSON.parse(text ?? '{}')
26 return typeof json === 'object' && json !== null && !Array.isArray(json) ? (json as Record<string, unknown>) : {}
27 } catch {
28 return {}
29 }
30}
31
32export function upstreamPrOf(text: string | undefined): UpstreamPr {
33 return parseUser(text).upstreamPr === 'never' ? 'never' : 'ask'
34}
35
36type UserFiles = Pick<Files, 'read' | 'write'>
37
38export async function readUpstreamPr(files: UserFiles, claudeDir: string): Promise<UpstreamPr> {
39 return upstreamPrOf(await files.read(userFile(claudeDir)).catch(() => undefined))
40}
41
42/** Saves the answer (only "never" sticks; the rest mean ask next time), keeping the file's other keys. */
43export async function saveUpstreamPr(files: UserFiles, claudeDir: string, given: string): Promise<string> {
44 const answer: UpstreamAnswer = UPSTREAM_ANSWERS.find(known => known === given) ?? 'ask'
45 const path = userFile(claudeDir)
46 const user = parseUser(await files.read(path).catch(() => undefined))
47 const choice: UpstreamPr = answer === 'never' ? 'never' : 'ask'
48 await files.write(path, `${JSON.stringify({ ...user, upstreamPr: choice }, null, 2)}\n`)
49 return ANSWER_REPLIES[answer]
50}
51
52const ANSWER_REPLIES: Record<UpstreamAnswer, string> = {
53 yes: `Saved. Open the PR now: push the fork's branch, then gh pr create --repo ${UPSTREAM_REPO}. Asked again after the next change.`,
54 'not-now': 'Saved: no PR now; asked again after the next change to better-tasks.',
55 never: 'Saved: never asked again. The user can undo it by asking for it (upstream_pr with answer ask).',
56 ask: 'Saved: asked again after the next change to better-tasks.',
57}
58
59export const UPSTREAM_PR_TOOL: ToolSpec = {
60 name: 'upstream_pr',
61 description:
62 'Saves the user\'s answer to "open a PR to the better-tasks repo?", asked after a change to better-tasks itself ' +
63 `(see the ${skillCall('contribute')} skill). yes or not-now: asked again next time; never: not asked again; ` +
64 'ask: undoes a never. Kept per user, for every project.',
65 inputSchema: {
66 type: 'object',
67 properties: { answer: { type: 'string', enum: [...UPSTREAM_ANSWERS] } },
68 required: ['answer'],
69 },
70}
71hooks/coordinator.ts 123 lines1import type { PromptComposeSection, PromptComposeTrait, PromptOrigin } from 'claude-code'
2
3import type { Task, TurnFacts } from '../types'
4import type { Io } from './io'
5import type { Settings } from './settings'
6import { questionTasks } from './spawn'
7import { goalOf, readSprints } from './sprintlog'
8import { sprintNumber, sprintStart, sprintTitle } from './sprints'
9import type { SprintConfig } from './sprints'
10import { isBlocked, isOpen, labelsText, listTasks, taskRef, today, waitsText, WHEN_LABELS, whenOf } from './tasks'
11import { isActive, mateLine, refreshTeam } from './team'
12
13// The main session as coordinator: its rules and a small context block per prompt.
14
15export const isPerson = (origin: PromptOrigin) => ['composer', 'bridge', 'sdk'].includes(origin.kind)
16
17/** The main session's own prompt: the full or short body, the Agent tool, and not a teammate's render. */
18export function isMainPrompt(
19 sections: readonly PromptComposeSection[],
20 traits: readonly PromptComposeTrait[],
21 tools: readonly string[],
22): boolean {
23 const hasMainBody = sections.some(section => section.id === 'intro' || section.id === 'lean_body')
24 return hasMainBody && tools.includes('Agent') && !traits.includes('teammate')
25}
26
27/** The system prompt with the coordinator rules (texts.ts, or the project's coordinator.md), only in the main session's. */
28export function withRules(
29 sections: readonly PromptComposeSection[],
30 traits: readonly PromptComposeTrait[],
31 tools: readonly string[],
32 rules: string,
33): readonly PromptComposeSection[] {
34 if (!isMainPrompt(sections, traits, tools) || rules === '') return sections
35 return [...sections, { id: 'better-tasks:coordinator', text: rules, scope: 'session' }]
36}
37
38/** What the coordinator reads beside each user prompt: sprint, due work, notice, teammates. */
39export async function contextBlock(io: Io, settings: Settings, notice: string): Promise<string> {
40 const config = settings.sprint
41 const day = await today(io)
42 const start = sprintStart(day, config)
43 const all = await listTasks(io)
44 const tasks = all.filter(task => task.sprint === start)
45 const team = (await refreshTeam(io)).filter(isActive)
46 const goal = goalOf(await readSprints(io), start)
47 const done = tasks.filter(task => task.status === 'done').length
48 const lines = [
49 `[better-tasks] ${sprintTitle(start, config, day)} · goal: ${goal || 'not set'} · ${done}/${tasks.length} done`,
50 ...waitingLines(tasks, all),
51 ...openTaskLines(all, day, config),
52 notice,
53 ...team.map(mate => `Teammate ${mateLine(mate)}`),
54 ]
55 return lines.filter(Boolean).join('\n')
56}
57
58const OTHERS_SHOWN = 10
59
60/** The open tasks the user's message may refer to: currently working on and this sprint, then the 10 newest others. */
61export function openTaskLines(tasks: readonly Task[], day: string, config: SprintConfig): string[] {
62 const open = tasks.filter(isOpen)
63 const isNear = (task: Task) => ['now', 'this-sprint'].includes(whenOf(task, day, config))
64 const newest = (a: Task, b: Task) => b.created.localeCompare(a.created) || b.id.localeCompare(a.id, undefined, { numeric: true })
65 const shown = [...open.filter(isNear), ...open.filter(task => !isNear(task)).sort(newest).slice(0, OTHERS_SHOWN)]
66 if (shown.length === 0) return []
67 const line = (task: Task) =>
68 [`- ${taskRef(task)} ${task.title}`, WHEN_LABELS[whenOf(task, day, config)], task.owner, labelsText(task), waitsText(task, tasks)].filter(Boolean).join(' · ')
69 const hidden = open.length - shown.length
70 return ['Open tasks (match the message against these):', ...shown.map(line), ...(hidden > 0 ? [`- … ${hidden} more: task_list`] : [])]
71}
72
73const QUESTION = /^\s*(what|which|who|where|when|why|how|is|are|was|were|does|do|did|show|list)\b|\?\s*$/i
74
75/** A message that reads as a question: answered, not filed. */
76export const isQuestion = (text: string) => QUESTION.test(text)
77
78/** One gentle line when the last message was neither filed, nor a question, nor answered with a question back. */
79export function unfiledLine(last: TurnFacts): string | undefined {
80 if (!last.prompted || last.filed || last.asked || last.question) return undefined
81 return 'Your last message was not filed. If it was work or an observation, file it: task_create, or task_note on the task it refers to.'
82}
83
84/** The Finishing question's option that closes a task (texts.ts). */
85export const RESOLVE_OPTION = 'Mark as resolved'
86
87/** The task ids an AskUserQuestion answer resolved: each question naming a task (or a bundle's tasks), answered "Mark as resolved". */
88export function resolvedIn(answers: unknown, prefix: string): string[] {
89 if (!answers || typeof answers !== 'object') return []
90 return Object.entries(answers)
91 .filter(([, answer]) => answer === RESOLVE_OPTION)
92 .flatMap(([question]) => questionTasks(question, prefix))
93}
94
95/** One line while a task the user resolved is still open: the lead closes it now. */
96export function unclosedLine(ids: readonly string[]): string {
97 if (ids.length === 0) return ''
98 return (
99 `The user marked ${ids.join(', ')} resolved, still open: close each now (task_update status done with a one-line ` +
100 'summary and the commits from the task file; its full tests still running: once they pass), then stop its teammate.'
101 )
102}
103
104/** The currently-working-on tasks not started yet: route the ones free to start; the blocked ones wait for their dependencies. */
105function waitingLines(tasks: readonly Task[], all: readonly Task[]): string[] {
106 const waiting = tasks.filter(task => task.urgent && task.status === 'todo')
107 const named = (task: Task) => `${taskRef(task)} ${task.title}`
108 const ready = waiting.filter(task => !isBlocked(task, all)).map(named)
109 const held = waiting.filter(task => isBlocked(task, all)).map(task => `${named(task)} (${waitsText(task, all)})`)
110 return [
111 ready.length > 0 ? `Currently working on, not started yet: ${ready.join('; ')}. Route each now: its owner or a new teammate.` : '',
112 held.length > 0 ? `Blocked, not started: ${held.join('; ')}. Start each once its dependencies are done.` : '',
113 ].filter(Boolean)
114}
115
116/** The footer label: "Sprint 41 · 1/4 done", plus " · 1 due" when a currently-working-on task waits. Teammates are Claude Code's to show. */
117export function footerText(tasks: readonly Task[], start: string, config: SprintConfig): string {
118 const sprint = tasks.filter(task => task.sprint === start)
119 const done = sprint.filter(task => task.status === 'done').length
120 const due = sprint.filter(task => task.urgent && isOpen(task)).length
121 return `Sprint ${sprintNumber(start, config)} · ${done}/${sprint.length} done${due > 0 ? ` · ${due} due` : ''}`
122}
123