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
| Slot | Covers | Adapters |
|---|---|---|
| tracker | Issues · boards · wiki · dependency links | gitlab · github (experimental) · azure (experimental) · (calm — upcoming) |
| code | Source branches · pull/merge requests · CI templates | gitlab · 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):
| Method | Replaces |
|---|---|
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:
| Method | GitLab (glab) | GitHub (gh) | Azure DevOps (az) |
|---|---|---|---|
closeIssue / reopenIssue | issue close / issue reopen | issue close / issue reopen | boards work-item update --state Closed/Active |
deleteIssue | issue delete | issue delete --yes | boards work-item delete --yes |
listLabels | label list | label list --json (color normalized to #RRGGBB) | az devops invoke --area wit --resource tags (live-verification pending) |
createLabel | label create (idempotent) | label create (idempotent, # stripped) | no-op — Azure tags are created on use |
listMilestones / createMilestone / closeMilestone | api 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 / unmilestone | api PUT projects/:id/issues/:iid milestone_id=<id> (0 clears) | 0` | issue edit --milestone <title> / --remove-milestone |
authStatus | auth status | auth status | account show |
getUser | api user | api user (login → username) | account show (signed-in account) |
api | api pass-through | api pass-through (multipart --form unsupported — gh has none) | unsupported — use az devops invoke directly |
upload | api projects/:id/uploads --form | unsupported — GitHub has no attachment API | unsupported in this cut |
Code Interface
Every code adapter exports (lib/providers/code/<x>.js):
| Method | Purpose |
|---|---|
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:
| Method | GitLab (glab) | GitHub (gh) | Azure DevOps (az) |
|---|---|---|---|
listPRs | mr list | pr list | repos pr list --status active/completed/abandoned |
updatePR | mr update | pr edit (+ pr ready [--undo] for draft state) | repos pr update (no label/target-branch flags) |
closePR / reopenPR | mr close / mr reopen | pr close / pr reopen | repos pr update --status abandoned/active |
approvePR / revokePR | mr approve / mr revoke | pr review --approve / unsupported (dismiss in web UI) | repos pr set-vote --vote approve/reset |
rebasePR | mr rebase | update-branch API (merges base in; GitHub has no rebase option there) | unsupported |
diffPR | mr diff | pr diff [--patch] | unsupported (web UI) |
checkoutPR | mr checkout | pr checkout | repos 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:
// 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:
- Run natively — platform supports it.
- Emulate — e.g. GitHub has no stable dependency-link API, so blockers are written into the issue body as
Blocked by #Nand the marathon picker reads that instead of the links API. - 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 (
#42for GitLab/GitHub;AB#42for 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
| Platform | Tier | Tracker adapter | Code adapter |
|---|---|---|---|
| GitLab | First-class | Fully implemented, field-proven | Fully implemented, field-proven |
| GitHub | Supported | Implemented, contract + golden-shape tested; live e2e pending | same |
| Azure DevOps | Best-effort | Implemented; az syntax and System.* paths reconstructed from docs (⚠ live-verification markers in the source) | same |
| SAP CALM | Later (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 likestage::buildexist as ordinary label names;setStageswaps them manually since GitHub has no single-value enforcement. - Dependency links are emulated (
capabilities.dependencyLinks = false) — GitHub has no stable link API, sogetLinks/createLinkread and writeBlocked by #N/Blocks #N/Relates to #Nlines 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);syncBoardsreturns a logged skip rather than provisioning. Create the project manually. - Wiki is pushed to the
.wiki.gitrepo; 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 theSystem.Statefield. This is a deliberate choice for fidelity and reversibility — mappingstageontoSystem.Stateis 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/pushWikireturn logged skips. - PR comments and CI templating are unsupported in this cut —
commentPRandciTemplatereturn{ ok: false, code: 'unsupported' }. - Commit references use
AB#42native (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.
azwas not installed in the build environment, so everyazcommand, flag, andSystem.*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
- Create
lib/providers/tracker/<name>.js(and/orlib/providers/code/<name>.js) implementing the interface above. - The acceptance gate is the shared contract suite in
test/contract/tracker-contract.jsandtest/contract/code-contract.js. Every adapter must pass all assertions inassertTrackerInterface/assertCodeInterfaceand all normalized-shape assertions. - Register the new adapter in the
TRACKERSand/orCODEregistry maps inlib/platform.js. - Add
detect(remoteUrl)logic so the platform is auto-detected from the git remote URL. - Update this page and
docs/design/storage.mdwith the new provider'sprojectreference 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.