Pane listing quoin tasks with their stage and the next workflow command to fill

<img src="quoin/docs/images/quoin-hero.png" alt="Quoin" width="420">
Workflow state for stateless coding agents.
Quoin is a workflow-memory toolkit for coding agents. Its core value is the artifact-centric workflow: plans, architecture notes, review artifacts, gates, session handoff, lessons learned, and a cost ledger stored in predictable files under .workflow_artifacts/.
Quoin now has two runtime paths:
~/.claude.AGENTS.md and points Codex at portable workflow contracts. It uses native Codex planning, approvals, sandboxing, repo-scoped instructions, and model/reasoning controls.Quoin does not install global Codex commands, plugins, hooks, command files, or runtime extensions.
quoin/core/, while Claude and Codex behavior is isolated in adapter directories..workflow_artifacts/memory/sessions/.not_available.Install the package:
pip install quoin
Install or update the Claude Code adapter:
quoin install --runtime claude
quoin doctor --runtime claude
When --scope is not specified, the installer prompts interactively to ask whether to install globally (~/.claude/) or project-level (./.claude/). In non-interactive environments (CI, pipes), it defaults to global without prompting.
quoin install and bare quoin remain backward-compatible aliases for the Claude install path.
bash quoin/install.sh needs a Python that meets requires-python in pyproject.toml (3.10 or newer). It looks at QUOIN_PYTHON first, then every directory on PATH in order (any python3.N), then the usual Homebrew, python.org, pyenv, uv, asdf, mise, conda (root and named environments, including /opt and ~/opt installs and the Homebrew cask), Linuxbrew and MacPorts locations, and finally uv python find. bash quoin/install.sh --print-python prints the interpreter it would use without installing; set QUOIN_PYTHON=/path/to/python3 to choose one yourself. A .venv synced from another computer usually has a dangling interpreter link: recreate it on this machine, or point QUOIN_PYTHON at a local interpreter. The affected-area test runner reports such a venv by name.
quoin install records the interpreter and source tree it ran from (quoin-runtime.json, next to the installed .claude/ tree), so an autonomous run's detached hand-off can relaunch the exact CLI you meant to run instead of guessing via PATH. Run quoin install with whichever interpreter or tool you actually use day to day — for a uv tool install or pipx install setup, that means running that tool's own quoin install, not a system Python's. Re-run quoin install after every upgrade, recreated virtualenv, or Python version change; a stale record makes hand-offs refuse rather than run the wrong code. quoin doctor checks the record and names the exact fix when it's missing or stale.
The Claude adapter can be installed at two scopes:
User scope (global) — installs to ~/.claude/. Skills, hooks, and workflow rules are available in every Claude Code session on the machine.
quoin install --runtime claude --scope user # explicit global, no prompt
Project scope — installs to <project>/.claude/. Skills and hooks activate only when Claude Code is opened in that directory. Hooks register in <project>/.claude/settings.json instead of your personal settings file.
quoin install --runtime claude --scope project # explicit project, no prompt
quoin install --runtime claude --scope project:/path # explicit project root
Pass --scope explicitly to skip the interactive prompt (useful in scripts and CI).
Note: Claude Code resolves personal scope (
~/.claude/skills/) before project scope. If you have a user-scope install, it will shadow a project-scope install for skills. Runquoin doctor --scope projectto detect conflicts.
Generate the Codex repo-local scaffold:
quoin install --runtime codex --project-root .
quoin install --runtime codex --project-root . --check
quoin doctor --runtime codex --project-root . --smoke
The Codex install path writes AGENTS.md in the selected project root. The --check form verifies that AGENTS.md is up to date without writing files. The doctor command verifies repo-local readiness; --smoke also runs the deterministic Codex workflow smoke test.
Equivalent Codex setup command:
quoin codex init --project-root .
quoin codex init --project-root . --check
Legacy source install for Claude remains supported (prompts for scope interactively):
git clone https://github.com/FourthWiz/quoin
cd quoin
bash quoin/install.sh # prompts: global or project?
bash quoin/install.sh --scope user # global, no prompt
bash quoin/install.sh --scope project # project, no prompt
GitHub redirects the old FourthWiz/claude_dev_workflow URL to this repository.
| Runtime | Setup command | Scope | Implemented behavior |
|---|---|---|---|
| Claude Code | quoin install --runtime claude | Global adapter under ~/.claude | Skills, scripts, hooks, memory files, CLAUDE.md workflow rules, slash-command workflow |
| Codex | quoin install --runtime codex --project-root . | Repo-local AGENTS.md | Portable artifact workflow, generated root instructions, readiness/smoke checks, handoff validation, cost event writer |
The portable core is shared. Runtime-specific mechanics are adapter-owned:
quoin/adapters/claude/ and install to ~/.claude.quoin/adapters/codex/ and are used from the repository. They are documentation, generated instructions, and validation scripts, not Codex command packages.In the project where you want Codex to use Quoin:
pip install quoin
quoin install --runtime codex --project-root .
quoin doctor --runtime codex --project-root . --smoke
Then ask Codex for Quoin phases in natural language:
.workflow_artifacts/."The practical Codex workflow loop is documented as:
discover -> plan -> implement -> review -> gate
Codex procedure docs live in quoin/adapters/codex/procedures/ and link back to portable contracts in quoin/core/skills/.
Codex readiness and smoke:
quoin doctor --runtime codex --project-root .
quoin doctor --runtime codex --project-root . --smoke
Handoff validation:
python3 quoin/adapters/codex/validate_codex_handoff.py --self-test
python3 quoin/adapters/codex/validate_codex_handoff.py --project-root . --file .workflow_artifacts/memory/sessions/<date>-<task>-codex.md
Cost event writing and validation:
python3 quoin/adapters/codex/cost_event.py --self-test
python3 quoin/adapters/codex/cost_event.py write --project-root . --task <task> --phase <phase> --effort <low|medium|high|max|unknown>
python3 quoin/adapters/codex/cost_event.py validate --project-root . --task <task> --expect-codex
Codex cost rows use the portable cost-ledger shape. They record known local values such as runtime, task, phase, timestamp, session id when supplied, and effort. Token counts, dollar cost, and telemetry source are recorded as not_available because this repository has no verified Codex local telemetry interface.
After installing the Claude adapter:
/init_workflow one-time project bootstrap
/architect design the solution
/thorough_plan converge on a plan with critic review
/implement write code from the plan
/review verify implementation against the plan
/gate stop for an explicit quality checkpoint
/end_of_task finalize, push, and capture lessons
Claude-specific command behavior, model assignments, hook behavior, and cost-capture plumbing are documented in quoin/CLAUDE.md.
These slash commands are Claude adapter commands. Codex uses the same portable workflow intent through natural-language phase requests and repo-local docs.
| Command | Model | What it does |
|---|---|---|
/architect | Opus | Deep architectural analysis; internal critic loop |
/plan | Opus | Detailed implementation plan |
/thorough_plan | Opus | Triages task size; runs plan -> critic -> revise convergence |
/critic | Opus | Reviews a plan for gaps, risks, and integration issues |
/revise | Opus | Revises plan from critic feedback in strict/large mode |
/revise-fast | Sonnet | Revises plan from critic feedback in medium mode |
| Command | Model | What it does |
|---|---|---|
/implement | Sonnet | Writes code from the plan |
/review | Opus | Verifies implementation against the plan |
/gate | Sonnet | Automated quality checkpoint between phases; requires approval |
/rollback | Sonnet | Safely undoes an implementation phase or specific tasks |
/pr | Sonnet | Full pull-request lifecycle: optional version bump, push, create PR, wait for merge, switch branch |
| Command | Model | What it does |
|---|---|---|
/init_workflow | Opus | One-time project bootstrap; creates .workflow_artifacts/ and runs discovery |
/discover | Opus | Scans repos; maps architecture, dependencies, and git history |
/start_of_day | Haiku | Morning briefing from daily/session memory |
/end_of_day | Sonnet | Saves session state, dedupes and promotes insights; auto-invokes /sleep |
/end_of_task | Sonnet | Pushes branch, captures lessons, and finalizes task state |
/checkpoint | Sonnet | Save/restore session context mid-session; writes pending-restore sentinel |
/continue_work | Sonnet | Resume context from a prior session using the recent-sessions index |
/sleep | Sonnet | Memory consolidation: promotes insights to lessons-learned, archives stale entries |
| Command | Model | What it does |
|---|---|---|
/run | Opus | End-to-end pipeline orchestrator; pauses at gates |
/cost_snapshot | Haiku | Live Claude cost: today, lifetime, and per-task breakdown |
/triage | Haiku | Routes the request to the right workflow phase |
/weekly_review | Haiku | Aggregates weekly progress into a structured review |
/capture_insight | Haiku | Logs a pattern or discovery to daily insights |
/expand <path> | Sonnet | Re-renders a terse workflow artifact in readable English |
/status | Haiku | Renders the workflow pipeline graph with the active phase marked (read-only) |
/cleanup | Sonnet | Trash-moves stale sentinels and old checkpoints into recoverable archive; standalone runs also offer a confirmed task-folder bookkeeping pass |
/next-steps | Haiku | Append-only queue for future work items (add / list / done N) |
Quoin can route Claude Code through claude-code-router to use open models from OpenRouter (DeepSeek V4.1, GLM-5.3, and others) when your Anthropic quota is low. This is fully opt-in — quoin install is unchanged.
export OPENROUTER_API_KEY=sk-or-... # your OpenRouter key
quoin router setup # install CCR + scaffold config
This installs claude-code-router globally (requires Node 22 or newer) and scaffolds ~/.claude-code-router/config.json with an OpenRouter provider and a tier routing map. Any existing config is backed up with a timestamp before changes are applied.
Check the result:
quoin router status # installed? config present? proxy running? key set?
quoin doctor # includes a one-line CCR probe
| Mode | Command | When to use |
|---|---|---|
| Open models via CCR (v2) | ccr code | Quota exhausted, cost-sensitive work |
| Open models via CCR (v3) | ccr default-claude-code | Same, on CCR v3 — profile exists but routes nothing until configured in ccr ui |
| Native Anthropic | claude | Normal use, full quoin skill fidelity |
On CCR v2, ccr code auto-starts the local proxy and launches the real claude binary in your terminal — quoin's slash commands and skills work normally. CCR v3 has no code subcommand; ccr default-claude-code selects its profile, but that profile routes nothing until you configure its models in ccr ui.
Sanity-check: inside an open-model session, type /help. The quoin skill list should resolve. If it does not, run quoin doctor and file an issue.
Note: the model shown in the Claude Code header will still say "Sonnet 4.6" (or whatever your default Claude model is). This is expected — CCR routes requests transparently at the HTTP layer, below what the Claude Code UI can see. The actual model invoked is the one CCR maps to (e.g. DeepSeek V4 for default requests).
Run claude directly (not ccr code / ccr default-claude-code) to use native Anthropic models. No config changes needed — the CCR config is left intact so you can flip back by running ccr code (v2) or ccr default-claude-code (v3) again.
The tier → model mapping is seeded to ~/.config/quoin/models.json on first setup. Edit it any time, or use the quoin models command (see below). Re-running quoin router setup re-applies the current models.json to the CCR Router config — it does not reset it.
View and manage the tier → open-model mapping:
quoin models # show current mapping and active launch mode
quoin models set <tier> <slug> # update one tier's slug
quoin models preset open # restore all three defaults at once
quoin models reset # document native-launch; back up CCR config (non-destructive)
quoin models reset --native # identical to reset (explicit-intent spelling)
Tiers: haiku, sonnet, opus
Friendly aliases (expand to the corresponding default slug):
| Alias | Slug |
|---|---|
flash | z-ai/glm-5.3-flash |
pro | deepseek/deepseek-v4.1-flash |
glm | z-ai/glm-5.3 |
Examples:
quoin models set opus glm # set opus → z-ai/glm-5.3 (alias)
quoin models set sonnet pro # set sonnet → deepseek/deepseek-v4.1-flash (alias)
quoin models set haiku anthropic/claude-3-haiku # any OpenRouter slug
Slug validation is advisory: known slugs are accepted silently; unknown-but-plausible slugs (containing a /) are accepted with a warning; only structurally malformed input is rejected. You can always edit ~/.config/quoin/models.json directly.
Secret rule: quoin models never reads or writes OPENROUTER_API_KEY. The key lives only in the CCR config authored by quoin router setup. set and preset update the provider's models list in-place, leaving api_key byte-unchanged.
reset is non-destructive: quoin models reset backs up the CCR config and prints native-launch instructions, but leaves the Router keys, provider block, and models.json intact. Run ccr code (v2) or ccr default-claude-code (v3) again to switch back to open models with no re-setup required.
Agentdesk is a Zellij terminal layout launcher bundled with quoin. It opens a named Zellij session with panes pre-configured for agent-assisted development — Claude Code, Codex, and a plain shell — in a single command.
Agentdesk is deployed automatically when you install the Claude adapter at user scope:
quoin install --runtime claude # or: quoin install --runtime claude --scope user
This copies agentdesk.zsh and setup-agentdesk.sh to ~/.config/agentdesk/. Running the setup (Zellij, lazygit and fzf via Homebrew, plus a line in your .zshrc) changes your system, so it only happens with your consent:
~/.zshrc (backup kept). It is not asked when ~/.zshrc already sources the helper.quoin install --setup-agentdesk or bash quoin/install.sh --setup-agentdesk.The setup runs as the last install step. If it fails, you get a warning and quoin stays installed; the deployed script is idempotent, so re-run the install with --setup-agentdesk to retry. After that, agentdesk is available in every new shell session.
Agentdesk is user-scope only — it is not deployed in project-scope installs.
agentdesk # interactive picker (when run in a TTY)
agentdesk <session-name> # named session with fixed layout
agentdesk --mode solo|duo|trio # preset layout
agentdesk --mode trio --name my-sess # named preset-layout session
agentdesk claude [codex] [shell] [...] # custom layout from window-type tokens
Preset modes:
| Mode | Panes |
|---|---|
solo | One Claude Code pane, full-width |
duo | Claude Code + Shell, side-by-side |
trio | Claude Code + Codex + Shell, side-by-side |
Window-type tokens (positional, mutually exclusive with --mode):
| Token | Pane contents |
|---|---|
claude | Claude Code |
codex | Codex |
shell | Plain zsh shell |
Examples:
agentdesk # picker — choose a preset or enter custom tokens
agentdesk pricing # named session, fixed layout
agentdesk --mode trio # Claude + Codex + Shell
agentdesk claude codex # custom two-pane layout
agentdesk claude claude # two Claude panes side-by-side
If Zellij is not running, agentdesk starts a new session. agentdesk never re-attaches: if a session with the chosen name already exists, it starts a fresh session with a numeric suffix (foo_1, foo_2, …). To resume an existing session, use agentdesk-attach SESSION-NAME.

