feat: meaningful commit messages from task summaries (#803)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
TÂCHES 2026-03-16 23:30:33 -06:00 committed by GitHub
parent 28bb77b999
commit 3adacf3ff5
17 changed files with 251 additions and 49 deletions

View file

@ -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): <what was built>` | The real work |
| Plan/docs committed | `docs(S01): add slice plan` | Bundled with first task |
| Slice squash to main | `type(M001/S01): <slice title>` | Type inferred from title (`feat`, `fix`, `docs`, etc.) |
| Task completed | `{type}(S01/T02): <one-liner from summary>` | 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): <slice title>` | 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

View file

@ -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,
});
}

View file

@ -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
}

View file

@ -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) {

View file

@ -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 <milestone list>"),
});
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);

View file

@ -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`

View file

@ -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

View file

@ -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}}

View file

@ -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}}`.

View file

@ -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.

View file

@ -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}}

View file

@ -59,7 +59,7 @@ Then:
- **Scope sanity:** Target 25 steps and 38 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}}`.

View file

@ -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 <milestone list>`
7. {{commitInstruction}}
**Do NOT write roadmaps for queued milestones.**
**Do NOT update `.gsd/STATE.md`.**

View file

@ -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.**

View file

@ -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<void> {
"'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<void> {
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 });
}

View file

@ -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"),

View file

@ -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);
}