SLOPSHOPPER

taskflow

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

paneguardcommandpromptprocesstimer
★ 1v0.8.13MITupdated 2026-10-25GuiMoraesDev/taskflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · taskflow
│ ┃ Taskflow ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▄ │ ┃ ▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ No plans yet. ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ No plans in docs/projects. ⎿ 3 pass, 1 fail │ ┃ [ ⚙ Settings (s) ] │ ┃ [ 🗑️ Trash (t) ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /open-mod │ ┃ ⎿ taskflow: Taskflow pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Taskflow
▄▄ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀ No plans yet. No plans in docs/projects. [ ⚙ Settings (s) ] [ 🗑️ Trash (t) ]
README

Taskflow

claude code python github cli license </br> <small>Every badge is a link to its docs</small>

</br>

Description of that project 📖

Taskflow is a Claude Code plugin that gives Claude a disciplined way to ship work, the way a careful senior dev would:

  • 🗺️ Plan first. Work is planned as the pull requests you will review, each one about one topic and under a line budget.
  • ✅ Agree what "done" means. Each pull request gets acceptance criteria you sign off on before any code is written.
  • 🔴🟢 Red before green. You see the tests fail for the right reason before Claude writes the implementation.
  • 📦 One task, one commit. Claude does a single task end to end, then moves to the next, stopping only when it needs you.
  • 🚦 Gates before every commit. Lint, type check, unit tests, and e2e when the change needs them.
  • 📚 Docs stay true. The docs, and the diagrams if your repo has them, are checked before a change counts as done.

The plugin carries the procedure. Your repo keeps its own rules in CLAUDE.md and .claude/workflow.json.

Requirements 🛑

To use it, you will need these tools in your environment:

  • Claude Code
  • Python 3 on your PATH, because the hooks are Python scripts
  • Git and the GitHub CLI, signed in to your repo's remote, because Taskflow opens and updates your pull requests
  • Lint, type check and test commands your repo can already run

Installing the plugin 🧰

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

How a piece of work flows 🔁

  plan  ──▶  agree criteria  ──▶  run a task  ──▶  repeat  ──▶  check in
  1. Plan. Ask for one, like "plan the export feature". Claude grills you on the design until you both agree, then writes SCOPE.md, TASKS.md, QUESTIONS.md and PROGRESS.md in your plan folder (see Where plans live).
  2. Agree the criteria. Settle each pull request's acceptance criteria before its first task starts.
  3. Run a task. /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.
  4. Repeat. The first task of a pull request opens it as a draft, and the last task marks it ready for review.
  5. Check in. /plan-status shows where you are, the open questions and what they block. /deviation writes down anything that didn't go as planned.

What's inside 🎁

Commands

CommandWhat it does
/init-workflowSets 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

Skills

SkillWhat it does
init-workflowReads 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-startGrills 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-runRuns 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-testProves a regression test fails against the old behaviour before the fix ships
docs-syncGoes through your docs checklist, plus the glossary and diagrams if your repo has them
standards-reviewChecks 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

Agents

AgentTierWhat it does
plan-architectdeepSurveys the code and returns the task breakdown a plan needs
standards-reviewerdeepAudits a diff against the standards without changing anything
docs-diagram-auditorsurfaceLooks for outdated claims in the docs and diagram labels
gate-runnermechanicalRuns lint, types, unit and e2e tests and reports only the failures

Hooks

HookWhat it does
guard-bashAsks 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-signatureBlocks 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-reminderAfter a commit, reminds you to close the task in PROGRESS.md
version-bumpIn 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

Where plans live 🗂️

Plans are written to one of two places:

  • The repo (default, and the source of truth). projectsDir in workflow.json, docs/projects unless you change it.
  • An Obsidian vault, chosen per machine. Set it in .claude/workflow.env, which is untracked: .claude/.gitignore keeps it out of git, so teammates keep the repo folder.
Key in .claude/workflow.envWhat it holds
TASKFLOW_PLANS_SOURCErepo or obsidian. Anything else, or missing, means repo
TASKFLOW_OBSIDIAN_VAULTThe vault's folder name, looked up in Obsidian's vault list
TASKFLOW_OBSIDIAN_VAULT_PATHThe vault's absolute path instead; it wins over the name
TASKFLOW_OBSIDIAN_PATHThe 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.additionalDirectories in .claude/settings.local.json to stop the prompts.

Configuration ⚙️

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.

KeyWhat it holds
projectsDirWhere plan folders live in the repo (see Where plans live above)
gatesYour lint, types, unit and e2e commands
e2eTriggerPathsFile patterns whose changes make the e2e suite run
docsChecklistEach doc and what it covers, checked before a behaviour change is done
decisionsWhere your decision records and glossary live
diagramsWhether you keep diagrams, where, in what format and who edits them
pullRequestsLine budget and the paths it skips, branch and title prefixes, labels, assignee, stacking and whether independent pull requests run in parallel
aiSignatureWhether commits and pull requests may carry an AI signature
commitTypesThe allowed conventional-commit types
modelsWhich model each tier uses, e.g. { "deep": "opus", "surface": "sonnet", "mechanical": "haiku" }

💡 The models mapping 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.

The rules it follows 📏

  1. No code before the plan. 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.
  2. Plans are pull requests. Each one has one topic and stays under the line budget (400 changed lines by default, tests not counted). Its title, branch, commits and files are written down before any of them exist.
  3. Criteria come first. Each pull request's acceptance criteria are agreed with you before its tasks start.
  4. One task, one commit. Each task makes something visibly true and lists its category, model tier, files and tests. A wide mechanical change is done as expand, migrate, contract.
  5. Only real decisions become questions. Claude reads the repo for anything it can answer itself. Each question comes with a recommendation, examples, cost and how to undo it. It is asked at the task that needs it, and it blocks only that task.
  6. Tests before code. You see the tests and why they matter, watch them fail, then Claude implements until they pass without touching them. Claude then walks you through the logic it wrote until you understand the code. Nothing reaches the default branch without a reviewed pull request.
  7. No test? Say why. A task without a new test explains why: it changes no behaviour and the existing tests cover it. A bug fix's test targets how the bug happens.
  8. Docs before done. The docs checklist, and the diagrams if your repo keeps them, are checked before a behaviour change is called done.
  9. Write down surprises. Any change from the plan goes in the log. The plan shows what you expected, the log shows what happened.

Project Maintenance 👨‍🔧

  • The plugin follows the Claude Code plugin structure: skills/, agents/, commands/ and hooks/ at the root, with the manifest and marketplace entry in .claude-plugin/.
  • Commits follow Conventional Commits.
  • With the plugin installed while you work on it, its 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.
  • When a pull request merges into main, the Release workflow tags the merge commit v<version> and publishes a GitHub release.
Source 31 files
panel/register.tsx 94 lines
1import { 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}
94
panel/data/board.ts 247 lines
1import { 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}
247
panel/domain/mascot.ts 73 lines
1import 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}
73
panel/ui/pane.tsx 257 lines
1import { 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}
257
panel/domain/parse.ts 93 lines
1import 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}
93
panel/domain/plan-dir.ts 79 lines
1import 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}
79
panel/domain/trash.ts 56 lines
1import 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}
56
panel/domain/mascot-frames.ts 589 lines
1export 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[]>
589
panel/domain/attention.ts 55 lines
1import 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}
55
panel/data/settings.ts 79 lines
1import { 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}
79
panel/domain/status.ts 52 lines
1import 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}
52
panel/ui/components/molecules/Loading.tsx 17 lines
1import { 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