SLOPSHOPPER

workflow-tasks

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

newpanecommandtoaststatus
★ 3v0.1.0NOASSERTIONupdated 2026-10-03FourthWiz/quoin/quoin/plugins/workflow-tasks
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workflow-tasks
│ ┃ Quoin tasks ✕ › fix the failing auth test and add an audit log call │ ┃ Quoin tasks │ ┃ No quoin project found above /work/app ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Fill prompt ] [ Refresh ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /quoin-tasks │ ⎿ workflow-tasks: No quoin project found above /work/app │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Quoin tasks
Quoin tasks No quoin project found above /work/app [ Fill prompt ] [ Refresh ]
README

<img src="quoin/docs/images/quoin-hero.png" alt="Quoin" width="420">

Quoin

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 Code: an installable global adapter that deploys skills, scripts, hooks, memory files, and workflow rules to ~/.claude.
  • Codex: a repo-local scaffold that generates 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.

Why Quoin?

  • Persistent workflow state: sessions share structured artifacts instead of restarting from memory alone.
  • Planning rigor: architecture, planning, review, and gate phases have explicit artifact contracts.
  • Runtime portability: shared workflow semantics live in quoin/core/, while Claude and Codex behavior is isolated in adapter directories.
  • Session continuity: both runtime paths preserve handoff state under .workflow_artifacts/memory/sessions/.
  • Cost discipline: Claude has live cost tooling; Codex writes portable cost rows with unavailable telemetry marked honestly as not_available.

Install

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.

Installing from a source checkout

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.

Auto-resume and the install record

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.

Claude install scope: user vs project

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. Run quoin doctor --scope project to 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 Support

RuntimeSetup commandScopeImplemented behavior
Claude Codequoin install --runtime claudeGlobal adapter under ~/.claudeSkills, scripts, hooks, memory files, CLAUDE.md workflow rules, slash-command workflow
Codexquoin install --runtime codex --project-root .Repo-local AGENTS.mdPortable artifact workflow, generated root instructions, readiness/smoke checks, handoff validation, cost event writer

The portable core is shared. Runtime-specific mechanics are adapter-owned:

  • Claude adapter files live under quoin/adapters/claude/ and install to ~/.claude.
  • Codex adapter files live under quoin/adapters/codex/ and are used from the repository. They are documentation, generated instructions, and validation scripts, not Codex command packages.

Codex Quickstart

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:

  • "Use Quoin to discover this repository and write the discovery artifacts."
  • "Use Quoin to create an architecture artifact for this task."
  • "Use Quoin to write a current plan under .workflow_artifacts/."
  • "Use Quoin to implement the current plan."
  • "Use Quoin to review this implementation against the current plan."
  • "Use Quoin to run a gate before the next phase."
  • "Update Quoin session handoff and lessons learned."
  • "Record a Codex cost event for this task."

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 Validation And Utilities

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.

Claude Quickstart

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.

Claude Skills

These slash commands are Claude adapter commands. Codex uses the same portable workflow intent through natural-language phase requests and repo-local docs.

Planning And Architecture

CommandModelWhat it does
/architectOpusDeep architectural analysis; internal critic loop
/planOpusDetailed implementation plan
/thorough_planOpusTriages task size; runs plan -> critic -> revise convergence
/criticOpusReviews a plan for gaps, risks, and integration issues
/reviseOpusRevises plan from critic feedback in strict/large mode
/revise-fastSonnetRevises plan from critic feedback in medium mode

Implementation And Review

CommandModelWhat it does
/implementSonnetWrites code from the plan
/reviewOpusVerifies implementation against the plan
/gateSonnetAutomated quality checkpoint between phases; requires approval
/rollbackSonnetSafely undoes an implementation phase or specific tasks
/prSonnetFull pull-request lifecycle: optional version bump, push, create PR, wait for merge, switch branch

Session Lifecycle

CommandModelWhat it does
/init_workflowOpusOne-time project bootstrap; creates .workflow_artifacts/ and runs discovery
/discoverOpusScans repos; maps architecture, dependencies, and git history
/start_of_dayHaikuMorning briefing from daily/session memory
/end_of_daySonnetSaves session state, dedupes and promotes insights; auto-invokes /sleep
/end_of_taskSonnetPushes branch, captures lessons, and finalizes task state
/checkpointSonnetSave/restore session context mid-session; writes pending-restore sentinel
/continue_workSonnetResume context from a prior session using the recent-sessions index
/sleepSonnetMemory consolidation: promotes insights to lessons-learned, archives stale entries

Utilities

CommandModelWhat it does
/runOpusEnd-to-end pipeline orchestrator; pauses at gates
/cost_snapshotHaikuLive Claude cost: today, lifetime, and per-task breakdown
/triageHaikuRoutes the request to the right workflow phase
/weekly_reviewHaikuAggregates weekly progress into a structured review
/capture_insightHaikuLogs a pattern or discovery to daily insights
/expand <path>SonnetRe-renders a terse workflow artifact in readable English
/statusHaikuRenders the workflow pipeline graph with the active phase marked (read-only)
/cleanupSonnetTrash-moves stale sentinels and old checkpoints into recoverable archive; standalone runs also offer a confirmed task-folder bookkeeping pass
/next-stepsHaikuAppend-only queue for future work items (add / list / done N)

Open-model routing (opt-in)

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.

Setup

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

Launch modes

ModeCommandWhen to use
Open models via CCR (v2)ccr codeQuota exhausted, cost-sensitive work
Open models via CCR (v3)ccr default-claude-codeSame, on CCR v3 — profile exists but routes nothing until configured in ccr ui
Native AnthropicclaudeNormal 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).

