Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions cmd/nightshift/commands/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
9 changes: 2 additions & 7 deletions cmd/nightshift/commands/preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
9 changes: 2 additions & 7 deletions cmd/nightshift/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
11 changes: 6 additions & 5 deletions cmd/nightshift/commands/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
26 changes: 23 additions & 3 deletions cmd/nightshift/commands/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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 {
Expand Down
20 changes: 16 additions & 4 deletions docs/guides/adding-tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand All @@ -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
6 changes: 3 additions & 3 deletions internal/orchestrator/orchestrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
32 changes: 32 additions & 0 deletions internal/orchestrator/orchestrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 42 additions & 9 deletions internal/tasks/tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"cmp"
"fmt"
"slices"
"strings"
"time"
)

Expand Down Expand Up @@ -215,6 +216,7 @@ type TaskDefinition struct {
Category TaskCategory
Name string
Description string
AgentInstructions string
CostTier CostTier
RiskLevel RiskLevel
DefaultInterval time.Duration
Expand Down Expand Up @@ -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{}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading