Skip to content

Finish Flow ​

Flow Steps ​

Test gate → Refresh debt ledger → Merge spec glossary into GLOSSARY.md → Push branch → Create MR → Auto-merge into {targetBranch} → On conflict: rebase + retry → Pull updated main → Update issue (stage::ship + needs::manual-test) → Test hand-off (if configured) → Post test checklist → Post demo scenario (if present) → Archive spec to specs/ → Write learning card to docs/learning/ → Update CHANGELOG → Push docs commits once → Cleanup worktree → Sync

Before pushing, /spexso-finish refreshes the committed debt ledger: npx spexso debt harvests every // spexso-debt: marker into .spexso/debt.md (source files, UI5 XML views, and deployment YAML such as mta.yaml, *.mtaext and .gitlab-ci.yml with # spexso-debt:), and new markers are surfaced in the MR description under ## Deferred (debt) — a standing section in the MR description template, omitted when the harvest reports none.

Next, /spexso-finish merges the issue spec's ## Glossary section into the project's GLOSSARY.md (new terms inserted alphabetically, changed terms replaced by name, the file created from .spexso/templates/glossary.md when missing) and commits it as docs(glossary): … — so domain terms ship with the code through the MR. Skipped when the section is empty. This also runs with --train; concurrent issues that both add terms are reconciled at the train's rebase by the GLOSSARY.md merge=union line that spexso init writes to .gitattributes.

In a project with releaseNotes in its config, finish then writes a release-note fragment release-notes/unreleased/{id}.md on the feature branch: category, audience and one or two business-readable sentences from the spec, in releaseNotes.language. It travels in the MR, so the reviewer corrects it there. No fragment for skip-release-note or type::tech-debt issues. npx spexso release-notes collects them at release time — see Release notes per ticket.

After the spec archive, /spexso-finish writes a learning card to docs/learning/{YYYY-MM-DD}-issue-{id}.md (template: .spexso/templates/learning-card.md). The card is Socratic — guiding questions (core problem, crux decision, where the logic lives, what could break) each followed by their answer, closing with a "verify it yourself" prompt. It is generated from context the pipeline already holds (plan + diff + review), so it is near-free — and it is the ownership mechanism: the developer actively understands every shipped change instead of skimming a summary. Cards are committed, forming a durable knowledge trail.

--no-merge ​

/spexso-finish --no-merge stops after MR creation: the issue moves to stage::review, the MR stays open for human review, and the worktree and branch are kept. Everything that belongs to the merge — auto-merge, stage::ship, the manual-test checklist, the demo comment, the CHANGELOG entry, and worktree cleanup — is skipped; it happens when the MR is eventually merged (e.g. a later /spexso-finish without the flag, or the reviewer merging in GitLab).

Built for orchestration layers on top of spexso (a final journey→develop MR that needs human approval); a no-op when absent. Setup-side counterparts: /spexso-start --base / --setup-only.

Bundles ​

A bundle lives on the lead's branch. Finish reads the members with npx spexso bundle members {lead}. It runs the gate, the push, the MR, the train-merge, the learning card and the cleanup once, on the lead. The MR title carries one reference group for all members, (closes #45, closes #47). The stage moves, labels, checklist, demo note, spec archive and CHANGELOG entry happen per member. handoff --deployed reads every id in such a group.

--train / --train-merge (merge train) ​

The marathon integrates issues through a merge train: the per-issue runner ends with /spexso-finish --train — push + MR creation, then stop, like --no-merge, except the full-suite test gate is deferred and the hand-off is to the marathon orchestrator instead of a human. Before the push, --train does run the suite and the static checks once as a sweep on the un-rebased branch (in a bundle once, on the lead): most tests an issue makes stale come from its own spec, and this is the last point where the issue's scope still covers them. A red sweep gets the same repair rounds as the post-rebase gate; still red → the failing tests are listed in the MR description under ## Sweep still red and the post-rebase gate decides. The orchestrator then runs /spexso-finish --train-merge {id} --mr {mr_id} per issue, strictly one at a time.

--train-merge works on the MR's source branch, read with pr view, not on the issue's name for it: when a human moved the work to a replacement branch with a new MR (spexso/issue-<id>-b, in a project that forbids force-push), the worktree is switched to that branch first. A rebase conflict gets one repair round before the hand-off: a builder resolves it in the worktree, keeping the intent of both sides (two additions to the same list keep both entries); the orchestrator checks that the rebase finished, no conflict marker is left and no commit was dropped, and posts what was resolved on the MR. The full gate then runs as usual, the local suite always in full. A failed round resets the branch and takes today's path: needs::conflict-resolution.