Quoin separates portable workflow contracts from runtime adapters:
quoin/core/workflow/ defines shared rules, task layout, session state, cost ledger shape, and skill metadata.quoin/core/skills/ defines runtime-neutral skill intent.quoin/core/scripts/ contains portable helper implementations.quoin/adapters/claude/ contains Claude-specific skill bodies and runtime assumptions.quoin/adapters/codex/ contains Codex-facing repo-local instructions, procedures, readiness checks, handoff validation, and cost-event tooling.The project root containing AGENTS.md is the Quoin project root for Codex. Codex writes .workflow_artifacts/ there even when the code being edited lives in a nested subdirectory.
Claude and Codex expose different runtime mechanics, so Quoin keeps cost and effort handling honest:
ccusage output.low, medium, high, max, and unknown.not_available.For runtime-neutral effort vocabulary, see quoin/docs/effort-levels.md.
quoin/QUICKSTART.md - Claude command reference tablequoin/CLAUDE.md - Claude workflow rules, model assignments, artifacts, and cost conventionsquoin/adapters/codex/setup.md - Codex repo-local setup and readinessquoin/adapters/codex/workflow.md - Codex workflow execution guidequoin/adapters/codex/handoff.md - Codex session handoff contractquoin/adapters/codex/cost.md - Codex cost event behaviorquoin/docs/runtime-portability.md - portable core and runtime adapter boundaryquoin/docs/runtime-portability-status.md - current migration status by runtimequoin/docs/runtime-parity-matrix.md - evidence-based runtime parity matrixquoin/docs/effort-levels.md - runtime-neutral effort vocabularyQuoin intentionally does not:
Future runtime integrations should add explicit adapter contracts instead of copying Claude-specific behavior into the portable core.
Bug reports and PRs welcome. Quoin development uses Quoin itself: keep changes artifact-aware, runtime boundaries explicit, and documentation honest about what is implemented versus planned.
PolyForm Noncommercial 1.0.0 — source-available, noncommercial use only.
hooks/register.tsx 218 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { TaskRow } from '../types'
5import { findProjectRoot, loadRows, relativeAge, sessionContext } from './tasks.ts'
6import type { Entry, Fs } from './tasks.ts'
7
8const PANE = 'quoin-tasks'
9const TITLE = 'Quoin tasks'
10
11const rows = atom({ plugin: 'workflow-tasks', key: 'rows' } as const, [] as TaskRow[])
12const root = atom({ plugin: 'workflow-tasks', key: 'root' } as const, null as string | null)
13const selected = atom({ plugin: 'workflow-tasks', key: 'selected' } as const, null as string | null)
14const error = atom({ plugin: 'workflow-tasks', key: 'error' } as const, null as string | null)
15
16const storeKey = (projectRoot: string) => `selected:${projectRoot}`
17
18/** Gives the pure logic read access to the project through the engine's file calls. */
19const adaptFs = ($: EngineInterface): Fs => ({
20 list: async path => (await $.fs.list(path)) as Entry[],
21 read: async path => {
22 const text = await $.fs.read(path)
23 return typeof text === 'string' ? text : ''
24 },
25 exists: path => $.fs.exists(path),
26})
27
28const statusLine = (row: TaskRow | undefined) =>
29 row ? `quoin ${row.arg}: ${row.stage.short} -> ${row.next.command ?? 'no command'}` : undefined
30
31/**
32 * Looks for the project from the session's directory and lists its tasks. The
33 * stored selection is restored when that task still exists, else the first row.
34 */
35async function scan($: EngineInterface, keep: string | null): Promise<void> {
36 const cwd = await $.session.cwd()
37 const fs = adaptFs($)
38 let found: string | null
39 try {
40 found = await findProjectRoot(fs, cwd)
41 } catch {
42 found = null
43 }
44 if (!found) {
45 await update($, root, () => null)
46 await update($, rows, () => [])
47 await update($, selected, () => null)
48 await update($, error, () => `No quoin project found above ${cwd}`)
49 $.ui.status(undefined)
50 return
51 }
52 let list: TaskRow[] = []
53 let failure: string | null = null
54 try {
55 list = await loadRows(fs, found)
56 } catch (err) {
57 failure = `Could not read ${found}/.workflow_artifacts: ${err instanceof Error ? err.message : String(err)}`
58 }
59 const stored = await $.store.get(storeKey(found))
60 const wanted = keep ?? (typeof stored === 'string' ? stored : null)
61 const pick = list.find(r => r.arg === wanted)?.arg ?? list[0]?.arg ?? null
62 await update($, root, () => found)
63 await update($, rows, () => list)
64 await update($, selected, () => pick)
65 await update($, error, () => failure)
66 $.ui.status(statusLine(list.find(r => r.arg === pick)))
67}
68
69async function choose($: EngineInterface, value: string): Promise<void> {
70 await update($, selected, () => value)
71 const [projectRoot, list] = await Promise.all([read($, root), read($, rows)])
72 if (projectRoot) await $.store.set(storeKey(projectRoot), value)
73 $.ui.status(statusLine(list.find(r => r.arg === value)))
74}
75
76/** Puts the row's command in the prompt box. It is never sent; the person presses Enter. */
77async function fillPrompt($: EngineInterface, row: TaskRow | undefined): Promise<void> {
78 if (!row) return
79 const command = row.next.command
80 if (!command) {
81 $.ui.toast(row.next.note ?? 'No command to fill for this task')
82 return
83 }
84 const result = await $.prompt.fill({ text: command, mode: 'replace' })
85 if (result.isFilled) {
86 // Close the pane so the keys return to the prompt box.
87 await $.ui.close({ id: PANE })
88 return
89 }
90 const why =
91 result.refusal === 'dialog'
92 ? 'a dialog holds the keys'
93 : result.refusal === 'no_composer'
94 ? 'no prompt box here'
95 : 'the prompt box did not take it'
96 $.ui.toast(`Could not fill the prompt (${why}): ${command}`)
97}
98
99const padEnd = (s: string, n: number) => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
100
101export const register: Register = on => {
102 on('session.start', async ($, e, next) => {
103 await $.command.register({
104 name: 'quoin-tasks',
105 description: 'List quoin tasks and fill the next workflow command',
106 })
107 return next(e)
108 })
109
110 on('command.run', { command: 'quoin-tasks' }, async $ => {
111 await scan($, null)
112 await $.ui.open({ id: PANE, title: TITLE, focus: true, closeOnEscape: true })
113 const [projectRoot, list, problem] = await Promise.all([read($, root), read($, rows), read($, error)])
114 if (!projectRoot) return { text: problem ?? 'No quoin project found above the working directory' }
115 if (problem) return { text: problem }
116 return {
117 text: `${list.length} active tasks in ${projectRoot}; pick a task, press f to fill its next command`,
118 }
119 })
120
121 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
122 const { Box, Text, Button, Select } = $.ui.resolve(e)
123 const [list, projectRoot, current, problem] = await Promise.all([
124 read($, rows),
125 read($, root),
126 read($, selected),
127 read($, error),
128 ])
129 const now = await $.clock.now()
130 const row = list.find(r => r.arg === current)
131
132 let lastSession: string | null = null
133 if (row && projectRoot) {
134 try {
135 lastSession = await sessionContext(adaptFs($), projectRoot, row.arg)
136 } catch {
137 lastSession = null
138 }
139 }
140
141 const nameWidth = Math.min(40, Math.max(12, ...list.map(r => r.depth * 2 + r.name.length)))
142 const options = list.map(r => ({
143 value: r.arg,
144 label: `${padEnd(' '.repeat(r.depth) + r.name, nameWidth)} ${padEnd(r.stage.short, 12)} ${relativeAge(r.activity, now)}`,
145 }))
146
147 const command = row?.next.command ?? null
148 return (
149 <Box flexDirection="column">
150 <Box>
151 <Text bold>Quoin tasks </Text>
152 <Text dimColor>{projectRoot ?? ''}</Text>
153 </Box>
154 {problem && <Text dimColor>{problem}</Text>}
155 {!problem && list.length === 0 && <Text dimColor>No active tasks.</Text>}
156 {list.length > 0 && (
157 <Select
158 key="tasks"
159 autoFocus
160 options={options}
161 value={current ?? undefined}
162 onSelect={value => choose($, value)}
163 />
164 )}
165 {row && (
166 <Box flexDirection="column" marginTop={1}>
167 <Text>
168 <Text bold>{row.arg}</Text>
169 <Text dimColor> {row.kind === 'program' ? 'program' : 'task'}</Text>
170 </Text>
171 <Text>stage: {row.stage.label}</Text>
172 {row.stage.stage !== null && row.stage.multi && (
173 <Text dimColor>
174 stage {row.stage.stage}
175 {row.stage.stageCount !== null ? ` of ${row.stage.stageCount}` : ''}
176 </Text>
177 )}
178 {row.stage.gate && (
179 <Text dimColor>
180 gate: {row.stage.gate.name} ({row.stage.gate.verdict})
181 </Text>
182 )}
183 {row.stage.run && (
184 <Box flexDirection="column">
185 <Text dimColor>
186 run: {row.stage.run.phase}
187 {row.stage.run.subphase ? `/${row.stage.run.subphase}` : ''} {row.stage.run.step}
188 </Text>
189 {row.stage.run.nextAction !== '' && <Text dimColor>next action: {row.stage.run.nextAction}</Text>}
190 <Text dimColor>
191 profile: {row.stage.run.profile || '-'} · updated {row.stage.run.updatedAt || '-'}
192 </Text>
193 {row.stage.runStale && <Text color="#e0af68">run record is older than the artifacts</Text>}
194 {row.stage.artifact && <Text dimColor>artifacts say: {row.stage.artifact.label}</Text>}
195 </Box>
196 )}
197 <Text>next: {command ?? '(none)'}</Text>
198 {row.next.then && <Text dimColor>{row.next.then}</Text>}
199 {row.next.note && <Text dimColor>{row.next.note}</Text>}
200 {lastSession && <Text dimColor>last session: {lastSession}</Text>}
201 </Box>
202 )}
203 <Box gap={2} marginTop={1}>
204 <Button
205 key="fill"
206 hotkey="f"
207 variant="primary"
208 label="Fill prompt"
209 dimColor={command === null}
210 onPress={() => fillPrompt($, row)}
211 />
212 <Button key="refresh" hotkey="r" label="Refresh" onPress={() => scan($, current)} />
213 </Box>
214 </Box>
215 )
216 })
217}
218hooks/tasks.ts 818 lines1import type {
2 GateVerdict,
3 NextCommand,
4 RunState,
5 StageInfo,
6 StageKey,
7 TaskNode,
8 TaskRow,
9} from '../types'
10
11// Pure logic for the workflow-tasks mod. Every function takes a small Fs and
12// plain data, so the tests can drive it with an in-memory tree. The base phase
13// detection mirrors core/scripts/status_graph.py (detect_phase without the git
14// probe); the stage refinement and the next command build on top of it.
15
16export type Entry = {
17 name: string
18 kind: 'file' | 'dir' | 'other'
19 size: number
20 mtimeMs: number
21}
22
23export type Fs = {
24 list(path: string): Promise<Entry[]>
25 read(path: string): Promise<string>
26 exists(path: string): Promise<boolean>
27}
28
29export type Phase =
30 | 'done'
31 | 'review-gated'
32 | 'review'
33 | 'implement-gated'
34 | 'plan-gated'
35 | 'planning'
36 | 'architecture'
37 | 'discover'
38
39export type PhaseResult = { phase: Phase; criticRounds: number; reviewRounds: number }
40
41const ARTIFACTS = '.workflow_artifacts'
42const MAX_DEPTH = 3
43const EXCLUDED_DIRS = new Set(['finalized', 'memory', 'cache', 'trash'])
44const MARKER_FILES = [
45 'task-brief.md',
46 'task-description.md',
47 'enriched-prompt.md',
48 'spec.md',
49 'architecture.md',
50 'current-plan.md',
51 'cost-ledger.md',
52 'program.md',
53]
54const STAGE_DIR_RE = /^stage-(\d+)$/
55
56// The gate prefixes detect_phase keys on. detectPhaseCompat and newestGate
57// share these so a new prefix cannot be added to one and missed by the other.
58export const REVIEW_GATE_PREFIXES = ['gate-review-', 'gate-post-review-']
59export const IMPLEMENT_GATE_PREFIXES = ['gate-implement-', 'gate-post-implement-']
60export const PLAN_GATE_PREFIXES = ['gate-post-plan-', 'gate-plan-']
61export const ARCHITECT_GATE_PREFIXES = ['gate-architect-']
62export const SPECIFY_GATE_PREFIXES = ['gate-specify-']
63
64const TASK_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\/[A-Za-z0-9][A-Za-z0-9._-]*)*$/
65
66// ---------------------------------------------------------------- basics
67
68const joinPath = (...parts: string[]) => parts.join('/').replace(/\/{2,}/g, '/')
69
70/** Same anchored pattern status_graph uses to drop Google Drive conflict copies. */
71const DRIVE_CONFLICT_RE = / \d{1,3}(\.[^ ]*)?$/
72export const isDriveConflict = (name: string) => DRIVE_CONFLICT_RE.test(name)
73
74const isLiveFile = (e: Entry) => e.kind === 'file' && !isDriveConflict(e.name)
75
76async function readSafe(fs: Fs, path: string): Promise<string> {
77 try {
78 return await fs.read(path)
79 } catch {
80 return ''
81 }
82}
83
84async function listSafe(fs: Fs, path: string): Promise<Entry[]> {
85 try {
86 return await fs.list(path)
87 } catch {
88 return []
89 }
90}
91
92/** Walks up from `cwd` to the first directory holding a `.workflow_artifacts` directory. */
93export async function findProjectRoot(fs: Fs, cwd: string): Promise<string | null> {
94 let dir = cwd.replace(/\/+$/, '') || '/'
95 for (let guard = 0; guard < 128; guard += 1) {
96 const entries = await listSafe(fs, dir)
97 if (entries.some(e => e.name === ARTIFACTS && e.kind === 'dir')) return dir
98 if (dir === '/') return null
99 const cut = dir.lastIndexOf('/')
100 dir = cut <= 0 ? '/' : dir.slice(0, cut)
101 }
102 return null
103}
104
105// ---------------------------------------------------------------- discovery
106
107/** Newest change among non-empty files in the folder and its stage-N folders. */
108async function ownActivity(fs: Fs, path: string, entries: Entry[]): Promise<number> {
109 let newest = 0
110 for (const e of entries) {
111 if (isDriveConflict(e.name)) continue
112 if (e.kind === 'file' && e.size > 0) newest = Math.max(newest, e.mtimeMs)
113 }
114 for (const e of entries) {
115 if (e.kind !== 'dir' || isDriveConflict(e.name) || !STAGE_DIR_RE.test(e.name)) continue
116 for (const sub of await listSafe(fs, joinPath(path, e.name))) {
117 if (isDriveConflict(sub.name)) continue
118 if (sub.kind === 'file' && sub.size > 0) newest = Math.max(newest, sub.mtimeMs)
119 }
120 }
121 return newest
122}
123
124const hasMarker = (entries: Entry[]) =>
125 entries.some(
126 e =>
127 (e.kind === 'file' && MARKER_FILES.includes(e.name)) ||
128 (e.kind === 'dir' && STAGE_DIR_RE.test(e.name)),
129 )
130
131async function scanTask(
132 fs: Fs,
133 path: string,
134 name: string,
135 arg: string,
136 depth: number,
137): Promise<TaskNode | null> {
138 let entries: Entry[]
139 try {
140 entries = await fs.list(path)
141 } catch {
142 return { name, arg, kind: 'task', activity: 0, subtree: 0, children: [], failed: true }
143 }
144 if (!hasMarker(entries)) return null
145 const kind = entries.some(e => e.kind === 'file' && e.name === 'program.md') ? 'program' : 'task'
146 const activity = await ownActivity(fs, path, entries)
147 const children: TaskNode[] = []
148 if (depth < MAX_DEPTH) {
149 for (const e of entries) {
150 if (e.kind !== 'dir') continue
151 if (e.name.startsWith('.') || e.name === 'finalized') continue
152 if (STAGE_DIR_RE.test(e.name) || isDriveConflict(e.name)) continue
153 const child = await scanTask(fs, joinPath(path, e.name), e.name, `${arg}/${e.name}`, depth + 1)
154 if (child) children.push(child)
155 }
156 }
157 const subtree = Math.max(activity, ...children.map(c => c.subtree))
158 return { name, arg, kind, activity, subtree, children, failed: false }
159}
160
161const PROGRAM_NAME_RE = /^[a-z0-9][a-z0-9._-]*-[a-z0-9._-]*$/
162const ISSUE_KEY_RE = /^[a-z]+-\d+-/
163
164/** Folder names a program.md points at: backticked hyphenated names and `/run` arguments. */
165export function programCandidates(text: string): string[] {
166 const found: string[] = []
167 for (const m of text.matchAll(/`([^`\n]+)`/g)) {
168 if (PROGRAM_NAME_RE.test(m[1])) found.push(m[1])
169 }
170 for (const m of text.matchAll(/\/run\s+(?:(?:small|medium|large|strict|fast):\s*)?([a-z0-9][a-z0-9._-]*)/g)) {
171 if (PROGRAM_NAME_RE.test(m[1])) found.push(m[1])
172 }
173 return found
174}
175
176/** Whether a folder name matches a name a program lists: equal, same issue key, or a hyphen-boundary prefix. */
177export function namesMatch(folder: string, candidate: string): boolean {
178 if (folder === candidate) return true
179 const a = folder.match(ISSUE_KEY_RE)
180 const b = candidate.match(ISSUE_KEY_RE)
181 if (a && b && a[0] === b[0]) return true
182 return candidate.startsWith(`${folder}-`) || folder.startsWith(`${candidate}-`)
183}
184
185function sortNodes(nodes: TaskNode[]): TaskNode[] {
186 for (const n of nodes) sortNodes(n.children)
187 nodes.sort((a, b) => b.subtree - a.subtree || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
188 return nodes
189}
190
191/**
192 * Lists the project's active tasks as a tree: nested child folders under their
193 * parent, matched top-level tasks under the program that names them. Sorted most
194 * recent activity first, ties by name.
195 */
196export async function discoverTasks(fs: Fs, root: string): Promise<TaskNode[]> {
197 const base = joinPath(root, ARTIFACTS)
198 const top: TaskNode[] = []
199 for (const e of await listSafe(fs, base)) {
200 if (e.kind !== 'dir') continue
201 if (EXCLUDED_DIRS.has(e.name) || e.name.startsWith('.') || isDriveConflict(e.name)) continue
202 const node = await scanTask(fs, joinPath(base, e.name), e.name, e.name, 1)
203 if (node) top.push(node)
204 }
205
206 const programs = top.filter(n => n.kind === 'program' && !n.failed)
207 if (programs.length > 0) {
208 const candidates = new Map<string, string[]>()
209 for (const p of programs) {
210 candidates.set(p.name, programCandidates(await readSafe(fs, joinPath(base, p.name, 'program.md'))))
211 }
212 const attached = new Set<string>()
213 for (const node of top) {
214 if (node.kind === 'program') continue
215 const owners = programs.filter(p => (candidates.get(p.name) ?? []).some(c => namesMatch(node.name, c)))
216 if (owners.length === 1) {
217 owners[0].children.push(node)
218 attached.add(node.name)
219 }
220 }
221 for (const p of programs) {
222 p.subtree = Math.max(p.subtree, ...p.children.map(c => c.subtree))
223 }
224 return sortNodes(top.filter(n => !attached.has(n.name)))
225 }
226 return sortNodes(top)
227}
228
229/** The tree as rows in display order, each with its depth. */
230export function flattenTasks(nodes: TaskNode[], depth = 0): { node: TaskNode; depth: number }[] {
231 const out: { node: TaskNode; depth: number }[] = []
232 for (const n of nodes) {
233 out.push({ node: n, depth })
234 out.push(...flattenTasks(n.children, depth + 1))
235 }
236 return out
237}
238
239// ---------------------------------------------------------------- base phase
240
241const maxN = (re: RegExp, names: string[]) =>
242 names.reduce((best, f) => {
243 const m = f.match(re)
244 return m ? Math.max(best, Number(m[1])) : best
245 }, 0)
246
247const hasPrefix = (names: string[], prefixes: string[]) =>
248 names.some(f => prefixes.some(p => f.startsWith(p)))
249
250/** Port of status_graph.detect_phase with the git probe off. */
251export function detectPhaseCompat(dirPath: string, entries: Entry[]): PhaseResult {
252 if (dirPath.split('/').includes('finalized')) return { phase: 'done', criticRounds: 0, reviewRounds: 0 }
253 const names = entries.filter(isLiveFile).map(e => e.name)
254 const criticRounds = maxN(/^critic-response-(\d+)\.md$/, names)
255 const reviewRounds = maxN(/^review-(\d+)\.md$/, names)
256 if (hasPrefix(names, REVIEW_GATE_PREFIXES)) return { phase: 'review-gated', criticRounds, reviewRounds }
257 if (reviewRounds >= 1) return { phase: 'review', criticRounds, reviewRounds }
258 if (hasPrefix(names, IMPLEMENT_GATE_PREFIXES)) return { phase: 'implement-gated', criticRounds, reviewRounds: 0 }
259 if (hasPrefix(names, PLAN_GATE_PREFIXES)) return { phase: 'plan-gated', criticRounds, reviewRounds: 0 }
260 if (names.includes('current-plan.md')) return { phase: 'planning', criticRounds, reviewRounds: 0 }
261 if (names.includes('architecture.md')) return { phase: 'architecture', criticRounds, reviewRounds: 0 }
262 return { phase: 'discover', criticRounds: 0, reviewRounds: 0 }
263}
264
265// ---------------------------------------------------------------- stages and gates
266
267const STAGE_SECTION_RE = /^## Stage decomposition\s*$/m
268const NEXT_H2_RE = /^## /m
269const STAGE_ROW_RE = /^[0-9]+\.\s+(?:[✅✓✗⏳⛔⚠️\s])*S-([0-9]+):\s*(.+?)\s*$/gm
270
271/** Rows in the architecture's `## Stage decomposition` section, or null when it has none. */
272export function stageCount(architectureText: string): number | null {
273 const start = architectureText.match(STAGE_SECTION_RE)
274 if (!start || start.index === undefined) return null
275 const from = start.index + start[0].length
276 const rest = architectureText.slice(from)
277 const next = rest.match(NEXT_H2_RE)
278 const body = next && next.index !== undefined ? rest.slice(0, next.index) : rest
279 const rows = body.match(STAGE_ROW_RE)
280 return rows && rows.length > 0 ? rows.length : null
281}
282
283const firstWord = (raw: string) => {
284 const cleaned = raw.replace(/<[^>]*>/g, ' ').replace(/[*`]/g, '').toUpperCase()
285 const m = cleaned.match(/[A-Z]+/)
286 return m ? m[0] : ''
287}
288
289/**
290 * Reads a gate file's outcome: the text after `## Verdict:`, else the first
291 * non-blank line under a `## Verdict` (or `###`) heading, else an inline `Verdict:` line,
292 * else a frontmatter `verdict:` key. A first word starting FAIL, or NO-GO, is a fail;
293 * NEEDS, BLOCKED and PARTIAL are undecided (the gate has to be run again);
294 * anything else, a missing verdict included, reads as passed, as detect_phase
295 * does by never looking inside gate files.
296 */
297export function gateVerdict(text: string): GateVerdict {
298 const lines = text.split(/\r?\n/)
299 let raw: string | null = null
300 for (let i = 0; i < lines.length && raw === null; i += 1) {
301 const inline = lines[i].match(/^#{2,3}\s+Verdict\s*:\s*(\S.*)$/)
302 if (inline) {
303 raw = inline[1]
304 break
305 }
306 if (/^#{2,3}\s+Verdict\s*:?\s*$/.test(lines[i])) {
307 for (let j = i + 1; j < lines.length; j += 1) {
308 if (lines[j].trim() !== '') {
309 raw = lines[j]
310 break
311 }
312 }
313 break
314 }
315 }
316 if (raw === null) {
317 for (const line of lines) {
318 const m = line.match(/^\s*(?:[-*]\s+)?\**Verdict\**(?:\s*\([^)]*\))?\s*:\s*\**\s*(\S.*)$/i)
319 if (m) {
320 raw = m[1]
321 break
322 }
323 }
324 }
325 if (raw === null && lines[0] === '---') {
326 for (let i = 1; i < lines.length && lines[i] !== '---'; i += 1) {
327 const m = lines[i].match(/^verdict:\s*(\S.*)$/)
328 if (m) {
329 raw = m[1]
330 break
331 }
332 }
333 }
334 if (raw === null) return 'pass'
335 const word = firstWord(raw)
336 if (word.startsWith('FAIL')) return 'fail'
337 if (/^[\s\W]*NO-GO/i.test(raw.replace(/<[^>]*>/g, ' ').replace(/[*`]/g, ''))) return 'fail'
338 if (word === 'NEEDS' || word === 'BLOCKED' || word === 'PARTIAL') return 'undecided'
339 return 'pass'
340}
341
342const DATE_RE = /\d{4}-\d{2}-\d{2}/
343// First retry marker anywhere in a gate name: `-r2`, `round3`, `fix-1`, `fix2`,
344// or a bare number just before the date, or a number right after the date
345// (`gate-x-2026-09-30-2.md`; the lookbehind keeps the day itself from reading
346// as a retry). Each number is one or two digits
347// that no digit follows, so the date in `fix-2026-08-15` is not read as a retry.
348const RETRY_RE =
349 /(?:^|[^A-Za-z])r(\d{1,2})(?!\d)|round(\d{1,2})(?!\d)|fix-?(\d{1,2})(?!\d)|-(\d{1,2})-(?=\d{4}-\d{2}-\d{2})|(?<=\d{4}-\d{2}-\d{2})-(\d{1,2})(?=\.md$)/
350
351const gateDate = (name: string) => (name.match(DATE_RE) ?? [''])[0]
352const gateRetry = (name: string) => {
353 const m = name.match(RETRY_RE)
354 if (!m) return 1
355 return Number(m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5])
356}
357
358/**
359 * The newest gate file among entries whose name starts with one of `prefixes`.
360 * Only real `.md` files count, so a leftover `.md.tmp` is never a verdict.
361 * Order: the date in the name (undated below dated), the retry number, the
362 * modification time, then the name.
363 */
364export function newestGate(entries: Entry[], prefixes: string[]): Entry | null {
365 const candidates = entries.filter(
366 e => isLiveFile(e) && e.name.endsWith('.md') && prefixes.some(p => e.name.startsWith(p)),
367 )
368 if (candidates.length === 0) return null
369 return candidates.reduce((best, e) => {
370 const a = gateDate(e.name)
371 const b = gateDate(best.name)
372 if (a !== b) return a > b ? e : best
373 const ra = gateRetry(e.name)
374 const rb = gateRetry(best.name)
375 if (ra !== rb) return ra > rb ? e : best
376 if (e.mtimeMs !== best.mtimeMs) return e.mtimeMs > best.mtimeMs ? e : best
377 return e.name > best.name ? e : best
378 })
379}
380
381// ---------------------------------------------------------------- labels
382
383const SHORT: Record<StageKey, string> = {
384 'run-active': 'run',
385 started: 'new',
386 'pre-spec': 'brief',
387 spec: 'spec',
388 'spec-approved': 'spec ✓',
389 'spec-gate-failed': 'spec ✗',
390 architecture: 'arch',
391 'architecture-approved': 'arch ✓',
392 'architecture-gate-failed': 'arch ✗',
393 planning: 'plan',
394 'plan-approved': 'plan ✓',
395 'plan-gate-failed': 'plan ✗',
396 'implement-done': 'impl ✓',
397 'implement-gate-failed': 'impl ✗',
398 review: 'review',
399 'review-approved': 'review ✓',
400 'review-gate-failed': 'review ✗',
401 'gate-undecided': 'gate ?',
402 'end-of-task-done': 'shipped',
403 'stages-done': 'stages done',
404 program: 'program',
405 done: 'done',
406 unknown: '?',
407}
408
409const LONG: Record<StageKey, string> = {
410 'run-active': 'run in progress',
411 started: 'started, nothing written yet',
412 'pre-spec': 'task brief written, no spec yet',
413 spec: 'spec written, not gated',
414 'spec-approved': 'spec approved',
415 'spec-gate-failed': 'spec gate failed',
416 architecture: 'architecture written, not gated',
417 'architecture-approved': 'architecture approved',
418 'architecture-gate-failed': 'architecture gate failed',
419 planning: 'plan written, not gated',
420 'plan-approved': 'plan approved',
421 'plan-gate-failed': 'plan gate failed',
422 'implement-done': 'implemented, gate passed',
423 'implement-gate-failed': 'implement gate failed',
424 review: 'review written, not gated',
425 'review-approved': 'review approved',
426 'review-gate-failed': 'review gate failed',
427 'gate-undecided': 'gate left undecided',
428 'end-of-task-done': 'shipped, pull request next',
429 'stages-done': 'every stage archived',
430 program: 'program folder',
431 done: 'finalized',
432 unknown: 'folder could not be read',
433}
434
435export function shortLabel(info: Pick<StageInfo, 'key' | 'reviewRounds'>): string {
436 if (info.key === 'review' && info.reviewRounds > 0) return `review ${info.reviewRounds}`
437 return SHORT[info.key]
438}
439
440/** `now`, `5m`, `3h`, `2d`, `4mo`; `-` when there is no timestamp. */
441export function relativeAge(ms: number, now: number): string {
442 if (!ms) return '-'
443 const seconds = Math.max(0, Math.round((now - ms) / 1000))
444 if (seconds < 60) return 'now'
445 const minutes = Math.floor(seconds / 60)
446 if (minutes < 60) return `${minutes}m`
447 const hours = Math.floor(minutes / 60)
448 if (hours < 24) return `${hours}h`
449 const days = Math.floor(hours / 24)
450 if (days < 30) return `${days}d`
451 return `${Math.floor(days / 30)}mo`
452}
453
454// ---------------------------------------------------------------- stage derivation
455
456type StageCtx = { multi: boolean; stage: number | null; count: number | null }
457
458function makeStage(
459 key: StageKey,
460 base: PhaseResult | null,
461 ctx: StageCtx,
462 extra: Partial<StageInfo> = {},
463): StageInfo {
464 const reviewRounds = base?.reviewRounds ?? 0
465 return {
466 key,
467 short: shortLabel({ key, reviewRounds }),
468 label: LONG[key],
469 stage: ctx.stage,
470 multi: ctx.multi,
471 stageCount: ctx.count,
472 basePhase: base?.phase ?? 'discover',
473 reviewRounds,
474 scope: 'stage',
475 gate: null,
476 run: null,
477 runStale: false,
478 artifact: null,
479 ...extra,
480 }
481}
482
483type GateRead = { entry: Entry | null; verdict: GateVerdict }
484
485async function readGate(fs: Fs, dirPath: string, entries: Entry[], prefixes: string[]): Promise<GateRead> {
486 const entry = newestGate(entries, prefixes)
487 if (!entry) return { entry: null, verdict: 'undecided' }
488 try {
489 return { entry, verdict: gateVerdict(await fs.read(joinPath(dirPath, entry.name))) }
490 } catch {
491 return { entry, verdict: 'pass' }
492 }
493}
494
495const fileMtime = (entries: Entry[], name: string) =>
496 entries.find(e => isLiveFile(e) && e.name === name)?.mtimeMs ?? 0
497
498const latestReviewMtime = (entries: Entry[]) =>
499 entries.reduce(
500 (best, e) => (isLiveFile(e) && /^review-\d+\.md$/.test(e.name) ? Math.max(best, e.mtimeMs) : best),
501 0,
502 )
503
504const gateInfo = (g: GateRead) => (g.entry ? { name: g.entry.name, verdict: g.verdict } : null)
505
506/** Refines the base phase of one directory (a task folder or a stage folder) into a stage. */
507async function evaluateDir(fs: Fs, dirPath: string, entries: Entry[], ctx: StageCtx): Promise<StageInfo> {
508 const base = detectPhaseCompat(dirPath, entries)
509 const names = entries.filter(isLiveFile).map(e => e.name)
510 const has = (name: string) => names.includes(name)
511
512 // A gate left failed or undecided, cleared when its producing artifact is newer.
513 const refine = (
514 g: GateRead,
515 producedAt: number,
516 key: { pass: StageKey; fail: StageKey; cleared: StageKey },
517 scope: 'task' | 'stage',
518 ): StageInfo => {
519 if (g.entry && g.verdict === 'pass') return makeStage(key.pass, base, ctx, { gate: gateInfo(g), scope })
520 if (g.entry && producedAt > g.entry.mtimeMs) return makeStage(key.cleared, base, ctx, { gate: gateInfo(g), scope })
521 if (g.entry && g.verdict === 'fail') return makeStage(key.fail, base, ctx, { gate: gateInfo(g), scope })
522 return makeStage('gate-undecided', base, ctx, { gate: gateInfo(g), scope })
523 }
524
525 switch (base.phase) {
526 case 'done':
527 return makeStage('done', base, ctx)
528 case 'review-gated': {
529 const g = await readGate(fs, dirPath, entries, REVIEW_GATE_PREFIXES)
530 return refine(
531 g,
532 latestReviewMtime(entries),
533 { pass: 'review-approved', fail: 'review-gate-failed', cleared: 'review' },
534 'stage',
535 )
536 }
537 case 'review':
538 return makeStage('review', base, ctx)
539 case 'implement-gated': {
540 const g = await readGate(fs, dirPath, entries, IMPLEMENT_GATE_PREFIXES)
541 if (g.entry && g.verdict === 'pass') return makeStage('implement-done', base, ctx, { gate: gateInfo(g) })
542 if (g.entry && g.verdict === 'fail') return makeStage('implement-gate-failed', base, ctx, { gate: gateInfo(g) })
543 return makeStage('gate-undecided', base, ctx, { gate: gateInfo(g) })
544 }
545 case 'plan-gated': {
546 const g = await readGate(fs, dirPath, entries, PLAN_GATE_PREFIXES)
547 return refine(
548 g,
549 fileMtime(entries, 'current-plan.md'),
550 { pass: 'plan-approved', fail: 'plan-gate-failed', cleared: 'planning' },
551 'stage',
552 )
553 }
554 case 'planning':
555 return makeStage('planning', base, ctx)
556 case 'architecture': {
557 const hasGateFile = names.some(n => ARCHITECT_GATE_PREFIXES.some(p => n.startsWith(p)))
558 if (!hasGateFile) return makeStage('architecture', base, ctx, { scope: 'task' })
559 const g = await readGate(fs, dirPath, entries, ARCHITECT_GATE_PREFIXES)
560 return refine(
561 g,
562 fileMtime(entries, 'architecture.md'),
563 { pass: 'architecture-approved', fail: 'architecture-gate-failed', cleared: 'architecture' },
564 'task',
565 )
566 }
567 default: {
568 if (ctx.multi && ctx.stage !== null) {
569 return makeStage('architecture-approved', base, ctx)
570 }
571 if (has('program.md')) return makeStage('program', base, ctx)
572 if (has('spec.md')) {
573 const hasGateFile = names.some(n => SPECIFY_GATE_PREFIXES.some(p => n.startsWith(p)))
574 if (!hasGateFile) return makeStage('spec', base, ctx, { scope: 'task' })
575 const g = await readGate(fs, dirPath, entries, SPECIFY_GATE_PREFIXES)
576 return refine(
577 g,
578 fileMtime(entries, 'spec.md'),
579 { pass: 'spec-approved', fail: 'spec-gate-failed', cleared: 'spec' },
580 'task',
581 )
582 }
583 if (has('task-brief.md') || has('enriched-prompt.md') || has('task-description.md')) {
584 return makeStage('pre-spec', base, ctx)
585 }
586 return makeStage('started', base, ctx)
587 }
588 }
589}
590
591const stageNumbers = (entries: Entry[]) =>
592 entries
593 .filter(e => e.kind === 'dir' && !isDriveConflict(e.name))
594 .map(e => e.name.match(STAGE_DIR_RE))
595 .filter((m): m is RegExpMatchArray => m !== null)
596 .map(m => Number(m[1]))
597 .sort((a, b) => a - b)
598
599/** What the artifacts alone say, with multi-stage tasks resolved to the stage in play. */
600async function artifactStage(fs: Fs, taskPath: string, entries: Entry[]): Promise<StageInfo> {
601 const single: StageCtx = { multi: false, stage: null, count: null }
602 const names = entries.filter(isLiveFile).map(e => e.name)
603 const architecture = names.includes('architecture.md') ? await readSafe(fs, joinPath(taskPath, 'architecture.md')) : ''
604 const closed = stageNumbers(await listSafe(fs, joinPath(taskPath, 'finalized')))
605 const live = stageNumbers(entries).filter(n => !closed.includes(n))
606 const hasArchitectGate = names.some(n => ARCHITECT_GATE_PREFIXES.some(p => n.startsWith(p)))
607 const multi =
608 STAGE_SECTION_RE.test(architecture) && (live.length > 0 || closed.length > 0 || hasArchitectGate)
609 if (!multi) return evaluateDir(fs, taskPath, entries, single)
610
611 const count = stageCount(architecture)
612 if (live.length > 0) {
613 const n = live[live.length - 1]
614 const stagePath = joinPath(taskPath, `stage-${n}`)
615 return evaluateDir(fs, stagePath, await listSafe(fs, stagePath), { multi: true, stage: n, count })
616 }
617 if (count !== null && closed.length > 0 && Array.from({ length: count }, (_, i) => i + 1).every(n => closed.includes(n))) {
618 return makeStage('stages-done', null, { multi: true, stage: null, count })
619 }
620 const next = Math.max(0, ...closed) + 1
621 if (closed.length === 0) return evaluateDir(fs, taskPath, entries, { multi: true, stage: 1, count })
622 return makeStage('architecture-approved', null, { multi: true, stage: next, count }, { scope: 'task' })
623}
624
625// Run-state record: stage `run-active` when a run owns the task.
626function parseRunState(text: string): RunState | null {
627 try {
628 const data = JSON.parse(text)
629 if (!data || typeof data !== 'object' || data.active !== true) return null
630 const str = (v: unknown) => (typeof v === 'string' ? v : '')
631 return {
632 phase: str(data.phase),
633 subphase: str(data.subphase),
634 step: str(data.step),
635 nextAction: str(data.next_action),
636 profile: str(data.profile),
637 updatedAt: str(data.updated_at),
638 resumeCommand: str(data.resume_command),
639 }
640 } catch {
641 return null
642 }
643}
644
645/** Parses a run-state timestamp; microsecond fractions are cut to milliseconds first. NaN when unparseable. */
646export function parseTimestamp(value: string): number {
647 return Date.parse(value.replace(/(\.\d{3})\d+/, (_all, ms) => ms))
648}
649
650const unknownStage = (): StageInfo => makeStage('unknown', null, { multi: false, stage: null, count: null })
651
652/**
653 * The stage of a task: an active run record first (top-level tasks), else the
654 * artifacts. Any read failure gives stage `unknown` for this task alone.
655 */
656export async function deriveStage(fs: Fs, root: string, task: string): Promise<StageInfo> {
657 try {
658 const artifacts = joinPath(root, ARTIFACTS)
659 const taskPath = joinPath(artifacts, task)
660 const entries = await fs.list(taskPath)
661 const fromArtifacts = await artifactStage(fs, taskPath, entries)
662 if (task.includes('/')) return fromArtifacts
663
664 const recordPath = joinPath(artifacts, 'memory', `run-state-${task}.json`)
665 if (!(await fs.exists(recordPath))) return fromArtifacts
666 const run = parseRunState(await readSafe(fs, recordPath))
667 if (!run || (await fs.exists(joinPath(artifacts, 'finalized', task)))) return fromArtifacts
668
669 const updated = parseTimestamp(run.updatedAt)
670 const newest = await ownActivity(fs, taskPath, entries)
671 const label = `run: ${run.phase}${run.subphase ? `/${run.subphase}` : ''}`
672 return makeStage('run-active', null, { multi: fromArtifacts.multi, stage: fromArtifacts.stage, count: fromArtifacts.stageCount }, {
673 label,
674 run,
675 runStale: !Number.isNaN(updated) && newest > updated,
676 artifact: { key: fromArtifacts.key, short: fromArtifacts.short, label: fromArtifacts.label },
677 })
678 } catch {
679 return unknownStage()
680 }
681}
682
683// ---------------------------------------------------------------- next command
684
685const THEN: Partial<Record<StageKey, string>> = {
686 started: 'then /architect',
687 'pre-spec': 'then /architect',
688 spec: 'then /architect',
689 'spec-approved': 'then /thorough_plan',
690 architecture: 'then /thorough_plan',
691 'architecture-approved': 'then /implement',
692 planning: 'then /implement',
693 'plan-approved': 'then /review',
694 'implement-done': 'then /gate',
695 review: 'then /end_of_task',
696 'review-approved': 'then /pr',
697 'end-of-task-done': 'then merge',
698}
699
700/** The command that moves a task forward, validated before it is shown. `task` is the task argument. */
701export function nextCommand(stage: StageInfo, task: string): NextCommand {
702 if (!TASK_ARG_RE.test(task)) return { command: null, then: null, note: 'no command (unusual folder name)' }
703 const stageArg = stage.multi && stage.stage !== null ? `stage ${stage.stage} of ${task}` : task
704 const then = THEN[stage.key] ?? null
705 const make = (command: string | null, note: string | null = null): NextCommand => ({ command, then, note })
706
707 switch (stage.key) {
708 case 'run-active': {
709 // Only the exact resume command for this task is ever shown; a record's own text is not trusted.
710 return make(`/run --resume ${task}`)
711 }
712 case 'started':
713 case 'pre-spec':
714 case 'spec-gate-failed':
715 return make(`/specify ${task}`, stage.key === 'spec-gate-failed' ? 'revise the spec, then run /gate again' : null)
716 case 'spec':
717 case 'architecture':
718 return make(`/gate ${task}`)
719 case 'spec-approved':
720 case 'architecture-gate-failed':
721 return make(`/architect ${task}`)
722 case 'architecture-approved':
723 return make(`/thorough_plan ${stageArg}`)
724 case 'planning':
725 case 'review':
726 return make(`/gate ${stageArg}`)
727 case 'plan-approved':
728 return make(`/implement ${stageArg}`)
729 case 'implement-done':
730 return make(`/review ${stageArg}`)
731 case 'review-approved': {
732 if (!stage.multi || stage.stage === null) return make(`/end_of_task ${task}`)
733 const archive = `/end_of_task stage ${stage.stage} of ${task}`
734 if (stage.stageCount !== null && stage.stage < stage.stageCount) {
735 return make(`/thorough_plan stage ${stage.stage + 1} of ${task}`, `or ${archive} to archive this stage now`)
736 }
737 return make(archive)
738 }
739 case 'plan-gate-failed':
740 return make(`/thorough_plan ${stageArg}`, 'revise the plan, then run /gate again')
741 case 'implement-gate-failed':
742 return make(`/implement ${stageArg}`, `after fixing, run /gate ${stageArg}`)
743 case 'review-gate-failed':
744 return make(`/review ${stageArg}`, 'revise the review, then run /gate again')
745 case 'gate-undecided':
746 return make(`/gate ${stage.scope === 'task' ? task : stageArg}`, 'the last gate left no decision')
747 case 'end-of-task-done':
748 return make('/pr')
749 case 'stages-done':
750 return make(null, 'all stages archived')
751 default:
752 return make(null)
753 }
754}
755
756// ---------------------------------------------------------------- session context
757
758const headingValue = (lines: string[], i: number) => {
759 const inline = lines[i].match(/^## Current stage:\s*(\S.*)$/)
760 if (inline) return inline[1].trim()
761 if (/^## Current stage\s*$/.test(lines[i])) {
762 for (let j = i + 1; j < lines.length; j += 1) {
763 if (lines[j].trim() !== '') return lines[j].trim()
764 }
765 }
766 return null
767}
768
769/** The `Current stage` line of the task's newest session file, or null. */
770export async function sessionContext(fs: Fs, root: string, task: string): Promise<string | null> {
771 const dir = joinPath(root, ARTIFACTS, 'memory', 'sessions')
772 const key = task.replace(/\//g, '-')
773 const plain: Entry[] = []
774 const orchestrator: Entry[] = []
775 for (const e of await listSafe(fs, dir)) {
776 if (e.kind !== 'file' || isDriveConflict(e.name)) continue
777 const m = e.name.match(/^\d{4}-\d{2}-\d{2}-(.+)\.md$/)
778 if (!m) continue
779 if (m[1] === key) plain.push(e)
780 else if (m[1] === `${key}-orchestrator`) orchestrator.push(e)
781 }
782 const pool = plain.length > 0 ? plain : orchestrator
783 if (pool.length === 0) return null
784 const newest = pool.reduce((best, e) => (e.name > best.name ? e : best))
785 const lines = (await readSafe(fs, joinPath(dir, newest.name))).split(/\r?\n/)
786 for (let i = 0; i < lines.length; i += 1) {
787 const value = headingValue(lines, i)
788 if (value !== null) return value
789 }
790 return null
791}
792
793// ---------------------------------------------------------------- rows
794
795/** Every active task with its stage and next command, in display order. */
796export async function loadRows(fs: Fs, root: string): Promise<TaskRow[]> {
797 const flat = flattenTasks(await discoverTasks(fs, root))
798 return Promise.all(
799 flat.map(async ({ node, depth }) => {
800 const stage =
801 node.failed
802 ? unknownStage()
803 : node.kind === 'program'
804 ? makeStage('program', null, { multi: false, stage: null, count: null })
805 : await deriveStage(fs, root, node.arg)
806 return {
807 arg: node.arg,
808 name: node.name,
809 depth,
810 kind: node.kind,
811 activity: node.subtree,
812 stage,
813 next: nextCommand(stage, node.arg),
814 }
815 }),
816 )
817}
818types/index.d.ts 121 lines1export type TaskKind = 'task' | 'program'
2
3export type GateVerdict = 'pass' | 'fail' | 'undecided'
4
5/** Every state the stage derivation can land on; `unknown` marks a task whose folder could not be read. */
6export type StageKey =
7 | 'run-active'
8 | 'started'
9 | 'pre-spec'
10 | 'spec'
11 | 'spec-approved'
12 | 'spec-gate-failed'
13 | 'architecture'
14 | 'architecture-approved'
15 | 'architecture-gate-failed'
16 | 'planning'
17 | 'plan-approved'
18 | 'plan-gate-failed'
19 | 'implement-done'
20 | 'implement-gate-failed'
21 | 'review'
22 | 'review-approved'
23 | 'review-gate-failed'
24 | 'gate-undecided'
25 | 'end-of-task-done'
26 | 'stages-done'
27 | 'program'
28 | 'done'
29 | 'unknown'
30
31/** The fields of a run-state record the pane shows. */
32export type RunState = {
33 phase: string
34 subphase: string
35 step: string
36 nextAction: string
37 profile: string
38 updatedAt: string
39 resumeCommand: string
40}
41
42export type StageInfo = {
43 key: StageKey
44 /** Row text: `plan`, `impl ✓`, `review 2`. */
45 short: string
46 /** Detail-block text for the current stage. */
47 label: string
48 /** The stage number evaluated for a multi-stage task. */
49 stage: number | null
50 multi: boolean
51 /** Rows of the architecture's stage decomposition, when it has any. */
52 stageCount: number | null
53 /** The detect_phase result the stage was refined from. */
54 basePhase: string
55 reviewRounds: number
56 /** Whether a gate-undecided command names the task or the stage. */
57 scope: 'task' | 'stage'
58 /** The newest gate file the stage was refined from, with its verdict. */
59 gate: { name: string; verdict: GateVerdict } | null
60 /** The active run record, when one decided the stage. */
61 run: RunState | null
62 /** The record is older than the newest artifact. */
63 runStale: boolean
64 /** What the artifacts say, shown beside an active run record. */
65 artifact: { key: StageKey; short: string; label: string } | null
66}
67
68export type NextCommand = {
69 command: string | null
70 /** The phase that follows the command. */
71 then: string | null
72 note: string | null
73}
74
75export type TaskRow = {
76 /** The task argument a command takes: `foo` or `parent/child`. */
77 arg: string
78 name: string
79 depth: number
80 kind: TaskKind
81 /** Latest activity of the task and everything under it, ms since the epoch. */
82 activity: number
83 stage: StageInfo
84 next: NextCommand
85}
86
87/** A discovered task folder before its stage is derived. */
88export type TaskNode = {
89 /** Folder name. */
90 name: string
91 /** The task argument: `foo` or `parent/child`. */
92 arg: string
93 kind: TaskKind
94 /** Latest artifact change in the folder itself, ms since the epoch. */
95 activity: number
96 /** Latest change in the folder and every child. */
97 subtree: number
98 children: TaskNode[]
99 /** The folder could not be listed. */
100 failed: boolean
101}
102
103/** What the pane draws from; the same four values `$.state` holds under the plugin's name. */
104export type PaneState = {
105 rows: TaskRow[]
106 root: string | null
107 selected: string | null
108 error: string | null
109}
110
111declare module 'claude-code' {
112 interface PluginState {
113 'workflow-tasks': {
114 rows: TaskRow[]
115 root: string | null
116 selected: string | null
117 error: string | null
118 }
119 }
120}
121