From bc8a44b41c876a3516cf3204055189df856d0773 Mon Sep 17 00:00:00 2001 From: Sylvain Gougouzian Date: Tue, 14 Jul 2026 09:33:50 +0200 Subject: [PATCH] :sparkles: feat(openspec): add openspec-git skill driving branch/commit/merge Tie git to the OpenSpec lifecycle: branch on proposal (git_propose), commit each finished task (git_commit), and archive by merging the feature branch into main (git_archive). Add the openspec-git skill documenting the conventions and wire the triggers into the proposal, apply and archive step guidance. git_archive now also commits the openspec archive result and merges into main (--no-ff), with best-effort origin push so a remote-less repo still works. --- src/services/openspec-hook.ts | 6 ++-- src/skills/openspec-git.md | 51 ++++++++++++++++++++++++++++++++++ src/tools/executor.ts | 52 +++++++++++++++++++++++++---------- src/tools/registry.ts | 2 +- 4 files changed, 92 insertions(+), 19 deletions(-) create mode 100644 src/skills/openspec-git.md diff --git a/src/services/openspec-hook.ts b/src/services/openspec-hook.ts index ca46722..9105327 100644 --- a/src/services/openspec-hook.ts +++ b/src/services/openspec-hook.ts @@ -26,7 +26,7 @@ const STEP_GUIDANCE: Record = { discuss: "Free-form discussion. Explore the problem space with the user — constraints, tradeoffs, prior art, edge cases. Do NOT create any OpenSpec artifacts or run any openspec commands yet.", proposal: - "Create an OpenSpec change: run `openspec new change `, then write its `proposal.md` (Why / What Changes / Impact). Only write the proposal spec — do NOT write, edit, or generate any implementation/source code. Implementation happens later, in the apply step.", + "Create an OpenSpec change: run `openspec new change `, then write its `proposal.md` (Why / What Changes / Impact). Only write the proposal spec — do NOT write, edit, or generate any implementation/source code. Once the change exists, create its dedicated git branch with the `git_propose` tool (see the `openspec-git` skill). Implementation happens later, in the apply step.", design: "Write the technical design in the change's `design.md`: architecture, data model, API, integration points, alternatives considered. Only write the design doc — do NOT write, edit, or generate any implementation/source code. Implementation happens later, in the apply step.", specs: @@ -34,9 +34,9 @@ const STEP_GUIDANCE: Record = { tasks: "Break the change into an ordered checklist in its `tasks.md` (`- [ ]` items, grouped, parallelizable work flagged). Only write the task list — do NOT write, edit, or generate any implementation/source code. Implementation happens later, in the apply step.", apply: - "Implement the tasks from the change's `tasks.md`, checking them off (`- [x]`) as you complete them. Validate with `openspec validate `.", + "Implement the tasks from the change's `tasks.md`, checking them off (`- [x]`) as you complete them. After finishing each task, commit it with the `git_commit` tool using a conventional-commit message (see the `openspec-git` skill) — one task, one commit. Validate with `openspec validate `.", archive: - "The change is complete: archive it with `openspec archive --yes`, which merges its spec deltas into `openspec/specs/`. Summarize what was learned.", + "The change is complete: archive it with the `git_archive` tool, which runs `openspec archive` (merging its spec deltas into `openspec/specs/`), commits the result, and merges the feature branch into main (see the `openspec-git` skill). Summarize what was learned.", }; export function buildStepPrompt(stepId: string): string { diff --git a/src/skills/openspec-git.md b/src/skills/openspec-git.md new file mode 100644 index 0000000..1e4fcf0 --- /dev/null +++ b/src/skills/openspec-git.md @@ -0,0 +1,51 @@ +--- +name: openspec-git +description: Git branching and commit conventions tied to the OpenSpec workflow — branch when a proposal is created, commit when a task is done. +triggers: + - proposal created + - new openspec change + - task finished + - task checked off + - commit the change +--- + +# OpenSpec Git Workflow + +Keep git in lockstep with the OpenSpec lifecycle. Two moments matter. + +## 1. Proposal created → new branch + +As soon as a new change exists under `openspec/changes//` (proposal step), +create a dedicated branch **before** any design, spec, task, or implementation work. + +- Use the `git_propose` tool. It reads the latest change name and creates/switches + to `feat/`. +- One change = one branch. Never author a proposal directly on `main`. + +## 2. Task finished → commit + +During the apply step, commit **after each task is completed and checked off** +(`- [x]`) in `tasks.md`. One task = one commit. + +- Use the `git_commit` tool with a message that follows the conventional-commits + + gitmoji convention (see the `conventional-commits` reference): + `:gitmoji: type(scope): short description` +- Commit the task's implementation and its checked-off `tasks.md` together. +- Do not batch several tasks into one commit; do not commit unfinished work. + +## 3. Change complete → archive and merge into main + +At the archive step, use the `git_archive` tool. It: + +- runs `openspec archive` (merging the change's spec deltas into `openspec/specs/` + and moving the change under `archive/`), +- commits that result on the feature branch, then +- merges the feature branch into `main` (`--no-ff`), pushing to origin when one exists. + +Archive from the change's `feat/` branch, never from `main`. + +## Rules + +- Never commit on `main` for an in-progress change — always the `feat/` branch. +- Do not create the branch before the change exists (`git_propose` needs it). +- `main` only receives a change through the archive merge, never a direct commit. diff --git a/src/tools/executor.ts b/src/tools/executor.ts index 6772822..442c8dc 100644 --- a/src/tools/executor.ts +++ b/src/tools/executor.ts @@ -412,31 +412,53 @@ function gitCommit(message: string, projectDir: string): string { } } +const MAIN_BRANCH = "main"; + function gitArchive(projectDir: string): string { - try { - const branch = execSync("git branch --show-current", { + const run = (cmd: string) => + execSync(cmd, { cwd: projectDir, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], - }).trim(); + }); + + try { + const branch = run("git branch --show-current").trim(); if (!branch) { - return "Not on a branch. Cannot push."; + return "Not on a branch. Cannot archive."; + } + if (branch === MAIN_BRANCH) { + return `Already on ${MAIN_BRANCH}. Switch to the change's feat/ branch before archiving.`; } - execSync(`git push -u origin ${branch}`, { - cwd: projectDir, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); + // Archive the change first: this merges its spec deltas into + // openspec/specs/ and moves the change under archive/. Commit the result on + // the feature branch so it can be carried into main by the merge below. + const archiveResult = run("openspec archive --yes"); + run("git add -A"); + try { + run(`git commit -m ":clipboard: spec: archive ${branch}"`); + } catch { + // nothing to commit — archive produced no file changes + } - const archiveResult = execSync("openspec archive", { - cwd: projectDir, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); + // Merge the completed change into main. + run(`git checkout ${MAIN_BRANCH}`); + run(`git merge --no-ff ${branch} -m "Merge ${branch} into ${MAIN_BRANCH}"`); + + // Publishing is best-effort: a locally-initialized repo has no origin, and + // that must not fail the archive+merge that already succeeded. + let pushed = ""; + try { + run(`git push origin ${branch}`); + run(`git push origin ${MAIN_BRANCH}`); + pushed = " Pushed branch and main to origin."; + } catch { + pushed = " (no origin to push to — skipped.)"; + } - return `Pushed branch ${branch} to origin.\n${archiveResult.trim()}`; + return `Archived ${branch} and merged it into ${MAIN_BRANCH}.${pushed}\n${archiveResult.trim()}`; } catch (error) { const err = error as { stderr?: string; message?: string }; return `Archive failed: ${err.stderr ?? err.message ?? "unknown error"}`; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index cba638f..aef070e 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -191,7 +191,7 @@ function loadBundledTools(): ToolDefinition[] { { name: "git_archive", description: - "Push the current feature branch to origin and archive the OpenSpec change. Used at the end of the workflow.", + "Archive the OpenSpec change, commit the result, and merge the current feature branch into main (pushing to origin when one exists). Used at the end of the workflow.", parameters: { type: "object", properties: {}, -- 2.51.2