Skip to content

Providers (Multi-Platform) ​

Spexso decouples its lifecycle commands from any single platform through a two-slot adapter model. Work items and code may live on different platforms — or the same one.

The Two-Slot Model ​

SlotCoversAdapters
trackerIssues · boards · wiki · dependency linksgitlab · github (experimental) · azure (experimental) · (calm — upcoming)
codeSource branches · pull/merge requests · CI templatesgitlab · github (experimental) · azure (experimental)

Each slot is configured independently in .spexso/config.json. Mixing is allowed and intentional — for example, SAP CALM as the tracker with GitLab hosting the code. SAP CALM is tracker-only and can never occupy the code slot.

PROSE      src/skills/*/SKILL.md, src/agents/*.md (rendered to dist/)
           → call only `spexso <verb>`; never name glab/gh/az
   │
FAÇADE     bin/spexso.js → lib/facade.js
           nouns: issue / pr (alias: mr) / comment / link / label / board / wiki
                  / auth / api / upload / whoami
           (stage and commit-ref also call adapter methods, but route through
            bin/spexso.js directly — via lib/labels.js / lib/platform.js — not lib/facade.js)
           lib/platform.js : getTracker() / getCode() select the adapter from config
   │
ADAPTERS   lib/providers/tracker/{gitlab,...}.js   (shell out to glab / gh / az, or REST)
           lib/providers/code/{gitlab,...}.js
           Every adapter implements the same interface and returns normalized JSON.
   │
STATE      .spexso/index.json · worktrees/ · stop · config.json   (unchanged)

Adapters are the only place with platform knowledge. The façade and all prose read normalized shapes regardless of what is underneath.

Injection hardening (T10). Issue/PR ids are validated as numeric at the façade boundary (needNumericId), so a crafted "id" is rejected before any adapter CLI invocation on every platform. The GitLab adapters — the field-used primary path — invoke glab exclusively through the shell-free argv seam (cli.run/cli.runJson, execFileSync): user content (titles, bodies, search strings) travels as single argv elements and is never re-parsed by a shell, which also fixes Windows cmd.exe newline truncation for all glab calls (issue #18). The GitHub/Azure adapters (mock-tested, no field use) keep the escaped-string seam behind the façade id guard; their full argv migration lands with the G2/G3 field-hardening items — az is additionally blocked from execFileSync because it ships as a .cmd wrapper (Node's CVE-2024-27980 hardening requires a shell for those).

The façade mirrors glab's command surface 1:1. Nouns, sub-verbs, and long-flag names follow glab (spexso issue list|view|create|update|close|reopen|note|delete, spexso pr create|list|view|update|merge|comment|note|close|reopen|approve|revoke|rebase|diff|checkout — spexso mr is accepted as an alias — plus label list|create, milestone list|create|close, link list <id>, auth status, api <endpoint>, upload <file>, whoami --output json), so prose can swap glab X … for npx spexso X … mechanically. See the full verb tables for flags. All three adapters (GitLab, GitHub, Azure DevOps) implement the full interface; where a platform genuinely lacks the capability the method returns an explicit { ok: false, code: 'unsupported' } envelope (or throws with a pointer to the manual alternative for raw-output verbs). A verb missing from an adapter altogether would fail with <verb> is not supported by the "<id>" adapter yet — never a TypeError.

Tracker Interface ​

Every tracker adapter exports the following methods (lib/providers/tracker/<x>.js):

MethodReplaces
listIssues({stage, state, assignee, label, author, search, milestone})glab issue list — link-lazy: returns empty link buckets (a links lookup is one extra API round-trip per issue; the old eager fetch made every list an N+1 fan-out)
getIssue(id)glab issue view --json — carries populated link buckets (the single-issue view pays the one extra links call)
createIssue({title, body, type, priority, size, repo, ...})glab issue create
updateIssue(id, patch)glab issue update (title/description/labels/assignee/milestone — milestone takes a title, unmilestone: true clears it)
closeIssue(id) / reopenIssue(id) / deleteIssue(id)glab issue close / reopen / delete (not issue update --close/--reopen — those flags don't exist)
setStage(id, stage)label swap (or State field on Azure)
comment(id, body)glab issue note
getLinks(id, body?) / createLink(from, to, type)work-item dependency links. GitLab sends the required target_project_id and posts blocks from the blocker's side (workaround for the is_blocked_by 404 bug, gitlab#271168); on GitLab CE, where blocks is a paid tier, it falls back to a Blocked by #n description line on the dependent + a relates_to link. GitHub always uses description lines. getLinks merges native links with description lines (one parser: lib/providers/tracker/body-links.js, which also reads the brainstorm spec format - Blocked by: #n — title) and gives each link a state: open, merged (stage::ship/operate) or closed — only open blocks
ensureLabels(defs)glab label create (idempotent)
listLabels({repo}) / createLabel(def)glab label list / glab label create
listMilestones({state}) / createMilestone({title, due}) / closeMilestone(title)glab api projects/:id/milestones — milestones by exact title, rows `
syncBoards(defs)lib/boards.js
pushWiki(title, path)REST upsert: POST projects/:id/wikis, PUT projects/:id/wikis/:slug when the page exists (glab wiki create does not exist)
whoami() / getUser()glab api user (getUser returns {username, name})
authStatus()glab auth status — platform-agnostic auth preflight (used by spexso init)
api(endpoint, opts)glab api pass-through (escape hatch)
upload(file, {repo})multipart upload to projects/:id/uploads; returns a markdown link
getProjectPath()platform project reference (e.g. group/repo)
nativeCommitRef(id) / labeledCommitRef(id)commit-ref helper
detect(remoteUrl)auto-detection from git remote

Per-platform command mapping of the extended surface:

MethodGitLab (glab)GitHub (gh)Azure DevOps (az)
closeIssue / reopenIssueissue close / issue reopenissue close / issue reopenboards work-item update --state Closed/Active
deleteIssueissue deleteissue delete --yesboards work-item delete --yes
listLabelslabel listlabel list --json (color normalized to #RRGGBB)az devops invoke --area wit --resource tags (live-verification pending)
createLabellabel create (idempotent)label create (idempotent, # stripped)no-op — Azure tags are created on use
listMilestones / createMilestone / closeMilestoneapi projects/:id/milestones (GET, POST, PUT state_event=close)api repos/{owner}/{repo}/milestones (GET, POST, PATCH state=closed)unsupported ({ ok: false, code: 'unsupported' })
updateIssue milestone / unmilestoneapi PUT projects/:id/issues/:iid milestone_id=<id> (0 clears)0`issue edit --milestone <title> / --remove-milestone
authStatusauth statusauth statusaccount show
getUserapi userapi user (login → username)account show (signed-in account)
apiapi pass-throughapi pass-through (multipart --form unsupported — gh has none)unsupported — use az devops invoke directly
uploadapi projects/:id/uploads --formunsupported — GitHub has no attachment APIunsupported in this cut

Code Interface ​

Every code adapter exports (lib/providers/code/<x>.js):

MethodPurpose
createPR({title, body, source, target, labels, draft, assignee, reviewer, autoMerge, removeSourceBranch})open a merge/pull request
mergePR(id, {squash, deleteBranch, message, squashMessage, rebase, autoMerge, sha})merge/squash
commentPR(id, body)post a review comment
getPR(id)read PR details
listPRs(filters) / updatePR(id, patch)list PRs / update title, description, labels, reviewers
closePR(id) / reopenPR(id)close / reopen without merging
approvePR(id, {sha}) / revokePR(id)approve / revoke approval
rebasePR(id, {skipCi}) / diffPR(id, {raw}) / checkoutPR(id, {branch})rebase, show diff, check out locally
ciTemplate()return path to a CI YAML template

Per-platform command mapping of the extended PR surface:

MethodGitLab (glab)GitHub (gh)Azure DevOps (az)
listPRsmr listpr listrepos pr list --status active/completed/abandoned
updatePRmr updatepr edit (+ pr ready [--undo] for draft state)repos pr update (no label/target-branch flags)
closePR / reopenPRmr close / mr reopenpr close / pr reopenrepos pr update --status abandoned/active
approvePR / revokePRmr approve / mr revokepr review --approve / unsupported (dismiss in web UI)repos pr set-vote --vote approve/reset
rebasePRmr rebaseupdate-branch API (merges base in; GitHub has no rebase option there)unsupported
diffPRmr diffpr diff [--patch]unsupported (web UI)
checkoutPRmr checkoutpr checkoutrepos pr checkout

Azure az commands were mapped without a live org (az is not installed in the build env) — the adapters carry ⚠ LIVE-VERIFICATION REQUIRED comments on the ambiguous spots (relation direction, tags resource, unassign, pr update --status completed).

Normalized Shapes ​

Issue — returned by every tracker adapter read method:

{
  id, title, body, stage, type, priority, size, assignee,
  milestone, state, labels[], url,
  blockedBy[], blocks[], relatesTo[]
}

state is open | closed. labels[], blockedBy[], blocks[], and relatesTo[] are always arrays (empty if absent).

PR — returned by getPR and as embedded context in write results:

{ id, url, state, sourceBranch, targetBranch, mergeable }

state is open | closed | merged. mergeable is always a boolean.

Write results — write verbs return a small envelope, with fields depending on the verb:

createIssue / createPR  → { ok: true, id, url }
updateIssue / mergePR / commentPR / comment → { ok: true, id }
createLink              → { ok: true }
syncBoards              → { ok: true, createdBoards, createdLists, missingLabels }

Errors normalize to:

{ ok: false, code, message }

code is a short string (input, auth, network, …). The façade converts these to non-zero exit codes; the prose can branch on ok.

Capability Flags & Graceful Degradation ​

Each adapter declares what it supports natively:

js
// tracker adapters
capabilities: {
  dependencyLinks,  // true: platform has typed work-item links
  boards,           // true: platform has a kanban/board API
  wiki,             // true: platform has a push-able wiki
  scopedLabels,     // true: platform supports scoped labels (e.g. stage::build)
  autoMerge,        // true: CLI can auto-merge without human UI interaction
  ci,               // true: adapter provides a CI YAML template
  upload,           // true: platform has a file-attachment API
  whoamiCli,        // true: the CLI can resolve the current user cheaply (false → resolved once + cached)
}
// code adapters
capabilities: {
  ci, autoMerge,
  approveOwn,       // true: the MR/PR author may approve their own — false → degrade to a review comment
  closingKeywords,  // true: "closes #id" in the MR title/description closes the issue on merge
  pushCiSkip,       // true: finish pushes its docs commits with `git push -o ci.skip` (GitLab), so they start no pipeline
}

The prose layer reads them mechanically via npx spexso capabilities (JSON for the active tracker + code adapters) and degrades before optional steps — e.g. /spexso-finish skips auto-merge and parks the issue in stage::review when code.autoMerge is false. Capability sets are locked per adapter by a golden fixture (test/contract/golden/capabilities.json) — changing one is an explicit, reviewed act.

The façade checks flags and takes the best available path — in order:

  1. Run natively — platform supports it.
  2. Emulate — e.g. GitHub has no stable dependency-link API, so blockers are written into the issue body as Blocked by #N and the marathon picker reads that instead of the links API.
  3. Skip with a logged note — when neither native nor emulation is feasible. Never a silent failure; always surfaces a message.

Commit References ​

Every commit carries a ticket reference. The form depends on whether tracker and code are on the same platform:

  • Same platform → native, clickable form (#42 for GitLab/GitHub; AB#42 for Azure DevOps).
  • Different platforms → labeled form [<provider>: <id>] (e.g. [calm: 3-123456]).

lib/platform.js exports commitRef(id): if tracker.provider === code.provider and a native form exists, it returns the native form; otherwise it returns the labeled form. The spexso commit-ref <id> verb prints it, and the builder/finish prose appends it to every commit message.

Support tiers ​

PlatformTierTracker adapterCode adapter
GitLabFirst-classFully implemented, field-provenFully implemented, field-proven
GitHubSupportedImplemented, contract + golden-shape tested; live e2e pendingsame
Azure DevOpsBest-effortImplemented; az syntax and System.* paths reconstructed from docs (⚠ live-verification markers in the source)same
SAP CALMLater (tracker-only)—n/a

GitLab is the first-class, field-proven path. GitHub is supported: it passes the shared contract suite and golden-shape fixtures with the same literal stage::x vocabulary, and setStage always removes-then-adds (never relying on scoped-label auto-replace — that is GitLab-Premium-only anyway); the remaining gate is one real issue run end-to-end on a test repo. Azure DevOps is deliberately best-effort (Microsoft's GitHub-first trajectory; no formal ADO deprecation, but migration tooling ships in the CLI itself): stages live in System.Tags — never work-item states, which are process-template/org-admin territory — PR↔work-item linking is structural (no closing keywords), and whoami is resolved once and cached. Gaps are declared via capabilities(), and the orchestrator degrades; missing platform features are never emulated beyond the documented cases. SAP CALM (tracker-only, REST) is a later goal.

GitHub specifics ​

The GitHub adapters shell out to gh (run gh auth login first). GitHub differs from GitLab in four ways:

  • Plain labels (capabilities.scopedLabels = false) — labels like stage::build exist as ordinary label names; setStage swaps them manually since GitHub has no single-value enforcement.
  • Dependency links are emulated (capabilities.dependencyLinks = false) — GitHub has no stable link API, so getLinks/createLink read and write Blocked by #N / Blocks #N / Relates to #N lines in the issue body. Emulated links carry no per-link title/state (id only).
  • Boards are capability-gated (capabilities.boards = false) — GitHub kanban is Projects v2 (GraphQL, status fields, not label-list columns); syncBoards returns a logged skip rather than provisioning. Create the project manually.
  • Wiki is pushed to the .wiki.git repo; CI templating is unsupported (capabilities.ci = false) — ciTemplate() returns an error (GitHub Actions templates are out of scope).

Commit references use the native #42 form (same as GitLab) when tracker and code are both GitHub; otherwise the labeled [github: 42] form.

Azure DevOps specifics ​

The Azure adapters shell out to az (run az login and set az devops configure defaults first). Azure differs from the others:

  • Scoped values are stored as work-item tags (System.Tags), not labels and not the System.State field. This is a deliberate choice for fidelity and reversibility — mapping stage onto System.State is lossy and process-template-dependent (Agile/Scrum/Basic differ). Mapping to State is a future refinement once a target process template is known.
  • Dependency links use native work-item relations (capabilities.dependencyLinks = true) — predecessor/successor/related. ⚠ The predecessor/successor ↔ blocks/blocked-by direction is unverified; confirm against a real work item before stable.
  • Boards and wiki are capability-gated skips (capabilities.boards = false, wiki = false) — syncBoards/pushWiki return logged skips.
  • PR comments and CI templating are unsupported in this cut — commentPR and ciTemplate return { ok: false, code: 'unsupported' }.
  • Commit references use AB#42 native (Azure's work-item link syntax) when tracker and code are both Azure; otherwise [azure: 42].
  • New work items are created as System.WorkItemType = 'Issue' (exists in the Basic process; configurable later).

⚠ Azure is reconstructed from documentation, not introspected. az was not installed in the build environment, so every az command, flag, and System.* field path is unverified. Verify thoroughly against a real Azure DevOps organization (with a known process template) before promoting past the experimental channel.

Adding a Platform ​

  1. Create lib/providers/tracker/<name>.js (and/or lib/providers/code/<name>.js) implementing the interface above.
  2. The acceptance gate is the shared contract suite in test/contract/tracker-contract.js and test/contract/code-contract.js. Every adapter must pass all assertions in assertTrackerInterface / assertCodeInterface and all normalized-shape assertions.
  3. Register the new adapter in the TRACKERS and/or CODE registry maps in lib/platform.js.
  4. Add detect(remoteUrl) logic so the platform is auto-detected from the git remote URL.
  5. Update this page and docs/design/storage.md with the new provider's project reference format.

Contract tests are the interface spec

test/contract/tracker-contract.js lists every required method, every required normalized key, and every capability flag. If your adapter passes these assertions it is interface-compatible and the rest of Spexso will work with it.