flow-next-deps · diff
git:20261031.8a6e050 to git:20261106.d27e372
42 added, 22 removed. Audit A to A.
---
name: flow-next-deps
description: "Show spec dependency graph and execution order. Use when asking 'what's blocking what', 'execution order', 'dependency graph', 'what order should specs run', 'critical path', 'which specs can run in parallel'."
---
# Flow-Next Dependency Graph
Visualize spec dependencies, blocking chains, and execution phases.
## Preamble
flowctl is bundled with the plugin (not on PATH). Define once; subsequent blocks use `$FLOWCTL`:
```bash
FLOWCTL="${CODEX_HOME:-$HOME/.codex}/scripts/flowctl"
[ -x "$FLOWCTL" ] || FLOWCTL="<plugin-root>/scripts/flowctl" # <plugin-root> = the directory two levels above this skill's SKILL.md file (the harness gave you that file's absolute path when the skill loaded); substitute it literally
[ -x "$FLOWCTL" ] || FLOWCTL=".flow/bin/flowctl"
```
## Setup
```bash
$FLOWCTL detect --json | jq -e '.exists' >/dev/null && echo "OK: .flow/ exists" || echo "ERROR: run $FLOWCTL init"
command -v jq >/dev/null 2>&1 && echo "OK: jq installed" || echo "ERROR: brew install jq"
```
## Step 1: Gather Spec Data
Build a consolidated view of all specs with their dependencies — a single per-spec loop for the whole skill, over open specs only (a done spec needs only its status, which the specs list carries). Steps 2 and 3 reuse the cached file (bash vars do not survive across prompt turns, so the cache is a file at a literal agent-composed path — compose `<suffix>` once, e.g. 4 random chars, and reuse the same literal path in every later block):
```bash
# ONE gather — Steps 2 and 3 read this file; never re-run the per-spec loop
SPECS_FILE="${TMPDIR:-/tmp}/flow-deps-specs-<suffix>.json"
SPECS_LIST="$($FLOWCTL specs --json)"
{
printf '%s' "$SPECS_LIST" | jq -c '.specs[] | select(.status == "done") | {id, title, status}'
printf '%s' "$SPECS_LIST" | jq -r '.specs[] | select(.status != "done") | .id' | while read -r id; do
$FLOWCTL show "$id" --json | jq -c '{
id: .id,
title: .title,
status: .status,
plan_review: .plan_review_status,
deps: (.depends_on_epics // [])
}'
done
} | jq -s '.' > "$SPECS_FILE"
cat "$SPECS_FILE"
```
### Done when
- `$SPECS_FILE` exists at the composed literal path and parses as a JSON array with one object per spec returned by `flowctl specs --json`.
- The per-spec `flowctl show` loop has run exactly once for this invocation, over the open specs. **Steps 2 and 3 read that file.** A second run of the gather loop has broken this.
## Step 2: Identify Blocking Chains
Determine which specs are ready vs blocked (pure jq, works on any shell):
```bash
# Reuse the Step 1 gather — same literal path, NO re-fetch (one heavy loop total)
SPECS_FILE="${TMPDIR:-/tmp}/flow-deps-specs-<suffix>.json"
# Compute blocking status
jq -r '
# Build status lookup
(map({(.id): .status}) | add // {}) as $status |
# Check each non-done spec
.[] | select(.status != "done") |
.id as $id | .title as $title |
# Find deps that are not done
([.deps[] | select($status[.] != "done")] | join(", ")) as $blocked_by |
if ($blocked_by | length) == 0 then
"READY: \($id) - \($title)"
else
"BLOCKED: \($id) - \($title) (by: \($blocked_by))"
end
' "$SPECS_FILE"
```
### Done when
- Every non-`done` spec in `$SPECS_FILE` emitted exactly one `READY:` or `BLOCKED:` line, and each `BLOCKED:` line names the specific deps holding it.
- The jq ran against the cached file, not a fresh `flowctl show` sweep.
## Step 3: Compute Execution Phases
Group specs into parallel execution phases:
```bash
# Reuse the Step 1 gather — same literal path, NO re-fetch (one heavy loop total)
SPECS_FILE="${TMPDIR:-/tmp}/flow-deps-specs-<suffix>.json"
+ # fence:deps-phases
# Phase assignment algorithm (run in jq for reliability)
jq '
# Build status lookup
(map({(.id): .status}) | add // {}) as $status |
# Filter to non-done specs
[.[] | select(.status != "done")] as $open |
- # Assign phases iteratively
- reduce range(10) as $phase (
- {assigned: [], result: [], open: $open};
-
+ # Assign phases until a round places nothing, so a chain of any depth is phased
+ {assigned: [], result: [], open: $open, stalled: false} |
+ until(.stalled;
.assigned as $assigned |
- .open as $remaining |
# Find specs not yet assigned whose deps are all done or in earlier phases
([.open[] | select(
([.id] | inside($assigned) | not) and
((.deps // []) | all(. as $d | $status[$d] == "done" or ($assigned | index($d))))
)] | map(.id)) as $ready |
if ($ready | length) > 0 then
- .result += [{phase: ($phase + 1), specs: [.open[] | select(.id | IN($ready[]))]}] |
+ .result += [{phase: ((.result | length) + 1), specs: [.open[] | select(.id | IN($ready[]))]}] |
.assigned += $ready
- else . end
+ else .stalled = true end
) |
- # Emit the phases AND the residue: any open spec never assigned is UNRESOLVABLE — a
- # dependency cycle (A→B→A), a dep on a missing/closed spec, or a chain deeper than 10.
+ # Emit the phases AND the residue: any open spec never assigned can never start.
# Dropping it silently is the one way /deps gives a WRONG answer (the graph it exists to
- # expose hides the deadlock). Surface it, with the offending deps for diagnosis.
+ # expose hides the deadlock). Surface it with its unresolved deps and its cause: on a
+ # dependency cycle (with the path), a dep that is not among the specs, or blocked only
+ # by another deadlocked spec.
.assigned as $asg |
+ ([ $open[] | select(.id as $i | ($asg | index($i)) | not)
+ | {key: .id, value: [ (.deps // [])[] | select(. as $d | ($asg | index($d)) or ($status[$d] == "done") | not) ]} ]
+ | from_entries) as $unres |
+ # Breadth-first walk over unresolved deps back to $id; the path, or null
+ def cycle_of($id):
+ {queue: [$id], parent: {}, last: null} |
+ until((.queue | length) == 0 or .last != null;
+ .queue[0] as $n | .queue |= .[1:] |
+ reduce $unres[$n][] as $d (.;
+ if .last != null then .
+ elif $d == $id then .last = $n
+ elif .parent[$d] != null or $unres[$d] == null then .
+ else .parent[$d] = $n | .queue += [$d] end)) |
+ .parent as $p |
+ if .last == null then null
+ else [ .last | recurse(if . == $id then empty else $p[.] end) ] | reverse + [$id] end;
{ phases: .result,
- deadlocked: [ $open[] | select(.id as $i | ($asg | index($i)) | not)
- | { id, status,
- unresolved_deps: [ (.deps // [])[] | select(. as $d | ($asg | index($d)) or ($status[$d] == "done") | not) ] } ] }
+ deadlocked: [ $open[] | select($unres[.id] != null) | .id as $i | cycle_of($i) as $cycle
+ | { id, status, unresolved_deps: $unres[$i],
+ cause: (if $cycle != null then "cycle"
+ elif any($unres[$i][]; $status[.] == null) then "missing"
+ else "blocked" end) }
+ + (if $cycle != null then {cycle: $cycle} else {} end) ] }
' "$SPECS_FILE"
```
### Done when
- - The jq result carries both `.phases` and `.deadlocked`, and every open spec appears in exactly one of them.
+ - The jq result carries both `.phases` and `.deadlocked`, every open spec appears in exactly one of them, and every `.deadlocked` entry carries a `cause`.
- **The report is rendered from `.phases` and `.deadlocked`.** Phases narrated from a reading of the spec titles have broken this.
## Output Format
Present results as:
```markdown
## Spec Dependency Graph
### Status Overview
| Spec | Title | Status | Dependencies | Blocked By |
|------|-------|--------|--------------|------------|
| **fn-1-add-auth** | Add Authentication | **READY** | - | - |
| fn-2-add-oauth | Add OAuth Login | blocked | fn-1-add-auth | fn-1-add-auth |
| fn-3-user-profile | User Profile Page | blocked | fn-1-add-auth, fn-2-add-oauth | fn-2-add-oauth |
### Execution Phases
Render from the jq result's `.phases`:
| Phase | Specs | Can Start |
|-------|-------|-----------|
| **1** | fn-1-add-auth | **NOW** |
| 2 | fn-2-add-oauth | After Phase 1 |
| 3 | fn-3-user-profile | After Phase 2 |
### ⚠️ Deadlocked / Unresolvable
**This section renders exactly when `.deadlocked` is non-empty** — and when it does, it is the
most important part of the report. Each entry is an open spec that could not be placed in
- any phase: a dependency **cycle**, a dep on a **missing/closed** spec, or a chain deeper
- than 10. These are invisible to `ready`/`flow --auto` (they just never become ready) — this is the
+ any phase. These are invisible to `ready`/`flow --auto` (they just never become ready) — this is the
one place the graph surfaces them.
- | Spec | Status | Unresolved deps | Likely cause |
- |------|--------|-----------------|--------------|
- | fn-7-x | blocked | fn-9-y | fn-9-y not found / closed, or a cycle fn-7↔fn-9 |
+ | Spec | Status | Unresolved deps | Cause |
+ |------|--------|-----------------|-------|
+ | fn-7-x | open | fn-9-y | cycle: fn-7-x → fn-9-y → fn-7-x |
+ | fn-8-z | open | fn-99-gone | missing dependency |
+ | fn-10-w | open | fn-7-x | blocked by a deadlocked spec |
- For each, state the likely cause: if two deadlocked specs list each other → **cycle** (fix
- with `flowctl spec rm-dep`); if an unresolved dep isn't among the open specs → **missing or
- closed dependency**. **Every entry of `.deadlocked` reaches the report.** A report that lists phases while silently dropping an open spec has broken this.
+ The Cause column renders each entry's `cause` (and `cycle` path); it is never guessed. A cycle is
+ broken with `flowctl spec rm-dep` on one of its edges; a missing dependency was deleted or never
+ existed. **Every entry of `.deadlocked` reaches the report.** A report that lists phases while silently dropping an open spec has broken this.
### Critical Path
fn-1-add-auth → fn-2-add-oauth → fn-3-user-profile (3 phases)
```
**This skill is read-only inspection.** Edge changes are reported as commands for the
operator to run — `flowctl spec add-dep <spec> <dep>` / `rm-dep <spec> <dep>`. A run that
executes either command itself, or edits a spec file, has broken this.
## Quick One-Liner
For a fast dependency check:
```bash
$FLOWCTL specs --json | jq -r '.specs[] | select(.status != "done") | "\(.id): \(.title) [\(.status)]"'
```
## When to Use
- "What's the execution order for specs?"
- "What's blocking progress?"
- "Show me the dependency graph"
- "What's the critical path?"
- "Which specs can run in parallel?"
- "What should I work on next?"