Proposal: specsync Go library (pkg/specsync)
Why
specsync is currently a standalone CLI binary. Orchestrators like skein need
the same operations (sync, pull, branch naming, worktree creation) as
first-class function calls — not subprocess output to parse. Extracting an
importable pkg/specsync package makes specsync the shared engine for both
CLI and embedded use, with structured return values and no shell boundary.
What
pkg/specsync package with exported API: Sync(), Pull(), Scan(),
BranchName(), and CreateWorktree()
Sync() returns SyncResult{IssueNumber int, URL string, Created bool} —
callers get the GitHub Issue number directly, no file I/O needed
BranchName(issueNumber int, slug string) string encodes the canonical
convention: feat/<N>-<slug> (consistent across standalone and embedded use)
CreateWorktree(repoRoot, branch, path string) error wraps
git worktree add -b <branch> <path> for CLI use; embedded callers like
skein call their own worktree manager using the returned branch name
cmd/specsync/main.go becomes a thin wrapper: flag parsing → pkg/specsync
calls → output formatting. Zero logic in main.
pluggable-providers change builds on top of this: the WorkProvider
interface lives in pkg/specsync/provider.go and is part of the library API
Scope
In scope
- Reorganise all current root-package
.go files into pkg/specsync/
- Export the five functions listed above with stable signatures
cmd/specsync/main.go delegates entirely to the package; behaviour unchanged
BranchName and CreateWorktree as new functionality (zero today)
go.work setup instructions in README for local co-development with skein
Not in scope
- Switching from
gh CLI to direct GitHub REST API calls (tracked separately;
pluggable-providers owns the provider interface that enables this swap)
- Sub-issue / epic support
- Any skein-internal changes (tracked in skein's
specsync-library-integration)
Related
- skein:
openspec/changes/specsync-library-integration — consumer side; imports
pkg/specsync and wires issue number into ChangeStateStore and branch naming.
Must land after or in parallel with this change (use go.work to unblock).
GitHub: https://github.com/androidand/skein/issues/20
Risks
- Import path break: all existing callers of
specsync as a binary are
unaffected; only the Go module path changes for library consumers. No public
library consumers exist today.
- Circular test dependencies: root-package tests must move to
pkg/specsync/ or cmd/specsync/; test coverage must not regress.
- Flat → nested migration: the root package currently has ~20
.go files.
Moving them requires updating all package specsync declarations and internal
imports. No logic changes — purely structural.
pluggable-providers sequencing: if that change lands first in the root
package, the migration gets slightly larger. Preferred order: this change
first, then pluggable-providers builds on pkg/specsync.
Tasks
Tasks: specsync Go library (pkg/specsync)
Slice 1: Create pkg/specsync package skeleton
Slice 2: Export Sync() with structured return value
Slice 3: BranchName() and CreateWorktree()
Slice 4: CLI worktree subcommand
Slice 5: go.work and README
Proposal: specsync Go library (pkg/specsync)
Why
specsync is currently a standalone CLI binary. Orchestrators like skein need
the same operations (sync, pull, branch naming, worktree creation) as
first-class function calls — not subprocess output to parse. Extracting an
importable
pkg/specsyncpackage makes specsync the shared engine for bothCLI and embedded use, with structured return values and no shell boundary.
What
pkg/specsyncpackage with exported API:Sync(),Pull(),Scan(),BranchName(), andCreateWorktree()Sync()returnsSyncResult{IssueNumber int, URL string, Created bool}—callers get the GitHub Issue number directly, no file I/O needed
BranchName(issueNumber int, slug string) stringencodes the canonicalconvention:
feat/<N>-<slug>(consistent across standalone and embedded use)CreateWorktree(repoRoot, branch, path string) errorwrapsgit worktree add -b <branch> <path>for CLI use; embedded callers likeskein call their own worktree manager using the returned branch name
cmd/specsync/main.gobecomes a thin wrapper: flag parsing →pkg/specsynccalls → output formatting. Zero logic in main.
pluggable-providerschange builds on top of this: theWorkProviderinterface lives in
pkg/specsync/provider.goand is part of the library APIScope
In scope
.gofiles intopkg/specsync/cmd/specsync/main.godelegates entirely to the package; behaviour unchangedBranchNameandCreateWorktreeas new functionality (zero today)go.worksetup instructions in README for local co-development with skeinNot in scope
ghCLI to direct GitHub REST API calls (tracked separately;pluggable-providersowns the provider interface that enables this swap)specsync-library-integration)Related
openspec/changes/specsync-library-integration— consumer side; importspkg/specsyncand wires issue number intoChangeStateStoreand branch naming.Must land after or in parallel with this change (use
go.workto unblock).GitHub: https://github.com/androidand/skein/issues/20
Risks
specsyncas a binary areunaffected; only the Go module path changes for library consumers. No public
library consumers exist today.
pkg/specsync/orcmd/specsync/; test coverage must not regress..gofiles.Moving them requires updating all
package specsyncdeclarations and internalimports. No logic changes — purely structural.
pluggable-providerssequencing: if that change lands first in the rootpackage, the migration gets slightly larger. Preferred order: this change
first, then pluggable-providers builds on
pkg/specsync.Tasks
Tasks: specsync Go library (pkg/specsync)
Slice 1: Create pkg/specsync package skeleton
pkg/specsync/directory and move all root.gofiles into itpkg/specsync/*.go(migrate from root)go build ./pkg/specsync/compiles;go build ./cmd/specsync/compilescmd/specsync/main.goto importpkg/specsyncinstead of rootcmd/specsync/main.go./specsync --helpoutput unchanged; all existing CLI flags work*_test.gofiles topkg/specsync/pkg/specsync/*_test.gogo test ./pkg/specsync/passes with same coverage as beforeSlice 2: Export Sync() with structured return value
SyncResulttype and updateSync()signature to return(*SyncResult, error)pkg/specsync/sync.gogo test ./pkg/specsync/ -run TestSyncpasses;SyncResult.IssueNumber > 0for a real repoSyncOptionsstruct covering all current flags (Slug,DryRun,Reconcile,Repo,OpenspecDir)pkg/specsync/sync.gogo vet ./pkg/specsync/clean; all fields documentedSlice 3: BranchName() and CreateWorktree()
BranchName(issueNumber int, slug string) string→feat/<N>-<slug>pkg/specsync/worktree.gogo test ./pkg/specsync/ -run TestBranchNamecovers zero-issue fallback (feat/0-slugorfeat/<slug>)CreateWorktree(repoRoot, branch, path string) errorwrappinggit worktree add -b <branch> <path>pkg/specsync/worktree.gogo test ./pkg/specsync/ -run TestCreateWorktreecreates and removes a real worktree in a temp repoSlice 4: CLI worktree subcommand
specsync worktree -slug <slug>subcommand: reads.specsync/for issue number, callsBranchName+CreateWorktreecmd/specsync/main.gospecsync worktree -slug test-slug -dry-runprints branch name and worktree path without creating anythingSlice 5: go.work and README
go.workexample to README showing local co-development setup with skeinREADME.mdgo work init,go work use ./specsync ../skein)