Explicit Repo Resolution
Why
specsync never resolves the target repository itself. When -repo is not supplied it omits --repo from the gh invocations entirely (github.go:26, :42, :99) and lets gh infer the target from the working directory.
For a fork, gh does not infer origin. It resolves to the upstream parent unless a default has been set with gh repo set-default. So in any fork — the normal shape for a customised tool — specsync silently targets someone else's repository.
Observed 2026-07-25 in a fork whose origin is the user's own repo and whose upstream is the parent project. Running specsync without -repo produced:
gh label create specsync --force: exit status 1
HTTP 404: Not Found (https://api.github.com/repos/<upstream-owner>/<upstream-name>/labels)
It failed safe only because the user lacked write access to the parent. The failure mode is entirely permission-dependent:
- No write access to the parent → 404, confusing but harmless.
- Write access to the parent (a maintainer, an org member, a fork inside the same org) → specsync creates labels and files issues on the parent repository, publishing internal planning content to a project the user does not own. On a public parent, that is an irreversible disclosure — issues are indexed quickly and deleting them does not reliably un-publish.
The user's origin was correct in every case. Nothing about the local configuration was ambiguous; specsync simply never looked at it.
This is a correctness and blast-radius defect, not an ergonomics one: the tool writes to a repository the user never named, chosen by an implicit rule most users do not know applies.
What Changes
- specsync resolves the target repository explicitly and always passes
--repo. No gh invocation may rely on implicit working-directory inference.
- Resolution order, first match wins:
-repo owner/name (explicit flag — unchanged).
- The repository configured via
gh repo set-default, when set, since that is the user's stated intent for this checkout.
- The
origin remote.
- Fork divergence is reported, never silently resolved. When
origin and upstream both exist and disagree, specsync states which it chose and how to override, so the choice is visible in the output rather than inferred.
- A guard against writing to a repository the user does not own. Before any write, specsync confirms the resolved target is the intended one; when the resolved target is a fork parent rather than
origin, it refuses without an explicit -repo.
- Dry-run reports the resolved target.
-dry-run currently prints target: auto-detected from the current repo's git remote, which is precisely the ambiguity that caused this. It prints the concrete owner/name instead.
Capabilities
New Capabilities
explicit-repo-resolution: deterministic, stated resolution of the target repository, with fork-parent writes refused by default.
Modified Capabilities
github-provider: every gh invocation carries an explicit --repo.
Non-Goals
- No change to board/project targeting (
-project), which is already explicit.
- No change to the resolution order's first entry —
-repo remains authoritative.
- specsync does not attempt to configure
gh repo set-default on the user's behalf.
Impact
github.go — repo is resolved eagerly rather than left empty; all gh command construction gains --repo.
- Dry-run output — prints the resolved
owner/name and which rule produced it.
- Behaviour change for existing fork users: runs that previously targeted the parent now target
origin, or refuse. This is the fix, but it is a visible change and belongs in release notes.
- Any workflow that deliberately relied on implicit parent targeting must now pass
-repo explicitly.
Release note
Repo resolution is now explicit. specsync resolves the target repository deterministically and always passes --repo to every gh invocation. Resolution order: -repo flag → gh repo set-default → origin. Fork-parent writes are refused by default; use -repo to override. Board resolution also gained a repository-local declaration: openspec/specsync.yml with board: owner/number. The -project flag and this config file are the only ways to set a board; the SPECSYNC_PROJECT env var is no longer used.
Tasks
Tasks: Explicit Repo Resolution
1. Resolver
2. Thread it through the provider
3. Reporting
3b. Board resolution is per-repository
4. Regression coverage
5. Release
6. Verification
Plan changes
20 done
Explicit Repo Resolution
Why
specsync never resolves the target repository itself. When
-repois not supplied it omits--repofrom theghinvocations entirely (github.go:26,:42,:99) and letsghinfer the target from the working directory.For a fork,
ghdoes not inferorigin. It resolves to the upstream parent unless a default has been set withgh repo set-default. So in any fork — the normal shape for a customised tool — specsync silently targets someone else's repository.Observed 2026-07-25 in a fork whose
originis the user's own repo and whoseupstreamis the parent project. Running specsync without-repoproduced:It failed safe only because the user lacked write access to the parent. The failure mode is entirely permission-dependent:
The user's
originwas correct in every case. Nothing about the local configuration was ambiguous; specsync simply never looked at it.This is a correctness and blast-radius defect, not an ergonomics one: the tool writes to a repository the user never named, chosen by an implicit rule most users do not know applies.
What Changes
--repo. Noghinvocation may rely on implicit working-directory inference.-repo owner/name(explicit flag — unchanged).gh repo set-default, when set, since that is the user's stated intent for this checkout.originremote.originandupstreamboth exist and disagree, specsync states which it chose and how to override, so the choice is visible in the output rather than inferred.origin, it refuses without an explicit-repo.-dry-runcurrently printstarget: auto-detected from the current repo's git remote, which is precisely the ambiguity that caused this. It prints the concreteowner/nameinstead.Capabilities
New Capabilities
explicit-repo-resolution: deterministic, stated resolution of the target repository, with fork-parent writes refused by default.Modified Capabilities
github-provider: everyghinvocation carries an explicit--repo.Non-Goals
-project), which is already explicit.-reporemains authoritative.gh repo set-defaulton the user's behalf.Impact
github.go—repois resolved eagerly rather than left empty; allghcommand construction gains--repo.owner/nameand which rule produced it.origin, or refuse. This is the fix, but it is a visible change and belongs in release notes.-repoexplicitly.Release note
Repo resolution is now explicit. specsync resolves the target repository deterministically and always passes
--repoto everyghinvocation. Resolution order:-repoflag →gh repo set-default→origin. Fork-parent writes are refused by default; use-repoto override. Board resolution also gained a repository-local declaration:openspec/specsync.ymlwithboard: owner/number. The-projectflag and this config file are the only ways to set a board; theSPECSYNC_PROJECTenv var is no longer used.Tasks
Tasks: Explicit Repo Resolution
1. Resolver
resolveRepo()returning(owner/name, rule, error)implementing the order: explicit flag → CLI-configured default for this checkout →origin.rulenames which branch produced the result, for reporting. Verify:go test ./... -run ResolveRepo -count=1covers each branch.gh repo set-defaultrecords it in the repo's git config). Verify: with a default configured, the resolver returns it and reports the rule; with none, it falls through toorigin.originandupstreamboth present and naming different repositories. Verify: test with both remotes returns divergence true; with onlyorigin, false.2. Thread it through the provider
github.go: resolve eagerly at construction instead of leavingrepoempty (:26,:42,:99), and pass an explicit repository argument on every command — label create, issue list, issue create, issue edit, issue close. Verify:grepshows no command construction path that omits the repo argument.3. Reporting
target: auto-detected from the current repo's git remotewith the concreteowner/nameplus the rule that selected it. Verify: a dry run in a fork prints the origin repository by name, not the word "auto-detected".3b. Board resolution is per-repository
openspec/specsync.ymlwithboard: owner/number). Resolution order:-projectflag → repository-local declaration → no board. Verify:go test ./... -run ResolveBoard -count=1covers all three.SPECSYNC_PROJECTso it can no longer select a board on its own. A shell-wide value that spans every repository is the exact mechanism by which personal work reaches a work board. Either remove it or require it to agree with the repository-local declaration. Verify: with the variable set and no local declaration, a sync touches no board.4. Regression coverage
originis the user's repository and whoseupstreamis a different parent, a sync with no explicit repository targetsoriginand never the parent. Verify: the test fails against the current implementation and passes after task 2.5. Release
originor be refused. State the-repooverride for anyone who relied on the old behaviour. Verify: the note names the change and the override.-repoflag. Verify: the documented order matches the implementation.6. Verification
go build ./... && go vet ./...clean;go test ./... -count=1with only pre-existing baseline failures. Record the baseline diff in task notes.origin, confirm a real sync creates the issue onorigin, and confirm that explicitly naming the parent is still possible. Record the commands and output in task notes.Plan changes
20 done