Plan-and-task workflow: scope/tasks/questions/progress scaffolding split into pull requests under a line budget, acceptance criteria agreed up front, tests red…

</br> <small>Every badge is a link to its docs</small>
</br>
Taskflow is a Claude Code plugin that gives Claude a disciplined way to ship work, the way a careful senior dev would:
The plugin carries the procedure. Your repo keeps its own rules in CLAUDE.md and .claude/workflow.json.
To use it, you will need these tools in your environment:
PATH, because the hooks are Python scriptsAdd the marketplace and install the plugin from inside Claude Code:
/plugin marketplace add GuiMoraesDev/taskflow
/plugin install taskflow@taskflow-tools
Then, in the repo you want to use it on:
/init-workflow # writes .claude/workflow.json and the workflow section of CLAUDE.md
/plan-status # checks that the config works
💡 To install from a local checkout instead, pass its path to
/plugin marketplace add.
The plugin also adds a side pane. /open-mod opens it: every plan's progress, its active tasks and open questions, the pull requests with their links, a key for /next-task, and a delete button for a plan folder that asks before it removes anything. It opens by itself at session start on terminals of 144 columns or more.
plan ──▶ agree criteria ──▶ run a task ──▶ repeat ──▶ check in
SCOPE.md, TASKS.md, QUESTIONS.md and PROGRESS.md in your plan folder (see Where plans live)./next-task picks the first task that isn't blocked (or use /next-task TASK-3 to pick one). Claude runs the full cycle: branch, failing tests, implementation, a review of the logic with you, gates, size check, commit, push and log. Then it stops./plan-status shows where you are, the open questions and what they block. /deviation writes down anything that didn't go as planned.| Command | What it does |
|---|---|
/init-workflow | Sets up or updates the workflow config for your repo |
/plan-status [project] | Shows the task list, open questions, latest log entries and the next task you can do |
/next-task [TASK-ID] | Picks the next task, or the one you name, and runs the cycle on it |
/deviation <what changed> | Logs a change from the plan |
| Skill | What it does |
|---|---|
init-workflow | Reads your repo's scripts and layout, asks what it can't figure out (models, diagrams, pull-request conventions), then writes .claude/workflow.json and the workflow section of CLAUDE.md. Running it again asks only about what changed |
plan-start | Grills you on the design, then writes the four plan docs: the scope, the pull requests, their acceptance criteria, one-commit tasks with their tests, and the open questions, each with a recommendation |
task-run | Runs one task through the full cycle, from the failing tests to the commit and pull request, then carries on to the next until it needs you |
bug-red-test | Proves a regression test fails against the old behaviour before the fix ships |
docs-sync | Goes through your docs checklist, plus the glossary and diagrams if your repo has them |
standards-review | Checks a diff against the standards in your CLAUDE.md, and warns you when a test was bent to pass instead of updated with the behaviour |
| Agent | Tier | What it does |
|---|---|---|
plan-architect | deep | Surveys the code and returns the task breakdown a plan needs |
standards-reviewer | deep | Audits a diff against the standards without changing anything |
docs-diagram-auditor | surface | Looks for outdated claims in the docs and diagram labels |
gate-runner | mechanical | Runs lint, types, unit and e2e tests and reports only the failures |
| Hook | What it does |
|---|---|
guard-bash | Asks before risky commands: --no-verify, git add -A, bare npx-style gate calls, automatic branching and force flags. It refuses a force flag unless you give a reason |
guard-signature | Blocks AI signatures (like Co-Authored-By for an AI) in commits and pull requests unless workflow.json sets aiSignature: true. It's false by default |
progress-reminder | After a commit, reminds you to close the task in PROGRESS.md |
version-bump | In a repo that ships .claude-plugin/plugin.json, bumps the patch version on a branch's first commit and blocks a push that didn't bump it |
Plans are written to one of two places:
projectsDir in workflow.json, docs/projects unless you change it..claude/workflow.env, which is untracked: .claude/.gitignore keeps it out of git, so teammates keep the repo folder.Key in .claude/workflow.env | What it holds |
|---|---|
TASKFLOW_PLANS_SOURCE | repo or obsidian. Anything else, or missing, means repo |
TASKFLOW_OBSIDIAN_VAULT | The vault's folder name, looked up in Obsidian's vault list |
TASKFLOW_OBSIDIAN_VAULT_PATH | The vault's absolute path instead; it wins over the name |
TASKFLOW_OBSIDIAN_PATH | The folder inside the vault, e.g. taskflow/my-repo |
See examples/workflow.env.example. /init-workflow and the Taskflow panel both set it for you. With Obsidian on, plans are read and written in the vault only, and a bad vault setting stops with a message instead of falling back to the repo.
💡 Plan files in a vault are written by absolute path, which asks permission every time. Add the vault to
permissions.additionalDirectoriesin.claude/settings.local.jsonto stop the prompts.
Everything specific to your repo lives in one file you own: .claude/workflow.json. /init-workflow fills it in for you. See examples/workflow.example.json for the full shape.
| Key | What it holds |
|---|---|
projectsDir | Where plan folders live in the repo (see Where plans live above) |
gates | Your lint, types, unit and e2e commands |
e2eTriggerPaths | File patterns whose changes make the e2e suite run |
docsChecklist | Each doc and what it covers, checked before a behaviour change is done |
decisions | Where your decision records and glossary live |
diagrams | Whether you keep diagrams, where, in what format and who edits them |
pullRequests | Line budget and the paths it skips, branch and title prefixes, labels, assignee, stacking and whether independent pull requests run in parallel |
aiSignature | Whether commits and pull requests may carry an AI signature |
commitTypes | The allowed conventional-commit types |
models | Which model each tier uses, e.g. { "deep": "opus", "surface": "sonnet", "mechanical": "haiku" } |
💡 The
modelsmapping covers both your plan's tasks and the plugin's own agents, so you set your cost and speed trade-off once.
Rules about dependency direction belong in your repo's lint config, not in this file. Prose rules go stale; lint rules don't.
SCOPE.md says what may change and what may not, TASKS.md holds the work, QUESTIONS.md holds the open decisions and PROGRESS.md tracks progress.skills/, agents/, commands/ and hooks/ at the root, with the manifest and marketplace entry in .claude-plugin/.version-bump hook bumps the patch version in plugin.json on a branch's first commit and blocks a push that didn't bump it.main, the Release workflow tags the merge commit v<version> and publishes a GitHub release.panel/register.tsx 94 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { SessionWait } from '../types'
5import { boardSignature, loadBoard } from './data/board'
6import { endsOnQuestion } from './domain/mascot'
7import { renderPane } from './ui/pane'
8
9const PANE = 'taskflow'
10const TITLE = 'Taskflow'
11const WATCH_MS = 1000
12
13const animationStep = atom({ plugin: 'taskflow', key: 'animationStep' } as const, 0)
14const waiting = atom({ plugin: 'taskflow', key: 'waiting' } as const, {
15 dialog: false,
16 permission: false,
17 lastTurnAsked: false,
18})
19
20function setWaiting($: EngineInterface, change: Partial<SessionWait>) {
21 void update($, waiting, wait => ({ ...wait, ...change }))
22}
23
24async function openPane($: EngineInterface, focus = false) {
25 await $.ui.open({ id: PANE, title: TITLE, columns: 60, ...(focus ? { focus: true as const } : {}) })
26}
27
28export const register: Register = on => {
29 on('session.start', async ($, e, next) => {
30 await $.command.register({ name: 'open-mod', description: 'Open the Taskflow pane: plans, tasks, PRs' })
31 void loadBoard($)
32 void openPane($)
33 $.clock.every(100, () => void update($, animationStep, step => step + 1))
34
35 // Reload whenever a PROGRESS.md changes - mid-turn, from a subagent or from Obsidian - not only at turn end.
36 let lastSignature = ''
37 $.clock.every(WATCH_MS, async () => {
38 const signature = await boardSignature($)
39 if (signature === lastSignature) return
40 lastSignature = signature
41 await loadBoard($)
42 })
43
44 return next(e)
45 })
46
47 on('command.run', { command: 'open-mod' }, async $ => {
48 await loadBoard($)
49 await openPane($, true)
50
51 return { text: 'Taskflow pane opened.' }
52 })
53
54 on('turn.complete', async ($, e, next) => {
55 void loadBoard($)
56 if (!e.agentId) setWaiting($, { permission: false, lastTurnAsked: !e.isAborted && endsOnQuestion(e.answer) })
57
58 return next(e)
59 }).catch(($, e, next) => (next.called ? undefined : next(e)))
60
61 on('prompt.submit', async ($, e, next) => {
62 setWaiting($, e.isMeta ? { permission: false } : { permission: false, lastTurnAsked: false })
63
64 return next(e)
65 }).catch(($, e, next) => (next.called ? undefined : next(e)))
66
67 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
68 setWaiting($, { dialog: true })
69 try {
70 return await next(e)
71 } finally {
72 setWaiting($, { dialog: false })
73 }
74 }).catch(($, e, next) => (next.called ? undefined : next(e)))
75
76 // The permission prompt is answered inside tool.call's next(e), so its end clears the flag.
77 on('tool.call', async ($, e, next) => {
78 try {
79 return await next(e)
80 } finally {
81 setWaiting($, { permission: false })
82 }
83 }).catch(($, e, next) => (next.called ? undefined : next(e)))
84
85 on('tool.check', async ($, e, next) => {
86 const verdict = await next(e)
87 if (verdict.decision === 'ask' && e.tool_use_id && !e.agentId) setWaiting($, { permission: true })
88
89 return verdict
90 }).catch(($, e, next) => (next.called ? undefined : next(e)))
91
92 on('ui.render', { component: 'Pane', requestId: PANE }, renderPane)
93}
94panel/data/board.ts 247 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { PlanBoard, PlanDir, PlanProject, TrashEntry } from '../../types'
5import { isSafeProjectName, parseProgress, parseWorktrees, repoWebUrl } from '../domain/parse'
6import { parseWorkflowEnv, resolvePlanDir } from '../domain/plan-dir'
7import {
8 TRASH_DIR,
9 clearTargets,
10 isDirectChild,
11 isPlanFolder,
12 parseTrashEntry,
13 recoverDecision,
14 trashEntryName,
15} from '../domain/trash'
16
17const board = atom({ plugin: 'taskflow', key: 'board' } as const, null)
18const selected = atom({ plugin: 'taskflow', key: 'selected' } as const, null)
19const pendingDelete = atom({ plugin: 'taskflow', key: 'pendingDelete' } as const, null)
20const notice = atom({ plugin: 'taskflow', key: 'notice' } as const, null)
21// null while the trash view is closed, so loadBoard only lists the trash when someone is looking at it.
22const trashEntries = atom({ plugin: 'taskflow', key: 'trashEntries' } as const, null)
23const pendingClear = atom({ plugin: 'taskflow', key: 'pendingClear' } as const, false)
24
25export async function repoRoot($: EngineInterface) {
26 const toplevel = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
27
28 return toplevel.exitCode === 0 ? toplevel.stdout.trim() : await $.session.cwd()
29}
30
31async function readProjectsDir($: EngineInterface, root: string) {
32 try {
33 const config = JSON.parse(await $.fs.read(`${root}/.claude/workflow.json`))
34
35 return typeof config.projectsDir === 'string' ? config.projectsDir : null
36 } catch {
37 return null
38 }
39}
40
41export async function readWorkflowEnv($: EngineInterface, root: string) {
42 const path = `${root}/.claude/workflow.env`
43
44 return parseWorkflowEnv((await $.fs.exists(path)) ? await $.fs.read(path) : '')
45}
46
47// null when obsidian.json is missing or unreadable, so the resolver can name that as the problem.
48export async function readObsidianVaults($: EngineInterface) {
49 const home = await $.process.run(['printenv', 'HOME'])
50 if (home.exitCode !== 0) return null
51 try {
52 const config = JSON.parse(
53 await $.fs.read(`${home.stdout.trim()}/Library/Application Support/obsidian/obsidian.json`),
54 )
55 const vaults: unknown[] = Object.values(config.vaults ?? {})
56
57 return vaults.flatMap(vault =>
58 typeof vault === 'object' && vault !== null && 'path' in vault && typeof vault.path === 'string'
59 ? [vault.path]
60 : [],
61 )
62 } catch {
63 return null
64 }
65}
66
67export async function readPlanDir($: EngineInterface, root: string) {
68 const env = await readWorkflowEnv($, root)
69 const needsVaults = env.TASKFLOW_PLANS_SOURCE === 'obsidian' && !env.TASKFLOW_OBSIDIAN_VAULT_PATH
70
71 return resolvePlanDir({
72 projectsDir: await readProjectsDir($, root),
73 env,
74 obsidianVaults: needsVaults ? await readObsidianVaults($) : null,
75 repoRoot: root,
76 })
77}
78
79export async function loadBoard($: EngineInterface) {
80 const root = await repoRoot($)
81 const resolved = await readPlanDir($, root)
82 const planDir = 'error' in resolved ? null : resolved
83 const remote = await $.process.run(['git', 'remote', 'get-url', 'origin'], { cwd: root })
84 const repoUrl = remote.exitCode === 0 ? repoWebUrl(remote.stdout) : null
85 const worktreeList = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: root })
86 const worktrees = worktreeList.exitCode === 0 ? parseWorktrees(worktreeList.stdout) : {}
87
88 const projects: PlanProject[] = []
89 if (planDir !== null && (await $.fs.exists(planDir.base))) {
90 for (const entry of await $.fs.list(planDir.base)) {
91 if (entry.kind !== 'dir' || !isPlanFolder(entry.name)) continue
92 const progressPath = `${planDir.base}/${entry.name}/PROGRESS.md`
93 if (!(await $.fs.exists(progressPath))) continue
94 const { title, rows } = parseProgress(await $.fs.read(progressPath))
95 const { mtimeMs } = await $.fs.stat(progressPath)
96 projects.push({ name: entry.name, title: title || entry.name, rows, updatedAt: mtimeMs })
97 }
98 }
99 projects.sort((first, second) => second.updatedAt - first.updatedAt)
100
101 const planDirError = 'error' in resolved ? resolved.error : null
102 const next: PlanBoard = { planDir, planDirError, repoUrl, worktrees, projects }
103 await update($, board, () => next)
104 if ((await read($, trashEntries)) !== null) {
105 const listed = planDir === null ? [] : await listTrash($, planDir)
106 await update($, trashEntries, () => listed)
107 }
108
109 const current = await read($, selected)
110 if (!projects.some(project => project.name === current)) {
111 await update($, selected, () => null)
112 }
113}
114
115// A cheap fingerprint of what the board shows: the resolved plan dir and every PROGRESS.md's mtime.
116export async function boardSignature($: EngineInterface) {
117 const root = await repoRoot($)
118 const resolved = await readPlanDir($, root)
119 if ('error' in resolved) return `error:${resolved.error}`
120 if (!(await $.fs.exists(resolved.base))) return `missing:${resolved.base}`
121
122 const stamps: string[] = [resolved.base]
123 for (const entry of await $.fs.list(resolved.base)) {
124 if (entry.kind !== 'dir') continue
125 const progressPath = `${resolved.base}/${entry.name}/PROGRESS.md`
126 if (!(await $.fs.exists(progressPath))) continue
127 stamps.push(`${entry.name}:${(await $.fs.stat(progressPath)).mtimeMs}`)
128 }
129
130 return stamps.join('|')
131}
132
133async function realPath($: EngineInterface, path: string) {
134 const resolved = await $.process.run(['realpath', '--', path])
135
136 return resolved.exitCode === 0 ? resolved.stdout.trim() : null
137}
138
139// Both sides resolved, so a symlinked plan or base cannot point a move or removal outside the base.
140async function isContained($: EngineInterface, base: string, target: string) {
141 const [realBase, realTarget] = [await realPath($, base), await realPath($, target)]
142
143 return realBase !== null && realTarget !== null && isDirectChild(realBase, realTarget)
144}
145
146async function finish($: EngineInterface, message: string) {
147 await update($, pendingClear, () => false)
148 await update($, notice, () => message)
149 await loadBoard($)
150}
151
152async function currentPlanDir($: EngineInterface) {
153 return (await read($, board))?.planDir ?? null
154}
155
156export async function deleteProject($: EngineInterface, name: string) {
157 const current = await read($, board)
158 const isKnown = current?.projects.some(project => project.name === name) ?? false
159 const planDir = current?.planDir ?? null
160 await update($, pendingDelete, () => null)
161 if (planDir === null || !isKnown || !isSafeProjectName(name)) {
162 await update($, notice, () => `Refused to delete "${name}".`)
163 return
164 }
165
166 const target = `${planDir.base}/${name}`
167 if (!(await isContained($, planDir.base, target))) {
168 await update($, notice, () => `Refused to delete "${name}": it is not a plan folder inside ${planDir.label}.`)
169 return
170 }
171
172 const trash = `${planDir.base}/${TRASH_DIR}`
173 const made = await $.process.run(['mkdir', '-p', '--', trash])
174 const destination = `${trash}/${trashEntryName(name, new Date())}`
175 // -n: never overwrite an entry trashed in the same second.
176 const moved =
177 made.exitCode === 0 && !(await $.fs.exists(destination))
178 ? await $.process.run(['mv', '-n', '--', target, destination])
179 : null
180 await update($, selected, () => null)
181 await finish(
182 $,
183 moved?.exitCode === 0
184 ? `Moved ${planDir.label}/${name} to the trash`
185 : `Delete failed: ${moved?.stderr.trim() || made.stderr.trim() || 'the trash entry already exists'}`,
186 )
187}
188
189export async function listTrash($: EngineInterface, planDir: PlanDir): Promise<TrashEntry[]> {
190 const trash = `${planDir.base}/${TRASH_DIR}`
191 if (!(await $.fs.exists(trash))) return []
192 const entries = (await $.fs.list(trash)).flatMap(entry => {
193 const parsed = entry.kind === 'dir' ? parseTrashEntry(entry.name) : null
194
195 return parsed === null ? [] : [parsed]
196 })
197
198 return entries.sort((first, second) => second.deletedAt - first.deletedAt)
199}
200
201export async function recoverPlan($: EngineInterface, entry: string) {
202 const planDir = await currentPlanDir($)
203 if (planDir === null) return finish($, `Refused to recover "${entry}".`)
204 const trash = `${planDir.base}/${TRASH_DIR}`
205 const existing = (await $.fs.list(planDir.base)).map(item => item.name)
206 const decision = recoverDecision(entry, existing)
207 if ('error' in decision) return finish($, decision.error)
208
209 const source = `${trash}/${entry}`
210 if (!(await isContained($, trash, source))) return finish($, `Refused to recover "${entry}".`)
211 const moved = await $.process.run(['mv', '-n', '--', source, `${planDir.base}/${decision.name}`])
212 await finish(
213 $,
214 moved.exitCode === 0 && !(await $.fs.exists(source))
215 ? `Recovered ${planDir.label}/${decision.name}`
216 : `Recover failed: ${moved.stderr.trim() || `"${decision.name}" already exists`}`,
217 )
218}
219
220// The only permanent delete left: each child of .trash is checked on its real path before removal.
221export async function clearTrash($: EngineInterface) {
222 const planDir = await currentPlanDir($)
223 const trash = planDir === null ? null : `${planDir.base}/${TRASH_DIR}`
224 if (planDir === null || trash === null || !(await $.fs.exists(trash))) return finish($, 'The trash is empty.')
225 if (!(await isContained($, planDir.base, trash))) return finish($, 'Refused to clear the trash.')
226
227 const names = (await $.fs.list(trash)).map(item => item.name)
228 const targets: string[] = []
229 for (const target of clearTargets(planDir.base, names)) {
230 if (await isContained($, trash, target)) targets.push(target)
231 }
232 if (targets.length === 0) return finish($, 'The trash is empty.')
233 const removal = await $.process.run(['rm', '-rf', '--', ...targets])
234 await finish($, removal.exitCode === 0 ? 'Cleared the trash.' : `Clear failed: ${removal.stderr.trim()}`)
235}
236
237export async function openTrash($: EngineInterface) {
238 await update($, notice, () => null)
239 await update($, trashEntries, () => [])
240 await loadBoard($)
241}
242
243export async function closeTrash($: EngineInterface) {
244 await update($, pendingClear, () => false)
245 await update($, trashEntries, () => null)
246}
247panel/domain/mascot.ts 73 lines1import type { PlanRow } from '../../types'
2
3import { MASCOT_ANIMATIONS, type MascotKeyframe } from './mascot-frames'
4import { isNeedsYou } from './attention'
5import { taskTotals } from './parse'
6
7export type MascotMood = 'idle' | 'working' | 'blocked' | 'asking' | 'done'
8
9const PALETTE: Record<string, string> = {
10 A: '#00B4D8',
11 B: '#00B7BC',
12 D: '#1B3137',
13 E: '#D5DFE0',
14 G: '#00BD74',
15 K: '#11191C',
16 M: '#8E2A20',
17 N: '#91513C',
18 R: '#F36353',
19 S: '#1F6D75',
20 T: '#C6966D',
21 W: '#FFFFFF',
22 Y: '#FFBF36',
23}
24
25function keyframeAt(keyframes: MascotKeyframe[], step: number): string[] {
26 const loop = keyframes.reduce((total, { steps }) => total + steps, 0)
27 let elapsed = step % loop
28 for (const { steps, rows } of keyframes) {
29 if (elapsed < steps) return rows
30 elapsed -= steps
31 }
32
33 return keyframes[0]?.rows ?? []
34}
35
36export type MascotCell = { top?: string; bottom?: string }
37
38export type MascotContext = { waiting?: boolean }
39
40export function mascotMood(rows: PlanRow[], { waiting = false }: MascotContext = {}): MascotMood {
41 const { done, total } = taskTotals(rows)
42 const tasks = rows.filter(row => row.kind === 'task')
43 if (tasks.some(row => row.status.startsWith('🚧'))) return 'blocked'
44 if (waiting || rows.some(isNeedsYou)) return 'asking'
45 if (tasks.some(row => row.status.startsWith('🔄'))) return 'working'
46 if (total > 0 && done === total) return 'done'
47
48 return 'idle'
49}
50
51export function endsOnQuestion(text: string): boolean {
52 return /\?[\s*_`)"']*$/.test(text)
53}
54
55export function mascotFrame(mood: MascotMood, step: number): MascotCell[][] {
56 return toCells(keyframeAt(MASCOT_ANIMATIONS[mood], step))
57}
58
59export function mascotSpinner(step: number): MascotCell[][] {
60 return toCells(keyframeAt(MASCOT_ANIMATIONS.loading, step))
61}
62
63function toCells(pixels: string[]): MascotCell[][] {
64 const rows: MascotCell[][] = []
65 for (let y = 0; y < pixels.length; y += 2) {
66 const upper = pixels[y] ?? ''
67 const lower = pixels[y + 1] ?? ''
68 rows.push([...upper].map((top, x) => ({ top: PALETTE[top], bottom: PALETTE[lower[x] ?? '.'] })))
69 }
70
71 return rows
72}
73panel/ui/pane.tsx 257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, RenderInput } from 'claude-code'
3
4import type { PlanBoard, PlanProject, TrashEntry } from '../../types'
5import { clearTrash, closeTrash, deleteProject, loadBoard, openTrash, recoverPlan } from '../data/board'
6import { closeSettings, editSettings, openSettings, submitSettings } from '../data/settings'
7import { boardNeedsYou } from '../domain/attention'
8import { boardStatus, planStatus } from '../domain/status'
9import { Loading } from './components/molecules/Loading'
10import { Mascot } from './components/molecules/Mascot'
11import { MenuSheet } from './components/molecules/MenuSheet'
12import { NeedsYouCard } from './components/molecules/NeedsYouCard'
13import { TabBar, type Tab } from './components/molecules/TabBar'
14import { ObsidianSettings } from './components/organisms/ObsidianSettings'
15import { PlanDetail } from './components/organisms/PlanDetail'
16import { PlanList } from './components/organisms/PlanList'
17import { TrashView } from './components/organisms/TrashView'
18import type { Ui } from './components/ui'
19
20const board = atom({ plugin: 'taskflow', key: 'board' } as const, null)
21const selected = atom({ plugin: 'taskflow', key: 'selected' } as const, null)
22const pendingDelete = atom({ plugin: 'taskflow', key: 'pendingDelete' } as const, null)
23const notice = atom({ plugin: 'taskflow', key: 'notice' } as const, null)
24const trashEntries = atom({ plugin: 'taskflow', key: 'trashEntries' } as const, null)
25const pendingClear = atom({ plugin: 'taskflow', key: 'pendingClear' } as const, false)
26const animationStep = atom({ plugin: 'taskflow', key: 'animationStep' } as const, 0)
27const isMenuOpen = atom({ plugin: 'taskflow', key: 'isMenuOpen' } as const, false)
28const waiting = atom({ plugin: 'taskflow', key: 'waiting' } as const, {
29 dialog: false,
30 permission: false,
31 lastTurnAsked: false,
32})
33const tab = atom({ plugin: 'taskflow', key: 'tab' } as const, 'plans')
34const settings = atom({ plugin: 'taskflow', key: 'settings' } as const, null)
35
36export async function renderPane($: EngineInterface, e: RenderInput<'Pane'>) {
37 const ui = $.ui.resolve(e)
38 const { Box } = ui
39 const state = await read($, board)
40 const selectedName = await read($, selected)
41 const deleting = await read($, pendingDelete)
42 const message = await read($, notice)
43
44 const step = await read($, animationStep)
45 if (state === null) return <Loading ui={ui} step={step} />
46
47 const settingsView = await read($, settings)
48 if (settingsView !== null) {
49 return (
50 <Box flexDirection="column" gap={1} minHeight={e.props.scroll.bodyRows}>
51 <ObsidianSettings
52 ui={ui}
53 view={settingsView}
54 onBack={() => void closeSettings($)}
55 onChange={change => void editSettings($, change)}
56 onSave={() => void submitSettings($, settingsView.draft)}
57 />
58 </Box>
59 )
60 }
61
62 const activeTab = await read($, tab)
63 const found = state.projects.find(candidate => candidate.name === selectedName)
64 const project = activeTab === 'needs-you' ? undefined : found
65 const trashList = await read($, trashEntries)
66 const hasBar = trashList === null && state.projects.length > 0
67 const back = () => {
68 void update($, isMenuOpen, () => false)
69 void update($, tab, () => 'plans')
70 void update($, selected, () => null)
71 }
72 const menuOpen = await read($, isMenuOpen)
73 const hasSheet = menuOpen && hasBar && activeTab === 'plans'
74 const wait = await read($, waiting)
75 const rows = project?.rows ?? state.projects.flatMap(candidate => candidate.rows)
76
77 return (
78 <Box flexDirection="column" gap={1} minHeight={e.props.scroll.bodyRows}>
79 <Mascot
80 ui={ui}
81 rows={rows}
82 status={project ? planStatus(project) : boardStatus(state.projects)}
83 step={step}
84 waiting={wait.dialog || wait.permission || wait.lastTurnAsked}
85 card={state.projects.length > 0}
86 />
87
88 <PaneContent
89 ui={ui}
90 board={state}
91 columns={e.props.bodyColumns}
92 project={project}
93 deleting={deleting}
94 trash={trashList}
95 tab={activeTab}
96 isClearing={await read($, pendingClear)}
97 isMenuOpen={menuOpen}
98 message={message}
99 actions={{
100 open: name => {
101 void update($, isMenuOpen, () => false)
102 void update($, tab, () => 'plans')
103 void update($, selected, () => name)
104 },
105 back,
106 toggleMenu: () => update($, isMenuOpen, open => !open),
107 nextTask: () => void $.prompt.submit({ text: '/taskflow:next-task' }),
108 openSettings: () => void openSettings($),
109 confirmDelete: name => deleteProject($, name),
110 cancelDelete: () => update($, pendingDelete, () => null),
111 openTrash: () => openTrash($),
112 closeTrash: () => closeTrash($),
113 recover: entry => recoverPlan($, entry),
114 askClear: () => update($, pendingClear, () => true),
115 confirmClear: () => clearTrash($),
116 cancelClear: () => update($, pendingClear, () => false),
117 }}
118 />
119
120 {hasBar && (
121 <>
122 <Box flexGrow={1} />
123 <TabBar ui={ui} active={activeTab} onPlans={back} onNeedsYou={() => void update($, tab, () => 'needs-you')} />
124 </>
125 )}
126
127 {hasSheet && (
128 <MenuSheet
129 ui={ui}
130 plan={
131 project && {
132 onNextTask: () => void $.prompt.submit({ text: '/taskflow:next-task' }),
133 onPlanStatus: () => void $.prompt.submit({ text: '/taskflow:plan-status' }),
134 onAskDelete: () => {
135 void update($, isMenuOpen, () => false)
136 void update($, pendingDelete, () => project.name)
137 },
138 }
139 }
140 onRefresh={() => {
141 void update($, isMenuOpen, () => false)
142 void loadBoard($)
143 }}
144 onSettings={() => {
145 void update($, isMenuOpen, () => false)
146 void openSettings($)
147 }}
148 onOpenTrash={() => {
149 void update($, isMenuOpen, () => false)
150 void openTrash($)
151 }}
152 />
153 )}
154 </Box>
155 )
156}
157
158type PaneActions = {
159 open: (name: string) => void
160 back: () => void
161 toggleMenu: () => void
162 nextTask: () => void
163 openSettings: () => void
164 confirmDelete: (name: string) => void
165 cancelDelete: () => void
166 openTrash: () => void
167 closeTrash: () => void
168 recover: (entry: string) => void
169 askClear: () => void
170 confirmClear: () => void
171 cancelClear: () => void
172}
173
174type PaneContentProps = {
175 ui: Ui
176 board: PlanBoard
177 columns: number
178 project: PlanProject | undefined
179 deleting: string | null
180 trash: TrashEntry[] | null
181 tab: Tab
182 isClearing: boolean
183 isMenuOpen: boolean
184 message: string | null
185 actions: PaneActions
186}
187
188const PaneContent = ({
189 ui,
190 board: state,
191 columns,
192 project,
193 deleting,
194 trash,
195 tab: activeTab,
196 isClearing,
197 isMenuOpen,
198 message,
199 actions,
200}: PaneContentProps) => {
201 const { Box, Text, Button } = ui
202
203 return trash !== null ? (
204 <TrashView
205 ui={ui}
206 entries={trash}
207 isClearing={isClearing}
208 message={message}
209 onBack={actions.closeTrash}
210 onRecover={actions.recover}
211 onAskClear={actions.askClear}
212 onConfirmClear={actions.confirmClear}
213 onCancelClear={actions.cancelClear}
214 />
215 ) : state.projects.length === 0 ? (
216 <Box flexDirection="column">
217 {state.planDir === null ? (
218 <Text color="error">{state.planDirError}</Text>
219 ) : (
220 <Text dimColor>No plans in {state.planDir.label}.</Text>
221 )}
222 {message && <Text color="warning">{message}</Text>}
223 <Button key="settings" hotkey="s" label="⚙ Settings (s)" onPress={actions.openSettings} />
224 {state.planDir !== null && <Button key="trash" hotkey="t" label="🗑️ Trash (t)" onPress={actions.openTrash} />}
225 </Box>
226 ) : activeTab === 'needs-you' ? (
227 <NeedsYouCard ui={ui} items={boardNeedsYou(state.projects)} columns={columns - 4} onOpen={actions.open} />
228 ) : project === undefined ? (
229 <PlanList
230 ui={ui}
231 projects={state.projects}
232 columns={columns}
233 message={message}
234 isMenuOpen={isMenuOpen}
235 dim={isMenuOpen}
236 onOpen={actions.open}
237 onToggleMenu={actions.toggleMenu}
238 />
239 ) : (
240 <PlanDetail
241 ui={ui}
242 board={state}
243 project={project}
244 columns={columns}
245 isDeleting={deleting === project.name}
246 isMenuOpen={isMenuOpen}
247 dim={isMenuOpen}
248 message={message}
249 onBack={actions.back}
250 onToggleMenu={actions.toggleMenu}
251 onNextTask={actions.nextTask}
252 onConfirmDelete={() => actions.confirmDelete(project.name)}
253 onCancelDelete={actions.cancelDelete}
254 />
255 )
256}
257panel/domain/parse.ts 93 lines1import type { PlanRow, RowKind } from '../../types'
2
3const SEPARATOR_CELL = /^:?-+:?$/
4
5function rowKind(id: string, category: string): RowKind | null {
6 if (/^TASK-\d+/i.test(id)) return 'task'
7 if (/^Q\d+/i.test(id)) return 'question'
8 if (/^PR-\d+/i.test(id) || /^#\d+/.test(id) || category.includes('🔀')) return 'pr'
9
10 return null
11}
12
13export function parseProgress(markdown: string): { title: string; rows: PlanRow[] } {
14 const heading = markdown.match(/^#\s+(.+)$/m)?.[1] ?? ''
15 const title = heading.replace(/^Progress\s+[—-]\s+/, '').trim()
16 const rows: PlanRow[] = []
17
18 for (const line of markdown.split('\n')) {
19 if (!line.trim().startsWith('|')) continue
20 const cells = line
21 .trim()
22 .replace(/^\||\|$/g, '')
23 .split('|')
24 .map(cell => cell.trim())
25 if (cells.length < 5 || cells.every(cell => SEPARATOR_CELL.test(cell)) || cells[1] === 'ID') continue
26
27 const [status = '', id = '', category = '', owner = '', ...summaryCells] = cells
28 const kind = rowKind(id, category)
29 if (kind === null) continue
30
31 const prMatch = kind === 'pr' ? `${id} ${status}`.match(/#(\d+)/) : null
32 const branchMatch = kind === 'pr' ? summaryCells.join(' | ').match(/`([^`]+)`/) : null
33 rows.push({
34 id,
35 kind,
36 status,
37 owner,
38 summary: summaryCells.join(' | ').replace(/`/g, ''),
39 prNumber: prMatch ? Number(prMatch[1]) : null,
40 branch: branchMatch?.[1] ?? null,
41 })
42 }
43
44 return { title, rows }
45}
46
47export function isTaskDone(status: string) {
48 return status.startsWith('✅') || status.startsWith('❌')
49}
50
51export function isQuestionOpen(status: string) {
52 return !status.startsWith('✅') && !status.startsWith('❌')
53}
54
55export function openQuestions(rows: PlanRow[]) {
56 return rows.filter(row => row.kind === 'question' && isQuestionOpen(row.status))
57}
58
59export function taskTotals(rows: PlanRow[]) {
60 const tasks = rows.filter(row => row.kind === 'task')
61
62 return { done: tasks.filter(row => isTaskDone(row.status)).length, total: tasks.length }
63}
64
65export function activeTasks(rows: PlanRow[], pendingLimit = 3) {
66 const tasks = rows.filter(row => row.kind === 'task')
67 const working = tasks.filter(row => /^(🔄|🚧|⚠️)/.test(row.status))
68 const pending = tasks.filter(row => row.status.startsWith('⬜')).slice(0, pendingLimit)
69
70 return [...working, ...pending]
71}
72
73export function repoWebUrl(remote: string): string | null {
74 const match = remote.trim().match(/github\.com[:/]([^/]+)\/(.+?)(\.git)?$/)
75
76 return match ? `https://github.com/${match[1]}/${match[2]}` : null
77}
78
79export function isSafeProjectName(name: string) {
80 return /^[\w.-]+$/.test(name) && name !== '.' && name !== '..'
81}
82
83export function parseWorktrees(porcelain: string): Record<string, string> {
84 const worktrees: Record<string, string> = {}
85 for (const block of porcelain.split('\n\n')) {
86 const path = block.match(/^worktree (.+)$/m)?.[1]
87 const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1]
88 if (path && branch) worktrees[branch] = path
89 }
90
91 return worktrees
92}
93panel/domain/plan-dir.ts 79 lines1import type { PlanDir } from '../../types'
2
3export const ENV_KEYS = [
4 'TASKFLOW_PLANS_SOURCE',
5 'TASKFLOW_OBSIDIAN_VAULT',
6 'TASKFLOW_OBSIDIAN_VAULT_PATH',
7 'TASKFLOW_OBSIDIAN_PATH',
8] as const
9
10export type WorkflowEnv = Partial<Record<(typeof ENV_KEYS)[number], string>>
11
12export const DEFAULT_PROJECTS_DIR = 'docs/projects'
13
14export const isEnvKey = (key: string): key is (typeof ENV_KEYS)[number] => ENV_KEYS.some(allowed => allowed === key)
15
16export const basename = (path: string) => path.replace(/\/+$/, '').split('/').pop() ?? ''
17
18export const escapesVault = (inner: string) => inner.startsWith('/') || inner.split('/').includes('..')
19
20// workflow.env is untracked, so only the keys that pick the plan source are read - never gates or models.
21export function parseWorkflowEnv(text: string): WorkflowEnv {
22 const env: WorkflowEnv = {}
23 for (const line of text.split('\n')) {
24 const trimmed = line.trim()
25 const separator = trimmed.indexOf('=')
26 if (trimmed.startsWith('#') || separator < 1) continue
27 const key = trimmed.slice(0, separator).trim()
28 if (isEnvKey(key)) env[key] = trimmed.slice(separator + 1).trim()
29 }
30
31 return env
32}
33
34function vaultPath(env: WorkflowEnv, obsidianVaults: string[] | null): { path: string } | { error: string } {
35 const explicit = env.TASKFLOW_OBSIDIAN_VAULT_PATH
36 if (explicit) {
37 return explicit.startsWith('/') ? { path: explicit } : { error: 'TASKFLOW_OBSIDIAN_VAULT_PATH must be absolute.' }
38 }
39
40 const name = env.TASKFLOW_OBSIDIAN_VAULT
41 if (!name) return { error: 'Set TASKFLOW_OBSIDIAN_VAULT or TASKFLOW_OBSIDIAN_VAULT_PATH in .claude/workflow.env.' }
42 if (obsidianVaults === null) return { error: 'Cannot read Obsidian’s obsidian.json to find the vault.' }
43
44 const matches = obsidianVaults.filter(path => basename(path) === name)
45 if (matches.length === 0) return { error: `No Obsidian vault named "${name}".` }
46 if (matches.length > 1) {
47 return { error: `${matches.length} Obsidian vaults are named "${name}" - set TASKFLOW_OBSIDIAN_VAULT_PATH.` }
48 }
49
50 return { path: matches[0] ?? '' }
51}
52
53// With Obsidian as the source a problem is an error, never a fallback: the repo folder may hold other plans.
54export function resolvePlanDir(input: {
55 projectsDir: string | null
56 env: WorkflowEnv
57 obsidianVaults: string[] | null
58 repoRoot: string
59}): PlanDir | { error: string } {
60 const { env, repoRoot } = input
61 if (env.TASKFLOW_PLANS_SOURCE !== 'obsidian') {
62 const projectsDir = input.projectsDir ?? DEFAULT_PROJECTS_DIR
63
64 return { base: `${repoRoot}/${projectsDir}`, label: projectsDir }
65 }
66
67 const inner = (env.TASKFLOW_OBSIDIAN_PATH ?? '').replace(/\/+$/, '')
68 if (escapesVault(inner)) {
69 return { error: `TASKFLOW_OBSIDIAN_PATH "${inner}" must stay inside the vault.` }
70 }
71
72 const vault = vaultPath(env, input.obsidianVaults)
73 if ('error' in vault) return vault
74 const root = vault.path.replace(/\/+$/, '')
75 const shown = inner ? `${basename(root)}/${inner}` : basename(root)
76
77 return { base: inner ? `${root}/${inner}` : root, label: `Obsidian · ${shown}` }
78}
79panel/domain/trash.ts 56 lines1import type { TrashEntry } from '../../types'
2import { isSafeProjectName } from './parse'
3
4export const TRASH_DIR = '.trash'
5
6const ENTRY = /^(.+)--(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/
7
8const pad = (value: number) => String(value).padStart(2, '0')
9
10// Local time, so the folder name matches the clock the user saw when deleting.
11export function trashEntryName(name: string, at: Date) {
12 const date = `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}`
13 const time = `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`
14
15 return `${name}--${date}-${time}`
16}
17
18export function parseTrashEntry(entry: string): TrashEntry | null {
19 const match = entry.match(ENTRY)
20 if (!match) return null
21 const part = (index: number) => Number(match[index])
22 const deletedAt = new Date(part(2), part(3) - 1, part(4), part(5), part(6), part(7)).getTime()
23
24 return { entry, name: match[1] ?? '', deletedAt }
25}
26
27const trimSlashes = (path: string) => path.replace(/\/+$/, '')
28
29// Compared on real paths by the caller, so a symlink cannot smuggle a target out of the base.
30export function isDirectChild(base: string, target: string) {
31 const prefix = `${trimSlashes(base)}/`
32 if (!target.startsWith(prefix)) return false
33
34 return isSafeProjectName(trimSlashes(target).slice(prefix.length))
35}
36
37export function recoverDecision(entry: string, existingPlans: string[]): { name: string } | { error: string } {
38 const parsed = parseTrashEntry(entry)
39 if (parsed === null || !isSafeProjectName(parsed.name) || !isSafeProjectName(entry)) {
40 return { error: `Refused to recover "${entry}".` }
41 }
42 if (existingPlans.includes(parsed.name)) {
43 return { error: `A plan named "${parsed.name}" already exists - rename or delete it first.` }
44 }
45
46 return { name: parsed.name }
47}
48
49export function clearTargets(base: string, entries: string[]) {
50 return entries.filter(isSafeProjectName).map(entry => `${trimSlashes(base)}/${TRASH_DIR}/${entry}`)
51}
52
53export function isPlanFolder(name: string) {
54 return name !== TRASH_DIR
55}
56panel/domain/mascot-frames.ts 589 lines1export type MascotKeyframe = { steps: number; rows: string[] }
2
3export const MASCOT_ANIMATIONS = {
4 idle: [
5 {
6 steps: 4,
7 rows: [
8 '............',
9 '.......AA...',
10 '...BBBBBBBSS',
11 '...WWWWWWWSS',
12 '...WWWWWWWSS',
13 '...WDDWDDWSS',
14 '...WWWWWWWSS',
15 '............',
16 ],
17 },
18 {
19 steps: 3,
20 rows: [
21 '............',
22 '.......AA...',
23 '...BBBBBBBSS',
24 '...WWWWWWWSS',
25 '.W.WWWWWWWSS',
26 '...WDDWDDWSS',
27 '...WWWWWWWSS',
28 '............',
29 ],
30 },
31 {
32 steps: 3,
33 rows: [
34 '............',
35 '.......AA...',
36 '...BBBBBBBSS',
37 'W..WWWWWWWSS',
38 '.E.WWWWWWWSS',
39 '...WDDWDDWSS',
40 '...WWWWWWWSS',
41 '............',
42 ],
43 },
44 {
45 steps: 3,
46 rows: [
47 '............',
48 '.......AA...',
49 '.W.BBBBBBBSS',
50 'E..WWWWWWWSS',
51 '.E.WWWWWWWSS',
52 '...WDDWDDWSS',
53 '...WWWWWWWSS',
54 '............',
55 ],
56 },
57 {
58 steps: 3,
59 rows: [
60 '............',
61 '..W....AA...',
62 '.W.BBBBBBBSS',
63 'E..WWWWWWWSS',
64 '...WWWWWWWSS',
65 '...WDDWDDWSS',
66 '...WWWWWWWSS',
67 '............',
68 ],
69 },
70 {
71 steps: 3,
72 rows: [
73 '............',
74 '..E....AA...',
75 '.E.BBBBBBBSS',
76 '...WWWWWWWSS',
77 '...WWWWWWWSS',
78 '...WDDWDDWSS',
79 '...WWWWWWWSS',
80 '............',
81 ],
82 },
83 {
84 steps: 3,
85 rows: [
86 '............',
87 '..E....AA...',
88 '...BBBBBBBSS',
89 '...WWWWWWWSS',
90 '...WWWWWWWSS',
91 '...WDDWDDWSS',
92 '...WWWWWWWSS',
93 '............',
94 ],
95 },
96 {
97 steps: 3,
98 rows: [
99 '............',
100 '.......AA...',
101 '...BBBBBBBSS',
102 '...WWWWWWWSS',
103 '...WWWWWWWSS',
104 '...WDDWDDWSS',
105 '...WWWWWWWSS',
106 '............',
107 ],
108 },
109 {
110 steps: 3,
111 rows: [
112 '............',
113 '.......AA...',
114 '...BBBBBBBSS',
115 '...WWWWWWWSS',
116 '...WWWWWWWSS',
117 '...WDDWDDWSS',
118 '...WWWWWWWSS',
119 '............',
120 ],
121 },
122 {
123 steps: 3,
124 rows: [
125 '............',
126 '.......AA...',
127 '...BBBBBBBSS',
128 '...WWWWWWWSS',
129 '...WWWWWWWSS',
130 '...WDDWDDWSS',
131 '...WWWWWWWSS',
132 '............',
133 ],
134 },
135 {
136 steps: 8,
137 rows: [
138 '............',
139 '.......AA...',
140 '...BBBBBBBSS',
141 '...WWWWWWWSS',
142 '...WYYWTTWSS',
143 '...WKYWKTWSS',
144 '...WWWWWWWSS',
145 '............',
146 ],
147 },
148 {
149 steps: 8,
150 rows: [
151 '............',
152 '.......AA...',
153 '...BBBBBBBSS',
154 '...WWWWWWWSS',
155 '...WYYWTTWSS',
156 '...WYKWTKWSS',
157 '...WWWWWWWSS',
158 '............',
159 ],
160 },
161 ],
162 working: [
163 {
164 steps: 2,
165 rows: [
166 '............',
167 '.......AA...',
168 '...BBBBBBBSS',
169 '...WWWWWWWSS',
170 '...WKKKKWWSS',
171 '...WWWWWWWSS',
172 '...WWWWWWWSS',
173 '............',
174 ],
175 },
176 {
177 steps: 1,
178 rows: [
179 '............',
180 '.......AA...',
181 '...BBBBBBBSS',
182 '...WWWWWWWSS',
183 '...WKKKKWWSS',
184 '...WWWWWWWSS',
185 '...WKWWWWWSS',
186 '............',
187 ],
188 },
189 {
190 steps: 1,
191 rows: [
192 '............',
193 '.......AA...',
194 '...BBBBBBBSS',
195 '...WWWWWWWSS',
196 '...WKKKKWWSS',
197 '...WWWWWWWSS',
198 '...WKKWWWWSS',
199 '............',
200 ],
201 },
202 {
203 steps: 1,
204 rows: [
205 '............',
206 '.......AA...',
207 '...BBBBBBBSS',
208 '...WWWWWWWSS',
209 '...WKKKKWWSS',
210 '...WWWWWWWSS',
211 '...WKKKWWWSS',
212 '............',
213 ],
214 },
215 {
216 steps: 1,
217 rows: [
218 '............',
219 '.......AA...',
220 '...BBBBBBBSS',
221 '...WWWWWWWSS',
222 '...WKKKKWWSS',
223 '...WWWWWWWSS',
224 '...WKKKKWWSS',
225 '............',
226 ],
227 },
228 {
229 steps: 2,
230 rows: [
231 '............',
232 '.......AA...',
233 '...BBBBBBBSS',
234 '...WKKKKWWSS',
235 '...WWWWWWWSS',
236 '...WKKKKWWSS',
237 '...WWWWWWWSS',
238 '............',
239 ],
240 },
241 ],
242 blocked: [
243 {
244 steps: 4,
245 rows: [
246 '............',
247 '.......AA...',
248 '...BBBBBBBSS',
249 '...WWWWWWWSS',
250 '...WRRWRRWSS',
251 '...WRRWRRWSS',
252 '...WWWWWWWSS',
253 '............',
254 ],
255 },
256 {
257 steps: 4,
258 rows: [
259 '............',
260 '.......AA...',
261 '...BBBBBBBSS',
262 '...MMMMMMMSS',
263 '...MRRMRRMSS',
264 '...MRRMRRMSS',
265 '...MMMMMMMSS',
266 '............',
267 ],
268 },
269 ],
270 asking: [
271 {
272 steps: 2,
273 rows: [
274 '............',
275 '.......AA...',
276 '...BBBBBBBSS',
277 '...WWWWWWWSS',
278 '...WKYWKTWSS',
279 '...WYYWTTWSS',
280 '...WWWWWWWSS',
281 '............',
282 ],
283 },
284 {
285 steps: 2,
286 rows: [
287 '............',
288 '.......AA...',
289 '...BBBBBBBSS',
290 '...WWWWWWWSS',
291 '...WKYWKTWSS',
292 '...WYYWTTWSS',
293 '.Y.WWWWWWWSS',
294 '............',
295 ],
296 },
297 {
298 steps: 2,
299 rows: [
300 '............',
301 '.......AA...',
302 '...BBBBBBBSS',
303 '...WWWWWWWSS',
304 '.Y.WYKWTKWSS',
305 '...WYYWTTWSS',
306 '.Y.WWWWWWWSS',
307 '............',
308 ],
309 },
310 {
311 steps: 2,
312 rows: [
313 '............',
314 '.......AA...',
315 '...BBBBBBBSS',
316 '..YWWWWWWWSS',
317 '.Y.WYKWTKWSS',
318 '...WYYWTTWSS',
319 '.Y.WWWWWWWSS',
320 '............',
321 ],
322 },
323 {
324 steps: 2,
325 rows: [
326 '............',
327 '.......AA...',
328 '..YBBBBBBBSS',
329 '..YWWWWWWWSS',
330 '.Y.WKYWKTWSS',
331 '...WYYWTTWSS',
332 '.Y.WWWWWWWSS',
333 '............',
334 ],
335 },
336 {
337 steps: 2,
338 rows: [
339 '............',
340 '.Y.....AA...',
341 '..YBBBBBBBSS',
342 '..YWWWWWWWSS',
343 '.Y.WKYWKTWSS',
344 '...WYYWTTWSS',
345 '.Y.WWWWWWWSS',
346 '............',
347 ],
348 },
349 {
350 steps: 2,
351 rows: [
352 '............',
353 '.Y.....AA...',
354 'Y.YBBBBBBBSS',
355 '..YWWWWWWWSS',
356 '.Y.WYKWTKWSS',
357 '...WYYWTTWSS',
358 '.Y.WWWWWWWSS',
359 '............',
360 ],
361 },
362 {
363 steps: 2,
364 rows: [
365 '............',
366 '.Y.....AA...',
367 'Y.YBBBBBBBSS',
368 '..YWWWWWWWSS',
369 '.Y.WYKWTKWSS',
370 '...WYYWTTWSS',
371 '.Y.WWWWWWWSS',
372 '............',
373 ],
374 },
375 {
376 steps: 4,
377 rows: [
378 '............',
379 '.......AA...',
380 '...BBBBBBBSS',
381 '...WWWWWWWSS',
382 '...WKYWKTWSS',
383 '...WYYWTTWSS',
384 '...WWWWWWWSS',
385 '............',
386 ],
387 },
388 {
389 steps: 4,
390 rows: [
391 '............',
392 '.......AA...',
393 '...BBBBBBBSS',
394 '...WWWWWWWSS',
395 '...WYKWTKWSS',
396 '...WYYWTTWSS',
397 '...WWWWWWWSS',
398 '............',
399 ],
400 },
401 ],
402 done: [
403 {
404 steps: 2,
405 rows: [
406 '............',
407 '.......AA...',
408 '...BBBBBBBSS',
409 '...WWWWWWWSS',
410 '...WWWWWWWSS',
411 '...WDDWDDWSS',
412 '...WWWWWWWSS',
413 '............',
414 ],
415 },
416 {
417 steps: 2,
418 rows: [
419 '............',
420 '.......GG...',
421 '...BBBBBBBSS',
422 '...WWWWWWWSS',
423 '...WWWWWWWSS',
424 '...WDDWDDWSS',
425 '...WWWWWWWSS',
426 '............',
427 ],
428 },
429 {
430 steps: 2,
431 rows: [
432 '............',
433 '.......AA...',
434 '...BBBBBBBSS',
435 '...WWWWWWWSS',
436 '...WWWWWWWSS',
437 '...WDDWDDWSS',
438 '...WWWWWWWSS',
439 '............',
440 ],
441 },
442 {
443 steps: 2,
444 rows: [
445 '............',
446 '.......GG...',
447 '...BBBBBBBSS',
448 '...WWWWWWWSS',
449 '...WWWWWWWSS',
450 '...WDDWDDWSS',
451 '...WWWWWWWSS',
452 '............',
453 ],
454 },
455 {
456 steps: 4,
457 rows: [
458 '............',
459 '.......AA...',
460 '...BBBBBBBSS',
461 '...WWWWWWWSS',
462 '...WGGWGGWSS',
463 '...WGGWGGWSS',
464 '...WWWWWWWSS',
465 '............',
466 ],
467 },
468 {
469 steps: 4,
470 rows: [
471 '............',
472 '.......GG...',
473 '...BBBBBBBSS',
474 '...WWWWWWWSS',
475 '...WGGWGGWSS',
476 '...WGGWGGWSS',
477 '...WWWWWWWSS',
478 '............',
479 ],
480 },
481 ],
482 loading: [
483 {
484 steps: 1,
485 rows: [
486 '............',
487 '.......AA...',
488 '...BBBBBBBSS',
489 '...WWTYWWWSS',
490 '...WNWWEWWSS',
491 '...WEWWEWWSS',
492 '...WWEEWWWSS',
493 '............',
494 ],
495 },
496 {
497 steps: 1,
498 rows: [
499 '............',
500 '.......AA...',
501 '...BBBBBBBSS',
502 '...WWNTWWWSS',
503 '...WEWWYWWSS',
504 '...WEWWEWWSS',
505 '...WWEEWWWSS',
506 '............',
507 ],
508 },
509 {
510 steps: 1,
511 rows: [
512 '............',
513 '.......AA...',
514 '...BBBBBBBSS',
515 '...WWENWWWSS',
516 '...WEWWTWWSS',
517 '...WEWWYWWSS',
518 '...WWEEWWWSS',
519 '............',
520 ],
521 },
522 {
523 steps: 1,
524 rows: [
525 '............',
526 '.......AA...',
527 '...BBBBBBBSS',
528 '...WWEEWWWSS',
529 '...WEWWNWWSS',
530 '...WEWWTWWSS',
531 '...WWEYWWWSS',
532 '............',
533 ],
534 },
535 {
536 steps: 1,
537 rows: [
538 '............',
539 '.......AA...',
540 '...BBBBBBBSS',
541 '...WWEEWWWSS',
542 '...WEWWEWWSS',
543 '...WEWWNWWSS',
544 '...WWYTWWWSS',
545 '............',
546 ],
547 },
548 {
549 steps: 1,
550 rows: [
551 '............',
552 '.......AA...',
553 '...BBBBBBBSS',
554 '...WWEEWWWSS',
555 '...WEWWEWWSS',
556 '...WYWWEWWSS',
557 '...WWTNWWWSS',
558 '............',
559 ],
560 },
561 {
562 steps: 1,
563 rows: [
564 '............',
565 '.......AA...',
566 '...BBBBBBBSS',
567 '...WWEEWWWSS',
568 '...WYWWEWWSS',
569 '...WTWWEWWSS',
570 '...WWNEWWWSS',
571 '............',
572 ],
573 },
574 {
575 steps: 1,
576 rows: [
577 '............',
578 '.......AA...',
579 '...BBBBBBBSS',
580 '...WWYEWWWSS',
581 '...WTWWEWWSS',
582 '...WNWWEWWSS',
583 '...WWEEWWWSS',
584 '............',
585 ],
586 },
587 ],
588} satisfies Record<string, MascotKeyframe[]>
589panel/domain/attention.ts 55 lines1import type { PlanProject, PlanRow } from '../../types'
2import { isQuestionOpen } from './parse'
3
4export type NeedsYouItem = {
5 kind: 'question' | 'review' | 'criteria'
6 id: string
7 plan: string
8 project: string
9 unblocks: string | null
10 prNumber: number | null
11}
12
13export type PlanBadge = { kind: 'blocked' } | { kind: 'attention'; count: number } | { kind: 'none' }
14
15export function isNeedsYou(row: PlanRow): boolean {
16 if (row.kind === 'question') return isQuestionOpen(row.status)
17
18 return row.kind === 'pr' && (row.status.startsWith('👀') || row.status.startsWith('📋'))
19}
20
21function toItem(project: PlanProject, row: PlanRow): NeedsYouItem | null {
22 if (!isNeedsYou(row)) return null
23 const base = { id: row.id, plan: project.title, project: project.name, unblocks: null, prNumber: null }
24
25 if (row.kind === 'question') {
26 return { ...base, kind: 'question', unblocks: /blocks (TASK-\d+)/.exec(row.summary)?.[1] ?? null }
27 }
28
29 return row.status.startsWith('👀')
30 ? { ...base, kind: 'review', prNumber: row.prNumber }
31 : { ...base, kind: 'criteria' }
32}
33
34export function needsYou(project: PlanProject): NeedsYouItem[] {
35 return project.rows.map(row => toItem(project, row)).filter(item => item !== null)
36}
37
38export function boardNeedsYou(projects: PlanProject[]): NeedsYouItem[] {
39 return projects.flatMap(needsYou)
40}
41
42export function planBadge(project: PlanProject): PlanBadge {
43 if (project.rows.some(row => row.kind === 'task' && row.status.startsWith('🚧'))) return { kind: 'blocked' }
44
45 const count = needsYou(project).length
46
47 return count > 0 ? { kind: 'attention', count } : { kind: 'none' }
48}
49
50export function currentTask(rows: PlanRow[]): PlanRow | null {
51 const tasks = rows.filter(row => row.kind === 'task')
52
53 return tasks.find(row => row.status.startsWith('🔄')) ?? tasks.find(row => row.status.startsWith('🚧')) ?? null
54}
55panel/data/settings.ts 79 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface } from 'claude-code'
3
4import type { PlanSettings, SettingsView } from '../../types'
5import { basename } from '../domain/plan-dir'
6import { updateWorkflowEnv } from '../domain/workflow-env'
7import type { PlanSourceSettings } from '../domain/workflow-env'
8import { loadBoard, readObsidianVaults, readPlanDir, readWorkflowEnv, repoRoot } from './board'
9
10const settings = atom({ plugin: 'taskflow', key: 'settings' } as const, null)
11
12export async function loadSettings($: EngineInterface): Promise<PlanSettings> {
13 const root = await repoRoot($)
14 const env = await readWorkflowEnv($, root)
15 const resolved = await readPlanDir($, root)
16 const vaults = (await readObsidianVaults($))?.map(path => ({ name: basename(path), path })) ?? []
17
18 return {
19 source: env.TASKFLOW_PLANS_SOURCE === 'obsidian' ? 'obsidian' : 'repo',
20 vault: env.TASKFLOW_OBSIDIAN_VAULT,
21 vaultPath: env.TASKFLOW_OBSIDIAN_VAULT_PATH,
22 path: env.TASKFLOW_OBSIDIAN_PATH,
23 vaults,
24 error: 'error' in resolved ? resolved.error : null,
25 repoName: basename(root),
26 }
27}
28
29// workflow.env holds machine paths, so .claude/.gitignore must list it before the file is written.
30async function ensureIgnored($: EngineInterface, root: string) {
31 const path = `${root}/.claude/.gitignore`
32 const current = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
33 if (current.split('\n').some(line => line.trim() === 'workflow.env')) return
34 const separator = current === '' || current.endsWith('\n') ? '' : '\n'
35 await $.fs.write(path, `${current}${separator}workflow.env\n`)
36}
37
38export async function saveSettings($: EngineInterface, settings: PlanSourceSettings) {
39 const root = await repoRoot($)
40 const path = `${root}/.claude/workflow.env`
41 const updated = updateWorkflowEnv((await $.fs.exists(path)) ? await $.fs.read(path) : '', settings)
42 if ('error' in updated) return updated
43 await ensureIgnored($, root)
44 await $.fs.write(path, updated.text)
45 await loadBoard($)
46
47 return { error: null }
48}
49
50export async function openSettings($: EngineInterface) {
51 const current = await loadSettings($)
52 const view: SettingsView = {
53 current,
54 draft: {
55 source: current.source,
56 vault: current.vault ?? '',
57 vaultPath: current.vaultPath ?? '',
58 path: current.path ?? `taskflow/${current.repoName}`,
59 },
60 saveError: null,
61 }
62 await update($, settings, () => view)
63}
64
65export function closeSettings($: EngineInterface) {
66 return update($, settings, () => null)
67}
68
69export function editSettings($: EngineInterface, change: Partial<SettingsView['draft']>) {
70 return update($, settings, view => (view === null ? view : { ...view, draft: { ...view.draft, ...change } }))
71}
72
73export async function submitSettings($: EngineInterface, draft: SettingsView['draft']) {
74 const saved = await saveSettings($, draft)
75 if (saved.error === null) return closeSettings($)
76
77 return update($, settings, view => (view === null ? view : { ...view, saveError: saved.error }))
78}
79panel/domain/status.ts 52 lines1import type { PlanProject, PlanRow } from '../../types'
2import { openQuestions, taskTotals } from './parse'
3
4const tasksWith = (rows: PlanRow[], mark: string) =>
5 rows.filter(row => row.kind === 'task' && row.status.startsWith(mark))
6const plural = (count: number, one: string, many: string) => `${count} ${count === 1 ? one : many}`
7const joined = (parts: (string | false)[]) => parts.filter(part => part !== false).join(' · ')
8
9export function isPlanActive(project: PlanProject) {
10 const { done, total } = taskTotals(project.rows)
11 const started = project.rows.some(row => row.kind === 'task' && !row.status.startsWith('⬜'))
12
13 return started && done < total
14}
15
16export type FloStatus = { headline: string; progress?: { done: number; total: number }; details: string[] }
17
18export function boardStatus(projects: PlanProject[]): FloStatus {
19 if (projects.length === 0) return { headline: 'No plans yet.', details: [] }
20 const rows = projects.flatMap(project => project.rows)
21 const inProgress = tasksWith(rows, '🔄').length
22 const blocked = tasksWith(rows, '🚧').length
23 const questions = openQuestions(rows).length
24 const activity = joined([
25 inProgress > 0 && plural(inProgress, 'task in progress', 'tasks in progress'),
26 blocked > 0 && `${blocked} blocked`,
27 questions > 0 && plural(questions, 'open question', 'open questions'),
28 ])
29
30 return {
31 headline: `${plural(projects.length, 'plan', 'plans')} · ${projects.filter(isPlanActive).length} active`,
32 details: [activity || 'Nothing in progress'],
33 }
34}
35
36export function planStatus(project: PlanProject): FloStatus {
37 const { done, total } = taskTotals(project.rows)
38 const blocked = tasksWith(project.rows, '🚧')[0]
39 const working = tasksWith(project.rows, '🔄')[0]
40 const questions = openQuestions(project.rows).length
41 const attention = joined([
42 blocked ? `${blocked.id} blocked` : working ? `${working.id} in progress` : false,
43 questions > 0 && plural(questions, 'open question', 'open questions'),
44 ])
45
46 return {
47 headline: `Viewing '${project.name}' plan`,
48 progress: { done, total },
49 details: attention ? [attention] : [],
50 }
51}
52panel/ui/components/molecules/Loading.tsx 17 lines1import { mascotSpinner } from '../../../domain/mascot'
2import { MascotSprite } from '../atoms/MascotSprite'
3import type { Ui } from '../ui'
4
5type LoadingProps = { ui: Ui; step: number }
6
7export function Loading({ ui, step }: LoadingProps) {
8 const { Box, Text } = ui
9
10 return (
11 <Box flexDirection="column" alignItems="center">
12 <MascotSprite ui={ui} cells={mascotSpinner(step)} />
13 <Text dimColor>Loading plans…</Text>
14 </Box>
15 )
16}
17