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:
parent
28bb77b999
commit
3adacf3ff5
17 changed files with 251 additions and 49 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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}}`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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}}`.
|
||||
|
|
|
|||
|
|
@ -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`.**
|
||||
|
|
|
|||
|
|
@ -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.**
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue