diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go
index 519c380..67aef99 100644
--- a/internal/orchestrator/orchestrator.go
+++ b/internal/orchestrator/orchestrator.go
@@ -711,8 +711,34 @@ func (o *Orchestrator) PlanPrompt(task *tasks.Task) string {
return o.buildPlanPrompt(task)
}
-func taskSpecificPlanGuidance(task *tasks.Task) string {
+func changelogDocumentedBranchGuidance(documentedBranch string) string {
+ if documentedBranch != "" {
+ return fmt.Sprintf(
+ "- Nightshift provided `%s` as the branch being documented. Use `%s` as the changelog subject even after you create your working branch; do not compare the temporary implementation branch against itself.\n"+
+ "- Determine `%s`'s comparison base from PR metadata, its upstream tracking branch, or the repo's release/default-branch workflow. If the base is unclear, state the assumption you made.\n"+
+ "- Derive the commit range from `git merge-base %s` through `%s`, and exclude merge commits so the draft stays deterministic.\n",
+ documentedBranch,
+ documentedBranch,
+ documentedBranch,
+ documentedBranch,
+ documentedBranch,
+ )
+ }
+
+ return "- Record the branch you started on before creating the Nightshift working branch and use that recorded branch as the changelog subject; do not compare the temporary implementation branch against itself.\n" +
+ "- Determine that branch's comparison base from PR metadata, its upstream tracking branch, or the repo's release/default-branch workflow. If the base is unclear, state the assumption you made.\n" +
+ "- Derive the commit range from `git merge-base ` through ``, and exclude merge commits so the draft stays deterministic.\n"
+}
+
+func taskSpecificPlanGuidance(task *tasks.Task, documentedBranch string) string {
switch task.Type {
+ case tasks.TaskChangelogSynth:
+ return "\n\n## Task-Specific Guidance\n" +
+ "- Inspect the repo's current changelog and release signals before deciding the scope: CHANGELOG.md, release workflow files if present (for example .github/workflows/release.yml), and recent git tags/commits.\n" +
+ changelogDocumentedBranchGuidance(documentedBranch) +
+ "- If the release boundary or target changelog section is unclear, state the assumptions you made.\n" +
+ "- Prefer updating the existing changelog artifact and structure instead of inventing a new format, and preserve prior changelog history.\n" +
+ "- Plan for stable Markdown sections such as Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other. Omit empty sections when appropriate.\n"
case tasks.TaskReleaseNotes:
return "\n\n## Task-Specific Guidance\n" +
"- Inspect the repo's existing release signals before deciding the scope: CHANGELOG.md, .github/workflows/release.yml, and recent git tags/commits.\n" +
@@ -724,8 +750,16 @@ func taskSpecificPlanGuidance(task *tasks.Task) string {
}
}
-func taskSpecificImplementGuidance(task *tasks.Task) string {
+func taskSpecificImplementGuidance(task *tasks.Task, documentedBranch string) string {
switch task.Type {
+ case tasks.TaskChangelogSynth:
+ return "\n\n## Task-Specific Guidance\n" +
+ "- Inspect the repo's current changelog and release signals before drafting: CHANGELOG.md, release workflow files if present (for example .github/workflows/release.yml), and recent git tags/commits.\n" +
+ changelogDocumentedBranchGuidance(documentedBranch) +
+ "- Synthesize only the newest changelog section from commits in that range, note assumptions if the release boundary is unclear, and do not invent entries.\n" +
+ "- Preserve prior changelog sections and structure; prefer updating the existing changelog artifact over creating a new format.\n" +
+ "- Organize the update into stable Markdown sections such as Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other. Omit empty sections when appropriate.\n" +
+ "- Emit Markdown-ready changelog content only.\n"
case tasks.TaskReleaseNotes:
return "\n\n## Task-Specific Guidance\n" +
"- Inspect the repo's existing release signals before drafting: CHANGELOG.md, .github/workflows/release.yml, and recent git tags/commits.\n" +
@@ -743,7 +777,12 @@ func (o *Orchestrator) buildPlanPrompt(task *tasks.Task) string {
branchInstruction = fmt.Sprintf("\n Create your feature branch from `%s`.", o.runMeta.Branch)
}
- taskGuidance := taskSpecificPlanGuidance(task)
+ documentedBranch := ""
+ if o.runMeta != nil {
+ documentedBranch = o.runMeta.Branch
+ }
+
+ taskGuidance := taskSpecificPlanGuidance(task, documentedBranch)
return fmt.Sprintf(`You are a planning agent. Create a detailed execution plan for this task.
@@ -783,7 +822,12 @@ func (o *Orchestrator) buildImplementPrompt(task *tasks.Task, plan *PlanOutput,
branchInstruction = fmt.Sprintf("\n Checkout `%s` before creating your feature branch.", o.runMeta.Branch)
}
- taskGuidance := taskSpecificImplementGuidance(task)
+ documentedBranch := ""
+ if o.runMeta != nil {
+ documentedBranch = o.runMeta.Branch
+ }
+
+ taskGuidance := taskSpecificImplementGuidance(task, documentedBranch)
return fmt.Sprintf(`You are an implementation agent. Execute the plan for this task.
diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go
index 6c9ba4d..4fb057e 100644
--- a/internal/orchestrator/orchestrator_test.go
+++ b/internal/orchestrator/orchestrator_test.go
@@ -809,6 +809,99 @@ func TestBuildImplementPrompt_WithoutBranch(t *testing.T) {
}
}
+func TestBuildPlanPrompt_ChangelogSynthUsesProvidedBranchAsDocumentedBranch(t *testing.T) {
+ o := New()
+ o.SetRunMetadata(&RunMetadata{Branch: "codex/release-notes-drafter"})
+
+ task := &tasks.Task{
+ ID: "changelog-synth:/tmp/nightshift",
+ Title: "Changelog Synthesizer",
+ Description: "Draft a deterministic changelog update",
+ Type: tasks.TaskChangelogSynth,
+ }
+
+ prompt := o.buildPlanPrompt(task)
+
+ expected := []string{
+ "CHANGELOG.md",
+ ".github/workflows/release.yml",
+ "Nightshift provided `codex/release-notes-drafter` as the branch being documented",
+ "Use `codex/release-notes-drafter` as the changelog subject even after you create your working branch",
+ "Determine `codex/release-notes-drafter`'s comparison base",
+ "`git merge-base codex/release-notes-drafter`",
+ "exclude merge commits",
+ "preserve prior changelog history",
+ "Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other",
+ "state the assumptions you made",
+ }
+
+ for _, want := range expected {
+ if !strings.Contains(prompt, want) {
+ t.Errorf("plan prompt missing %q\nGot:\n%s", want, prompt)
+ }
+ }
+
+ unexpected := []string{
+ "as the comparison base",
+ "`git merge-base codex/release-notes-drafter HEAD`",
+ "git merge-base main HEAD",
+ }
+
+ for _, notWant := range unexpected {
+ if strings.Contains(prompt, notWant) {
+ t.Errorf("plan prompt should not contain %q\nGot:\n%s", notWant, prompt)
+ }
+ }
+}
+
+func TestBuildImplementPrompt_ChangelogSynthWithoutProvidedBranchUsesRecordedBranch(t *testing.T) {
+ o := New()
+
+ task := &tasks.Task{
+ ID: "changelog-synth:/tmp/nightshift",
+ Title: "Changelog Synthesizer",
+ Description: "Draft a deterministic changelog update",
+ Type: tasks.TaskChangelogSynth,
+ }
+ plan := &PlanOutput{
+ Steps: []string{"Inspect CHANGELOG.md", "Draft the newest changelog section"},
+ Description: "Use the correct comparison base for the documented branch and preserve changelog structure.",
+ }
+
+ prompt := o.buildImplementPrompt(task, plan, 1)
+
+ expected := []string{
+ "CHANGELOG.md",
+ ".github/workflows/release.yml",
+ "Record the branch you started on before creating the Nightshift working branch",
+ "do not compare the temporary implementation branch against itself",
+ "PR metadata, its upstream tracking branch, or the repo's release/default-branch workflow",
+ "`git merge-base `",
+ "exclude merge commits",
+ "do not invent entries",
+ "Preserve prior changelog sections and structure",
+ "Emit Markdown-ready changelog content only",
+ }
+
+ for _, want := range expected {
+ if !strings.Contains(prompt, want) {
+ t.Errorf("implement prompt missing %q\nGot:\n%s", want, prompt)
+ }
+ }
+
+ unexpected := []string{
+ "as the comparison base",
+ "`git merge-base HEAD`",
+ "git merge-base main HEAD",
+ }
+
+ for _, notWant := range unexpected {
+ if strings.Contains(prompt, notWant) {
+ t.Errorf("implement prompt should not contain %q\nGot:\n%s", notWant, prompt)
+ }
+ }
+}
+
func TestBuildPlanPrompt_ReleaseNotesIncludesRepoAwareGuidance(t *testing.T) {
o := New()
@@ -871,7 +964,7 @@ func TestBuildImplementPrompt_ReleaseNotesIncludesRepoAwareGuidance(t *testing.T
}
}
-func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesGuidance(t *testing.T) {
+func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesOrChangelogGuidance(t *testing.T) {
o := New()
task := &tasks.Task{
@@ -894,6 +987,11 @@ func TestBuildPrompts_GenericTasksDoNotReceiveReleaseNotesGuidance(t *testing.T)
"release boundary",
"current release-notes artifact and format",
"Features, Fixes, Breaking Changes, and Other",
+ "branch being documented",
+ "temporary implementation branch against itself",
+ "git merge-base ",
+ "Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other",
+ "Markdown-ready changelog content only",
}
for _, notWant := range unexpected {
diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go
index 0959c08..c732939 100644
--- a/internal/tasks/tasks.go
+++ b/internal/tasks/tasks.go
@@ -338,10 +338,16 @@ Apply safe updates directly, and leave concise follow-ups for anything uncertain
DefaultInterval: 24 * time.Hour,
},
TaskChangelogSynth: {
- Type: TaskChangelogSynth,
- Category: CategoryPR,
- Name: "Changelog Synthesizer",
- Description: "Generate changelog from commits",
+ Type: TaskChangelogSynth,
+ Category: CategoryPR,
+ Name: "Changelog Synthesizer",
+ Description: `Inspect the repo's current changelog and synthesize the next changelog update for the branch being documented.
+Use the branch Nightshift checked out before your working branch as the branch to document, or record the original branch yourself if none was provided.
+Determine that branch's comparison base from PR metadata, upstream tracking info, or the repo's release/default-branch workflow, and state the assumption if the base is unclear.
+Derive the commit range from git merge-base through , excluding merge commits so the output stays deterministic.
+Preserve existing changelog history and structure, updating only the newest relevant section instead of rewriting older entries.
+Group entries into stable Markdown sections such as Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other, omitting empty sections when appropriate.
+Base every bullet on actual commits, do not invent changes, and emit Markdown-ready changelog content only.`,
CostTier: CostLow,
RiskLevel: RiskLow,
DefaultInterval: 168 * time.Hour,
diff --git a/internal/tasks/tasks_test.go b/internal/tasks/tasks_test.go
index 03cdf18..aff5ef5 100644
--- a/internal/tasks/tasks_test.go
+++ b/internal/tasks/tasks_test.go
@@ -1,6 +1,7 @@
package tasks
import (
+ "strings"
"testing"
"time"
)
@@ -104,6 +105,42 @@ func TestGetDefinition(t *testing.T) {
}
}
+func TestTaskChangelogSynthDefinitionIncludesDeterministicBranchAwareGuidance(t *testing.T) {
+ def, err := GetDefinition(TaskChangelogSynth)
+ if err != nil {
+ t.Fatalf("GetDefinition(TaskChangelogSynth) returned error: %v", err)
+ }
+
+ expected := []string{
+ "branch being documented",
+ "branch Nightshift checked out before your working branch",
+ "PR metadata, upstream tracking info, or the repo's release/default-branch workflow",
+ "git merge-base ",
+ "excluding merge commits",
+ "Preserve existing changelog history and structure",
+ "Added, Changed, Fixed, Docs, Refactor, Tests, Chore, and Other",
+ "do not invent changes",
+ "Markdown-ready changelog content only",
+ }
+
+ for _, want := range expected {
+ if !strings.Contains(def.Description, want) {
+ t.Errorf("TaskChangelogSynth description missing %q\nGot:\n%s", want, def.Description)
+ }
+ }
+
+ unexpected := []string{
+ "Nightshift provides a base branch",
+ "git merge-base HEAD",
+ }
+
+ for _, notWant := range unexpected {
+ if strings.Contains(def.Description, notWant) {
+ t.Errorf("TaskChangelogSynth description should not contain %q\nGot:\n%s", notWant, def.Description)
+ }
+ }
+}
+
func TestGetCostEstimate(t *testing.T) {
// Low cost task
min, max, err := GetCostEstimate(TaskLintFix)
diff --git a/website/docs/task-reference.md b/website/docs/task-reference.md
index 754a067..de1db23 100644
--- a/website/docs/task-reference.md
+++ b/website/docs/task-reference.md
@@ -24,7 +24,7 @@ Fully formed, review-ready artifacts. These tasks create branches and open pull
| `build-optimize` | Build Time Optimization | Optimize build configuration for faster builds | High | Medium | 7d |
| `docs-backfill` | Documentation Backfiller | Generate missing documentation | Low | Low | 7d |
| `commit-normalize` | Commit Message Normalizer | Standardize commit message format | Low | Low | 24h |
-| `changelog-synth` | Changelog Synthesizer | Generate changelog from commits | Low | Low | 7d |
+| `changelog-synth` | Changelog Synthesizer | Draft a deterministic changelog update for the branch being documented, deriving real commits from `git merge-base ` and preserving existing `CHANGELOG.md` structure | Low | Low | 7d |
| `release-notes` | Release Note Drafter | Draft release-ready notes for the next version | Low | Low | 7d |
| `adr-draft` | ADR Drafter | Draft Architecture Decision Records | Medium | Low | 7d |
| `td-review` | TD Review Session | Review open td reviews, fix obvious bugs, create tasks for bigger issues | High | Medium | 72h |