diff --git a/Makefile b/Makefile
index bf76610..d3f805a 100644
--- a/Makefile
+++ b/Makefile
@@ -34,10 +34,18 @@ gendocs:
vhs: build
bash demo-setup.sh
vhs docs/commands/tapes/init.tape
- vhs docs/commands/tapes/state.tape
+ vhs docs/commands/tapes/edit-state.tape
vhs docs/commands/tapes/render.tape
vhs docs/commands/tapes/exec.tape
vhs docs/commands/tapes/list.tape
+ vhs docs/commands/tapes/edit-status.tape
+ vhs docs/commands/tapes/status.tape
+ vhs docs/commands/tapes/edit-config.tape
+ vhs docs/commands/tapes/open.tape
vhs docs/commands/tapes/delete.tape
+ vhs docs/commands/tapes/reset-status.tape
+ vhs docs/commands/tapes/reset-config.tape
+ vhs docs/commands/tapes/reset-state.tape
+ vhs docs/commands/tapes/reset-skills.tape
docs: gendocs vhs
diff --git a/README.md b/README.md
index 4b97329..28001d7 100644
--- a/README.md
+++ b/README.md
@@ -8,9 +8,16 @@ Working across multiple repos means repetitive setup, scattered branches, and cl

+## Motivation
+
+Flow is designed to be called by other AI agents — tools like [OpenClaw](https://github.com/openclaw) that integrate with Slack, Linear, or other services to understand context and then programmatically create state files and initialize workspaces. Prompt OpenClaw with a Slack thread or Linear ticket and let your agent create the workspace using Flow 🌊
+
+Agents should call deterministic tools rather than relying on freeform interpretation with skills. This leads to more consistent results and reproducible environments.
+
## Quickstart
-### 1. Install
+
+Install
```bash
brew install milldr/tap/flow
@@ -30,70 +37,54 @@ cd flow
make install
```
-### 2. Create a workspace
+
+
+
+Usage
+
+### 1. Create a workspace
+
+Flow creates a workspace with an empty state file.
```bash
flow init
```
-```
-✓ Created workspace calm-delta
-
- Next: flow state calm-delta
-```
+### 2. Add repos
-### 3. Add repos
+Open the state file in `$EDITOR` and define which repos and branches belong together.
```bash
-flow state calm-delta # Open state.yaml in $EDITOR
+flow edit state calm-delta
```
-### 4. Render it
+### 3. Render it
+
+Flow fetches each repo into a shared bare clone cache, then creates lightweight worktrees in the workspace directory. Rendering is idempotent — re-running fetches updates and skips worktrees that already exist.
```bash
flow render calm-delta
```
-```
-✓ Workspace ready
+### 4. Start working
- flow exec calm-delta --
- flow exec calm-delta -- cursor .
- flow exec calm-delta -- claude
-```
+Launch your default agent directly into the workspace. Flow reads the `spec.agents` list from your global config and runs the one marked `default: true`.
-Flow fetches each repo into a bare clone cache (`~/.flow/repos/`), then creates lightweight worktrees in the workspace directory. Running `render` again is idempotent — it fetches updates and skips worktrees that already exist.
-
-## State file
-
-Each workspace is defined by a `state.yaml` file. Run `flow state ` to open it in your editor.
-
-```yaml
-apiVersion: flow/v1
-kind: State
-metadata:
- name: vpc-ipv6
- description: IPv6 support across VPC services
- created: "2026-02-18T12:00:00Z"
-spec:
- repos:
- - url: git@github.com:acme/vpc-service.git
- branch: feature/ipv6
- path: vpc-service
- - url: git@github.com:acme/subnet-manager.git
- branch: feature/ipv6
- path: subnet-manager
+```bash
+flow exec calm-delta
```
-| Field | Description |
-|-------|-------------|
-| `metadata.name` | Optional human-friendly name |
-| `metadata.description` | Optional description |
-| `spec.repos[].url` | Git remote URL |
-| `spec.repos[].branch` | Branch to check out |
-| `spec.repos[].path` | Directory name in the workspace (defaults to repo name) |
+See the [spec reference](docs/specs/) for YAML file schemas and the [command reference](docs/commands/) for all commands.
+
+
-See the full [command reference](docs/commands/) for usage, flags, examples, and GIF demos.
+## What it does
+
+🌳 **Workspaces as code** — Declare git worktrees in a YAML state file for instant, reproducible workspace setup across repos and branches.
+
+🚦 **Status tracking** — Define custom check commands that dynamically resolve the status of each repo in a workspace.
+
+🤖 **AI agent integration** — Generate shared context files and agent instructions across repos so your AI tools have the right skills and knowledge from the start.
## How it works
@@ -101,18 +92,34 @@ Flow stores everything under `~/.flow` (override with `$FLOW_HOME`):
```
~/.flow/
+├── config.yaml # Global config
+├── status.yaml # Global status spec
+├── agents/
+│ └── claude/
+│ ├── CLAUDE.md # Shared agent instructions
+│ └── skills/
+│ ├── flow-cli/SKILL.md # Flow CLI skill
+│ └── workspace-structure/SKILL.md
├── workspaces/
│ └── calm-delta/ # Workspace ID
│ ├── state.yaml # Workspace manifest (name: vpc-ipv6)
+│ ├── status.yaml # Optional workspace-specific status spec
+│ ├── CLAUDE.md # Generated workspace context
+│ ├── .claude/
+│ │ ├── CLAUDE.md → agents/claude/CLAUDE.md
+│ │ └── skills → agents/claude/skills/
│ ├── vpc-service/ # Worktree
│ └── subnet-manager/ # Worktree
-└── cache/
- ├── acme-vpc-service.git/ # Bare clone
- └── acme-subnet-manager.git/ # Bare clone
+└── repos/
+ └── github.com/acme/
+ ├── vpc-service.git/ # Bare clone
+ └── subnet-manager.git/ # Bare clone
```
Bare clones are shared across workspaces. Worktrees are cheap — they share the object store with the bare clone, so multiple workspaces pointing at the same repo don't duplicate data.
+See the [spec reference](docs/specs/) for YAML file schemas and the [command reference](docs/commands/) for usage, flags, and GIF demos.
+
## Requirements
- Go 1.25+
diff --git a/demo-setup.sh b/demo-setup.sh
index 33c2bd6..914309b 100755
--- a/demo-setup.sh
+++ b/demo-setup.sh
@@ -3,18 +3,124 @@
# Called from Makefile: `make demo` runs this, then `vhs demo.tape`.
set -e
+export FLOW_HOME="/tmp/flow-demo/.flow"
+FLOW="$(pwd)/flow"
rm -rf /tmp/flow-demo /tmp/demo
-mkdir -p /tmp/demo
-
-# Create a local git repo with a feature branch
-dir="/tmp/demo/app"
-mkdir -p "$dir"
-git -C "$dir" init -q
-echo "# app" > "$dir/README.md"
-echo "package main" > "$dir/main.go"
-git -C "$dir" add .
-git -C "$dir" commit -q -m "initial commit"
-git -C "$dir" checkout -q -b feature/ipv6
-echo "// IPv6 support" >> "$dir/main.go"
-git -C "$dir" add .
-git -C "$dir" commit -q -m "add ipv6 support"
+mkdir -p /tmp/demo "$FLOW_HOME/workspaces" "$FLOW_HOME/repos"
+
+# --- Create local git repos with feature branches ---
+
+create_repo() {
+ local name="$1" branch="$2" file="$3" msg="$4"
+ local dir="/tmp/demo/$name"
+ mkdir -p "$dir"
+ git -C "$dir" init -q
+ echo "# $name" > "$dir/README.md"
+ echo "package main" > "$dir/main.go"
+ git -C "$dir" add .
+ git -C "$dir" commit -q -m "initial commit"
+ git -C "$dir" checkout -q -b "$branch"
+ echo "// $msg" >> "$dir/$file"
+ git -C "$dir" add .
+ git -C "$dir" commit -q -m "$msg"
+}
+
+create_repo "app" "feature/ipv6" "main.go" "add ipv6 support"
+create_repo "api" "feat/auth" "main.go" "add auth endpoints"
+create_repo "docs" "update/guides" "README.md" "update setup guide"
+create_repo "web" "feat/dashboard" "main.go" "add dashboard page"
+
+# --- Pre-populate bare clone cache for realistic URLs ---
+
+for name in app api docs web; do
+ bare="$FLOW_HOME/repos/github.com/acme/${name}.git"
+ mkdir -p "$(dirname "$bare")"
+ git clone --bare "/tmp/demo/$name" "$bare" -q
+ # Add fetch refspec so worktrees can resolve origin/* refs
+ git -C "$bare" config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"
+ git -C "$bare" fetch -q origin
+done
+
+# --- Create and render pre-existing workspaces ---
+
+create_workspace() {
+ local id="$1" yaml="$2"
+ mkdir -p "$FLOW_HOME/workspaces/$id"
+ echo "$yaml" > "$FLOW_HOME/workspaces/$id/state.yaml"
+}
+
+create_workspace "bold-creek" "$(cat <<'YAML'
+apiVersion: flow/v1
+kind: State
+metadata:
+ name: api-refactor
+ description: Refactor authentication API
+ created: "2026-02-21T14:30:00Z"
+spec:
+ repos:
+ - url: github.com/acme/api
+ branch: feat/auth
+ path: api
+ - url: github.com/acme/docs
+ branch: update/guides
+ path: docs
+YAML
+)"
+
+create_workspace "swift-pine" "$(cat <<'YAML'
+apiVersion: flow/v1
+kind: State
+metadata:
+ name: infra-update
+ description: Update infrastructure across services
+ created: "2026-02-19T09:00:00Z"
+spec:
+ repos:
+ - url: github.com/acme/app
+ branch: feature/ipv6
+ path: app
+YAML
+)"
+
+# Render both workspaces so exec and status work
+$FLOW render bold-creek
+$FLOW render swift-pine
+
+# --- Add local commits so status checks detect diffs ---
+
+add_local_change() {
+ local ws_dir="$FLOW_HOME/workspaces/$1/$2"
+ git -C "$ws_dir" fetch -q origin 2>/dev/null || true
+ echo "# local change" >> "$ws_dir/README.md"
+ git -C "$ws_dir" add .
+ git -C "$ws_dir" commit -q -m "wip: local changes"
+}
+
+add_local_change "bold-creek" "api"
+add_local_change "bold-creek" "docs"
+add_local_change "swift-pine" "app"
+
+# --- Create status specs ---
+# The default spec (written by EnsureDirs on first flow command) uses gh + jq.
+# For the demo, we keep the default for the edit-status tape (so it shows the
+# real commands), then the status tape swaps in a local-only spec before running.
+
+# Write a local-only spec that the status tape will swap in (no gh needed).
+cat > "$FLOW_HOME/status-local.yaml" </dev/null | grep -q .
+ - name: open
+ description: Workspace created, no changes yet
+ default: true
+YAML
diff --git a/demo.gif b/demo.gif
index cc50c9a..c42c764 100644
Binary files a/demo.gif and b/demo.gif differ
diff --git a/demo.tape b/demo.tape
index d58eaeb..ad4dcaa 100644
--- a/demo.tape
+++ b/demo.tape
@@ -28,24 +28,49 @@ Type `export EDITOR="vim -u NONE"`
Enter
Sleep 200ms
+# Swap in local-only status spec (no gh needed for demo)
+Type `cp "$FLOW_HOME/status-local.yaml" "$FLOW_HOME/status.yaml"`
+Enter
+Sleep 200ms
+
Type "clear"
Enter
Sleep 500ms
Show
-# === flow init ===
+# === flow status (pre-existing workspaces) ===
+
+Sleep 500ms
+
+Type "flow status"
+Sleep 500ms
+Enter
+Sleep 4s
+# === flow exec ===
+
+Type "flow exec api-refactor -- ls"
Sleep 500ms
+Enter
+Sleep 3s
+
+# === Clear and start creating a new workspace ===
+
+Type "clear"
+Enter
+Sleep 500ms
+
+# === flow init ===
Type "flow init demo"
Sleep 500ms
Enter
Sleep 3s
-# === flow state (add a repo in vim) ===
+# === flow edit state (add a repo in vim) ===
-Type "flow state demo"
+Type "flow edit state demo"
Sleep 500ms
Enter
Sleep 1500ms
@@ -57,11 +82,11 @@ Sleep 500ms
# Now in insert mode after " repos: " — type the repo entry
Enter
-Type@30ms " - url: /tmp/demo/app"
+Type@30ms " - url: github.com/acme/web"
Enter
-Type@30ms " branch: feature/ipv6"
+Type@30ms " branch: feat/dashboard"
Enter
-Type@30ms " path: app"
+Type@30ms " path: web"
Sleep 3s
# Exit insert mode, save and quit
@@ -78,13 +103,6 @@ Sleep 500ms
Enter
Sleep 5s
-# === flow exec ===
-
-Type "flow exec demo -- ls"
-Sleep 500ms
-Enter
-Sleep 3s
-
# === flow list ===
Type "flow list"
diff --git a/docs/commands/README.md b/docs/commands/README.md
index 83170df..9392c7d 100644
--- a/docs/commands/README.md
+++ b/docs/commands/README.md
@@ -6,9 +6,12 @@ Auto-generated CLI reference with GIF demos. See [flow.md](flow.md) for the comm
|---------|-------------|
| [flow init](flow_init.md) | Create a new workspace |
| [flow list](flow_list.md) | List all workspaces |
-| [flow state](flow_state.md) | Open workspace state file in editor |
+| [flow edit](flow_edit.md) | Edit flow configuration files |
| [flow render](flow_render.md) | Create worktrees from workspace state file |
| [flow exec](flow_exec.md) | Run a command from the workspace directory |
+| [flow open](flow_open.md) | Print the workspace directory path |
+| [flow status](flow_status.md) | Show workspace status |
+| [flow reset](flow_reset.md) | Reset a config file to its default value |
| [flow delete](flow_delete.md) | Delete a workspace and its worktrees |
| [flow version](flow_version.md) | Print the version |
diff --git a/docs/commands/flow.md b/docs/commands/flow.md
index e9aac3d..1fc5107 100644
--- a/docs/commands/flow.md
+++ b/docs/commands/flow.md
@@ -17,10 +17,13 @@ A YAML manifest defines which repos/branches belong together, and `flow render`
### SEE ALSO
* [flow delete](flow_delete.md) - Delete a workspace and its worktrees
+* [flow edit](flow_edit.md) - Open flow configuration files in editor
* [flow exec](flow_exec.md) - Run a command from the workspace directory
* [flow init](flow_init.md) - Create a new empty workspace
* [flow list](flow_list.md) - List all workspaces
+* [flow open](flow_open.md) - Open a shell in the workspace directory
* [flow render](flow_render.md) - Create worktrees from workspace state file
-* [flow state](flow_state.md) - Open workspace state file in editor
+* [flow reset](flow_reset.md) - Reset a config file to its default value
+* [flow status](flow_status.md) - Show workspace status
* [flow version](flow_version.md) - Print the version
diff --git a/docs/commands/flow_edit.md b/docs/commands/flow_edit.md
new file mode 100644
index 0000000..312f73c
--- /dev/null
+++ b/docs/commands/flow_edit.md
@@ -0,0 +1,23 @@
+## flow edit
+
+Open flow configuration files in editor
+
+### Options
+
+```
+ -h, --help help for edit
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow](flow.md) - Multi-repo workspace manager using git worktrees
+* [flow edit config](flow_edit_config.md) - Open global config file in editor
+* [flow edit state](flow_edit_state.md) - Open workspace state file in editor
+* [flow edit status](flow_edit_status.md) - Open status spec file in editor
+
diff --git a/docs/commands/flow_edit_config.md b/docs/commands/flow_edit_config.md
new file mode 100644
index 0000000..4515e18
--- /dev/null
+++ b/docs/commands/flow_edit_config.md
@@ -0,0 +1,33 @@
+## flow edit config
+
+Open global config file in editor
+
+
+
+
+```
+flow edit config [flags]
+```
+
+### Examples
+
+```
+ flow edit config # Opens ~/.flow/config.yaml in $EDITOR
+```
+
+### Options
+
+```
+ -h, --help help for config
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow edit](flow_edit.md) - Open flow configuration files in editor
+
diff --git a/docs/commands/flow_edit_state.md b/docs/commands/flow_edit_state.md
new file mode 100644
index 0000000..d134ea7
--- /dev/null
+++ b/docs/commands/flow_edit_state.md
@@ -0,0 +1,33 @@
+## flow edit state
+
+Open workspace state file in editor
+
+
+
+
+```
+flow edit state [flags]
+```
+
+### Examples
+
+```
+ flow edit state calm-delta # Opens state.yaml in $EDITOR
+```
+
+### Options
+
+```
+ -h, --help help for state
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow edit](flow_edit.md) - Open flow configuration files in editor
+
diff --git a/docs/commands/flow_edit_status.md b/docs/commands/flow_edit_status.md
new file mode 100644
index 0000000..17ddf76
--- /dev/null
+++ b/docs/commands/flow_edit_status.md
@@ -0,0 +1,41 @@
+## flow edit status
+
+Open status spec file in editor
+
+
+
+
+### Synopsis
+
+Open a status spec file in your editor.
+
+Without arguments, opens the global status spec (~/.flow/status.yaml).
+With a workspace argument, opens the workspace-specific status spec.
+
+```
+flow edit status [workspace] [flags]
+```
+
+### Examples
+
+```
+ flow edit status # Edit global status spec
+ flow edit status vpc-ipv6 # Edit workspace status spec
+```
+
+### Options
+
+```
+ -h, --help help for status
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow edit](flow_edit.md) - Open flow configuration files in editor
+
diff --git a/docs/commands/flow_exec.md b/docs/commands/flow_exec.md
index 868fe8d..ddef6c8 100644
--- a/docs/commands/flow_exec.md
+++ b/docs/commands/flow_exec.md
@@ -6,15 +6,15 @@ Run a command from the workspace directory
```
-flow exec -- [flags]
+flow exec [-- ] [flags]
```
### Examples
```
+ flow exec calm-delta
flow exec calm-delta -- cursor .
flow exec calm-delta -- git status
- flow exec calm-delta -- ls -la
```
### Options
diff --git a/docs/commands/flow_state.md b/docs/commands/flow_open.md
similarity index 54%
rename from docs/commands/flow_state.md
rename to docs/commands/flow_open.md
index 73a5496..1a1676f 100644
--- a/docs/commands/flow_state.md
+++ b/docs/commands/flow_open.md
@@ -1,24 +1,24 @@
-## flow state
+## flow open
-Open workspace state file in editor
+Open a shell in the workspace directory
-
+
```
-flow state [flags]
+flow open [flags]
```
### Examples
```
- flow state calm-delta # Opens state.yaml in $EDITOR
+ flow open calm-delta
```
### Options
```
- -h, --help help for state
+ -h, --help help for open
```
### Options inherited from parent commands
diff --git a/docs/commands/flow_reset.md b/docs/commands/flow_reset.md
new file mode 100644
index 0000000..896e001
--- /dev/null
+++ b/docs/commands/flow_reset.md
@@ -0,0 +1,28 @@
+## flow reset
+
+Reset a config file to its default value
+
+### Synopsis
+
+Reset status, config, or state files to their default values.
+
+### Options
+
+```
+ -h, --help help for reset
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow](flow.md) - Multi-repo workspace manager using git worktrees
+* [flow reset config](flow_reset_config.md) - Reset the global config to its default
+* [flow reset skills](flow_reset_skills.md) - Reset shared agent skills to their defaults
+* [flow reset state](flow_reset_state.md) - Reset a workspace state file to its default
+* [flow reset status](flow_reset_status.md) - Reset the global status spec to its default
+
diff --git a/docs/commands/flow_reset_config.md b/docs/commands/flow_reset_config.md
new file mode 100644
index 0000000..783bc81
--- /dev/null
+++ b/docs/commands/flow_reset_config.md
@@ -0,0 +1,35 @@
+## flow reset config
+
+Reset the global config to its default
+
+
+
+
+```
+flow reset config [flags]
+```
+
+### Examples
+
+```
+ flow reset config
+ flow reset config --force
+```
+
+### Options
+
+```
+ -f, --force Skip confirmation prompt
+ -h, --help help for config
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow reset](flow_reset.md) - Reset a config file to its default value
+
diff --git a/docs/commands/flow_reset_skills.md b/docs/commands/flow_reset_skills.md
new file mode 100644
index 0000000..7bf2f3f
--- /dev/null
+++ b/docs/commands/flow_reset_skills.md
@@ -0,0 +1,35 @@
+## flow reset skills
+
+Reset shared agent skills to their defaults
+
+
+
+
+```
+flow reset skills [flags]
+```
+
+### Examples
+
+```
+ flow reset skills
+ flow reset skills --force
+```
+
+### Options
+
+```
+ -f, --force Skip confirmation prompt
+ -h, --help help for skills
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow reset](flow_reset.md) - Reset a config file to its default value
+
diff --git a/docs/commands/flow_reset_state.md b/docs/commands/flow_reset_state.md
new file mode 100644
index 0000000..173ab40
--- /dev/null
+++ b/docs/commands/flow_reset_state.md
@@ -0,0 +1,35 @@
+## flow reset state
+
+Reset a workspace state file to its default
+
+
+
+
+```
+flow reset state [flags]
+```
+
+### Examples
+
+```
+ flow reset state vpc-ipv6
+ flow reset state vpc-ipv6 --force
+```
+
+### Options
+
+```
+ -f, --force Skip confirmation prompt
+ -h, --help help for state
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow reset](flow_reset.md) - Reset a config file to its default value
+
diff --git a/docs/commands/flow_reset_status.md b/docs/commands/flow_reset_status.md
new file mode 100644
index 0000000..64965ce
--- /dev/null
+++ b/docs/commands/flow_reset_status.md
@@ -0,0 +1,35 @@
+## flow reset status
+
+Reset the global status spec to its default
+
+
+
+
+```
+flow reset status [flags]
+```
+
+### Examples
+
+```
+ flow reset status
+ flow reset status --force
+```
+
+### Options
+
+```
+ -f, --force Skip confirmation prompt
+ -h, --help help for status
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow reset](flow_reset.md) - Reset a config file to its default value
+
diff --git a/docs/commands/flow_status.md b/docs/commands/flow_status.md
new file mode 100644
index 0000000..a9bd560
--- /dev/null
+++ b/docs/commands/flow_status.md
@@ -0,0 +1,41 @@
+## flow status
+
+Show workspace status
+
+
+
+
+### Synopsis
+
+Show the resolved status of workspaces.
+
+Without arguments, shows all workspaces with their statuses.
+With a workspace argument, shows a detailed per-repo status breakdown.
+
+```
+flow status [workspace] [flags]
+```
+
+### Examples
+
+```
+ flow status # Show all workspace statuses
+ flow status vpc-ipv6 # Show per-repo breakdown
+```
+
+### Options
+
+```
+ -h, --help help for status
+```
+
+### Options inherited from parent commands
+
+```
+ -v, --verbose Enable verbose debug output
+```
+
+### SEE ALSO
+
+* [flow](flow.md) - Multi-repo workspace manager using git worktrees
+
diff --git a/docs/commands/tapes/delete.gif b/docs/commands/tapes/delete.gif
index b53bfff..40d56a4 100644
Binary files a/docs/commands/tapes/delete.gif and b/docs/commands/tapes/delete.gif differ
diff --git a/docs/commands/tapes/edit-config.tape b/docs/commands/tapes/edit-config.tape
new file mode 100644
index 0000000..b842f3e
--- /dev/null
+++ b/docs/commands/tapes/edit-config.tape
@@ -0,0 +1,50 @@
+# Regenerate: make docs
+Output docs/commands/tapes/edit_config.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 700
+Set Height 300
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+Type `export EDITOR="vim -u NONE"`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+Type "flow edit config"
+Sleep 500ms
+Enter
+Sleep 1500ms
+
+# Vim opens config.yaml. Browse briefly, then quit.
+Sleep 2s
+
+Type ":q"
+Enter
+Sleep 1500ms
+
+Sleep 2s
diff --git a/docs/commands/tapes/state.tape b/docs/commands/tapes/edit-state.tape
similarity index 77%
rename from docs/commands/tapes/state.tape
rename to docs/commands/tapes/edit-state.tape
index 5c9ccdf..851b12e 100644
--- a/docs/commands/tapes/state.tape
+++ b/docs/commands/tapes/edit-state.tape
@@ -1,5 +1,5 @@
# Regenerate: make docs
-Output docs/commands/tapes/state.gif
+Output docs/commands/tapes/edit_state.gif
Set Shell "bash"
Set FontFamily "Menlo"
@@ -35,7 +35,7 @@ Show
Sleep 500ms
-Type "flow state demo"
+Type "flow edit state demo"
Sleep 500ms
Enter
Sleep 1500ms
@@ -45,11 +45,11 @@ Type@0ms "G$F[C"
Sleep 500ms
Enter
-Type@30ms " - url: /tmp/demo/app"
+Type@30ms " - url: github.com/acme/web"
Enter
-Type@30ms " branch: feature/ipv6"
+Type@30ms " branch: feat/dashboard"
Enter
-Type@30ms " path: app"
+Type@30ms " path: web"
Sleep 2s
Escape
diff --git a/docs/commands/tapes/edit-status.tape b/docs/commands/tapes/edit-status.tape
new file mode 100644
index 0000000..572b32b
--- /dev/null
+++ b/docs/commands/tapes/edit-status.tape
@@ -0,0 +1,52 @@
+# Regenerate: make docs
+Output docs/commands/tapes/edit_status.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 900
+Set Height 500
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+Type `export EDITOR="vim -u NONE"`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+# Creates starter spec and opens in vim
+Type "flow edit status"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# Vim opens the status spec. Browse the content.
+Sleep 3s
+
+# Quit
+Type ":q"
+Enter
+Sleep 1500ms
+
+Sleep 2s
diff --git a/docs/commands/tapes/edit_config.gif b/docs/commands/tapes/edit_config.gif
new file mode 100644
index 0000000..8754546
Binary files /dev/null and b/docs/commands/tapes/edit_config.gif differ
diff --git a/docs/commands/tapes/edit_state.gif b/docs/commands/tapes/edit_state.gif
new file mode 100644
index 0000000..af22e5a
Binary files /dev/null and b/docs/commands/tapes/edit_state.gif differ
diff --git a/docs/commands/tapes/edit_status.gif b/docs/commands/tapes/edit_status.gif
new file mode 100644
index 0000000..539d996
Binary files /dev/null and b/docs/commands/tapes/edit_status.gif differ
diff --git a/docs/commands/tapes/exec.gif b/docs/commands/tapes/exec.gif
index a91774b..539449b 100644
Binary files a/docs/commands/tapes/exec.gif and b/docs/commands/tapes/exec.gif differ
diff --git a/docs/commands/tapes/init.gif b/docs/commands/tapes/init.gif
index 9ce47df..6ada3de 100644
Binary files a/docs/commands/tapes/init.gif and b/docs/commands/tapes/init.gif differ
diff --git a/docs/commands/tapes/list.gif b/docs/commands/tapes/list.gif
index 0d2a55a..7f36ae4 100644
Binary files a/docs/commands/tapes/list.gif and b/docs/commands/tapes/list.gif differ
diff --git a/docs/commands/tapes/open.gif b/docs/commands/tapes/open.gif
new file mode 100644
index 0000000..990b69e
Binary files /dev/null and b/docs/commands/tapes/open.gif differ
diff --git a/docs/commands/tapes/open.tape b/docs/commands/tapes/open.tape
new file mode 100644
index 0000000..0b1127c
--- /dev/null
+++ b/docs/commands/tapes/open.tape
@@ -0,0 +1,53 @@
+# Regenerate: make docs
+Output docs/commands/tapes/open.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 700
+Set Height 300
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+Type `export SHELL=/bin/bash`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+Type "flow open api-refactor"
+Sleep 500ms
+Enter
+Sleep 2s
+
+# Now in a subshell inside the workspace directory
+Type "pwd"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Type "exit"
+Enter
+Sleep 1s
+
+Sleep 2s
diff --git a/docs/commands/tapes/render.gif b/docs/commands/tapes/render.gif
index 47847a3..7746847 100644
Binary files a/docs/commands/tapes/render.gif and b/docs/commands/tapes/render.gif differ
diff --git a/docs/commands/tapes/reset-config.tape b/docs/commands/tapes/reset-config.tape
new file mode 100644
index 0000000..d19579f
--- /dev/null
+++ b/docs/commands/tapes/reset-config.tape
@@ -0,0 +1,39 @@
+# Regenerate: make docs
+Output docs/commands/tapes/reset_config.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 700
+Set Height 300
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+Type "flow reset config --force"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Sleep 3s
diff --git a/docs/commands/tapes/reset-skills.tape b/docs/commands/tapes/reset-skills.tape
new file mode 100644
index 0000000..8d640fd
--- /dev/null
+++ b/docs/commands/tapes/reset-skills.tape
@@ -0,0 +1,39 @@
+# Regenerate: make docs
+Output docs/commands/tapes/reset_skills.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 700
+Set Height 300
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+Type "flow reset skills --force"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Sleep 3s
diff --git a/docs/commands/tapes/reset-state.tape b/docs/commands/tapes/reset-state.tape
new file mode 100644
index 0000000..2c187fa
--- /dev/null
+++ b/docs/commands/tapes/reset-state.tape
@@ -0,0 +1,39 @@
+# Regenerate: make docs
+Output docs/commands/tapes/reset_state.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 700
+Set Height 300
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+Type "flow reset state api-refactor --force"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Sleep 3s
diff --git a/docs/commands/tapes/reset-status.tape b/docs/commands/tapes/reset-status.tape
new file mode 100644
index 0000000..21bfd17
--- /dev/null
+++ b/docs/commands/tapes/reset-status.tape
@@ -0,0 +1,39 @@
+# Regenerate: make docs
+Output docs/commands/tapes/reset_status.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 700
+Set Height 300
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+Type "flow reset status --force"
+Sleep 500ms
+Enter
+Sleep 2s
+
+Sleep 3s
diff --git a/docs/commands/tapes/reset_config.gif b/docs/commands/tapes/reset_config.gif
new file mode 100644
index 0000000..d8e6a1a
Binary files /dev/null and b/docs/commands/tapes/reset_config.gif differ
diff --git a/docs/commands/tapes/reset_skills.gif b/docs/commands/tapes/reset_skills.gif
new file mode 100644
index 0000000..962acc7
Binary files /dev/null and b/docs/commands/tapes/reset_skills.gif differ
diff --git a/docs/commands/tapes/reset_state.gif b/docs/commands/tapes/reset_state.gif
new file mode 100644
index 0000000..90b4c61
Binary files /dev/null and b/docs/commands/tapes/reset_state.gif differ
diff --git a/docs/commands/tapes/reset_status.gif b/docs/commands/tapes/reset_status.gif
new file mode 100644
index 0000000..e50ec44
Binary files /dev/null and b/docs/commands/tapes/reset_status.gif differ
diff --git a/docs/commands/tapes/state.gif b/docs/commands/tapes/state.gif
deleted file mode 100644
index 883976c..0000000
Binary files a/docs/commands/tapes/state.gif and /dev/null differ
diff --git a/docs/commands/tapes/status.gif b/docs/commands/tapes/status.gif
new file mode 100644
index 0000000..6caff1f
Binary files /dev/null and b/docs/commands/tapes/status.gif differ
diff --git a/docs/commands/tapes/status.tape b/docs/commands/tapes/status.tape
new file mode 100644
index 0000000..3bcf613
--- /dev/null
+++ b/docs/commands/tapes/status.tape
@@ -0,0 +1,51 @@
+# Regenerate: make docs
+Output docs/commands/tapes/status.gif
+
+Set Shell "bash"
+Set FontFamily "Menlo"
+Set FontSize 16
+Set Width 900
+Set Height 500
+Set Padding 20
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 60ms
+Set PlaybackSpeed 1
+Set LetterSpacing 0
+Set LineHeight 1.2
+
+Hide
+
+Type `export FLOW_HOME=/tmp/flow-demo/.flow`
+Enter
+Sleep 200ms
+
+Type `export PATH="$(pwd):$PATH"`
+Enter
+Sleep 200ms
+
+# Swap in local-only status spec (no gh needed for demo)
+Type `cp "$FLOW_HOME/status-local.yaml" "$FLOW_HOME/status.yaml"`
+Enter
+Sleep 200ms
+
+Type "clear"
+Enter
+Sleep 500ms
+
+Show
+
+Sleep 500ms
+
+# Show all workspace statuses
+Type "flow status"
+Sleep 500ms
+Enter
+Sleep 4s
+
+# Show per-repo breakdown
+Type "flow status demo"
+Sleep 500ms
+Enter
+Sleep 4s
+
+Sleep 3s
diff --git a/docs/prd/006-status.md b/docs/prd/006-status.md
new file mode 100644
index 0000000..4052d67
--- /dev/null
+++ b/docs/prd/006-status.md
@@ -0,0 +1,268 @@
+# PRD-006: Workspace Status
+
+**Depends on:** [002-mvp.md](./002-mvp.md)
+
+## Summary
+
+Add a `flow status` command that dynamically resolves the current status of each workspace by running user-defined check commands. Statuses (e.g., "in-progress", "in-review", "closed") are not hardcoded — they are defined in a spec file along with the shell commands that determine when each status applies.
+
+## Motivation
+
+Flow manages the lifecycle of multi-repo workspaces, but currently has no concept of where a workspace is in the development workflow. Users manually track whether their work is in progress, under review, merged, etc. This is exactly the kind of state that can be derived automatically from external systems (GitHub PRs, CI status, deployment state).
+
+By letting users define their own statuses and the shell commands that resolve them, flow becomes workflow-aware without being workflow-opinionated. A user working with GitHub PRs defines checks against `gh`. A user with a different workflow defines different checks. Flow just runs the commands and reports the results.
+
+## Concepts
+
+### Status Spec
+
+A YAML file that defines the available statuses and how to check for them.
+
+**Precedence:** The global spec at `~/.flow/status.yaml` serves as the default for all workspaces. A workspace can override this by placing a `status.yaml` in its workspace directory (`~/.flow/workspaces//status.yaml`). When a workspace-level spec exists, it **fully replaces** the global spec for that workspace — there is no merging.
+
+```yaml
+apiVersion: flow/v1
+kind: Status
+statuses:
+ - name: string # Required. Status identifier (used in output).
+ description: string # Optional. Human-readable description.
+ check: string # Required (unless default). Shell command, exit 0 = match.
+ default: bool # Optional. Fallback if no checks match. Only one allowed.
+```
+
+**Rules:**
+- At least one status must have `default: true`
+- A default status does not need a `check` field
+- Status names must be unique
+- Statuses are evaluated top-to-bottom (first match wins per repo)
+
+**Example:**
+```yaml
+apiVersion: flow/v1
+kind: Status
+statuses:
+ - name: closed
+ description: PR merged
+ check: gh pr list --repo "https://$FLOW_REPO_URL" --head "$FLOW_REPO_BRANCH" --state merged --json number | jq -e 'length > 0' > /dev/null 2>&1
+
+ - name: in-review
+ description: PR open
+ check: gh pr list --repo "https://$FLOW_REPO_URL" --head "$FLOW_REPO_BRANCH" --state open --json number | jq -e 'length > 0' > /dev/null 2>&1
+
+ - name: in-progress
+ description: Active development
+ default: true
+```
+
+### Resolution Model
+
+Statuses are evaluated **per-repo** using the check commands. Each check is a shell command executed via `sh -c` with environment variables providing context:
+
+| Variable | Value |
+|----------|-------|
+| `FLOW_REPO_URL` | Repo URL from state (e.g., `github.com/org/repo`) |
+| `FLOW_REPO_BRANCH` | Branch name |
+| `FLOW_REPO_PATH` | Worktree path |
+| `FLOW_WORKSPACE_ID` | Workspace ID |
+| `FLOW_WORKSPACE_NAME` | Workspace name |
+
+**Per-repo resolution:** Statuses are checked in spec order (top to bottom). The first check that exits 0 determines the repo's status. If no check matches, the `default: true` status is used.
+
+**Workspace-level aggregation:** The workspace status is the **least advanced** status across all its repos. "Least advanced" means latest in spec order. If repo A is "closed" (index 0) and repo B is "in-review" (index 1), the workspace status is "in-review" — the workspace isn't done until all repos are done.
+
+### Why First-Match-Wins
+
+The spec order matters. List the most advanced/specific statuses first, with the default fallback last. This is analogous to pattern matching or firewall rules — intuitive for users who work with config-as-code tools.
+
+## Commands
+
+### `flow status`
+
+Show all workspaces with their resolved statuses.
+
+**Behavior:**
+1. List all workspaces
+2. For each workspace, load its status spec (workspace-level if present, otherwise global)
+3. For each workspace, resolve status (run checks per-repo, aggregate)
+4. Display table
+
+**Output:**
+```
+ Resolving workspace statuses...
+
+NAME STATUS REPOS CREATED
+vpc-ipv6 in-review 2 2h ago
+webhook-fix in-progress 1 3d ago
+auth-refactor closed 3 5d ago
+```
+
+### `flow status `
+
+Show detailed per-repo status breakdown for a single workspace. The `` argument accepts either an ID or name, resolved via `resolveWorkspace`.
+
+**Behavior:**
+1. Resolve workspace by ID or name
+2. Load status spec (workspace-level if present, otherwise global)
+3. Resolve status for each repo
+4. Display workspace status and per-repo breakdown
+
+**Output:**
+```
+ Resolving status for vpc-ipv6...
+
+Status: in-review
+
+ REPO BRANCH STATUS
+ github.com/cloudposse/atmos feat/ipv6-support closed
+ github.com/cloudposse/docs feat/ipv6-support in-review
+```
+
+### `flow status --init`
+
+Generate a starter status spec file at `~/.flow/status.yaml` with a commented example. Opens in `$EDITOR` after creation.
+
+**Output:**
+```
+✓ Created status spec at ~/.flow/status.yaml
+```
+
+### `flow edit status`
+
+Open the global status spec (`~/.flow/status.yaml`) in `$EDITOR`. If the file doesn't exist, create a starter spec first (same as `--init`).
+
+**Example:**
+```bash
+flow edit status # Opens ~/.flow/status.yaml in $EDITOR
+```
+
+### `flow edit status `
+
+Open a workspace-specific status spec in `$EDITOR`. The `` argument accepts either an ID or name, resolved via `resolveWorkspace`. If the file doesn't exist, create it by copying the global spec as a starting point.
+
+**Example:**
+```bash
+flow edit status vpc-ipv6 # Opens ~/.flow/workspaces//status.yaml in $EDITOR
+```
+
+## Execution Details
+
+**Concurrency:** Checks run concurrently across repos within a workspace and across workspaces. Each check has a timeout (default: 10 seconds) to prevent hanging on network calls.
+
+**Error handling:** A check that exits non-zero means "this status doesn't match" — there's no distinction between "check failed" and "doesn't apply." If all non-default checks fail for a repo, the default status is used. This is simple and predictable.
+
+**Spec lookup:** For each workspace, check for `~/.flow/workspaces//status.yaml` first. If not found, fall back to `~/.flow/status.yaml`. If neither exists, print a message suggesting `flow status --init` and exit.
+
+**Spinner:** Since checks may involve network calls, show a spinner while resolving. Use the existing `ui.RunWithSpinner` pattern.
+
+## Directory Structure
+
+```
+~/.flow/
+├── config.yaml
+├── status.yaml # NEW — global default status spec
+├── agents/
+├── repos/
+└── workspaces/
+ └── /
+ ├── state.yaml
+ ├── status.yaml # NEW — optional workspace-level override
+ └── ...
+```
+
+## Example Workflows
+
+### Basic PR workflow
+```yaml
+# ~/.flow/status.yaml
+apiVersion: flow/v1
+kind: Status
+statuses:
+ - name: closed
+ description: PR merged
+ check: gh pr list --repo "https://$FLOW_REPO_URL" --head "$FLOW_REPO_BRANCH" --state merged --json number | jq -e 'length > 0' > /dev/null 2>&1
+
+ - name: in-review
+ description: PR open
+ check: gh pr list --repo "https://$FLOW_REPO_URL" --head "$FLOW_REPO_BRANCH" --state open --json number | jq -e 'length > 0' > /dev/null 2>&1
+
+ - name: in-progress
+ description: Active development
+ default: true
+```
+
+```bash
+flow status
+# NAME STATUS REPOS CREATED
+# vpc-ipv6 in-review 2 2h ago
+# webhook-fix in-progress 1 3d ago
+
+flow status vpc-ipv6
+# Status: in-review
+#
+# REPO BRANCH STATUS
+# github.com/cloudposse/atmos feat/ipv6-support closed
+# github.com/cloudposse/docs feat/ipv6-support in-review
+```
+
+### CI/CD pipeline workflow
+```yaml
+apiVersion: flow/v1
+kind: Status
+statuses:
+ - name: deployed
+ description: Changes live in production
+ check: gh run list --repo "https://$FLOW_REPO_URL" --branch "$FLOW_REPO_BRANCH" --workflow deploy --status completed --json conclusion -q '.[0].conclusion == "success"' | grep -q true
+
+ - name: approved
+ description: PR approved, awaiting merge
+ check: gh pr list --repo "https://$FLOW_REPO_URL" --head "$FLOW_REPO_BRANCH" --json reviewDecision -q '.[0].reviewDecision == "APPROVED"' | grep -q true
+
+ - name: in-review
+ description: PR open
+ check: gh pr list --repo "https://$FLOW_REPO_URL" --head "$FLOW_REPO_BRANCH" --state open --json number | jq -e 'length > 0' > /dev/null 2>&1
+
+ - name: draft
+ description: Draft PR
+ check: gh pr list --repo "https://$FLOW_REPO_URL" --head "$FLOW_REPO_BRANCH" --json isDraft -q '.[0].isDraft' | grep -q true
+
+ - name: in-progress
+ description: Active development
+ default: true
+```
+
+## Design Decisions
+
+| Decision | Choice | Rationale |
+|----------|--------|-----------|
+| Spec location | Global default + per-workspace override | Global spec covers the common case; workspace-level `status.yaml` fully replaces it when present |
+| Check mechanism | Shell commands via `sh -c` | Maximum flexibility; works with any tool (`gh`, `curl`, custom scripts) |
+| Context passing | Environment variables | Simpler than Go templates; natural for shell commands |
+| Resolution order | First-match-wins, top-to-bottom | Familiar pattern (routing tables, pattern matching); most specific first |
+| Aggregation | Least-advanced repo wins | Intuitive: workspace isn't done until all repos are done |
+| Error model | Non-zero exit = doesn't match | Simple; no need to distinguish error types for MVP |
+| Check timeout | 10 seconds per check | Prevents hanging on network issues |
+
+## Open Questions
+
+These are design choices to confirm before implementation:
+
+1. **Should `flow list` also show status?** Option A: Keep `flow status` separate (fast `list` vs slower `status`). Option B: Add a `--status` flag to `flow list`. Recommendation: Keep separate for MVP — status checks involve network calls and add latency.
+
+2. **Check timeout configurability?** Could add a `timeout` field per status or globally in the spec. Recommendation: Hardcode 10s for MVP, make configurable later.
+
+## Acceptance Criteria
+
+- [ ] `flow status --init` creates a starter `~/.flow/status.yaml` and opens in editor
+- [ ] `flow status` resolves and displays statuses for all workspaces
+- [ ] `flow status ` shows per-repo status breakdown
+- [ ] Statuses are evaluated top-to-bottom, first match wins per repo
+- [ ] Workspace status is the least-advanced repo status
+- [ ] Check commands receive correct environment variables
+- [ ] Checks run concurrently with a timeout
+- [ ] Default status is used when no checks match
+- [ ] Workspace-level `status.yaml` fully overrides the global spec for that workspace
+- [ ] Missing spec file (no workspace-level or global) produces helpful error with `--init` suggestion
+- [ ] `flow edit status` opens global status spec in editor (creates starter if missing)
+- [ ] `flow edit status ` opens workspace-level status spec in editor (copies global as starting point if missing)
+- [ ] Spec validation: requires unique names, exactly one default, valid structure
+- [ ] `make build`, `make lint`, `make test` all pass
diff --git a/docs/prd/007-edit-command.md b/docs/prd/007-edit-command.md
new file mode 100644
index 0000000..f7a12e5
--- /dev/null
+++ b/docs/prd/007-edit-command.md
@@ -0,0 +1,56 @@
+# PRD-007: `flow edit` Command
+
+**Depends on:** [002-mvp.md](./002-mvp.md), [006-status.md](./006-status.md)
+
+## Summary
+
+Introduce `flow edit` as a parent command for opening flow configuration files in `$EDITOR`. Migrate the existing `flow state` command to `flow edit state` and add `flow edit config` for the global config file.
+
+## Motivation
+
+Flow currently has `flow state ` to open a workspace's state file in an editor. PRD-006 adds `flow edit status` for status specs. Rather than scattering editor commands across unrelated top-level verbs, consolidate them under a single `flow edit` parent command. This gives a consistent, discoverable pattern: `flow edit `.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `flow edit state ` | Open workspace state file in editor |
+| `flow edit config` | Open global config file in editor |
+| `flow edit status` | Defined in PRD-006 |
+| `flow edit status ` | Defined in PRD-006 |
+
+---
+
+### `flow edit state `
+
+Open the workspace's `state.yaml` in `$EDITOR`. The `` argument accepts either an ID or name, resolved via `resolveWorkspace`. Identical behavior to the current `flow state` command.
+
+**Example:**
+```bash
+flow edit state calm-delta # Opens ~/.flow/workspaces//state.yaml in $EDITOR
+```
+
+---
+
+### `flow edit config`
+
+Open the global config file (`~/.flow/config.yaml`) in `$EDITOR`.
+
+**Example:**
+```bash
+flow edit config # Opens ~/.flow/config.yaml in $EDITOR
+```
+
+---
+
+## Migration
+
+**Breaking change:** `flow state` is removed. Use `flow edit state` instead. This is acceptable since flow is pre-release.
+
+## Acceptance Criteria
+
+- [ ] `flow edit state ` opens state file in editor
+- [ ] `flow edit config` opens global config in editor
+- [ ] `flow state` is removed (no alias)
+- [ ] `flow edit` with no subcommand prints help listing available subcommands
+- [ ] `make build`, `make lint`, `make test` all pass
diff --git a/docs/prd/README.md b/docs/prd/README.md
index a290349..46dce59 100644
--- a/docs/prd/README.md
+++ b/docs/prd/README.md
@@ -7,3 +7,5 @@
| [003](003-prompt.md) | flow prompt |
| [004](004-tui.md) | TUI Overhaul |
| [005](005-config-and-claude.md) | Global Config & Claude Workspace Instructions |
+| [006](006-status.md) | Workspace Status |
+| [007](007-edit-command.md) | `flow edit` Command |
diff --git a/docs/specs/config.md b/docs/specs/config.md
new file mode 100644
index 0000000..6ea33d4
--- /dev/null
+++ b/docs/specs/config.md
@@ -0,0 +1,36 @@
+# Config
+
+The global config file stores Flow-wide settings.
+
+## Location
+
+```
+~/.flow/config.yaml
+```
+
+This file is created automatically on first run.
+
+## Schema
+
+```yaml
+apiVersion: flow/v1
+kind: Config
+spec:
+ agents:
+ - name: claude
+ exec: claude
+ default: true
+ - name: cursor
+ exec: cursor .
+```
+
+## Fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `apiVersion` | Yes | Must be `flow/v1` |
+| `kind` | Yes | Must be `Config` |
+| `spec.agents[]` | No | List of agent tools. The agent marked `default: true` is shown in `flow render` output. When omitted, a generic `` placeholder is shown. |
+| `spec.agents[].name` | Yes | Identifier for the agent |
+| `spec.agents[].exec` | Yes | Command to run via `flow exec` |
+| `spec.agents[].default` | No | When `true`, this agent's `exec` is used in render output |
diff --git a/docs/specs/state.md b/docs/specs/state.md
new file mode 100644
index 0000000..7dc8d92
--- /dev/null
+++ b/docs/specs/state.md
@@ -0,0 +1,41 @@
+# State
+
+Each workspace is defined by a `state.yaml` file. Run `flow edit state ` to open it in your editor.
+
+## Location
+
+```
+~/.flow/workspaces//state.yaml
+```
+
+## Schema
+
+```yaml
+apiVersion: flow/v1
+kind: State
+metadata:
+ name: vpc-ipv6
+ description: IPv6 support across VPC services
+ created: "2026-02-18T12:00:00Z"
+spec:
+ repos:
+ - url: github.com/acme/vpc-service
+ branch: feature/ipv6
+ - url: github.com/acme/subnet-manager
+ branch: feature/ipv6
+ path: subnet-manager # optional, defaults to repo name
+```
+
+## Fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `apiVersion` | Yes | Must be `flow/v1` |
+| `kind` | Yes | Must be `State` |
+| `metadata.name` | No | Human-friendly workspace name |
+| `metadata.description` | No | Optional description |
+| `metadata.created` | Yes | RFC 3339 timestamp (set automatically on init) |
+| `spec.repos` | Yes | Must contain at least one repo |
+| `spec.repos[].url` | Yes | Git remote URL |
+| `spec.repos[].branch` | Yes | Branch to check out |
+| `spec.repos[].path` | No | Directory name in the workspace (defaults to repo name) |
diff --git a/docs/specs/status.md b/docs/specs/status.md
new file mode 100644
index 0000000..3bc9084
--- /dev/null
+++ b/docs/specs/status.md
@@ -0,0 +1,97 @@
+# Status
+
+The status spec defines how Flow resolves the status of each repo in a workspace. Statuses are evaluated top-to-bottom — the first passing check wins. A workspace's overall status is the least-advanced repo (highest index in the spec).
+
+## Location
+
+```
+~/.flow/status.yaml # Global default
+~/.flow/workspaces//status.yaml # Workspace override (fully replaces global)
+```
+
+## Default Statuses
+
+Flow ships with four statuses that model a standard PR-based workflow. These are created automatically on first run at `~/.flow/status.yaml`.
+
+| Status | Condition |
+|--------|-----------|
+| `closed` | A merged PR exists for the branch and no open PRs remain |
+| `in-review` | A non-draft PR is open for the branch |
+| `in-progress` | The worktree has uncommitted changes, local commits ahead of remote, or a draft PR is open |
+| `open` | Default — none of the above conditions matched |
+
+The check commands that implement these conditions are shell one-liners using `gh`, `git`, and `jq`. They aren't meant to be human-readable — they're generated by AI agents and only need to exit `0` or non-zero to signal whether a described condition is met. The `description` field is what matters for understanding intent; the `check` field is the machine-evaluated implementation.
+
+You can customize these statuses or add your own. The only requirement is that exactly one entry has `default: true`.
+
+## Schema
+
+```yaml
+apiVersion: flow/v1
+kind: Status
+spec:
+ statuses:
+ - name: closed
+ description: All PRs merged or closed
+ # AI-generated — exits 0 when a merged PR exists and no open PRs remain
+ check: >-
+ gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state merged --json number
+ | jq -e 'length > 0' > /dev/null 2>&1
+ && gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state open --json number
+ | jq -e 'length == 0' > /dev/null 2>&1
+ - name: in-review
+ description: Non-draft PR open
+ # AI-generated — exits 0 when a non-draft PR is open for the branch
+ check: >-
+ gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state open --json isDraft
+ | jq -e 'map(select(.isDraft == false)) | length > 0' > /dev/null 2>&1
+ - name: in-progress
+ description: Uncommitted changes, unpushed commits, or draft PR
+ # AI-generated — exits 0 when the worktree is dirty, local HEAD differs from remote, or a draft PR exists
+ check: >-
+ git -C "$FLOW_REPO_PATH" status --porcelain 2>/dev/null | grep -q .
+ || { _r=$(git ls-remote "$(git -C "$FLOW_REPO_PATH" remote get-url origin 2>/dev/null)"
+ "$FLOW_REPO_BRANCH" 2>/dev/null | cut -f1)
+ && [ -n "$_r" ]
+ && [ "$(git -C "$FLOW_REPO_PATH" rev-parse HEAD 2>/dev/null)" != "$_r" ]
+ && git -C "$FLOW_REPO_PATH" cat-file -e "$_r" 2>/dev/null
+ && if git -C "$FLOW_REPO_PATH" merge-base --is-ancestor HEAD "$_r" 2>/dev/null;
+ then false; fi; }
+ || gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state open --json isDraft
+ | jq -e 'map(select(.isDraft)) | length > 0' > /dev/null 2>&1
+ - name: open
+ description: Workspace created, no changes yet
+ default: true
+```
+
+## Fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `apiVersion` | Yes | Must be `flow/v1` |
+| `kind` | Yes | Must be `Status` |
+| `spec.statuses[]` | Yes | Must contain at least one entry |
+| `spec.statuses[].name` | Yes | Unique status name |
+| `spec.statuses[].description` | No | Human-readable description |
+| `spec.statuses[].check` | Yes* | Shell command evaluated via `sh -c` (*not required for the default entry) |
+| `spec.statuses[].default` | No | Exactly one entry must have `default: true` |
+
+## Environment Variables
+
+Each check command receives the following environment variables:
+
+| Variable | Description |
+|----------|-------------|
+| `FLOW_REPO_URL` | Repo URL from the state file |
+| `FLOW_REPO_BRANCH` | Branch name from the state file |
+| `FLOW_REPO_PATH` | Absolute path to the worktree directory |
+| `FLOW_REPO_SLUG` | Repo in `owner/repo` format (derived from URL, works with `gh --repo`) |
+| `FLOW_WORKSPACE_ID` | Workspace directory ID |
+| `FLOW_WORKSPACE_NAME` | Workspace display name |
+
+## Resolution
+
+1. Checks run top-to-bottom per repo. The first check that exits `0` determines that repo's status.
+2. If no check passes, the default status is used.
+3. A workspace's overall status is the least-advanced repo — the one with the highest index in the statuses list.
+4. If a workspace has its own `status.yaml`, it fully replaces the global spec (no merging).
diff --git a/internal/agents/claude.go b/internal/agents/claude.go
index bb41ebe..1209bf3 100644
--- a/internal/agents/claude.go
+++ b/internal/agents/claude.go
@@ -42,6 +42,37 @@ func EnsureSharedAgent(agentsDir string) error {
return nil
}
+// ResetSharedAgent overwrites the shared Claude agent files with their defaults,
+// regardless of whether they already exist.
+func ResetSharedAgent(agentsDir string) error {
+ claudeDir := filepath.Join(agentsDir, "claude")
+ flowCLIDir := filepath.Join(claudeDir, "skills", "flow-cli")
+ wsStructDir := filepath.Join(claudeDir, "skills", "workspace-structure")
+
+ for _, dir := range []string{flowCLIDir, wsStructDir} {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ }
+
+ defaults := []struct {
+ path string
+ content []byte
+ }{
+ {filepath.Join(claudeDir, "CLAUDE.md"), defaultClaudeMD},
+ {filepath.Join(flowCLIDir, "SKILL.md"), defaultFlowCLI},
+ {filepath.Join(wsStructDir, "SKILL.md"), defaultWorkspaceStructure},
+ }
+
+ for _, d := range defaults {
+ if err := os.WriteFile(d.path, d.content, 0o644); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
// SetupWorkspaceClaude generates workspace-specific Claude files and creates
// symlinks to the shared agent directory.
func SetupWorkspaceClaude(wsDir, agentsDir string, st *state.State, id string) error {
@@ -111,7 +142,7 @@ func generateWorkspaceClaude(st *state.State, id string) string {
}
b.WriteString("\n## Quick Reference\n\n")
- b.WriteString(fmt.Sprintf("- View state: `flow state %s`\n", id))
+ b.WriteString(fmt.Sprintf("- Edit state: `flow edit state %s`\n", id))
b.WriteString(fmt.Sprintf("- Re-render: `flow render %s`\n", id))
b.WriteString(fmt.Sprintf("- Run command: `flow exec %s -- `\n", id))
diff --git a/internal/agents/defaults/claude/skills/flow-cli/SKILL.md b/internal/agents/defaults/claude/skills/flow-cli/SKILL.md
index 6326cb0..9db6087 100644
--- a/internal/agents/defaults/claude/skills/flow-cli/SKILL.md
+++ b/internal/agents/defaults/claude/skills/flow-cli/SKILL.md
@@ -11,11 +11,16 @@ user-invocable: false
| Command | Description |
|---------|-------------|
| `flow list` | List all workspaces |
-| `flow state ` | Show workspace state (YAML manifest) |
+| `flow edit state ` | Open workspace state file in editor |
| `flow render ` | Clone repos and check out branches (creates worktrees) |
+| `flow open ` | Open a shell in the workspace directory |
| `flow exec -- ` | Run a command inside the workspace directory |
| `flow init ` | Create a new workspace interactively |
| `flow delete ` | Delete a workspace and its worktrees |
+| `flow reset status` | Reset global status spec to default |
+| `flow reset config` | Reset global config to default |
+| `flow reset state ` | Reset workspace state to default (preserves name/description) |
+| `flow reset skills` | Reset shared agent skills to their defaults |
## State File Format
diff --git a/internal/cmd/edit.go b/internal/cmd/edit.go
new file mode 100644
index 0000000..ddfa741
--- /dev/null
+++ b/internal/cmd/edit.go
@@ -0,0 +1,137 @@
+package cmd
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+
+ "github.com/milldr/flow/internal/config"
+ "github.com/milldr/flow/internal/status"
+ "github.com/milldr/flow/internal/ui"
+ "github.com/milldr/flow/internal/workspace"
+ "github.com/spf13/cobra"
+)
+
+// openInEditor opens a file in the user's preferred editor.
+func openInEditor(path string) error {
+ editor := os.Getenv("EDITOR")
+ if editor == "" {
+ editor = "vim"
+ }
+
+ parts := strings.Fields(editor)
+ editorArgs := make([]string, len(parts)-1, len(parts))
+ copy(editorArgs, parts[1:])
+ editorArgs = append(editorArgs, path)
+
+ c := exec.Command(parts[0], editorArgs...)
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+
+ if err := c.Run(); err != nil {
+ return fmt.Errorf("editor exited with error: %w", err)
+ }
+ return nil
+}
+
+func newEditCmd(svc *workspace.Service, cfg *config.Config) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "edit",
+ Short: "Open flow configuration files in editor",
+ }
+
+ cmd.AddCommand(newEditStateCmd(svc))
+ cmd.AddCommand(newEditConfigCmd(cfg))
+ cmd.AddCommand(newEditStatusCmd(svc, cfg))
+
+ return cmd
+}
+
+func newEditStateCmd(svc *workspace.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "state ",
+ Short: "Open workspace state file in editor",
+ Args: cobra.ExactArgs(1),
+ Example: ` flow edit state calm-delta # Opens state.yaml in $EDITOR`,
+ RunE: func(_ *cobra.Command, args []string) error {
+ id, _, err := resolveWorkspace(svc, args[0])
+ if err != nil {
+ return err
+ }
+
+ return openInEditor(svc.Config.StatePath(id))
+ },
+ }
+}
+
+func newEditConfigCmd(cfg *config.Config) *cobra.Command {
+ return &cobra.Command{
+ Use: "config",
+ Short: "Open global config file in editor",
+ Args: cobra.NoArgs,
+ Example: ` flow edit config # Opens ~/.flow/config.yaml in $EDITOR`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return openInEditor(cfg.ConfigFile)
+ },
+ }
+}
+
+func newEditStatusCmd(svc *workspace.Service, cfg *config.Config) *cobra.Command {
+ return &cobra.Command{
+ Use: "status [workspace]",
+ Short: "Open status spec file in editor",
+ Long: `Open a status spec file in your editor.
+
+Without arguments, opens the global status spec (~/.flow/status.yaml).
+With a workspace argument, opens the workspace-specific status spec.`,
+ Args: cobra.MaximumNArgs(1),
+ Example: " flow edit status # Edit global status spec\n flow edit status vpc-ipv6 # Edit workspace status spec",
+ RunE: func(_ *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return editGlobalStatus(cfg)
+ }
+ return editWorkspaceStatus(svc, cfg, args[0])
+ },
+ }
+}
+
+func editGlobalStatus(cfg *config.Config) error {
+ path := cfg.StatusSpecFile
+
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ if err := status.Save(path, status.DefaultSpec()); err != nil {
+ return fmt.Errorf("creating status spec: %w", err)
+ }
+ ui.Success("Created status spec at " + path)
+ }
+
+ return openInEditor(path)
+}
+
+func editWorkspaceStatus(svc *workspace.Service, cfg *config.Config, idOrName string) error {
+ id, _, err := resolveWorkspace(svc, idOrName)
+ if err != nil {
+ return err
+ }
+
+ path := cfg.WorkspaceStatusSpecPath(id)
+
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ // Copy global spec as starting point, or create default.
+ var spec *status.Spec
+ if globalSpec, loadErr := status.Load(cfg.StatusSpecFile); loadErr == nil {
+ spec = globalSpec
+ } else {
+ spec = status.DefaultSpec()
+ }
+
+ if err := status.Save(path, spec); err != nil {
+ return fmt.Errorf("creating workspace status spec: %w", err)
+ }
+ ui.Success("Created workspace status spec at " + path)
+ }
+
+ return openInEditor(path)
+}
diff --git a/internal/cmd/exec.go b/internal/cmd/exec.go
index 8f5daf1..10ef0af 100644
--- a/internal/cmd/exec.go
+++ b/internal/cmd/exec.go
@@ -4,33 +4,40 @@ import (
"errors"
"os"
"os/exec"
+ "strings"
"github.com/milldr/flow/internal/iterm"
+ "github.com/milldr/flow/internal/ui"
"github.com/milldr/flow/internal/workspace"
"github.com/spf13/cobra"
)
-// ErrNoCommand is returned when no command is specified after --.
-var ErrNoCommand = errors.New("no command specified after '--'")
+// ErrNoAgent is returned when no agents are configured and no command is given.
+var ErrNoAgent = errors.New("no agents configured; specify a command with -- or add agents to config")
func newExecCmd(svc *workspace.Service) *cobra.Command {
cmd := &cobra.Command{
- Use: "exec -- ",
+ Use: "exec [-- ]",
Short: "Run a command from the workspace directory",
Args: cobra.MinimumNArgs(1),
- Example: ` flow exec calm-delta -- cursor .
- flow exec calm-delta -- git status
- flow exec calm-delta -- ls -la`,
+ Example: ` flow exec calm-delta
+ flow exec calm-delta -- cursor .
+ flow exec calm-delta -- git status`,
RunE: func(_ *cobra.Command, args []string) error {
id, st, err := resolveWorkspace(svc, args[0])
if err != nil {
return err
}
- // Cobra places args after "--" starting at args[1:]
+ // Args after "--" are the explicit command.
cmdArgs := args[1:]
+
+ // No explicit command — resolve from configured agents.
if len(cmdArgs) == 0 {
- return ErrNoCommand
+ cmdArgs, err = resolveAgentCmd(svc)
+ if err != nil {
+ return err
+ }
}
wsDir := svc.Config.WorkspacePath(id)
@@ -50,3 +57,29 @@ func newExecCmd(svc *workspace.Service) *cobra.Command {
return cmd
}
+
+// resolveAgentCmd picks the command to run from configured agents.
+// One agent → use it directly. Multiple → prompt the user to choose.
+func resolveAgentCmd(svc *workspace.Service) ([]string, error) {
+ agents := svc.Config.FlowConfig.Spec.Agents
+ if len(agents) == 0 {
+ return nil, ErrNoAgent
+ }
+
+ var execStr string
+ if len(agents) == 1 {
+ execStr = agents[0].Exec
+ } else {
+ options := make([]ui.AgentOption, len(agents))
+ for i, a := range agents {
+ options[i] = ui.AgentOption{Name: a.Name, Exec: a.Exec}
+ }
+ selected, err := ui.SelectAgent(options)
+ if err != nil {
+ return nil, err
+ }
+ execStr = selected
+ }
+
+ return strings.Fields(execStr), nil
+}
diff --git a/internal/cmd/open.go b/internal/cmd/open.go
new file mode 100644
index 0000000..ee35deb
--- /dev/null
+++ b/internal/cmd/open.go
@@ -0,0 +1,43 @@
+package cmd
+
+import (
+ "os"
+ "os/exec"
+
+ "github.com/milldr/flow/internal/iterm"
+ "github.com/milldr/flow/internal/workspace"
+ "github.com/spf13/cobra"
+)
+
+func newOpenCmd(svc *workspace.Service) *cobra.Command {
+ return &cobra.Command{
+ Use: "open ",
+ Short: "Open a shell in the workspace directory",
+ Args: cobra.ExactArgs(1),
+ Example: " flow open calm-delta",
+ RunE: func(_ *cobra.Command, args []string) error {
+ id, st, err := resolveWorkspace(svc, args[0])
+ if err != nil {
+ return err
+ }
+
+ wsDir := svc.Config.WorkspacePath(id)
+
+ iterm.SetTabColor(id)
+ iterm.SetTabTitle(workspaceDisplayName(id, st))
+
+ shell := os.Getenv("SHELL")
+ if shell == "" {
+ shell = "/bin/sh"
+ }
+
+ c := exec.Command(shell)
+ c.Dir = wsDir
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+
+ return c.Run()
+ },
+ }
+}
diff --git a/internal/cmd/render.go b/internal/cmd/render.go
index 87f3d96..27edcc1 100644
--- a/internal/cmd/render.go
+++ b/internal/cmd/render.go
@@ -30,9 +30,11 @@ func newRenderCmd(svc *workspace.Service) *cobra.Command {
ui.Print("")
ui.Success("Workspace ready")
ui.Print("")
- ui.Printf(" %s\n", ui.Code("flow exec "+name+" -- "))
- ui.Printf(" %s\n", ui.Code("flow exec "+name+" -- cursor ."))
- ui.Printf(" %s\n", ui.Code("flow exec "+name+" -- claude"))
+ if len(svc.Config.FlowConfig.Spec.Agents) > 0 {
+ ui.Printf(" %s\n", ui.Code("flow exec "+name))
+ } else {
+ ui.Printf(" %s\n", ui.Code("flow exec "+name+" -- "))
+ }
return nil
},
diff --git a/internal/cmd/reset.go b/internal/cmd/reset.go
new file mode 100644
index 0000000..dd170a6
--- /dev/null
+++ b/internal/cmd/reset.go
@@ -0,0 +1,171 @@
+package cmd
+
+import (
+ "fmt"
+
+ "github.com/milldr/flow/internal/agents"
+ "github.com/milldr/flow/internal/config"
+ "github.com/milldr/flow/internal/state"
+ "github.com/milldr/flow/internal/status"
+ "github.com/milldr/flow/internal/ui"
+ "github.com/milldr/flow/internal/workspace"
+ "github.com/spf13/cobra"
+)
+
+func newResetCmd(svc *workspace.Service, cfg *config.Config) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "reset",
+ Short: "Reset a config file to its default value",
+ Long: "Reset status, config, or state files to their default values.",
+ }
+
+ cmd.AddCommand(newResetStatusCmd(cfg))
+ cmd.AddCommand(newResetConfigCmd(cfg))
+ cmd.AddCommand(newResetStateCmd(svc, cfg))
+ cmd.AddCommand(newResetSkillsCmd(cfg))
+
+ return cmd
+}
+
+func newResetStatusCmd(cfg *config.Config) *cobra.Command {
+ var force bool
+
+ cmd := &cobra.Command{
+ Use: "status",
+ Short: "Reset the global status spec to its default",
+ Example: " flow reset status\n flow reset status --force",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ path := cfg.StatusSpecFile
+
+ if !force {
+ confirmed, err := ui.ConfirmReset(path)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ ui.Print("Cancelled.")
+ return nil
+ }
+ }
+
+ if err := status.Save(path, status.DefaultSpec()); err != nil {
+ return fmt.Errorf("resetting status spec: %w", err)
+ }
+
+ ui.Success("Reset status spec at " + path)
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
+ return cmd
+}
+
+func newResetConfigCmd(cfg *config.Config) *cobra.Command {
+ var force bool
+
+ cmd := &cobra.Command{
+ Use: "config",
+ Short: "Reset the global config to its default",
+ Example: " flow reset config\n flow reset config --force",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ path := cfg.ConfigFile
+
+ if !force {
+ confirmed, err := ui.ConfirmReset(path)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ ui.Print("Cancelled.")
+ return nil
+ }
+ }
+
+ if err := config.SaveFlowConfig(path, config.DefaultFlowConfig()); err != nil {
+ return fmt.Errorf("resetting config: %w", err)
+ }
+
+ ui.Success("Reset config at " + path)
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
+ return cmd
+}
+
+func newResetStateCmd(svc *workspace.Service, cfg *config.Config) *cobra.Command {
+ var force bool
+
+ cmd := &cobra.Command{
+ Use: "state ",
+ Short: "Reset a workspace state file to its default",
+ Args: cobra.ExactArgs(1),
+ Example: " flow reset state vpc-ipv6\n flow reset state vpc-ipv6 --force",
+ RunE: func(_ *cobra.Command, args []string) error {
+ id, st, err := resolveWorkspace(svc, args[0])
+ if err != nil {
+ return err
+ }
+
+ path := cfg.StatePath(id)
+
+ if !force {
+ confirmed, err := ui.ConfirmReset(path)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ ui.Print("Cancelled.")
+ return nil
+ }
+ }
+
+ newSt := state.NewState(st.Metadata.Name, st.Metadata.Description, nil)
+ if err := state.Save(path, newSt); err != nil {
+ return fmt.Errorf("resetting state: %w", err)
+ }
+
+ ui.Success("Reset state at " + path)
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
+ return cmd
+}
+
+func newResetSkillsCmd(cfg *config.Config) *cobra.Command {
+ var force bool
+
+ cmd := &cobra.Command{
+ Use: "skills",
+ Short: "Reset shared agent skills to their defaults",
+ Example: " flow reset skills\n flow reset skills --force",
+ RunE: func(_ *cobra.Command, _ []string) error {
+ path := cfg.AgentsDir
+
+ if !force {
+ confirmed, err := ui.ConfirmReset(path)
+ if err != nil {
+ return err
+ }
+ if !confirmed {
+ ui.Print("Cancelled.")
+ return nil
+ }
+ }
+
+ if err := agents.ResetSharedAgent(path); err != nil {
+ return fmt.Errorf("resetting skills: %w", err)
+ }
+
+ ui.Success("Reset shared agent skills at " + path)
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation prompt")
+ return cmd
+}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 75516dd..7dcc4c9 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -89,9 +89,12 @@ func newRootCmd() *cobra.Command {
root.AddCommand(newInitCmd(svc))
root.AddCommand(newListCmd(svc))
root.AddCommand(newRenderCmd(svc))
- root.AddCommand(newStateCmd(svc))
+ root.AddCommand(newEditCmd(svc, cfg))
+ root.AddCommand(newStatusCmd(svc, cfg))
root.AddCommand(newExecCmd(svc))
+ root.AddCommand(newOpenCmd(svc))
root.AddCommand(newDeleteCmd(svc))
+ root.AddCommand(newResetCmd(svc, cfg))
return root
}
diff --git a/internal/cmd/state.go b/internal/cmd/state.go
deleted file mode 100644
index a761893..0000000
--- a/internal/cmd/state.go
+++ /dev/null
@@ -1,47 +0,0 @@
-package cmd
-
-import (
- "fmt"
- "os"
- "os/exec"
- "strings"
-
- "github.com/milldr/flow/internal/workspace"
- "github.com/spf13/cobra"
-)
-
-func newStateCmd(svc *workspace.Service) *cobra.Command {
- return &cobra.Command{
- Use: "state ",
- Short: "Open workspace state file in editor",
- Args: cobra.ExactArgs(1),
- Example: ` flow state calm-delta # Opens state.yaml in $EDITOR`,
- RunE: func(_ *cobra.Command, args []string) error {
- id, _, err := resolveWorkspace(svc, args[0])
- if err != nil {
- return err
- }
-
- statePath := svc.Config.StatePath(id)
- editor := os.Getenv("EDITOR")
- if editor == "" {
- editor = "vim"
- }
-
- // Split EDITOR to handle values with arguments (e.g. "code --wait")
- parts := strings.Fields(editor)
- editorArgs := make([]string, len(parts)-1, len(parts))
- copy(editorArgs, parts[1:])
- editorArgs = append(editorArgs, statePath)
- c := exec.Command(parts[0], editorArgs...)
- c.Stdin = os.Stdin
- c.Stdout = os.Stdout
- c.Stderr = os.Stderr
-
- if err := c.Run(); err != nil {
- return fmt.Errorf("editor exited with error: %w", err)
- }
- return nil
- },
- }
-}
diff --git a/internal/cmd/status.go b/internal/cmd/status.go
new file mode 100644
index 0000000..f53909d
--- /dev/null
+++ b/internal/cmd/status.go
@@ -0,0 +1,183 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path/filepath"
+
+ "github.com/milldr/flow/internal/config"
+ "github.com/milldr/flow/internal/state"
+ "github.com/milldr/flow/internal/status"
+ "github.com/milldr/flow/internal/ui"
+ "github.com/milldr/flow/internal/workspace"
+ "github.com/spf13/cobra"
+)
+
+func newStatusCmd(svc *workspace.Service, cfg *config.Config) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "status [workspace]",
+ Short: "Show workspace status",
+ Long: `Show the resolved status of workspaces.
+
+Without arguments, shows all workspaces with their statuses.
+With a workspace argument, shows a detailed per-repo status breakdown.`,
+ Args: cobra.MaximumNArgs(1),
+ Example: " flow status # Show all workspace statuses\n flow status vpc-ipv6 # Show per-repo breakdown",
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if len(args) == 0 {
+ return runStatusAll(cmd.Context(), svc, cfg)
+ }
+ return runStatusWorkspace(cmd.Context(), svc, cfg, args[0])
+ },
+ }
+
+ return cmd
+}
+
+func runStatusAll(ctx context.Context, svc *workspace.Service, cfg *config.Config) error {
+ infos, err := svc.List()
+ if err != nil {
+ return err
+ }
+
+ if len(infos) == 0 {
+ ui.Print("No workspaces found. Run " + ui.Code("flow init") + " to create one.")
+ return nil
+ }
+
+ resolver := &status.Resolver{Runner: &status.ShellRunner{}}
+
+ type wsResult struct {
+ info workspace.Info
+ status string
+ }
+
+ results := make([]wsResult, len(infos))
+
+ err = ui.RunWithSpinner("Resolving workspace statuses...", func(report func(string)) error {
+ for i, info := range infos {
+ report(workspaceDisplayName(info.ID, stateFromInfo(info)))
+
+ spec, specErr := status.LoadWithFallback(
+ cfg.WorkspaceStatusSpecPath(info.ID),
+ cfg.StatusSpecFile,
+ )
+ if specErr != nil {
+ if errors.Is(specErr, status.ErrSpecNotFound) {
+ results[i] = wsResult{info: info, status: "-"}
+ continue
+ }
+ return fmt.Errorf("loading status spec for %s: %w", info.ID, specErr)
+ }
+
+ st, findErr := svc.Find(info.ID)
+ if findErr != nil {
+ results[i] = wsResult{info: info, status: "-"}
+ continue
+ }
+
+ repos := stateReposToInfo(st, cfg.WorkspacePath(info.ID))
+ wsName := info.Name
+ if wsName == "" {
+ wsName = info.ID
+ }
+ result := resolver.ResolveWorkspace(ctx, spec, repos, info.ID, wsName)
+ results[i] = wsResult{info: info, status: result.Status}
+ }
+ return nil
+ })
+ if err != nil {
+ if errors.Is(err, status.ErrSpecNotFound) {
+ ui.Print("No status spec found. Run " + ui.Code("flow reset status") + " to create one.")
+ return nil
+ }
+ return err
+ }
+
+ headers := []string{"NAME", "STATUS", "REPOS", "CREATED"}
+ var rows [][]string
+ for _, r := range results {
+ name := r.info.Name
+ if name == "" {
+ name = r.info.ID
+ }
+ rows = append(rows, []string{
+ name,
+ r.status,
+ fmt.Sprintf("%d", r.info.RepoCount),
+ ui.RelativeTime(r.info.Created),
+ })
+ }
+
+ fmt.Println(ui.Table(headers, rows))
+ return nil
+}
+
+func runStatusWorkspace(ctx context.Context, svc *workspace.Service, cfg *config.Config, idOrName string) error {
+ id, st, err := resolveWorkspace(svc, idOrName)
+ if err != nil {
+ return err
+ }
+
+ spec, err := status.LoadWithFallback(
+ cfg.WorkspaceStatusSpecPath(id),
+ cfg.StatusSpecFile,
+ )
+ if err != nil {
+ if errors.Is(err, status.ErrSpecNotFound) {
+ ui.Print("No status spec found. Run " + ui.Code("flow reset status") + " to create one.")
+ return nil
+ }
+ return err
+ }
+
+ repos := stateReposToInfo(st, cfg.WorkspacePath(id))
+ wsName := workspaceDisplayName(id, st)
+
+ var result *status.WorkspaceResult
+ err = ui.RunWithSpinner("Resolving status for "+wsName+"...", func(_ func(string)) error {
+ resolver := &status.Resolver{Runner: &status.ShellRunner{}}
+ result = resolver.ResolveWorkspace(ctx, spec, repos, id, wsName)
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("Status: %s\n", result.Status)
+
+ if len(result.Repos) > 0 {
+ headers := []string{"REPO", "BRANCH", "STATUS"}
+ var rows [][]string
+ for _, r := range result.Repos {
+ rows = append(rows, []string{r.URL, r.Branch, r.Status})
+ }
+ fmt.Println(ui.Table(headers, rows))
+ }
+
+ return nil
+}
+
+// stateReposToInfo converts state repos to status RepoInfo slice.
+// wsDir is the absolute workspace directory so FLOW_REPO_PATH is a full path.
+func stateReposToInfo(st *state.State, wsDir string) []status.RepoInfo {
+ repos := make([]status.RepoInfo, len(st.Spec.Repos))
+ for i, r := range st.Spec.Repos {
+ repos[i] = status.RepoInfo{
+ URL: r.URL,
+ Branch: r.Branch,
+ Path: filepath.Join(wsDir, state.RepoPath(r)),
+ }
+ }
+ return repos
+}
+
+// stateFromInfo creates a minimal State from workspace.Info for display helpers.
+func stateFromInfo(info workspace.Info) *state.State {
+ return &state.State{
+ Metadata: state.Metadata{
+ Name: info.Name,
+ },
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 359d8ef..c18cd94 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -5,16 +5,19 @@ import (
"os"
"path/filepath"
"strings"
+
+ "github.com/milldr/flow/internal/status"
)
// Config holds resolved paths for Flow's directory structure.
type Config struct {
- Home string // Root directory (~/.flow or $FLOW_HOME)
- WorkspacesDir string // ~/.flow/workspaces/
- ReposDir string // ~/.flow/repos/
- AgentsDir string // ~/.flow/agents/
- ConfigFile string // ~/.flow/config.yaml
- FlowConfig *FlowConfig // loaded global config
+ Home string // Root directory (~/.flow or $FLOW_HOME)
+ WorkspacesDir string // ~/.flow/workspaces/
+ ReposDir string // ~/.flow/repos/
+ AgentsDir string // ~/.flow/agents/
+ ConfigFile string // ~/.flow/config.yaml
+ StatusSpecFile string // ~/.flow/status.yaml
+ FlowConfig *FlowConfig // loaded global config
}
// New creates a Config with resolved paths.
@@ -30,11 +33,12 @@ func New() (*Config, error) {
}
return &Config{
- Home: home,
- WorkspacesDir: filepath.Join(home, "workspaces"),
- ReposDir: filepath.Join(home, "repos"),
- AgentsDir: filepath.Join(home, "agents"),
- ConfigFile: filepath.Join(home, "config.yaml"),
+ Home: home,
+ WorkspacesDir: filepath.Join(home, "workspaces"),
+ ReposDir: filepath.Join(home, "repos"),
+ AgentsDir: filepath.Join(home, "agents"),
+ ConfigFile: filepath.Join(home, "config.yaml"),
+ StatusSpecFile: filepath.Join(home, "status.yaml"),
}, nil
}
@@ -60,6 +64,11 @@ func (c *Config) ClaudeAgentDir() string {
return filepath.Join(c.AgentsDir, "claude")
}
+// WorkspaceStatusSpecPath returns the status.yaml path for a workspace.
+func (c *Config) WorkspaceStatusSpecPath(id string) string {
+ return filepath.Join(c.WorkspacesDir, id, "status.yaml")
+}
+
// EnsureDirs creates the top-level directories if they don't exist.
// It also creates the default config file if missing, and loads the config.
func (c *Config) EnsureDirs() error {
@@ -76,6 +85,13 @@ func (c *Config) EnsureDirs() error {
}
}
+ // Create default status spec if missing
+ if _, err := os.Stat(c.StatusSpecFile); os.IsNotExist(err) {
+ if err := status.Save(c.StatusSpecFile, status.DefaultSpec()); err != nil {
+ return err
+ }
+ }
+
fc, err := LoadFlowConfig(c.ConfigFile)
if err != nil {
return err
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index 14d346f..f278639 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -96,11 +96,12 @@ func TestClaudeAgentDir(t *testing.T) {
func TestEnsureDirs(t *testing.T) {
dir := t.TempDir()
cfg := &Config{
- Home: dir,
- WorkspacesDir: filepath.Join(dir, "workspaces"),
- ReposDir: filepath.Join(dir, "repos"),
- AgentsDir: filepath.Join(dir, "agents"),
- ConfigFile: filepath.Join(dir, "config.yaml"),
+ Home: dir,
+ WorkspacesDir: filepath.Join(dir, "workspaces"),
+ ReposDir: filepath.Join(dir, "repos"),
+ AgentsDir: filepath.Join(dir, "agents"),
+ ConfigFile: filepath.Join(dir, "config.yaml"),
+ StatusSpecFile: filepath.Join(dir, "status.yaml"),
}
if err := cfg.EnsureDirs(); err != nil {
@@ -121,6 +122,11 @@ func TestEnsureDirs(t *testing.T) {
t.Errorf("config file not created: %v", err)
}
+ // Status spec file should be created
+ if _, err := os.Stat(cfg.StatusSpecFile); err != nil {
+ t.Errorf("status spec file not created: %v", err)
+ }
+
// FlowConfig should be loaded
if cfg.FlowConfig == nil {
t.Fatal("FlowConfig not loaded")
@@ -138,11 +144,12 @@ func TestEnsureDirs(t *testing.T) {
func TestEnsureDirsLoadsExistingConfig(t *testing.T) {
dir := t.TempDir()
cfg := &Config{
- Home: dir,
- WorkspacesDir: filepath.Join(dir, "workspaces"),
- ReposDir: filepath.Join(dir, "repos"),
- AgentsDir: filepath.Join(dir, "agents"),
- ConfigFile: filepath.Join(dir, "config.yaml"),
+ Home: dir,
+ WorkspacesDir: filepath.Join(dir, "workspaces"),
+ ReposDir: filepath.Join(dir, "repos"),
+ AgentsDir: filepath.Join(dir, "agents"),
+ ConfigFile: filepath.Join(dir, "config.yaml"),
+ StatusSpecFile: filepath.Join(dir, "status.yaml"),
}
// Pre-create a config file
diff --git a/internal/config/flowconfig.go b/internal/config/flowconfig.go
index 470d08a..c4e4fb6 100644
--- a/internal/config/flowconfig.go
+++ b/internal/config/flowconfig.go
@@ -7,10 +7,33 @@ import (
"gopkg.in/yaml.v3"
)
+// Agent represents a configured agent tool (editor, AI assistant, etc.).
+type Agent struct {
+ Name string `yaml:"name"`
+ Exec string `yaml:"exec"`
+ Default bool `yaml:"default,omitempty"`
+}
+
+// FlowConfigSpec holds optional configuration nested under spec.
+type FlowConfigSpec struct {
+ Agents []Agent `yaml:"agents,omitempty"`
+}
+
// FlowConfig represents the global flow configuration file.
type FlowConfig struct {
- APIVersion string `yaml:"apiVersion"`
- Kind string `yaml:"kind"`
+ APIVersion string `yaml:"apiVersion"`
+ Kind string `yaml:"kind"`
+ Spec FlowConfigSpec `yaml:"spec,omitempty"`
+}
+
+// DefaultAgent returns the agent marked as default, or nil if none is configured.
+func (fc *FlowConfig) DefaultAgent() *Agent {
+ for i := range fc.Spec.Agents {
+ if fc.Spec.Agents[i].Default {
+ return &fc.Spec.Agents[i]
+ }
+ }
+ return nil
}
// DefaultFlowConfig returns a FlowConfig with default values.
@@ -18,6 +41,11 @@ func DefaultFlowConfig() *FlowConfig {
return &FlowConfig{
APIVersion: "flow/v1",
Kind: "Config",
+ Spec: FlowConfigSpec{
+ Agents: []Agent{
+ {Name: "claude", Exec: "claude", Default: true},
+ },
+ },
}
}
diff --git a/internal/config/flowconfig_test.go b/internal/config/flowconfig_test.go
index 54df492..bb156ff 100644
--- a/internal/config/flowconfig_test.go
+++ b/internal/config/flowconfig_test.go
@@ -14,6 +14,19 @@ func TestDefaultFlowConfig(t *testing.T) {
if fc.Kind != "Config" {
t.Errorf("Kind = %q, want Config", fc.Kind)
}
+ if len(fc.Spec.Agents) != 1 {
+ t.Fatalf("Spec.Agents length = %d, want 1", len(fc.Spec.Agents))
+ }
+ agent := fc.DefaultAgent()
+ if agent == nil {
+ t.Fatal("DefaultAgent() should not be nil for default config")
+ }
+ if agent.Name != "claude" {
+ t.Errorf("DefaultAgent().Name = %q, want %q", agent.Name, "claude")
+ }
+ if agent.Exec != "claude" {
+ t.Errorf("DefaultAgent().Exec = %q, want %q", agent.Exec, "claude")
+ }
}
func TestFlowConfigRoundTrip(t *testing.T) {
@@ -38,6 +51,46 @@ func TestFlowConfigRoundTrip(t *testing.T) {
}
}
+func TestFlowConfigAgentsRoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.yaml")
+
+ fc := DefaultFlowConfig()
+ fc.Spec.Agents = []Agent{
+ {Name: "claude", Exec: "claude", Default: true},
+ {Name: "cursor", Exec: "cursor ."},
+ }
+ if err := SaveFlowConfig(path, fc); err != nil {
+ t.Fatalf("SaveFlowConfig: %v", err)
+ }
+
+ loaded, err := LoadFlowConfig(path)
+ if err != nil {
+ t.Fatalf("LoadFlowConfig: %v", err)
+ }
+
+ if len(loaded.Spec.Agents) != 2 {
+ t.Fatalf("Spec.Agents length = %d, want 2", len(loaded.Spec.Agents))
+ }
+ agent := loaded.DefaultAgent()
+ if agent == nil {
+ t.Fatal("DefaultAgent() returned nil")
+ }
+ if agent.Exec != "claude" {
+ t.Errorf("DefaultAgent().Exec = %q, want %q", agent.Exec, "claude")
+ }
+}
+
+func TestDefaultAgentNoDefault(t *testing.T) {
+ fc := DefaultFlowConfig()
+ fc.Spec.Agents = []Agent{
+ {Name: "claude", Exec: "claude"},
+ }
+ if fc.DefaultAgent() != nil {
+ t.Error("DefaultAgent() should be nil when no agent is marked default")
+ }
+}
+
func TestLoadFlowConfigNotFound(t *testing.T) {
_, err := LoadFlowConfig("/nonexistent/config.yaml")
if err == nil {
diff --git a/internal/status/resolve.go b/internal/status/resolve.go
new file mode 100644
index 0000000..b768a68
--- /dev/null
+++ b/internal/status/resolve.go
@@ -0,0 +1,143 @@
+package status
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "time"
+)
+
+const checkTimeout = 10 * time.Second
+
+// CheckRunner executes a status check command and returns whether it matched.
+type CheckRunner interface {
+ RunCheck(ctx context.Context, command string, env []string) bool
+}
+
+// ShellRunner executes check commands via sh -c.
+type ShellRunner struct{}
+
+// RunCheck executes the command in a shell. Returns true if exit code is 0.
+func (r *ShellRunner) RunCheck(ctx context.Context, command string, env []string) bool {
+ ctx, cancel := context.WithTimeout(ctx, checkTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "sh", "-c", command)
+ cmd.Env = append(os.Environ(), env...)
+ return cmd.Run() == nil
+}
+
+// Resolver resolves workspace statuses using a CheckRunner.
+type Resolver struct {
+ Runner CheckRunner
+}
+
+// repoSlug converts a repo URL to owner/repo format for gh CLI.
+// Handles SSH (git@github.com:org/repo.git) and HTTPS (github.com/org/repo) URLs.
+func repoSlug(url string) string {
+ s := url
+ // SSH: git@github.com:org/repo.git -> org/repo.git
+ if i := strings.Index(s, ":"); strings.Contains(s, "@") && i > 0 {
+ s = s[i+1:]
+ } else {
+ // HTTPS: github.com/org/repo -> org/repo
+ parts := strings.SplitN(s, "/", 2)
+ if len(parts) == 2 {
+ s = parts[1]
+ }
+ }
+ return strings.TrimSuffix(s, ".git")
+}
+
+// buildEnv creates the environment variables for a status check.
+func buildEnv(repo RepoInfo, wsID, wsName string) []string {
+ return []string{
+ "FLOW_REPO_URL=" + repo.URL,
+ "FLOW_REPO_BRANCH=" + repo.Branch,
+ "FLOW_REPO_PATH=" + repo.Path,
+ "FLOW_REPO_SLUG=" + repoSlug(repo.URL),
+ "FLOW_WORKSPACE_ID=" + wsID,
+ "FLOW_WORKSPACE_NAME=" + wsName,
+ }
+}
+
+// ResolveRepo determines the status of a single repo by evaluating checks
+// in order. Returns the name of the first matching status or the default.
+func (r *Resolver) ResolveRepo(ctx context.Context, spec *Spec, repo RepoInfo, wsID, wsName string) string {
+ env := buildEnv(repo, wsID, wsName)
+
+ for _, entry := range spec.Spec.Statuses {
+ if entry.Default {
+ return entry.Name
+ }
+ if r.Runner.RunCheck(ctx, entry.Check, env) {
+ return entry.Name
+ }
+ }
+
+ return ""
+}
+
+// ResolveWorkspace resolves the status for each repo concurrently and
+// returns the aggregated workspace result. The workspace status is the
+// least-advanced status (highest index in spec order) across all repos.
+func (r *Resolver) ResolveWorkspace(ctx context.Context, spec *Spec, repos []RepoInfo, wsID, wsName string) *WorkspaceResult {
+ result := &WorkspaceResult{
+ WorkspaceID: wsID,
+ WorkspaceName: wsName,
+ Repos: make([]RepoResult, len(repos)),
+ }
+
+ if len(repos) == 0 {
+ // No repos — use the default status.
+ for _, e := range spec.Spec.Statuses {
+ if e.Default {
+ result.Status = e.Name
+ break
+ }
+ }
+ return result
+ }
+
+ var mu sync.Mutex
+ var wg sync.WaitGroup
+
+ for i, repo := range repos {
+ wg.Add(1)
+ go func(idx int, rp RepoInfo) {
+ defer wg.Done()
+ status := r.ResolveRepo(ctx, spec, rp, wsID, wsName)
+ mu.Lock()
+ result.Repos[idx] = RepoResult{
+ URL: rp.URL,
+ Branch: rp.Branch,
+ Status: status,
+ }
+ mu.Unlock()
+ }(i, repo)
+ }
+
+ wg.Wait()
+
+ // Build index map for ordering.
+ orderIndex := make(map[string]int, len(spec.Spec.Statuses))
+ for i, e := range spec.Spec.Statuses {
+ orderIndex[e.Name] = i
+ }
+
+ // Workspace status = least advanced (highest index) across repos.
+ worstIdx := -1
+ for _, rr := range result.Repos {
+ idx, ok := orderIndex[rr.Status]
+ if ok && idx > worstIdx {
+ worstIdx = idx
+ }
+ }
+ if worstIdx >= 0 {
+ result.Status = spec.Spec.Statuses[worstIdx].Name
+ }
+
+ return result
+}
diff --git a/internal/status/resolve_test.go b/internal/status/resolve_test.go
new file mode 100644
index 0000000..b8cbf33
--- /dev/null
+++ b/internal/status/resolve_test.go
@@ -0,0 +1,216 @@
+package status
+
+import (
+ "context"
+ "testing"
+)
+
+// mockRunner implements CheckRunner for testing.
+type mockRunner struct {
+ // results maps command strings to their return value.
+ results map[string]bool
+}
+
+func (m *mockRunner) RunCheck(_ context.Context, command string, _ []string) bool {
+ if result, ok := m.results[command]; ok {
+ return result
+ }
+ return false
+}
+
+func testSpec() *Spec {
+ return &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{
+ Statuses: []Entry{
+ {Name: "closed", Check: "check-closed"},
+ {Name: "in-review", Check: "check-review"},
+ {Name: "in-progress", Check: "check-progress"},
+ {Name: "open", Default: true},
+ },
+ },
+ }
+}
+
+func TestResolveRepoFirstMatch(t *testing.T) {
+ mock := &mockRunner{results: map[string]bool{
+ "check-closed": true,
+ "check-review": true,
+ }}
+ resolver := &Resolver{Runner: mock}
+
+ repo := RepoInfo{URL: "github.com/org/repo", Branch: "feat/x", Path: "./repo"}
+ status := resolver.ResolveRepo(context.Background(), testSpec(), repo, "ws-1", "my-ws")
+
+ if status != "closed" {
+ t.Errorf("expected closed (first match), got %q", status)
+ }
+}
+
+func TestResolveRepoSecondMatch(t *testing.T) {
+ mock := &mockRunner{results: map[string]bool{
+ "check-closed": false,
+ "check-review": true,
+ }}
+ resolver := &Resolver{Runner: mock}
+
+ repo := RepoInfo{URL: "github.com/org/repo", Branch: "feat/x", Path: "./repo"}
+ status := resolver.ResolveRepo(context.Background(), testSpec(), repo, "ws-1", "my-ws")
+
+ if status != "in-review" {
+ t.Errorf("expected in-review, got %q", status)
+ }
+}
+
+func TestResolveRepoThirdMatch(t *testing.T) {
+ mock := &mockRunner{results: map[string]bool{
+ "check-closed": false,
+ "check-review": false,
+ "check-progress": true,
+ }}
+ resolver := &Resolver{Runner: mock}
+
+ repo := RepoInfo{URL: "github.com/org/repo", Branch: "feat/x", Path: "./repo"}
+ status := resolver.ResolveRepo(context.Background(), testSpec(), repo, "ws-1", "my-ws")
+
+ if status != "in-progress" {
+ t.Errorf("expected in-progress, got %q", status)
+ }
+}
+
+func TestResolveRepoDefault(t *testing.T) {
+ mock := &mockRunner{results: map[string]bool{
+ "check-closed": false,
+ "check-review": false,
+ "check-progress": false,
+ }}
+ resolver := &Resolver{Runner: mock}
+
+ repo := RepoInfo{URL: "github.com/org/repo", Branch: "feat/x", Path: "./repo"}
+ status := resolver.ResolveRepo(context.Background(), testSpec(), repo, "ws-1", "my-ws")
+
+ if status != "open" {
+ t.Errorf("expected open (default), got %q", status)
+ }
+}
+
+func TestResolveWorkspaceSingleRepo(t *testing.T) {
+ mock := &mockRunner{results: map[string]bool{
+ "check-closed": false,
+ "check-review": true,
+ "check-progress": false,
+ }}
+ resolver := &Resolver{Runner: mock}
+
+ repos := []RepoInfo{
+ {URL: "github.com/org/repo", Branch: "feat/x", Path: "./repo"},
+ }
+ result := resolver.ResolveWorkspace(context.Background(), testSpec(), repos, "ws-1", "my-ws")
+
+ if result.Status != "in-review" {
+ t.Errorf("workspace status = %q, want in-review", result.Status)
+ }
+ if len(result.Repos) != 1 {
+ t.Fatalf("repo count = %d, want 1", len(result.Repos))
+ }
+ if result.Repos[0].Status != "in-review" {
+ t.Errorf("repo status = %q, want in-review", result.Repos[0].Status)
+ }
+}
+
+func TestResolveWorkspaceMultiRepoLeastAdvanced(t *testing.T) {
+ // Two repos with different statuses — repo-a is closed, repo-b is in-review.
+ // Workspace status should be the least advanced (in-review).
+ perRepoMock := &perRepoRunner{
+ results: map[string]map[string]bool{
+ "github.com/org/repo-a": {"check-closed": true},
+ "github.com/org/repo-b": {"check-closed": false, "check-review": true, "check-progress": false},
+ },
+ }
+ resolver := &Resolver{Runner: perRepoMock}
+
+ repos := []RepoInfo{
+ {URL: "github.com/org/repo-a", Branch: "feat/x", Path: "./repo-a"},
+ {URL: "github.com/org/repo-b", Branch: "feat/x", Path: "./repo-b"},
+ }
+ result := resolver.ResolveWorkspace(context.Background(), testSpec(), repos, "ws-1", "my-ws")
+
+ if result.Status != "in-review" {
+ t.Errorf("workspace status = %q, want in-review (least advanced)", result.Status)
+ }
+ if result.Repos[0].Status != "closed" {
+ t.Errorf("repo-a status = %q, want closed", result.Repos[0].Status)
+ }
+ if result.Repos[1].Status != "in-review" {
+ t.Errorf("repo-b status = %q, want in-review", result.Repos[1].Status)
+ }
+}
+
+func TestResolveWorkspaceNoRepos(t *testing.T) {
+ mock := &mockRunner{results: map[string]bool{}}
+ resolver := &Resolver{Runner: mock}
+
+ result := resolver.ResolveWorkspace(context.Background(), testSpec(), nil, "ws-1", "my-ws")
+
+ if result.Status != "open" {
+ t.Errorf("workspace status = %q, want open (default)", result.Status)
+ }
+}
+
+func TestResolveWorkspaceAllClosed(t *testing.T) {
+ mock := &mockRunner{results: map[string]bool{
+ "check-closed": true,
+ }}
+ resolver := &Resolver{Runner: mock}
+
+ repos := []RepoInfo{
+ {URL: "github.com/org/repo-a", Branch: "feat/x", Path: "./repo-a"},
+ {URL: "github.com/org/repo-b", Branch: "feat/x", Path: "./repo-b"},
+ }
+ result := resolver.ResolveWorkspace(context.Background(), testSpec(), repos, "ws-1", "my-ws")
+
+ if result.Status != "closed" {
+ t.Errorf("workspace status = %q, want closed", result.Status)
+ }
+}
+
+func TestRepoSlug(t *testing.T) {
+ tests := []struct {
+ url string
+ want string
+ }{
+ {"git@github.com:org/repo.git", "org/repo"},
+ {"git@github.com:org/repo", "org/repo"},
+ {"github.com/org/repo", "org/repo"},
+ {"github.com/org/repo.git", "org/repo"},
+ {"git@gitlab.com:deep/nested/repo.git", "deep/nested/repo"},
+ }
+ for _, tt := range tests {
+ got := repoSlug(tt.url)
+ if got != tt.want {
+ t.Errorf("repoSlug(%q) = %q, want %q", tt.url, got, tt.want)
+ }
+ }
+}
+
+// perRepoRunner distinguishes check results by repo URL (extracted from env).
+type perRepoRunner struct {
+ results map[string]map[string]bool // repoURL -> command -> result
+}
+
+func (m *perRepoRunner) RunCheck(_ context.Context, command string, env []string) bool {
+ var repoURL string
+ for _, e := range env {
+ if len(e) > 14 && e[:14] == "FLOW_REPO_URL=" {
+ repoURL = e[14:]
+ break
+ }
+ }
+ if repoResults, ok := m.results[repoURL]; ok {
+ if result, ok := repoResults[command]; ok {
+ return result
+ }
+ }
+ return false
+}
diff --git a/internal/status/spec.go b/internal/status/spec.go
new file mode 100644
index 0000000..e78e2c6
--- /dev/null
+++ b/internal/status/spec.go
@@ -0,0 +1,134 @@
+package status
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v3"
+)
+
+// Validation errors for status spec files.
+var (
+ ErrInvalidAPIVersion = errors.New("apiVersion must be flow/v1")
+ ErrInvalidKind = errors.New("kind must be Status")
+ ErrNoStatuses = errors.New("statuses must not be empty")
+ ErrNoDefault = errors.New("exactly one status must have default: true")
+ ErrMultipleDefaults = errors.New("only one status may have default: true")
+ ErrDuplicateName = errors.New("status names must be unique")
+ ErrMissingName = errors.New("status name is required")
+ ErrMissingCheck = errors.New("non-default status must have a check command")
+ ErrSpecNotFound = errors.New("no status spec found")
+)
+
+// Load reads and parses a status spec file from disk.
+func Load(path string) (*Spec, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var s Spec
+ if err := yaml.Unmarshal(data, &s); err != nil {
+ return nil, fmt.Errorf("parsing status spec: %w", err)
+ }
+
+ return &s, nil
+}
+
+// Save writes a Spec to disk as YAML.
+func Save(path string, s *Spec) error {
+ data, err := yaml.Marshal(s)
+ if err != nil {
+ return fmt.Errorf("marshaling status spec: %w", err)
+ }
+
+ return os.WriteFile(path, data, 0o644)
+}
+
+// Validate checks that a Spec has all required fields and is well-formed.
+func Validate(s *Spec) error {
+ if s.APIVersion != "flow/v1" {
+ return ErrInvalidAPIVersion
+ }
+ if s.Kind != "Status" {
+ return ErrInvalidKind
+ }
+ if len(s.Spec.Statuses) == 0 {
+ return ErrNoStatuses
+ }
+
+ seen := make(map[string]bool)
+ defaults := 0
+
+ for i, e := range s.Spec.Statuses {
+ if e.Name == "" {
+ return fmt.Errorf("statuses[%d]: %w", i, ErrMissingName)
+ }
+ if seen[e.Name] {
+ return fmt.Errorf("statuses[%d] %q: %w", i, e.Name, ErrDuplicateName)
+ }
+ seen[e.Name] = true
+
+ if e.Default {
+ defaults++
+ } else if e.Check == "" {
+ return fmt.Errorf("statuses[%d] %q: %w", i, e.Name, ErrMissingCheck)
+ }
+ }
+
+ if defaults == 0 {
+ return ErrNoDefault
+ }
+ if defaults > 1 {
+ return ErrMultipleDefaults
+ }
+
+ return nil
+}
+
+// LoadWithFallback loads a workspace-level spec if it exists, otherwise
+// falls back to the global spec. Returns ErrSpecNotFound if neither exists.
+func LoadWithFallback(workspacePath, globalPath string) (*Spec, error) {
+ if _, err := os.Stat(workspacePath); err == nil {
+ return Load(workspacePath)
+ }
+
+ if _, err := os.Stat(globalPath); err == nil {
+ return Load(globalPath)
+ }
+
+ return nil, ErrSpecNotFound
+}
+
+// DefaultSpec returns a starter status spec with a basic PR workflow.
+func DefaultSpec() *Spec {
+ return &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{
+ Statuses: []Entry{
+ {
+ Name: "closed",
+ Description: "All PRs merged or closed",
+ Check: `gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state merged --json number | jq -e 'length > 0' > /dev/null 2>&1 && gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state open --json number | jq -e 'length == 0' > /dev/null 2>&1`,
+ },
+ {
+ Name: "in-review",
+ Description: "Non-draft PR open",
+ Check: `gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state open --json isDraft | jq -e 'map(select(.isDraft == false)) | length > 0' > /dev/null 2>&1`,
+ },
+ {
+ Name: "in-progress",
+ Description: "Local diffs or draft PR",
+ Check: `git -C "$FLOW_REPO_PATH" status --porcelain 2>/dev/null | grep -q . || { _r=$(git ls-remote "$(git -C "$FLOW_REPO_PATH" remote get-url origin 2>/dev/null)" "$FLOW_REPO_BRANCH" 2>/dev/null | cut -f1) && [ -n "$_r" ] && [ "$(git -C "$FLOW_REPO_PATH" rev-parse HEAD 2>/dev/null)" != "$_r" ] && git -C "$FLOW_REPO_PATH" cat-file -e "$_r" 2>/dev/null && if git -C "$FLOW_REPO_PATH" merge-base --is-ancestor HEAD "$_r" 2>/dev/null; then false; fi; } || gh pr list --repo "$FLOW_REPO_SLUG" --head "$FLOW_REPO_BRANCH" --state open --json isDraft | jq -e 'map(select(.isDraft)) | length > 0' > /dev/null 2>&1`,
+ },
+ {
+ Name: "open",
+ Description: "Workspace created, no changes yet",
+ Default: true,
+ },
+ },
+ },
+ }
+}
diff --git a/internal/status/spec_test.go b/internal/status/spec_test.go
new file mode 100644
index 0000000..4fe8f88
--- /dev/null
+++ b/internal/status/spec_test.go
@@ -0,0 +1,218 @@
+package status
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadAndSave(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "status.yaml")
+
+ spec := DefaultSpec()
+ if err := Save(path, spec); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ loaded, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ if loaded.APIVersion != "flow/v1" {
+ t.Errorf("APIVersion = %q, want flow/v1", loaded.APIVersion)
+ }
+ if loaded.Kind != "Status" {
+ t.Errorf("Kind = %q, want Status", loaded.Kind)
+ }
+ if len(loaded.Spec.Statuses) != 4 {
+ t.Errorf("Statuses count = %d, want 4", len(loaded.Spec.Statuses))
+ }
+}
+
+func TestLoadNotFound(t *testing.T) {
+ _, err := Load("/nonexistent/status.yaml")
+ if err == nil {
+ t.Fatal("expected error for nonexistent file")
+ }
+}
+
+func TestValidateValid(t *testing.T) {
+ spec := DefaultSpec()
+ if err := Validate(spec); err != nil {
+ t.Fatalf("Validate: %v", err)
+ }
+}
+
+func TestValidateInvalidAPIVersion(t *testing.T) {
+ spec := DefaultSpec()
+ spec.APIVersion = "wrong"
+ err := Validate(spec)
+ if !errors.Is(err, ErrInvalidAPIVersion) {
+ t.Errorf("expected ErrInvalidAPIVersion, got %v", err)
+ }
+}
+
+func TestValidateInvalidKind(t *testing.T) {
+ spec := DefaultSpec()
+ spec.Kind = "Wrong"
+ err := Validate(spec)
+ if !errors.Is(err, ErrInvalidKind) {
+ t.Errorf("expected ErrInvalidKind, got %v", err)
+ }
+}
+
+func TestValidateNoStatuses(t *testing.T) {
+ spec := &Spec{APIVersion: "flow/v1", Kind: "Status"}
+ err := Validate(spec)
+ if !errors.Is(err, ErrNoStatuses) {
+ t.Errorf("expected ErrNoStatuses, got %v", err)
+ }
+}
+
+func TestValidateNoDefault(t *testing.T) {
+ spec := &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{Statuses: []Entry{
+ {Name: "open", Check: "true"},
+ }},
+ }
+ err := Validate(spec)
+ if !errors.Is(err, ErrNoDefault) {
+ t.Errorf("expected ErrNoDefault, got %v", err)
+ }
+}
+
+func TestValidateMultipleDefaults(t *testing.T) {
+ spec := &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{Statuses: []Entry{
+ {Name: "a", Default: true},
+ {Name: "b", Default: true},
+ }},
+ }
+ err := Validate(spec)
+ if !errors.Is(err, ErrMultipleDefaults) {
+ t.Errorf("expected ErrMultipleDefaults, got %v", err)
+ }
+}
+
+func TestValidateDuplicateName(t *testing.T) {
+ spec := &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{Statuses: []Entry{
+ {Name: "open", Check: "true"},
+ {Name: "open", Default: true},
+ }},
+ }
+ err := Validate(spec)
+ if !errors.Is(err, ErrDuplicateName) {
+ t.Errorf("expected ErrDuplicateName, got %v", err)
+ }
+}
+
+func TestValidateMissingName(t *testing.T) {
+ spec := &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{Statuses: []Entry{
+ {Check: "true"},
+ }},
+ }
+ err := Validate(spec)
+ if !errors.Is(err, ErrMissingName) {
+ t.Errorf("expected ErrMissingName, got %v", err)
+ }
+}
+
+func TestValidateMissingCheck(t *testing.T) {
+ spec := &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{Statuses: []Entry{
+ {Name: "open"},
+ {Name: "default", Default: true},
+ }},
+ }
+ err := Validate(spec)
+ if !errors.Is(err, ErrMissingCheck) {
+ t.Errorf("expected ErrMissingCheck, got %v", err)
+ }
+}
+
+func TestLoadWithFallbackWorkspaceLevel(t *testing.T) {
+ dir := t.TempDir()
+ wsPath := filepath.Join(dir, "ws-status.yaml")
+ globalPath := filepath.Join(dir, "global-status.yaml")
+
+ wsSpec := &Spec{
+ APIVersion: "flow/v1",
+ Kind: "Status",
+ Spec: SpecBody{Statuses: []Entry{
+ {Name: "ws-only", Default: true},
+ }},
+ }
+ if err := Save(wsPath, wsSpec); err != nil {
+ t.Fatal(err)
+ }
+ if err := Save(globalPath, DefaultSpec()); err != nil {
+ t.Fatal(err)
+ }
+
+ loaded, err := LoadWithFallback(wsPath, globalPath)
+ if err != nil {
+ t.Fatalf("LoadWithFallback: %v", err)
+ }
+ if loaded.Spec.Statuses[0].Name != "ws-only" {
+ t.Errorf("expected workspace-level spec, got %q", loaded.Spec.Statuses[0].Name)
+ }
+}
+
+func TestLoadWithFallbackGlobal(t *testing.T) {
+ dir := t.TempDir()
+ wsPath := filepath.Join(dir, "ws-status.yaml") // does not exist
+ globalPath := filepath.Join(dir, "global-status.yaml")
+
+ if err := Save(globalPath, DefaultSpec()); err != nil {
+ t.Fatal(err)
+ }
+
+ loaded, err := LoadWithFallback(wsPath, globalPath)
+ if err != nil {
+ t.Fatalf("LoadWithFallback: %v", err)
+ }
+ if loaded.Spec.Statuses[0].Name != "closed" {
+ t.Errorf("expected global spec, got %q", loaded.Spec.Statuses[0].Name)
+ }
+}
+
+func TestLoadWithFallbackNotFound(t *testing.T) {
+ _, err := LoadWithFallback("/nonexistent/ws.yaml", "/nonexistent/global.yaml")
+ if !errors.Is(err, ErrSpecNotFound) {
+ t.Errorf("expected ErrSpecNotFound, got %v", err)
+ }
+}
+
+func TestDefaultSpec(t *testing.T) {
+ spec := DefaultSpec()
+ if err := Validate(spec); err != nil {
+ t.Fatalf("DefaultSpec should be valid: %v", err)
+ }
+}
+
+func TestSaveCreatesFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "new-status.yaml")
+
+ if err := Save(path, DefaultSpec()); err != nil {
+ t.Fatalf("Save: %v", err)
+ }
+
+ if _, err := os.Stat(path); err != nil {
+ t.Errorf("file not created: %v", err)
+ }
+}
diff --git a/internal/status/types.go b/internal/status/types.go
new file mode 100644
index 0000000..9a4f876
--- /dev/null
+++ b/internal/status/types.go
@@ -0,0 +1,44 @@
+// Package status handles status spec loading, validation, and resolution.
+package status
+
+// SpecBody holds the statuses list nested under spec.
+type SpecBody struct {
+ Statuses []Entry `yaml:"statuses,omitempty"`
+}
+
+// Spec represents a status spec file.
+type Spec struct {
+ APIVersion string `yaml:"apiVersion"`
+ Kind string `yaml:"kind"`
+ Spec SpecBody `yaml:"spec,omitempty"`
+}
+
+// Entry defines a single status in the spec.
+type Entry struct {
+ Name string `yaml:"name"`
+ Description string `yaml:"description,omitempty"`
+ Check string `yaml:"check,omitempty"`
+ Default bool `yaml:"default,omitempty"`
+}
+
+// RepoInfo provides context for running status checks against a repo.
+type RepoInfo struct {
+ URL string
+ Branch string
+ Path string
+}
+
+// RepoResult holds the resolved status for a single repo.
+type RepoResult struct {
+ URL string
+ Branch string
+ Status string
+}
+
+// WorkspaceResult holds the resolved status for a workspace.
+type WorkspaceResult struct {
+ WorkspaceID string
+ WorkspaceName string
+ Status string
+ Repos []RepoResult
+}
diff --git a/internal/ui/prompt.go b/internal/ui/prompt.go
index 18ad4a3..246273b 100644
--- a/internal/ui/prompt.go
+++ b/internal/ui/prompt.go
@@ -35,12 +35,54 @@ func SelectWorkspace(matches []WorkspaceOption) (string, error) {
return selected, err
}
+// AgentOption represents an agent choice for interactive selection.
+type AgentOption struct {
+ Name string
+ Exec string
+}
+
+// SelectAgent prompts the user to choose among multiple configured agents.
+// Returns the selected agent's exec command.
+func SelectAgent(agents []AgentOption) (string, error) {
+ options := make([]huh.Option[string], len(agents))
+ for i, a := range agents {
+ options[i] = huh.NewOption(a.Name, a.Exec)
+ }
+
+ var selected string
+ err := huh.NewForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Select an agent:").
+ Options(options...).
+ Value(&selected),
+ ),
+ ).Run()
+ return selected, err
+}
+
// DeleteRepo holds repo display info for the delete confirmation prompt.
type DeleteRepo struct {
Path string
Branch string
}
+// ConfirmReset prompts the user to confirm resetting a file to its default value.
+func ConfirmReset(filePath string) (bool, error) {
+ Warning("This will hard reset " + filePath + " to its default value")
+ Print("")
+
+ var confirm bool
+ err := huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title("Confirm?").
+ Value(&confirm),
+ ),
+ ).Run()
+ return confirm, err
+}
+
// ConfirmDelete prompts the user to confirm workspace deletion.
func ConfirmDelete(name, id string, repos []DeleteRepo) (bool, error) {
title := fmt.Sprintf("Delete workspace '%s'", name)
diff --git a/internal/workspace/workspace_test.go b/internal/workspace/workspace_test.go
index 5d6e5f0..fb9b3db 100644
--- a/internal/workspace/workspace_test.go
+++ b/internal/workspace/workspace_test.go
@@ -72,11 +72,12 @@ func testService(t *testing.T) (*Service, *mockRunner) {
t.Helper()
dir := t.TempDir()
cfg := &config.Config{
- Home: dir,
- WorkspacesDir: filepath.Join(dir, "workspaces"),
- ReposDir: filepath.Join(dir, "repos"),
- AgentsDir: filepath.Join(dir, "agents"),
- ConfigFile: filepath.Join(dir, "config.yaml"),
+ Home: dir,
+ WorkspacesDir: filepath.Join(dir, "workspaces"),
+ ReposDir: filepath.Join(dir, "repos"),
+ AgentsDir: filepath.Join(dir, "agents"),
+ ConfigFile: filepath.Join(dir, "config.yaml"),
+ StatusSpecFile: filepath.Join(dir, "status.yaml"),
}
if err := cfg.EnsureDirs(); err != nil {
t.Fatal(err)