Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/specsync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads).

## Basic Workflow

0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started.
1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes/<slug>/`
2. **Implement**: Write code, update `tasks.md`
3. **Sync**: `specsync -dry-run -change <slug>` then `specsync -change <slug>`
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ All subcommands, at a glance:
specsync [sync] # project changes -> issues (default command)
specsync pull # pull an issue into a local change
specsync scan # what already exists in an area?
specsync topology # join change <-> issue <-> branch <-> worktree across stores
specsync trace # print the raw spec<->commit<->issue link graph
specsync link # cross-link two or more changes
specsync spinoff # spawn emergent work as a linked sibling
Expand All @@ -115,7 +116,7 @@ specsync version # print the binary version
```

**Dry-run flags** — `sync`, `pull`, `link`, and `spinoff` support `-dry-run`. Beads can be
previewed through `specsync -dry-run -provider beads`. `scan`, `trace`,
previewed through `specsync -dry-run -provider beads`. `scan`, `topology`, `trace`,
`release-plan`, and `changelog` (unless `-apply`) are read-only and do not take
a dry-run flag.

Expand Down
1 change: 1 addition & 0 deletions cmd/specsync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads).

## Basic Workflow

0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started.
1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes/<slug>/`
2. **Implement**: Write code, update `tasks.md`
3. **Sync**: `specsync -dry-run -change <slug>` then `specsync -change <slug>`
Expand Down
43 changes: 43 additions & 0 deletions cmd/specsync/agent_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,49 @@ var commandMetadata = map[string]AgentCommandHelp{
"specsync changes -json",
},
},
"topology": {
Command: "topology",
Description: "Join every change with its tracker issue, git branch, and worktree across coordinated stores — read-only, for an agent to check before starting work.",
Mutates: false,
Workflow: AgentCommandWorkflow{
Position: "planning",
RelatedBefore: []string{"changes"},
RelatedAfter: []string{"pull", "sync"},
},
Flags: []AgentCommandFlag{
{
Name: "change",
Type: "string",
Required: false,
Description: "Scope to one change (default: every change)",
},
{
Name: "all",
Type: "boolean",
Required: false,
Default: false,
Description: "Include archived changes",
},
{
Name: "json",
Type: "boolean",
Required: false,
Default: false,
Description: "Emit machine-readable JSON output",
},
},
SafetyRules: []string{
"Read-only operation — mutates nothing, not even the local ref cache",
"Every source (openspec, gh, git worktrees) is optional; a missing one narrows a row instead of failing it",
"Exits non-zero only when no row could be produced at all",
},
Examples: []string{
"specsync topology",
"specsync topology -json",
"specsync topology -change my-change",
"specsync topology -all",
},
},
"set-stage": {
Command: "set-stage",
Description: "Set the workflow stage of a change.",
Expand Down
6 changes: 4 additions & 2 deletions cmd/specsync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ var knownSubcommands = map[string]bool{
"sync": true, "audit": true, "audit-tasks": true, "validate": true,
"spinoff": true, "pr-body": true, "verify": true,
"agent-help": true, "doctor": true, "idea": true, "ideas": true, "archive": true,
"epic": true, "adopt": true,
"epic": true, "adopt": true, "topology": true,
}

// knownConfusions maps a word someone might reach for by habit (e.g. git's
Expand Down Expand Up @@ -94,7 +94,7 @@ func deprecatedSlugFlag(args []string) error {
func main() {
cmd, rest, err := resolveSubcommand(os.Args[1:])
if err != nil {
fmt.Fprintf(os.Stderr, "specsync: %v\n\nRun with no subcommand (optionally with flags) to sync, or use one of: pull, adopt, link, scan, trace, release-plan, changelog, install-skill, changes, set-stage, set-priority, note, audit, audit-tasks, validate, spinoff, pr-body, verify, idea, ideas, archive, epic\n", err)
fmt.Fprintf(os.Stderr, "specsync: %v\n\nRun with no subcommand (optionally with flags) to sync, or use one of: pull, adopt, link, scan, trace, release-plan, changelog, install-skill, changes, set-stage, set-priority, note, audit, audit-tasks, validate, spinoff, pr-body, verify, idea, ideas, archive, epic, topology\n", err)
os.Exit(2)
}

Expand Down Expand Up @@ -151,6 +151,8 @@ func main() {
runArchive(rest)
case "epic":
runEpic(rest)
case "topology":
runTopology(rest)
default:
runSync(rest)
}
Expand Down
99 changes: 99 additions & 0 deletions cmd/specsync/topology.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package main

import (
"context"
"flag"
"fmt"
"os"

specsync "github.com/androidand/specsync"
)

// runTopology prints the change → repo → issue → branch → worktree join
// (see openspec/changes/agent-topology-command). Read-only; every source is
// optional, so a missing openspec/gh/git-worktree source narrows a row
// instead of dropping it. Exits non-zero only when nothing could be resolved
// at all — a command an agent checks before starting work must not fail
// closed just because one source degraded.
func runTopology(args []string) {
fs := flag.NewFlagSet("topology", flag.ExitOnError)
openspec := fs.String("openspec", "openspec", "path to the openspec/ directory")
change := fs.String("change", "", "scope to one change (default: every change)")
all := fs.Bool("all", false, "include archived changes")
asJSON := fs.Bool("json", false, "output as JSON")
if err := fs.Parse(args); err != nil {
fail(err)
}

ctx := context.Background()

topo, err := specsync.BuildTopology(ctx, specsync.TopologyOptions{
OpenSpecDir: *openspec,
Slug: *change,
All: *all,
})
if err != nil {
fail(err)
}

if *asJSON {
emitJSON(topo)
} else {
printTopologyTable(*topo)
}

if len(topo.Changes) == 0 {
os.Exit(1)
}
}

func printTopologyTable(topo specsync.Topology) {
if len(topo.Changes) == 0 {
fmt.Println("No changes resolved.")
} else {
fmt.Println("SLUG STAGE REPO ISSUE BRANCH WORKTREE")
fmt.Println("────────────────────────────── ────────── ───────────────────────── ────────── ──────────────────────────────────────── ────────────────────────────────────────")
for _, c := range topo.Changes {
issue := "-"
if c.Issue != nil {
issue = c.Issue.ID
if c.Issue.State != "" {
issue += " (" + c.Issue.State + ")"
}
}
branch := c.Branch
if branch == "" {
branch = "-"
}
worktree := c.Worktree
if worktree == "" {
worktree = "-"
}
repo := c.Repo
if repo == "" {
repo = "-"
}
fmt.Printf("%-32s %-11s %-27s %-11s %-41s %s\n", c.Slug, c.Stage, repo, issue, branch, worktree)
if c.Epic != "" {
fmt.Printf(" ↳ epic: %s\n", c.Epic)
}
if len(c.Linked) > 0 {
fmt.Printf(" ↳ linked: %s\n", joinCommaOr(c.Linked, "-"))
}
}
}
for _, s := range topo.Status {
fmt.Println("status:", s)
}
}

func joinCommaOr(vals []string, empty string) string {
if len(vals) == 0 {
return empty
}
out := vals[0]
for _, v := range vals[1:] {
out += ", " + v
}
return out
}
78 changes: 78 additions & 0 deletions cmd/specsync/topology_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"strings"
"testing"

specsync "github.com/androidand/specsync"
)

func TestPrintTopologyTable_ResolvedRow(t *testing.T) {
topo := specsync.Topology{
Changes: []specsync.ChangeTopology{
{
Slug: "add-field-forms",
Title: "Add field forms",
Stage: "active",
Repo: "acme/portal",
Issue: &specsync.TopologyIssue{
Provider: "github:acme/portal",
ID: "4231",
URL: "https://github.com/acme/portal/issues/4231",
State: "open",
},
Branch: "feat/4231-add-field-forms",
Worktree: "/repo/worktrees/add-field-forms",
Epic: "https://github.com/acme/planning/issues/88",
Linked: []string{"add-field-forms-api"},
},
},
}

out := captureStdout(t, func() { printTopologyTable(topo) })

for _, want := range []string{
"add-field-forms", "active", "acme/portal", "4231 (open)",
"feat/4231-add-field-forms", "/repo/worktrees/add-field-forms",
"epic: https://github.com/acme/planning/issues/88",
"linked: add-field-forms-api",
} {
if !strings.Contains(out, want) {
t.Errorf("expected output to contain %q, got:\n%s", want, out)
}
}
}

func TestPrintTopologyTable_MissingFieldsRenderAsDash(t *testing.T) {
topo := specsync.Topology{
Changes: []specsync.ChangeTopology{
{Slug: "no-worktree-yet", Stage: "active"},
},
}

out := captureStdout(t, func() { printTopologyTable(topo) })

if !strings.Contains(out, "no-worktree-yet") {
t.Fatalf("expected the slug in output, got:\n%s", out)
}
if strings.Contains(out, "epic:") || strings.Contains(out, "linked:") {
t.Errorf("expected no epic/linked lines for a row with neither, got:\n%s", out)
}
}

func TestPrintTopologyTable_EmptyPrintsMessageNotBlank(t *testing.T) {
out := captureStdout(t, func() { printTopologyTable(specsync.Topology{}) })
if !strings.Contains(out, "No changes resolved.") {
t.Errorf("expected an explicit empty-result message, got:\n%s", out)
}
}

func TestPrintTopologyTable_StatusLinesAlwaysPrinted(t *testing.T) {
topo := specsync.Topology{
Status: []string{"gh unavailable: issue state omitted"},
}
out := captureStdout(t, func() { printTopologyTable(topo) })
if !strings.Contains(out, "status: gh unavailable: issue state omitted") {
t.Errorf("expected the status line surfaced, got:\n%s", out)
}
}
1 change: 1 addition & 0 deletions npm/skills/specsync/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ specsync handles **tracker sync** (OpenSpec ↔ GitHub/Beads).

## Basic Workflow

0. **Check before you start**: `specsync topology -json` shows which changes already have an issue, branch, and worktree — so you don't duplicate work another agent has started.
1. **Create or pull**: `specsync pull -issue N` or create a change in `openspec/changes/<slug>/`
2. **Implement**: Write code, update `tasks.md`
3. **Sync**: `specsync -dry-run -change <slug>` then `specsync -change <slug>`
Expand Down
Loading
Loading