SLOPSHOPPER

better-tasks

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

newpanespinnerguardcommandtoast
★ 1v?MITupdated 2026-10-09iosifnicolae2/better-tasks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · better-tasks
│ ┃ Sprint ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ App │ better-tasks │ │ ┃ ⚡ Currently working on ● better-tasks: better│ Could not turn the screens off, so they │ │ ┃ — empty ⏺ Read(src/auth.ts) │ went to sleep instead. The Mac may lock. │ │ ┃ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ │ ┃ ◆ This sprint · Week 41 · Oct 6–12 · 4 da… ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ — empty ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ◇ Next sprint · Week 42 · Oct 13–19 │ ┃ — empty ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ○ Backlog ✻ Worked for 42s · done 4:20 PM │ ┃ — empty │ ┃ › /better-tasks │ ┃ ✓ Closed · 0 ▸ ⎿ better-tasks: Sprint board opened. If its keys do nothing, ctrl+ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ No task selected │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ better-tasks: better-tasks: agent teams are off

Draws

Pane · Sprint
App ⚡ Currently working on — empty ◆ This sprint · Week 41 · Oct 6–12 · 4 days left ▱▱▱▱▱ 0/0 — empty ◇ Next sprint · Week 42 · Oct 13–19 — empty ○ Backlog — empty ✓ Closed · 0 ▸ ╭────────────────────────────────────────────────────────╮ │ No task selected │ │ Create one by asking Claude, e.g. “add a task to …” │ │ │ │ │ ╰────────────────────────────────────────────────────────╯ Click or ctrl+x tab to use the board
README

better-tasks

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.

✨ Features

  • 📝 Tasks as Markdown: plain files in .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.
  • 🤖 A team of agents: a lead routes each task to a teammate; teammates work in parallel.
  • 🎥 Before/after video: after each task you get a short narrated video to review it (see an example). Turn it on with the demoVideos setting. A release can come with one video of all its tasks' videos: bin/release-video.sh (setting releaseVideos).
  • 👀 Live review: Gemini watches a teammate's test as it runs and flags anything off, each at its second in the recording, for the teammate to check. Your own Gemini key of a billed AI Studio project, kept in the Keychain; setting liveReview.
  • ✅ You approve: no task closes without your yes.
  • 🔀 Your git flow: a worktree and PR per task (the default), a shared dev branch, or straight to main. Asked once per project.
  • 🗂️ Sprint board: /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.

📦 Install

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).

⚙️ Settings

/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.

🔄 Update / uninstall

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.

🔒 What it runs, reads and sends

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.

  • Files it writes: your tasks in .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.
  • Files it reads: your project, Claude Code's settings (to pin and update the plugin), and a finished teammate's transcript in ~/.claude/projects/, which only its successor is pointed at.
  • Settings and environment: in this Claude Code process it sets 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).
  • Slash commands it runs: /config, only when you press "All Claude Code settings" on its settings page. It adds /better-tasks and /away.
  • Prompts it submits, in your session:
  • Once per machine: asks Claude to add a line to ~/.claude/CLAUDE.md pointing at its team rules. You approve the edit.
  • When agent teams are off: asks Claude to add "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.
  • After 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).
  • "Start" on a task of the board: a prompt to start that task.
  • What it adds to Claude's prompts: its team rules in the system prompt, and beside each of your prompts a short block with the sprint, the open tasks and its reminders. Its skills read with the settings in force filled in. All the texts: .claude/better-tasks/; one page with each as Claude gets it: bun scripts/instructions-doc.ts --open in a clone.
  • Tool calls it changes or answers: a named teammate's Agent call gets its task's title as the description, the teammate rules and its predecessor's transcript path added to the prompt, the agent type of its level, and a worktree when the git flow uses them. Its own tools (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).
  • Programs it runs, on your machine: 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).
  • Credentials: none of its own, except the Gemini API key you give live review: kept in the macOS Keychain (service "better-tasks gemini"), never in a file, read only by bin/live_review.mjs and sent only to Google's Gemini API. 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.
  • Network: 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.

🛠️ Contributing

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.

Source 45 files
hooks/register.tsx 948 lines
1import { 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}
948
hooks/activity.ts 47 lines
1// 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}
47
hooks/boundary.ts 56 lines
1import 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}
56
hooks/cache.ts 45 lines
1import 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}
45
hooks/projectsetup.ts 5 lines
1// 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)`
5
hooks/teaminstall.ts 102 lines
1// 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}
102
hooks/updatecheck.ts 167 lines
1// 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(/\/+$/, '') : '')
167
hooks/intellij.ts 113 lines
1// 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}
113
hooks/migrate.ts 95 lines
1import 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}
95
hooks/models.ts 43 lines
1import { 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}
43
hooks/contribute.ts 71 lines
1import 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}
71
hooks/coordinator.ts 123 lines
1import 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