From 20654bdde9839ff6e551a0540bab44f728ea19c7 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Sat, 4 Apr 2026 02:56:23 -0700 Subject: [PATCH] feat(tasks): split task summaries from agent prompts Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- cmd/nightshift/commands/daemon.go | 9 +-- cmd/nightshift/commands/preview.go | 9 +-- cmd/nightshift/commands/run.go | 9 +-- cmd/nightshift/commands/task.go | 11 +-- cmd/nightshift/commands/task_test.go | 26 ++++++- docs/guides/adding-tasks.md | 20 ++++- internal/orchestrator/orchestrator.go | 6 +- internal/orchestrator/orchestrator_test.go | 32 ++++++++ internal/tasks/tasks.go | 51 ++++++++++--- internal/tasks/tasks_test.go | 86 ++++++++++++++++++++++ 10 files changed, 214 insertions(+), 45 deletions(-) diff --git a/cmd/nightshift/commands/daemon.go b/cmd/nightshift/commands/daemon.go index d082b0e..3b8cec0 100644 --- a/cmd/nightshift/commands/daemon.go +++ b/cmd/nightshift/commands/daemon.go @@ -369,13 +369,8 @@ func runScheduledTasks(ctx context.Context, cfg *config.Config, database *db.DB, projectTaskTypes = append(projectTaskTypes, string(scoredTask.Definition.Type)) // Create task instance - taskInstance := &tasks.Task{ - ID: fmt.Sprintf("%s:%s", scoredTask.Definition.Type, projectPath), - Title: scoredTask.Definition.Name, - Description: scoredTask.Definition.Description, - Priority: int(scoredTask.Score), - Type: scoredTask.Definition.Type, - } + taskInstance := taskInstanceFromDef(scoredTask.Definition, projectPath) + taskInstance.Priority = int(scoredTask.Score) // Mark as assigned st.MarkAssigned(taskInstance.ID, projectPath, string(scoredTask.Definition.Type)) diff --git a/cmd/nightshift/commands/preview.go b/cmd/nightshift/commands/preview.go index 8691939..4b09c19 100644 --- a/cmd/nightshift/commands/preview.go +++ b/cmd/nightshift/commands/preview.go @@ -359,13 +359,8 @@ func buildPreviewResult(cfg *config.Config, database *db.DB, projects []string, } projectResult.Tasks = make([]previewTask, 0, len(selected)) for idx, scored := range selected { - taskInstance := &tasks.Task{ - ID: fmt.Sprintf("%s:%s", scored.Definition.Type, project), - Title: scored.Definition.Name, - Description: scored.Definition.Description, - Priority: int(scored.Score), - Type: scored.Definition.Type, - } + taskInstance := taskInstanceFromDef(scored.Definition, project) + taskInstance.Priority = int(scored.Score) prompt := orch.PlanPrompt(taskInstance) minTokens, maxTokens := scored.Definition.EstimatedTokens() diff --git a/cmd/nightshift/commands/run.go b/cmd/nightshift/commands/run.go index 9681002..cf5a95d 100644 --- a/cmd/nightshift/commands/run.go +++ b/cmd/nightshift/commands/run.go @@ -717,13 +717,8 @@ func executeRun(ctx context.Context, p executeRunParams) error { projectTaskTypes = append(projectTaskTypes, string(scoredTask.Definition.Type)) // Create task instance - taskInstance := &tasks.Task{ - ID: fmt.Sprintf("%s:%s", scoredTask.Definition.Type, projectPath), - Title: scoredTask.Definition.Name, - Description: scoredTask.Definition.Description, - Priority: int(scoredTask.Score), - Type: scoredTask.Definition.Type, - } + taskInstance := taskInstanceFromDef(scoredTask.Definition, projectPath) + taskInstance.Priority = int(scoredTask.Score) // Mark as assigned p.st.MarkAssigned(taskInstance.ID, projectPath, string(scoredTask.Definition.Type)) diff --git a/cmd/nightshift/commands/task.go b/cmd/nightshift/commands/task.go index 6b1820e..8befe35 100644 --- a/cmd/nightshift/commands/task.go +++ b/cmd/nightshift/commands/task.go @@ -307,11 +307,12 @@ func taskInstanceFromDef(def tasks.TaskDefinition, projectPath string) *tasks.Ta id = fmt.Sprintf("%s:%s", def.Type, projectPath) } return &tasks.Task{ - ID: id, - Title: def.Name, - Description: def.Description, - Priority: 0, - Type: def.Type, + ID: id, + Title: def.Name, + Description: def.Description, + AgentInstructions: def.PromptText(), + Priority: 0, + Type: def.Type, } } diff --git a/cmd/nightshift/commands/task_test.go b/cmd/nightshift/commands/task_test.go index 86f4e87..50e0323 100644 --- a/cmd/nightshift/commands/task_test.go +++ b/cmd/nightshift/commands/task_test.go @@ -143,9 +143,10 @@ func TestCategoryShort(t *testing.T) { func TestTaskInstanceFromDef(t *testing.T) { def := tasks.TaskDefinition{ - Type: "lint-fix", - Name: "Linter Fixes", - Description: "Fix lint errors", + Type: "lint-fix", + Name: "Linter Fixes", + Description: "Fix lint errors", + AgentInstructions: "Inspect lint output and apply safe fixes", } // Without project path @@ -156,6 +157,12 @@ func TestTaskInstanceFromDef(t *testing.T) { if task.Title != "Linter Fixes" { t.Errorf("Title = %q", task.Title) } + if task.Description != def.Description { + t.Errorf("Description = %q, want %q", task.Description, def.Description) + } + if task.AgentInstructions != def.AgentInstructions { + t.Errorf("AgentInstructions = %q, want %q", task.AgentInstructions, def.AgentInstructions) + } // With project path task = taskInstanceFromDef(def, "/tmp/proj") @@ -164,6 +171,19 @@ func TestTaskInstanceFromDef(t *testing.T) { } } +func TestTaskInstanceFromDefFallsBackToDescription(t *testing.T) { + def := tasks.TaskDefinition{ + Type: "docs-backfill", + Name: "Docs", + Description: "Generate missing documentation", + } + + task := taskInstanceFromDef(def, "") + if task.AgentInstructions != def.Description { + t.Errorf("AgentInstructions = %q, want fallback %q", task.AgentInstructions, def.Description) + } +} + func TestAgentByNameUnknown(t *testing.T) { _, err := agentByName(nil, "unknown-provider") if err == nil { diff --git a/docs/guides/adding-tasks.md b/docs/guides/adding-tasks.md index de87d5c..af606d1 100644 --- a/docs/guides/adding-tasks.md +++ b/docs/guides/adding-tasks.md @@ -25,14 +25,25 @@ TaskMyNewTask: { Type: TaskMyNewTask, Category: CategoryPR, // See categories below Name: "My New Task", - Description: "What the agent should do, written as instructions", + Description: "Short summary shown in task lists", + AgentInstructions: `Optional richer instructions for the agent. +Use this when the CLI summary should stay concise but the prompt +needs more concrete scope or guardrails.`, CostTier: CostMedium, // Estimated token usage RiskLevel: RiskLow, // Risk of unintended changes DefaultInterval: 72 * time.Hour, // Minimum time between runs per project }, ``` -The `Description` field is what the agent sees as its task instructions. Write it as a clear directive. +Use `Description` for the concise user-facing summary shown in task lists, previews, and JSON metadata. Add `AgentInstructions` when the agent needs richer directions; if it is omitted, Nightshift falls back to `Description`. + +`commit-normalize` is the reference example for this split: it stays a short "Standardize commit message format" entry in the catalog, while the agent prompt makes the scope explicit: + +- treat it as a PR task for future commit-message consistency +- inspect existing repo conventions before choosing a format +- avoid rewriting published history +- preserve Nightshift's required commit trailers +- prefer small, explainable enforcement or documentation changes ### Step 3: Update the Completeness Test @@ -115,7 +126,7 @@ tasks: ### How It Works -Custom tasks register into the same task registry as built-in tasks. They participate in the same scoring, cooldown, budget filtering, and plan-implement-review orchestration cycle. The `description` field becomes the agent's task prompt. +Custom tasks register into the same task registry as built-in tasks. They participate in the same scoring, cooldown, budget filtering, and plan-implement-review orchestration cycle. For custom tasks, the `description` field still becomes the agent's task prompt. Custom tasks appear in `nightshift task list` with a `[custom]` label and in JSON output with a `"custom": true` field. @@ -134,7 +145,8 @@ tasks: ### Tips -- Write descriptions as direct instructions to the agent — they become the task prompt +- For built-in tasks, keep `Description` short and add `AgentInstructions` only when the agent needs extra guardrails +- For custom tasks, write `description` as direct instructions to the agent — it becomes the task prompt - Use `nightshift preview --explain` to verify custom tasks appear in the run plan - Run `nightshift run --task my-custom-type --dry-run` to test a specific custom task - Custom task types must not match any built-in task name diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 6141c95..f231b74 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -741,7 +741,7 @@ Description: %s "files": ["file1.go", "file2.go", ...], "description": "overall approach" } -`, task.ID, task.Title, task.Description, branchInstruction, task.Type) +`, task.ID, task.Title, task.PromptText(), branchInstruction, task.Type) } func (o *Orchestrator) buildImplementPrompt(task *tasks.Task, plan *PlanOutput, iteration int) string { @@ -783,7 +783,7 @@ Description: %s "files_modified": ["file1.go", ...], "summary": "what was done" } -`, task.ID, task.Title, task.Description, plan.Description, plan.Steps, iterationNote, branchInstruction, task.Type) +`, task.ID, task.Title, task.PromptText(), plan.Description, plan.Steps, iterationNote, branchInstruction, task.Type) } func (o *Orchestrator) buildReviewPrompt(task *tasks.Task, impl *ImplementOutput) string { @@ -814,7 +814,7 @@ Description: %s } Set "passed" to true ONLY if the implementation is correct and complete. -`, task.ID, task.Title, task.Description, impl.Summary, impl.FilesModified) +`, task.ID, task.Title, task.PromptText(), impl.Summary, impl.FilesModified) } // prURLPattern matches standard GitHub pull request URLs. diff --git a/internal/orchestrator/orchestrator_test.go b/internal/orchestrator/orchestrator_test.go index 45bff5c..73f0065 100644 --- a/internal/orchestrator/orchestrator_test.go +++ b/internal/orchestrator/orchestrator_test.go @@ -468,6 +468,38 @@ func TestBuildPrompts(t *testing.T) { } } +func TestBuildPromptsUseAgentInstructions(t *testing.T) { + o := New() + task := &tasks.Task{ + ID: "prompt-test", + Title: "Build Prompts", + Description: "Short summary", + AgentInstructions: "Detailed agent-only instructions", + } + + plan := &PlanOutput{ + Steps: []string{"step1"}, + Description: "test plan", + } + impl := &ImplementOutput{ + FilesModified: []string{"file1.go"}, + Summary: "test implementation", + } + + for name, prompt := range map[string]string{ + "plan": o.buildPlanPrompt(task), + "implement": o.buildImplementPrompt(task, plan, 1), + "review": o.buildReviewPrompt(task, impl), + } { + if !strings.Contains(prompt, task.AgentInstructions) { + t.Errorf("%s prompt missing agent instructions\nGot:\n%s", name, prompt) + } + if strings.Contains(prompt, "Description: "+task.Description) { + t.Errorf("%s prompt should use agent instructions instead of short summary\nGot:\n%s", name, prompt) + } + } +} + func TestExtractPRURL(t *testing.T) { tests := []struct { name string diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 2c7dabb..81b7937 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -6,6 +6,7 @@ import ( "cmp" "fmt" "slices" + "strings" "time" ) @@ -215,6 +216,7 @@ type TaskDefinition struct { Category TaskCategory Name string Description string + AgentInstructions string CostTier CostTier RiskLevel RiskLevel DefaultInterval time.Duration @@ -246,6 +248,15 @@ func (d TaskDefinition) EstimatedTokens() (min, max int) { return d.CostTier.TokenRange() } +// PromptText returns the agent-facing instructions for this task definition. +// When no explicit agent instructions are provided, Description is reused. +func (d TaskDefinition) PromptText() string { + if strings.TrimSpace(d.AgentInstructions) != "" { + return d.AgentInstructions + } + return d.Description +} + // customTypes tracks which task types were registered via RegisterCustom. var customTypes = map[TaskType]bool{} @@ -329,10 +340,22 @@ Apply safe updates directly, and leave concise follow-ups for anything uncertain DefaultInterval: 168 * time.Hour, }, TaskCommitNormalize: { - Type: TaskCommitNormalize, - Category: CategoryPR, - Name: "Commit Message Normalizer", - Description: "Standardize commit message format", + Type: TaskCommitNormalize, + Category: CategoryPR, + Name: "Commit Message Normalizer", + Description: "Standardize commit message format", + AgentInstructions: `Inspect recent repository history, contribution docs, release tooling, and any automation that depends on commit messages. +Choose or infer one concise commit-message convention that fits this repo, then make safe forward-looking changes so future commits follow it consistently. + +Scope: +- Treat this as a PR task that standardizes future behavior; do not rewrite published history. +- Preserve Nightshift's required commit trailers and do not weaken existing PR/issue linkage conventions. +- Prefer minimal, explainable changes such as docs, templates, hooks, validation, or helper scripts over broad workflow churn. + +Deliverable: +- Update or add the smallest set of files needed to document or enforce the chosen convention. +- Keep commit output concise and consistent with the repository's prevailing style. +- If the repo already has a solid standard, tighten gaps rather than inventing a new format.`, CostTier: CostLow, RiskLevel: RiskLow, DefaultInterval: 24 * time.Hour, @@ -976,14 +999,24 @@ func ClearCustom() { // Task represents a unit of work for an AI agent. type Task struct { - ID string - Title string - Description string - Priority int - Type TaskType // Optional: links to a TaskDefinition + ID string + Title string + Description string + AgentInstructions string + Priority int + Type TaskType // Optional: links to a TaskDefinition // TODO: Add more fields (labels, assignee, source, etc.) } +// PromptText returns the agent-facing prompt text for this task instance. +// When no explicit instructions are attached, Description is reused. +func (t Task) PromptText() string { + if strings.TrimSpace(t.AgentInstructions) != "" { + return t.AgentInstructions + } + return t.Description +} + // Queue holds tasks to be processed. type Queue struct { // TODO: Add fields diff --git a/internal/tasks/tasks_test.go b/internal/tasks/tasks_test.go index 03cdf18..edec055 100644 --- a/internal/tasks/tasks_test.go +++ b/internal/tasks/tasks_test.go @@ -1,6 +1,7 @@ package tasks import ( + "strings" "testing" "time" ) @@ -243,6 +244,91 @@ func TestTaskDefinitionEstimatedTokens(t *testing.T) { } } +func TestTaskDefinitionPromptText(t *testing.T) { + tests := []struct { + name string + def TaskDefinition + want string + }{ + { + name: "falls back to description", + def: TaskDefinition{ + Description: "summary only", + }, + want: "summary only", + }, + { + name: "uses explicit agent instructions", + def: TaskDefinition{ + Description: "short summary", + AgentInstructions: "detailed agent prompt", + }, + want: "detailed agent prompt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.def.PromptText(); got != tt.want { + t.Errorf("PromptText() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestTaskPromptText(t *testing.T) { + tests := []struct { + name string + task Task + want string + }{ + { + name: "falls back to description", + task: Task{ + Description: "summary only", + }, + want: "summary only", + }, + { + name: "uses explicit agent instructions", + task: Task{ + Description: "short summary", + AgentInstructions: "detailed agent prompt", + }, + want: "detailed agent prompt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.task.PromptText(); got != tt.want { + t.Errorf("PromptText() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestCommitNormalizeHasDedicatedAgentInstructions(t *testing.T) { + def, err := GetDefinition(TaskCommitNormalize) + if err != nil { + t.Fatalf("GetDefinition(TaskCommitNormalize) error: %v", err) + } + + if def.Description != "Standardize commit message format" { + t.Errorf("Description = %q, want short catalog summary", def.Description) + } + + for _, want := range []string{ + "do not rewrite published history", + "Preserve Nightshift's required commit trailers", + "Inspect recent repository history", + } { + if !strings.Contains(def.PromptText(), want) { + t.Errorf("PromptText() missing %q\nGot:\n%s", want, def.PromptText()) + } + } +} + func TestRegistryCompleteness(t *testing.T) { // All task type constants should be in registry taskTypes := []TaskType{