With trainGate: "targeted" in the config the local suite (and the --train sweep) runs only the test files the branch changed plus the static checks; the MR pipeline is the full gate. For a suite that takes most of an hour on the factory server. Without a pipeline (pr ci returns null), and after a conflict repair, the local suite runs in full anyway.

--train-merge differs from the default flow on one essential point: rebase first, then the test gate. The issue's worktree is rebased onto the fresh target branch — unless every commit the target branch gained since the branch's base carries [skip ci] (docs pushes, spec archives, prototypes): those cannot break the branch, and a rebase onto them would only start a second MR pipeline for the same code, so the rebase is skipped and the existing pipeline is reused (a project that requires an up-to-date branch gets the rebase after all when the merge refuses) — and the full suite plus the configured staticChecks (the same gate CI runs as TEST_COMMAND / STATIC_CHECKS) run against that rebased state — so a semantic conflict with a previously merged issue (disjoint files, colliding behavior) is caught before the merge instead of afterwards in CI. The worktree first gets its own npm ci (otherwise Node finds the main checkout's node_modules and workspace links point at the wrong folder). When the suite is green, the branch is pushed and the MR pipeline is the second half of the gate (npx spexso pr ci --wait): it runs what the suite does not — a clean install, cds build --production, mbt build. A red gate is not a hand-off yet: repair rounds spawn the builder in the worktree with the failure — the suite output, or every failed CI job with its log tail plus the jobs CI skipped behind them — and the brief to fix the cause on every clean-install path, not the symptom in one job. It may fix code, a build script, a dependency range or the lockfile, never a test. One exception: when every failure is a compile/type error located in a test file (Jest is green, tsc is red), the round goes to the tester, which may fix only that error. The orchestrator checks its commit: only test files, and no added, removed or edited expect/assert line or skipped test. Otherwise the commit is reset and the issue is handed off. A second exception: a test made stale on purpose, because the spec reverses what it pins, adds an operation to an allowlist it checks, moved the code it points at, or because a commit on the target branch changed it, gets a stale-test round. The orchestrator first checks per failing test whether it is red on the target branch too (a throwaway worktree). The tester then updates only such tests, with one Supersedes: <file> <old> → <new>, because <#issue AC-n | sha> line per test in the commit body. The orchestrator checks the commit: only test paths, no .skip/.only/x-prefix added, no file with fewer it(/test( calls, a Supersedes line. A fresh reviewer must approve it: every changed test still pins behaviour, no acceptance criterion loses coverage, every reason is true. A guard hit or a rejection resets the commit and hands off as before; an approval posts the Supersedes lines on the MR for the human tester. The doctrine records this as the one exception to "never edit a test to make it pass". The gate then runs again. Rounds continue while they make progress (a different failure, further down the pipeline, counts) and stop when a round changes nothing or leaves the same failure, or after 5. The merge passes the tested SHA. Outcomes:

OutcomeWhat happenedIssue state
mergedrebase clean, gate green → squash-merge + all post-merge stepsstage::ship + needs::manual-test
conflictrebase conflicted and the repair round could not resolve it (or the merge itself conflicted)MR open, needs::conflict-resolution, worktree kept
gate_failedsuite or MR pipeline still red after rebase, once the repair rounds stop making progress — NOT mergedMR open, needs::attention, worktree kept
degradedplatform lacks the auto-merge capabilitystage::review, human merges

With --autopick (the online runner) the merge is replaced by an ownership re-check and a GitLab auto-merge queued on the pushed SHA (pr merge --auto-merge --sha, retried up to 5 more times 15 s apart while GitLab creates the pipeline). No CHANGELOG entry, no spec archive, learning card or demo note — the squash-merge commit message is the record, and the next runner sweep does the post-merge steps. Extra outcomes:

OutcomeWhat happenedIssue state
queuedMR queued for auto-merge — GitLab merges on a green pipelineauto-pick kept until the sweep sees the merge
taken_overthe assignee is no longer the botauto-pick removed, MR open without auto-merge
not_queuedpr merge --auto-merge kept failing (no pipeline for the commit, or the bot may not merge into the target branch)auto-pick removed, needs::attention + note

Recovery / idempotence: --train-merge is safe to re-run manually for an issue stuck at stage::review with an open MR (e.g. a crashed marathon) — it re-fetches, re-rebases and continues. The conflict / gate_failed / degraded outcomes deliberately keep the worktree for diagnosis; after manual resolution, re-run /spexso-finish --train-merge (or a plain /spexso-finish) to complete the issue.

The two flags are a pair and exist for the marathon; solo single-issue work keeps using the default flow or --no-merge. This split is also the groundwork for the parallel marathon (design: research/parallel-marathon.md): runners can build concurrently while integration stays serial and deterministic.

targetBranch ​

The target branch is configured during /spexso-setup and stored in .spexso/config.json:

json
{ "targetBranch": "develop" }

All MRs created by Spexso target this branch and are auto-merged into it after creation. Override at any time by editing the file.

Detailed Shell Commands Sequence ​

bash
# Step 1: Read target branch
TARGET=$(node -e "const c = require('./.spexso/config.json'); console.log(c.targetBranch || 'main');")

# Step 2: Gate — tests must pass (configured full-suite command)
TEST_CMD=$(node -e "const c = require('./.spexso/config.json'); console.log(c.testCommand || 'npm test');")
$TEST_CMD
if [ $? -ne 0 ]; then
  echo "Tests failing. Fix before finishing."
  exit 1
fi

# Step 3: Push branch
git push origin spexso/issue-${ISSUE_ID}

# Step 4: Create MR targeting configured branch
# COMMIT_TYPE derives from the issue's type:: label — bug→fix,
# tech-debt→refactor, maintenance→chore, otherwise feat. The squash merge
# makes this title the commit on main, which feeds commit-and-tag-version
# (version bump + changelog) in projects with the release flow.
# The description carries Summary/Changes/Review/Tests plus a
# "## Deferred (debt)" section listing new open markers from the
# debt-ledger refresh (omitted when there are none). It is written to a
# file first: a multiline inline --description breaks on Windows.
npx spexso pr create \
  --title "${COMMIT_TYPE}: ${SPEC_TITLE} (closes #${ISSUE_ID})" \
  --description-file .spexso/tmp-mr-${ISSUE_ID}.md \
  --label "stage::review,ai::ai-assisted" \
  --source-branch "spexso/issue-${ISSUE_ID}" \
  --target-branch "${TARGET}"

# Step 5: Auto-merge
npx spexso pr merge ${MR_ID} --squash --remove-source-branch

# Step 6: On conflict — rebase and retry once
git fetch origin
git rebase origin/${TARGET}
git push origin spexso/issue-${ISSUE_ID} --force-with-lease
npx spexso pr merge ${MR_ID} --squash --remove-source-branch
# If rebase fails: post MR comment, apply needs::conflict-resolution, skip merge

# Step 7: Pull updated main (sync local with squash commit from GitLab)
cd ${MAIN_WORKTREE}
git pull origin ${TARGET}

# Step 8: Update issue labels after successful merge.
# Stage transition goes through the single-value helper (strips every other
# stage::* — canonical or legacy — before adding stage::ship), then flag manual test.
npx spexso stage ${ISSUE_ID} ship
npx spexso issue update ${ISSUE_ID} --label "needs::manual-test"

# Step 9: Post acceptance criteria checklist as issue comment
npx spexso issue note ${ISSUE_ID} --message "## ✅ Manual Test Checklist
- [ ] {criterion from spec}
...
_Generated by Spexso after merge of !${MR_ID}_"

# Step 10: Post demo scenario (if spec has a ## Demo section)
npx spexso issue note ${ISSUE_ID} --message "## 🎬 Demo Scenario
{demo steps extracted from ## Demo section in the spec}
_Extracted by Spexso after merge of !${MR_ID}_"
# Skip this step silently if the issue has no ## Demo section

# Step 10b: Archive the spec to specs/{id}-{kebab-title}.md on main (provenance
# header + verbatim spec body) — specs accumulate as a living system description
# instead of dying in closed issues.

# Step 10c: Write the learning card to docs/learning/{YYYY-MM-DD}-issue-{id}.md
# (from .spexso/templates/learning-card.md), filled from the spec, plan, diff,
# and review findings already held by the pipeline — the developer-ownership artifact.

# Step 11: Update CHANGELOG on main worktree
# Skipped when the project has a .versionrc.json (Flexso release flow) — there
# CHANGELOG.md is generated by commit-and-tag-version at release time, and this
# squash-merge commit is what feeds it.
cd ${MAIN_WORKTREE}
git add CHANGELOG.md
git commit -m "docs: update changelog for issue #${ISSUE_ID}"

# Step 11b: Push the docs commits ONCE — spec archive (10b), learning card (10c)
# and CHANGELOG (11) stay separate atomic commits but travel in a single push
# (one network round-trip instead of three). Runs even when step 11 was skipped.
# On GitLab (code.pushCiSkip) without a pipeline: a docs-only pipeline would
# auto-cancel the merge commit's pipeline, deploy included (issue #32).
git push -o ci.skip origin ${TARGET}   # elsewhere: git push origin ${TARGET}

# Step 12: Cleanup (with Windows fallback)
git worktree remove ".spexso/worktrees/issue-${ISSUE_ID}" --force 2>/dev/null || (
  rm -rf ".spexso/worktrees/issue-${ISSUE_ID}" && git worktree prune
)

# Step 13: Refresh index
npx spexso sync

Conflict Handling ​

If auto-merge fails (conflict in MR):

  1. Rebase the feature branch onto {targetBranch} and force-push
  2. Retry merge once
  3. If rebase itself has conflicts:
    • Post comment on MR: "Auto-merge failed: rebase conflict. Manual resolution needed."
    • Apply needs::conflict-resolution label to the issue
    • Yolo mode: skip this issue, continue marathon
    • Pilot/copilot: exit, ask developer to resolve manually

Windows

If git worktree remove --force fails with "Permission denied" (Windows file locking), Spexso automatically falls back to rm -rf + git worktree prune. Same result, different path.

Post-Merge Labels ​

LabelMeaning
stage::shipIssue merged, awaiting deployment
needs::manual-testMerged — developer must verify manually in GitLab
needs::conflict-resolutionAuto-merge failed — requires human intervention

needs::manual-test is cleared by the developer after testing. They then move the issue to stage::operate.

Test hand-off ​

With handoff in .spexso/config.json (see storage), issues that need a manual test stay open after the merge and land with a tester instead of the developer. Absent = the behaviour above, unchanged.

StepWhat happens
Before MR createnpx spexso handoff {id} --dry-run decides. Needed → MR title ends in (#{id}) instead of (closes #{id}), and no --work-items (Azure), so the merge does not close the issue
After stage::ship + needs::manual-testnpx spexso handoff {id} assigns the tester, adds the project's handoff.labels and removes its handoff.unlabel (no-op when not needed; also runs in --train-merge). With handoff.on: "deploy" it only decides ("deferred": true) — see below
Checklist commentStarts with an @-mention of the tester
CleanupThe local spexso/issue-{id} branch is deleted too (always), so a rework round can recreate it

Who tests (lib/handoff.js, deterministic): the issue author when listed in testers; else fallback — a fixed username, or random = uniform pick from testers. Which issues: every type:: not in skipTypes (default maintenance, spike); no type label = tested. Unknown skipTypes values and an empty testers list warn.

Hand-off at deploy time (handoff.on: "deploy"): the merge is not the deploy — a pipeline can be slow or cancelled, and a tester should never start on code that is not on DEV yet. With "deploy", /spexso-finish and the auto-pick runner only decide; the handoff-dev job of the Flexso CI template runs after deploy-dev:

bash
GITLAB_TOKEN="$SPEXSO_TOKEN" npx spexso handoff --deployed --environment development

It asks GitLab for the previous successful deployment to that environment, takes every commit since then (compare API — no deep clone needed), extracts the issue ids ((#42) / closes #42 in squash titles, spexso/issue-42 in merge commits) and hands off each issue that is open, on stage::ship, needs a test, and is not already with a tester — so re-runs are safe and a cancelled pipeline's issues are picked up by the next deploy. It prints {environment, from, to, handedOff, skipped} with a reason per skipped issue. The job is allow_failure: a failing hand-off turns orange, the deploy stays green. It needs a project variable SPEXSO_TOKEN (api scope) — see CICD-SETUP.md. GitLab only.

Tester outcome (manual, in GitLab): pass → remove needs::manual-test, close the issue. Fail → comment the findings, assign back to the developer, move to stage::build or stage::spec. /spexso-start then drops the stale needs::manual-test and the handoff.labels, deletes a leftover local branch, and the planner receives npx spexso handoff {id} --rework — every comment after the last Manual Test Checklist — as rework context. Walkthrough: Issue testen op DEV.