Switching back

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.

Model defaults

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.

quoin models

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

AliasSlug
flashz-ai/glm-5.3-flash
prodeepseek/deepseek-v4.1-flash
glmz-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

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.

Setup

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:

  • In a terminal, the installer asks "Run the agentdesk setup now?" (default No). The question lists what the script does: it installs Homebrew if missing (and may ask for your password), installs zellij, lazygit, fzf and the Ghostty app, writes the agentdesk helper and the zellij layout and config (backups kept), and adds lines to ~/.zshrc (backup kept). It is not asked when ~/.zshrc already sources the helper.
  • For a non-interactive install, pass the flag instead: 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.

Usage

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:

ModePanes
soloOne Claude Code pane, full-width
duoClaude Code + Shell, side-by-side
trioClaude Code + Codex + Shell, side-by-side

Window-type tokens (positional, mutually exclusive with --mode):

TokenPane contents
claudeClaude Code
codexCodex
shellPlain 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.

Architecture

Quoin architecture

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.

Cost And Effort

Claude and Codex expose different runtime mechanics, so Quoin keeps cost and effort handling honest:

  • Claude skills declare Haiku/Sonnet/Opus model tiers and can self-dispatch through Claude's Agent/Skill behavior.
  • Claude cost tooling can read Claude session logs and ccusage output.
  • Codex adapter docs use runtime-neutral effort labels: low, medium, high, max, and unknown.
  • Codex cost rows do not infer token counts or dollars from another runtime. Unavailable telemetry is recorded as not_available.

For runtime-neutral effort vocabulary, see quoin/docs/effort-levels.md.

Documentation

Boundaries

Quoin intentionally does not:

  • install global Codex commands or plugins
  • guess Codex local runtime paths
  • duplicate Codex approvals or sandboxing
  • claim live Codex hooks
  • translate Claude slash commands into Codex command files
  • infer Codex token or dollar telemetry from Claude tooling

Future runtime integrations should add explicit adapter contracts instead of copying Claude-specific behavior into the portable core.

Contributing

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.

License

PolyForm Noncommercial 1.0.0 — source-available, noncommercial use only.

Source 3 files
hooks/register.tsx 218 lines
1import { 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}
218
hooks/tasks.ts 818 lines
1import 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}
818
types/index.d.ts 121 lines
1export 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