From 3adacf3ff5b8390390f2a1b2b87d480b8b7bf457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Mon, 16 Mar 2026 23:30:33 -0600 Subject: [PATCH] feat: meaningful commit messages from task summaries (#803) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: meaningful commit messages from task summaries (#785, #784) Post-task commits now derive messages from the task summary one-liner, inferred type, and key files. Planning prompts respect commit_docs: false. Commit type inference expanded with perf type and oneLiner parameter. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: replace invalidateStateCache with invalidateAllCaches in crash recovery PR #799 reintroduced invalidateStateCache() calls in the phantom skip loop crash recovery paths. These should use invalidateAllCaches() which is the renamed function. Co-Authored-By: Claude Opus 4.6 (1M context) * fix: resolve CI failures from main merge conflicts - Replace invalidateStateCache() → invalidateAllCaches() in crash recovery paths (reintroduced by PR #799 merge) - Expand smart-entry-draft test chunk window from 3000 to 4000 chars to accommodate commitInstruction additions Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- src/resources/GSD-WORKFLOW.md | 21 +++-- src/resources/extensions/gsd/auto-prompts.ts | 14 +++ src/resources/extensions/gsd/auto.ts | 42 +++++++-- src/resources/extensions/gsd/git-service.ts | 88 ++++++++++++++++--- src/resources/extensions/gsd/guided-flow.ts | 22 +++++ .../extensions/gsd/prompts/complete-slice.md | 2 +- .../gsd/prompts/discuss-headless.md | 4 +- .../extensions/gsd/prompts/discuss.md | 8 +- .../extensions/gsd/prompts/execute-task.md | 2 +- .../gsd/prompts/guided-discuss-milestone.md | 2 +- .../gsd/prompts/guided-discuss-slice.md | 2 +- .../extensions/gsd/prompts/plan-slice.md | 2 +- src/resources/extensions/gsd/prompts/queue.md | 2 +- .../gsd/prompts/reassess-roadmap.md | 2 +- .../extensions/gsd/tests/git-service.test.ts | 74 +++++++++++++++- .../gsd/tests/smart-entry-draft.test.ts | 2 +- src/resources/extensions/gsd/worktree.ts | 11 ++- 17 files changed, 251 insertions(+), 49 deletions(-) diff --git a/src/resources/GSD-WORKFLOW.md b/src/resources/GSD-WORKFLOW.md index 6ae9cc5b9..8c819643f 100644 --- a/src/resources/GSD-WORKFLOW.md +++ b/src/resources/GSD-WORKFLOW.md @@ -565,25 +565,28 @@ One commit per slice. Individually revertable. Reads like a changelog. ``` gsd/M001/S01: - test(S01): round-trip tests passing + test(S01/T03): round-trip tests passing feat(S01/T03): file writer with round-trip fidelity - chore(S01/T03): auto-commit after task feat(S01/T02): markdown parser for plan files - chore(S01/T02): auto-commit after task feat(S01/T01): core types and interfaces - chore(S01/T01): auto-commit after task + docs(S01): add slice plan ``` ### Commit Conventions | When | Format | Example | |------|--------|---------| -| Auto-commit (dirty state) | `chore(S01/T02): auto-commit after task` | Automatic save of work in progress | -| After task verified | `feat(S01/T02): ` | The real work | -| Plan/docs committed | `docs(S01): add slice plan` | Bundled with first task | -| Slice squash to main | `type(M001/S01): ` | Type inferred from title (`feat`, `fix`, `docs`, etc.) | +| Task completed | `{type}(S01/T02): ` | Type inferred from title (`feat`, `fix`, `test`, etc.) | +| Plan/docs committed | `docs(S01): add slice plan` | Planning artifacts | +| Slice squash to main | `type(M001/S01): ` | Type inferred from title | +| State rebuild | `chore(S01/T02): auto-commit after state-rebuild` | Bookkeeping only | -Commit types: `feat`, `fix`, `test`, `refactor`, `docs`, `chore` +The system reads the task summary after execution and builds a meaningful commit message: +- **Subject**: `{type}({sliceId}/{taskId}): {one-liner}` — the one-liner from the summary frontmatter +- **Type**: Inferred from the task title and one-liner (`feat`, `fix`, `test`, `refactor`, `docs`, `perf`, `chore`) +- **Body**: Key files from the summary frontmatter (up to 8 files listed) + +Commit types: `feat`, `fix`, `test`, `refactor`, `docs`, `perf`, `chore` ### Squash Merge Message diff --git a/src/resources/extensions/gsd/auto-prompts.ts b/src/resources/extensions/gsd/auto-prompts.ts index 9d7b93824..6daf4f8c6 100644 --- a/src/resources/extensions/gsd/auto-prompts.ts +++ b/src/resources/extensions/gsd/auto-prompts.ts @@ -637,6 +637,12 @@ export async function buildPlanSlicePrompt( const executorContextConstraints = formatExecutorConstraints(); const outputRelPath = relSliceFile(base, mid, sid, "PLAN"); + const prefs = loadEffectiveGSDPreferences(); + const commitDocsEnabled = prefs?.preferences?.git?.commit_docs !== false; + const commitInstruction = commitDocsEnabled + ? `Commit: \`docs(${sid}): add slice plan\`` + : "Do not commit — planning docs are not tracked in git for this project."; + return loadPrompt("plan-slice", { workingDirectory: base, milestoneId: mid, sliceId: sid, sliceTitle: sTitle, @@ -647,6 +653,7 @@ export async function buildPlanSlicePrompt( inlinedContext, dependencySummaries: depContent, executorContextConstraints, + commitInstruction, }); } @@ -1071,6 +1078,12 @@ export async function buildReassessRoadmapPrompt( // Non-fatal — captures module may not be available } + const reassessPrefs = loadEffectiveGSDPreferences(); + const reassessCommitDocsEnabled = reassessPrefs?.preferences?.git?.commit_docs !== false; + const reassessCommitInstruction = reassessCommitDocsEnabled + ? `Commit: \`docs(${mid}): reassess roadmap after ${completedSliceId}\`` + : "Do not commit — planning docs are not tracked in git for this project."; + return loadPrompt("reassess-roadmap", { workingDirectory: base, milestoneId: mid, @@ -1081,6 +1094,7 @@ export async function buildReassessRoadmapPrompt( assessmentPath, inlinedContext, deferredCaptures, + commitInstruction: reassessCommitInstruction, }); } diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index cab90b9da..ae2f8f9a5 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,7 +18,7 @@ import type { import { deriveState } from "./state.js"; import type { BudgetEnforcementMode, GSDState } from "./types.js"; -import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides } from "./files.js"; +import { loadFile, parseRoadmap, getManifestStatus, resolveAllOverrides, parseSummary } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; export { inlinePriorMilestoneSummary } from "./files.js"; import { collectSecretsFromManifest } from "../get-secrets-from-user.js"; @@ -94,7 +94,7 @@ import { parseSliceBranch, setActiveMilestoneId, } from "./worktree.js"; -import { GitServiceImpl } from "./git-service.js"; +import { GitServiceImpl, type TaskCommitContext } from "./git-service.js"; import { getPriorSliceCompletionBlocker } from "./dispatch-guard.js"; import { formatGitError } from "./git-self-heal.js"; import { @@ -1328,12 +1328,41 @@ export async function handleAgentEnd( // Small delay to let files settle (git commits, file writes) await new Promise(r => setTimeout(r, 500)); - // Auto-commit any dirty files the LLM left behind on the current branch. + // Commit any dirty files the LLM left behind on the current branch. + // For execute-task units, build a meaningful commit message from the + // task summary (one-liner, key_files, inferred type). For other unit + // types, fall back to the generic chore() message. if (currentUnit) { try { - const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id); + let taskContext: TaskCommitContext | undefined; + + if (currentUnit.type === "execute-task") { + const parts = currentUnit.id.split("/"); + const [mid, sid, tid] = parts; + if (mid && sid && tid) { + const summaryPath = resolveTaskFile(basePath, mid, sid, tid, "SUMMARY"); + if (summaryPath) { + try { + const summaryContent = await loadFile(summaryPath); + if (summaryContent) { + const summary = parseSummary(summaryContent); + taskContext = { + taskId: `${sid}/${tid}`, + taskTitle: summary.title?.replace(/^T\d+:\s*/, "") || tid, + oneLiner: summary.oneLiner || undefined, + keyFiles: summary.frontmatter.key_files?.filter(f => !f.includes("{{")) || undefined, + }; + } + } catch { + // Non-fatal — fall back to generic message + } + } + } + } + + const commitMsg = autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id, taskContext); if (commitMsg) { - ctx.ui.notify(`Auto-committed uncommitted changes.`, "info"); + ctx.ui.notify(`Committed: ${commitMsg.split("\n")[0]}`, "info"); } } catch { // Non-fatal @@ -1386,7 +1415,8 @@ export async function handleAgentEnd( } try { await rebuildState(basePath); - autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id); + // State rebuild commit is bookkeeping — generic message is appropriate + autoCommitCurrentBranch(basePath, "state-rebuild", currentUnit.id); } catch { // Non-fatal } diff --git a/src/resources/extensions/gsd/git-service.ts b/src/resources/extensions/gsd/git-service.ts index c151c3764..8f30cc883 100644 --- a/src/resources/extensions/gsd/git-service.ts +++ b/src/resources/extensions/gsd/git-service.ts @@ -68,6 +68,50 @@ export interface CommitOptions { allowEmpty?: boolean; } +// ─── Meaningful Commit Message Generation ─────────────────────────────────── + +/** Context for generating a meaningful commit message from task execution results. */ +export interface TaskCommitContext { + taskId: string; + taskTitle: string; + /** The one-liner from the task summary (e.g. "Added retry-aware worker status logging") */ + oneLiner?: string; + /** Files modified by this task (from task summary frontmatter) */ + keyFiles?: string[]; +} + +/** + * Build a meaningful conventional commit message from task execution context. + * Format: `{type}({sliceId}/{taskId}): {description}` + * + * The description is the task summary one-liner if available (it describes + * what was actually built), falling back to the task title (what was planned). + */ +export function buildTaskCommitMessage(ctx: TaskCommitContext): string { + const scope = ctx.taskId; // e.g. "S01/T02" or just "T02" + const description = ctx.oneLiner || ctx.taskTitle; + const type = inferCommitType(ctx.taskTitle, ctx.oneLiner); + + // Truncate description to ~72 chars for subject line + const maxDescLen = 68 - type.length - scope.length; + const truncated = description.length > maxDescLen + ? description.slice(0, maxDescLen - 1).trimEnd() + "…" + : description; + + const subject = `${type}(${scope}): ${truncated}`; + + // Build body with key files if available + if (ctx.keyFiles && ctx.keyFiles.length > 0) { + const fileLines = ctx.keyFiles + .slice(0, 8) // cap at 8 files to keep commit concise + .map(f => `- ${f}`) + .join("\n"); + return `${subject}\n\n${fileLines}`; + } + + return subject; +} + /** * Thrown when a slice merge hits code conflicts in non-.gsd files. * The working tree is left in a conflicted state (no reset) so the @@ -253,18 +297,14 @@ export function runGit(basePath: string, args: string[], options: { allowFailure * Each entry: [keywords[], commitType] */ const COMMIT_TYPE_RULES: [string[], string][] = [ - [["fix", "bug", "patch", "hotfix"], "fix"], + [["fix", "fixed", "fixes", "bug", "patch", "hotfix", "repair", "correct"], "fix"], [["refactor", "restructure", "reorganize"], "refactor"], - [["doc", "docs", "documentation"], "docs"], - [["test", "tests", "testing"], "test"], - [["chore", "cleanup", "clean up", "archive", "remove", "delete"], "chore"], + [["doc", "docs", "documentation", "readme", "changelog"], "docs"], + [["test", "tests", "testing", "spec", "coverage"], "test"], + [["perf", "performance", "optimize", "speed", "cache"], "perf"], + [["chore", "cleanup", "clean up", "dependencies", "deps", "bump", "config", "ci", "archive", "remove", "delete"], "chore"], ]; -/** - * Infer a conventional commit type from a slice title. - * Uses case-insensitive word-boundary matching against known keywords. - * Returns "feat" when no keywords match. - */ // ─── GitServiceImpl ──────────────────────────────────────────────────── export class GitServiceImpl { @@ -356,11 +396,22 @@ export class GitServiceImpl { } /** - * Auto-commit dirty working tree with a conventional chore message. + * Auto-commit dirty working tree. + * + * When `taskContext` is provided, generates a meaningful conventional commit + * message from the task execution results (one-liner, title, inferred type). + * Falls back to a generic `chore()` message when no context is available + * (e.g. pre-switch commits, stop commits, state rebuild commits). + * * Returns the commit message on success, or null if nothing to commit. * @param extraExclusions Additional paths to exclude from staging (e.g. [".gsd/"] for pre-switch commits). */ - autoCommit(unitType: string, unitId: string, extraExclusions: readonly string[] = []): string | null { + autoCommit( + unitType: string, + unitId: string, + extraExclusions: readonly string[] = [], + taskContext?: TaskCommitContext, + ): string | null { // Quick check: is there anything dirty at all? // Native path uses libgit2 (single syscall), fallback spawns git. if (!nativeHasChanges(this.basePath)) return null; @@ -371,7 +422,9 @@ export class GitServiceImpl { // (all changes might have been runtime files that got excluded) if (!nativeHasStagedChanges(this.basePath)) return null; - const message = `chore(${unitId}): auto-commit after ${unitType}`; + const message = taskContext + ? buildTaskCommitMessage(taskContext) + : `chore(${unitId}): auto-commit after ${unitType}`; nativeCommit(this.basePath, message, { allowEmpty: false }); return message; } @@ -497,8 +550,15 @@ export class GitServiceImpl { // ─── Commit Type Inference ───────────────────────────────────────────────── -export function inferCommitType(sliceTitle: string): string { - const lower = sliceTitle.toLowerCase(); +/** + * Infer a conventional commit type from a title (and optional one-liner). + * Uses case-insensitive word-boundary matching against known keywords. + * Returns "feat" when no keywords match. + * + * Used for both slice squash-merge titles and task commit messages. + */ +export function inferCommitType(title: string, oneLiner?: string): string { + const lower = `${title} ${oneLiner || ""}`.toLowerCase(); for (const [keywords, commitType] of COMMIT_TYPE_RULES) { for (const keyword of keywords) { diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index b63c77278..7202088ca 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -29,6 +29,17 @@ import { loadEffectiveGSDPreferences } from "./preferences.js"; import { showConfirm } from "../shared/confirm-ui.js"; import { loadQueueOrder, sortByQueueOrder, saveQueueOrder } from "./queue-order.js"; +// ─── Commit Instruction Helpers ────────────────────────────────────────────── + +/** Build conditional commit instruction for planning prompts based on commit_docs preference. */ +function buildDocsCommitInstruction(message: string): string { + const prefs = loadEffectiveGSDPreferences(); + const commitDocsEnabled = prefs?.preferences?.git?.commit_docs !== false; + return commitDocsEnabled + ? `Commit: \`${message}\`` + : "Do not commit — planning docs are not tracked in git for this project."; +} + // ─── Auto-start after discuss ───────────────────────────────────────────────── /** Stashed context + flag for auto-starting after discuss phase completes */ @@ -198,6 +209,8 @@ function buildDiscussPrompt(nextId: string, preamble: string, _basePath: string) contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`, roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`, inlinedTemplates, + commitInstruction: buildDocsCommitInstruction(`docs(${nextId}): context, requirements, and roadmap`), + multiMilestoneCommitInstruction: buildDocsCommitInstruction("docs: project plan — N milestones"), }); } @@ -220,6 +233,8 @@ function buildHeadlessDiscussPrompt(nextId: string, seedContext: string, _basePa contextPath: `${milestoneRel}/${nextId}-CONTEXT.md`, roadmapPath: `${milestoneRel}/${nextId}-ROADMAP.md`, inlinedTemplates, + commitInstruction: buildDocsCommitInstruction(`docs(${nextId}): context, requirements, and roadmap`), + multiMilestoneCommitInstruction: buildDocsCommitInstruction("docs: project plan — N milestones"), }); } @@ -648,6 +663,7 @@ async function showQueueAdd( nextIdPlus1, existingMilestonesContext: existingContext, inlinedTemplates: queueInlinedTemplates, + commitInstruction: buildDocsCommitInstruction("docs: queue "), }); pi.sendMessage( @@ -834,6 +850,7 @@ async function buildDiscussSlicePrompt( contextPath: sliceContextPath, projectRoot: base, inlinedTemplates, + commitInstruction: buildDocsCommitInstruction(`docs(${mid}/${sid}): slice context from discuss`), }); } @@ -899,6 +916,7 @@ export async function showDiscuss( const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; const basePrompt = loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, + commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), }); const seed = draftContent ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` @@ -911,6 +929,7 @@ export async function showDiscuss( pendingAutoStart = { ctx, pi, basePath, milestoneId: mid, step: false }; dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, + commitInstruction: buildDocsCommitInstruction(`docs(${mid}): milestone context from discuss`), }), "gsd-discuss"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); @@ -1216,6 +1235,7 @@ export async function showSmartEntry( const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; const basePrompt = loadPrompt("guided-discuss-milestone", { milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, + commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`), }); const seed = draftContent ? `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\n${draftContent}` @@ -1228,6 +1248,7 @@ export async function showSmartEntry( pendingAutoStart = { ctx, pi, basePath, milestoneId, step: stepMode }; dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, + commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`), }), "gsd-discuss"); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); @@ -1302,6 +1323,7 @@ export async function showSmartEntry( const structuredQuestionsAvailable = pi.getActiveTools().includes("ask_user_questions") ? "true" : "false"; dispatchWorkflow(pi, loadPrompt("guided-discuss-milestone", { milestoneId, milestoneTitle, inlinedTemplates: discussMilestoneTemplates, structuredQuestionsAvailable, + commitInstruction: buildDocsCommitInstruction(`docs(${milestoneId}): milestone context from discuss`), })); } else if (choice === "skip_milestone") { const milestoneIds = findMilestoneIds(basePath); diff --git a/src/resources/extensions/gsd/prompts/complete-slice.md b/src/resources/extensions/gsd/prompts/complete-slice.md index 66070bdc9..a616e13dd 100644 --- a/src/resources/extensions/gsd/prompts/complete-slice.md +++ b/src/resources/extensions/gsd/prompts/complete-slice.md @@ -28,7 +28,7 @@ Then: 7. Write `{{sliceUatPath}}` — a concrete UAT script with real test cases derived from the slice plan and task summaries. Include preconditions, numbered steps with expected outcomes, and edge cases. This must NOT be a placeholder or generic template — tailor every test case to what this slice actually built. 8. Review task summaries for `key_decisions`. Append any significant decisions to `.gsd/DECISIONS.md` if missing. 9. Mark {{sliceId}} done in `{{roadmapPath}}` (change `[ ]` to `[x]`) -10. Do not commit or squash-merge manually — the system auto-commits your changes and handles the merge after this unit succeeds. +10. Do not run git commands — the system commits your changes and handles any merge after this unit succeeds. 11. Update `.gsd/PROJECT.md` if it exists — refresh current state if needed. 12. Update `.gsd/STATE.md` diff --git a/src/resources/extensions/gsd/prompts/discuss-headless.md b/src/resources/extensions/gsd/prompts/discuss-headless.md index 8e2191667..4a5afb0a2 100644 --- a/src/resources/extensions/gsd/prompts/discuss-headless.md +++ b/src/resources/extensions/gsd/prompts/discuss-headless.md @@ -51,7 +51,7 @@ Use these templates exactly: 5. Write `{{roadmapPath}}` (using Roadmap template) — decompose into demoable vertical slices with checkboxes, risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, requirement coverage, and a boundary map. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice. 6. Seed `.gsd/DECISIONS.md` (using Decisions template) 7. Update `.gsd/STATE.md` -8. Commit: `docs({{milestoneId}}): context, requirements, and roadmap` +8. {{commitInstruction}} 9. Say exactly: "Milestone {{milestoneId}} ready." **For multi-milestone**, write in this order: @@ -71,7 +71,7 @@ Use these templates exactly: ``` Each context file should be rich enough that a future agent — with no memory of this conversation — can understand the intent, constraints, dependencies, what the milestone unlocks, and what "done" looks like. 8. Update `.gsd/STATE.md` -9. Commit: `docs: project plan — N milestones` +9. {{multiMilestoneCommitInstruction}} 10. Say exactly: "Milestone {{milestoneId}} ready." ## Critical Rules diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index d66ca2932..de1f5a56f 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -201,9 +201,9 @@ When writing context.md, preserve the user's exact terminology, emphasis, and sp 5. Write `{{roadmapPath}}` — use the **Roadmap** output template below. Decompose into demoable vertical slices with checkboxes, risk, depends, demo sentences, proof strategy, verification classes, milestone definition of done, requirement coverage, and a boundary map. If the milestone crosses multiple runtime boundaries, include an explicit final integration slice that proves the assembled system works end-to-end in a real environment. 6. Seed `.gsd/DECISIONS.md` — use the **Decisions** output template below. Append rows for any architectural or pattern decisions made during discussion. 7. Update `.gsd/STATE.md` -8. Commit: `docs({{milestoneId}}): context, requirements, and roadmap` +8. {{commitInstruction}} -After writing the files and committing, say exactly: "Milestone {{milestoneId}} ready." — nothing else. Auto-mode will start automatically. +After writing the files, say exactly: "Milestone {{milestoneId}} ready." — nothing else. Auto-mode will start automatically. ### Multi-Milestone @@ -271,8 +271,8 @@ For single-milestone projects, do NOT write this file — it is only for multi-m #### Phase 4: Finalize 7. Update `.gsd/STATE.md` -8. Commit: `docs: project plan — N milestones` (replace N with the actual milestone count) +8. {{multiMilestoneCommitInstruction}} -After writing the files and committing, say exactly: "Milestone M001 ready." — nothing else. Auto-mode will start automatically. +After writing the files, say exactly: "Milestone M001 ready." — nothing else. Auto-mode will start automatically. {{inlinedTemplates}} diff --git a/src/resources/extensions/gsd/prompts/execute-task.md b/src/resources/extensions/gsd/prompts/execute-task.md index 8f4690f74..452b5b735 100644 --- a/src/resources/extensions/gsd/prompts/execute-task.md +++ b/src/resources/extensions/gsd/prompts/execute-task.md @@ -63,7 +63,7 @@ Then: 14. Read the template at `~/.gsd/agent/extensions/gsd/templates/task-summary.md` 15. Write `{{taskSummaryPath}}` 16. Mark {{taskId}} done in `{{planPath}}` (change `[ ]` to `[x]`) -17. Do not commit manually — the system auto-commits your changes after this unit completes. +17. Do not run git commands — the system reads your task summary after completion and creates a meaningful commit from it (type inferred from title, message from your one-liner, key files from frontmatter). Write a clear, specific one-liner in the summary — it becomes the commit message. 18. Update `.gsd/STATE.md` All work stays in your working directory: `{{workingDirectory}}`. diff --git a/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md b/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md index c2e7f8836..180574018 100644 --- a/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md +++ b/src/resources/extensions/gsd/prompts/guided-discuss-milestone.md @@ -104,5 +104,5 @@ Once the user confirms depth: 1. Use the **Context** output template below 2. `mkdir -p` the milestone directory if needed 3. Write `{{milestoneId}}-CONTEXT.md` — preserve the user's exact terminology, emphasis, and framing. Do not paraphrase nuance into generic summaries. The context file is downstream agents' only window into this conversation. -4. Commit: `git add {{milestoneId}}-CONTEXT.md && git commit -m "docs({{milestoneId}}): milestone context from discuss"` +4. {{commitInstruction}} 5. Say exactly: `"{{milestoneId}} context written."` — nothing else. diff --git a/src/resources/extensions/gsd/prompts/guided-discuss-slice.md b/src/resources/extensions/gsd/prompts/guided-discuss-slice.md index 5c7806ebb..8bc9dadd6 100644 --- a/src/resources/extensions/gsd/prompts/guided-discuss-slice.md +++ b/src/resources/extensions/gsd/prompts/guided-discuss-slice.md @@ -55,7 +55,7 @@ Once the user is ready to wrap up: - **Constraints** — anything the user flagged as a hard constraint - **Integration Points** — what this slice consumes and produces - **Open Questions** — anything still unresolved, with current thinking -4. Commit: `git -C {{projectRoot}} add {{contextPath}} && git -C {{projectRoot}} commit -m "docs({{milestoneId}}/{{sliceId}}): slice context from discuss"` +4. {{commitInstruction}} 5. Say exactly: `"{{sliceId}} context written."` — nothing else. {{inlinedTemplates}} diff --git a/src/resources/extensions/gsd/prompts/plan-slice.md b/src/resources/extensions/gsd/prompts/plan-slice.md index 51d276a66..ae3099fa9 100644 --- a/src/resources/extensions/gsd/prompts/plan-slice.md +++ b/src/resources/extensions/gsd/prompts/plan-slice.md @@ -59,7 +59,7 @@ Then: - **Scope sanity:** Target 2–5 steps and 3–8 files per task. 10+ steps or 12+ files — must split. Each task must be completable in a single fresh context window. - **Feature completeness:** Every task produces real, user-facing progress — not just internal scaffolding. 9. If planning produced structural decisions, append them to `.gsd/DECISIONS.md` -10. Commit: `docs({{sliceId}}): add slice plan` +10. {{commitInstruction}} 11. Update `.gsd/STATE.md` The slice directory and tasks/ subdirectory already exist. Do NOT mkdir. All work stays in your working directory: `{{workingDirectory}}`. diff --git a/src/resources/extensions/gsd/prompts/queue.md b/src/resources/extensions/gsd/prompts/queue.md index 08bf5b4c6..55406fba7 100644 --- a/src/resources/extensions/gsd/prompts/queue.md +++ b/src/resources/extensions/gsd/prompts/queue.md @@ -96,7 +96,7 @@ Then, after all milestone directories and context files are written: 4. If `.gsd/REQUIREMENTS.md` exists and the queued work introduces new in-scope capabilities or promotes Deferred items, update it. 5. If discussion produced decisions relevant to existing work, append to `.gsd/DECISIONS.md`. 6. Append to `.gsd/QUEUE.md`. -7. Commit: `docs: queue ` +7. {{commitInstruction}} **Do NOT write roadmaps for queued milestones.** **Do NOT update `.gsd/STATE.md`.** diff --git a/src/resources/extensions/gsd/prompts/reassess-roadmap.md b/src/resources/extensions/gsd/prompts/reassess-roadmap.md index 4f9cf3628..48843d321 100644 --- a/src/resources/extensions/gsd/prompts/reassess-roadmap.md +++ b/src/resources/extensions/gsd/prompts/reassess-roadmap.md @@ -57,7 +57,7 @@ Write `{{assessmentPath}}` with a brief confirmation that roadmap coverage still 1. Rewrite the remaining (unchecked) slices in `{{roadmapPath}}`. Keep completed slices exactly as they are (`[x]`). Update the boundary map for changed slices. Update the proof strategy if risks changed. Update requirement coverage if ownership or scope changed. 2. Write `{{assessmentPath}}` explaining what changed and why — keep it brief and concrete. 3. If `.gsd/REQUIREMENTS.md` exists and requirement ownership or status changed, update it. -4. Commit: `docs({{milestoneId}}): reassess roadmap after {{completedSliceId}}` +4. {{commitInstruction}} **You MUST write the file `{{assessmentPath}}` before finishing.** diff --git a/src/resources/extensions/gsd/tests/git-service.test.ts b/src/resources/extensions/gsd/tests/git-service.test.ts index c7f69993f..d5e73a888 100644 --- a/src/resources/extensions/gsd/tests/git-service.test.ts +++ b/src/resources/extensions/gsd/tests/git-service.test.ts @@ -5,6 +5,7 @@ import { execSync } from "node:child_process"; import { inferCommitType, + buildTaskCommitMessage, GitServiceImpl, RUNTIME_EXCLUSION_PATHS, VALID_BRANCH_NAME, @@ -14,6 +15,7 @@ import { type GitPreferences, type CommitOptions, type PreMergeCheckResult, + type TaskCommitContext, } from "../git-service.ts"; import { createTestContext } from './test-helpers.ts'; @@ -188,6 +190,58 @@ async function main(): Promise { "'prefix' does not match 'fix' — word boundary prevents partial match" ); + // ─── inferCommitType with oneLiner ────────────────────────────────────── + + console.log("\n=== inferCommitType with oneLiner ==="); + + assertEq( + inferCommitType("implement dashboard", "Fixed rendering bug in sidebar"), + "fix", + "one-liner with 'fixed' overrides generic title → fix" + ); + + assertEq( + inferCommitType("add search", "Optimized query performance with caching"), + "perf", + "one-liner with 'performance' and 'caching' → perf" + ); + + // ─── buildTaskCommitMessage ───────────────────────────────────────────── + + console.log("\n=== buildTaskCommitMessage ==="); + + { + const msg = buildTaskCommitMessage({ + taskId: "S01/T02", + taskTitle: "implement user authentication", + oneLiner: "Added JWT-based auth with refresh token rotation", + keyFiles: ["src/auth.ts", "src/middleware/jwt.ts"], + }); + assertTrue(msg.startsWith("feat(S01/T02):"), "message starts with type(scope)"); + assertTrue(msg.includes("JWT-based auth"), "message includes one-liner content"); + assertTrue(msg.includes("- src/auth.ts"), "message body includes key files"); + assertTrue(msg.includes("- src/middleware/jwt.ts"), "message body includes second key file"); + } + + { + const msg = buildTaskCommitMessage({ + taskId: "S02/T01", + taskTitle: "fix login redirect bug", + }); + assertTrue(msg.startsWith("fix(S02/T01):"), "infers fix type from title"); + assertTrue(msg.includes("fix login redirect bug"), "uses task title when no one-liner"); + assertTrue(!msg.includes("\n"), "no body when no key files"); + } + + { + const msg = buildTaskCommitMessage({ + taskId: "S01/T03", + taskTitle: "add tests", + oneLiner: "Unit tests for auth module with coverage", + }); + assertTrue(msg.startsWith("test(S01/T03):"), "infers test type"); + } + // ─── RUNTIME_EXCLUSION_PATHS ─────────────────────────────────────────── console.log("\n=== RUNTIME_EXCLUSION_PATHS ==="); @@ -430,13 +484,25 @@ async function main(): Promise { const svc = new GitServiceImpl(repo); createFile(repo, "src/new-feature.ts", "export const x = 1;"); + + // Without task context, autoCommit uses generic chore message const msg = svc.autoCommit("task", "T01"); + assertEq(msg, "chore(T01): auto-commit after task", "autoCommit returns generic format without task context"); - assertEq(msg, "chore(T01): auto-commit after task", "autoCommit returns correct message format"); - - // Verify the commit exists const log = run("git log --oneline -1", repo); - assertTrue(log.includes("chore(T01): auto-commit after task"), "commit message is in git log"); + assertTrue(log.includes("chore(T01): auto-commit after task"), "generic commit message is in git log"); + + // With task context, autoCommit uses meaningful message + createFile(repo, "src/auth.ts", "export function login() {}"); + const msg2 = svc.autoCommit("task", "S01/T02", [], { + taskId: "S01/T02", + taskTitle: "implement user authentication endpoint", + oneLiner: "Added JWT-based auth with refresh token rotation", + keyFiles: ["src/auth.ts"], + }); + assertTrue(msg2 !== null, "autoCommit with task context returns a message"); + assertTrue(msg2!.startsWith("feat(S01/T02):"), "meaningful commit uses feat type and scope"); + assertTrue(msg2!.includes("JWT-based auth"), "meaningful commit includes one-liner content"); rmSync(repo, { recursive: true, force: true }); } diff --git a/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts b/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts index 904819a9a..978dba4a2 100644 --- a/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts +++ b/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts @@ -81,7 +81,7 @@ assert( // Check the branch has draft-aware menu options const branchIdx = guidedFlowSource.indexOf('state.phase === "needs-discussion"'); -const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 3000); +const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 4000); assert( branchChunk.includes("discuss_draft"), diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index a74b673f1..621867e2e 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -14,10 +14,11 @@ import { sep } from "node:path"; -import { GitServiceImpl, writeIntegrationBranch } from "./git-service.js"; +import { GitServiceImpl, writeIntegrationBranch, type TaskCommitContext } from "./git-service.js"; import { loadEffectiveGSDPreferences } from "./preferences.js"; export { MergeConflictError } from "./git-service.js"; +export type { TaskCommitContext } from "./git-service.js"; // ─── Lazy GitServiceImpl Cache ───────────────────────────────────────────── @@ -162,12 +163,18 @@ export function getCurrentBranch(basePath: string): string { /** * Auto-commit any dirty files in the current working tree. + * + * When `taskContext` is provided, generates a meaningful conventional commit + * message from the task summary (one-liner, inferred type, key files). + * Falls back to a generic `chore()` message for non-task commits. + * * Returns the commit message used, or null if already clean. */ export function autoCommitCurrentBranch( basePath: string, unitType: string, unitId: string, + taskContext?: TaskCommitContext, ): string | null { - return getService(basePath).autoCommit(unitType, unitId); + return getService(basePath).autoCommit(unitType, unitId, [], taskContext); }