diff --git a/.gitmessage b/.gitmessage new file mode 100644 index 0000000..466d1de --- /dev/null +++ b/.gitmessage @@ -0,0 +1,16 @@ +# Commit format: +# (): +# +# Examples: +# fix(tasks): standardize commit prompt handling +# docs: add commit message guide +# +# Allowed types: +# build, chore, ci, docs, feat, fix, perf, refactor, release, revert, style, test +# +# Keep the subject under 72 characters and do not end it with a period. +# Add a body only when it helps explain why or captures tradeoffs. +# +# When Nightshift creates the change, include both trailers: +# Nightshift-Task: +# Nightshift-Ref: https://github.com/marcus/nightshift diff --git a/Makefile b/Makefile index 088be01..a154a99 100644 --- a/Makefile +++ b/Makefile @@ -75,10 +75,13 @@ help: @echo " check - Run tests and lint" @echo " install - Build and install to Go bin directory" @echo " calibrate-providers - Compare local Claude/Codex session usage for calibration" - @echo " install-hooks - Install git pre-commit hook" + @echo " install-hooks - Install git hooks and commit template" @echo " help - Show this help" -# Install git pre-commit hook +# Install git hooks and commit template install-hooks: @ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit - @echo "✓ pre-commit hook installed (.git/hooks/pre-commit → scripts/pre-commit.sh)" + @ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg + @git config commit.template "$$(pwd)/.gitmessage" + @echo "✓ hooks installed (.git/hooks/pre-commit, .git/hooks/commit-msg)" + @echo "✓ commit template configured (.gitmessage)" diff --git a/README.md b/README.md index 84f92cd..3c920e4 100644 --- a/README.md +++ b/README.md @@ -258,18 +258,33 @@ Each task has a default cooldown interval to prevent the same task from running ## Development -### Pre-commit hooks +### Git Hooks And Commit Template -Install the git pre-commit hook to catch formatting and vet issues before pushing: +Install the local git hooks and commit template: ```bash make install-hooks ``` -This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs: +This installs: + +- `scripts/pre-commit.sh` into `.git/hooks/pre-commit` +- `scripts/commit-msg.sh` into `.git/hooks/commit-msg` +- `.gitmessage` as the local `commit.template` + +The commit-message standard for this repo is: + +```text +(): +``` + +See `docs/guides/commit-messages.md` for examples and the Nightshift trailer rules. + +The hooks run: - **gofmt** — flags any staged `.go` files that need formatting - **go vet** — catches common correctness issues - **go build** — ensures the project compiles +- **commit-msg** — validates conventional-style subjects and Nightshift trailers To bypass in a pinch: `git commit --no-verify` 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/docs/guides/commit-messages.md b/docs/guides/commit-messages.md new file mode 100644 index 0000000..e01ae3d --- /dev/null +++ b/docs/guides/commit-messages.md @@ -0,0 +1,48 @@ +# Commit Message Guide + +Nightshift now standardizes future commits around a small conventional format that matches most recent history: + +```text +(): +``` + +Examples: + +- `fix(tasks): standardize commit message template` +- `feat(run): add preflight summary display` +- `docs: add commit message guide` + +## Rules + +- Use one of: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `release`, `revert`, `style`, `test` +- Keep the subject under 72 characters +- Use an imperative summary +- Do not end the subject with a period +- Add a body only when it adds useful context + +## Nightshift Trailers + +When a change is made by Nightshift or one of its agents, include both trailers together: + +```text +Nightshift-Task: +Nightshift-Ref: https://github.com/marcus/nightshift +``` + +Leave a blank line before the trailers. + +## Local Setup + +Run: + +```bash +make install-hooks +``` + +This installs: + +- `.git/hooks/pre-commit` for formatting, vet, and build checks +- `.git/hooks/commit-msg` for commit subject and Nightshift trailer validation +- `.gitmessage` as the local commit template for this repository + +The hook is intentionally forward-looking. It does not rewrite or validate old history. 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/commit_message_hook_test.go b/internal/tasks/commit_message_hook_test.go new file mode 100644 index 0000000..62b5c5b --- /dev/null +++ b/internal/tasks/commit_message_hook_test.go @@ -0,0 +1,78 @@ +package tasks + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestCommitMsgHookAcceptsConventionalSubject(t *testing.T) { + out, err := runCommitMsgHook(t, "fix(tasks): standardize commit message template\n") + if err != nil { + t.Fatalf("commit-msg hook rejected valid message: %v\n%s", err, out) + } +} + +func TestCommitMsgHookRejectsNonStandardSubject(t *testing.T) { + out, err := runCommitMsgHook(t, "Update commit message handling\n") + if err == nil { + t.Fatal("expected commit-msg hook to reject non-standard subject") + } + if !strings.Contains(out, "(): ") { + t.Fatalf("unexpected hook output:\n%s", out) + } +} + +func TestCommitMsgHookRequiresPairedNightshiftTrailers(t *testing.T) { + out, err := runCommitMsgHook(t, "fix(tasks): standardize commit message template\n\nNightshift-Task: commit-normalize\n") + if err == nil { + t.Fatal("expected commit-msg hook to reject partial Nightshift trailers") + } + if !strings.Contains(out, "Nightshift commits must include both trailers") { + t.Fatalf("unexpected hook output:\n%s", out) + } +} + +func TestCommitMsgHookAcceptsNightshiftTrailers(t *testing.T) { + msg := strings.Join([]string{ + "docs: add commit message guide", + "", + "Nightshift-Task: commit-normalize", + "Nightshift-Ref: https://github.com/marcus/nightshift", + "", + }, "\n") + + out, err := runCommitMsgHook(t, msg) + if err != nil { + t.Fatalf("commit-msg hook rejected valid Nightshift trailers: %v\n%s", err, out) + } +} + +func runCommitMsgHook(t *testing.T, message string) (string, error) { + t.Helper() + + dir := t.TempDir() + msgFile := filepath.Join(dir, "COMMIT_EDITMSG") + if err := os.WriteFile(msgFile, []byte(message), 0644); err != nil { + t.Fatalf("write temp commit message: %v", err) + } + + scriptPath := filepath.Join(repoRoot(t), "scripts", "commit-msg.sh") + cmd := exec.Command("bash", scriptPath, msgFile) + cmd.Dir = repoRoot(t) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func repoRoot(t *testing.T) string { + t.Helper() + + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} 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{ diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..126dab3 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# Install: make install-hooks +set -euo pipefail + +msg_file="${1:-}" +if [[ -z "${msg_file}" || ! -f "${msg_file}" ]]; then + echo "commit-msg: expected path to commit message file" >&2 + exit 1 +fi + +subject=$( + grep -vE '^[[:space:]]*#' "$msg_file" | awk 'NF { print; exit }' +) + +if [[ -z "${subject}" ]]; then + exit 0 +fi + +case "${subject}" in + Merge\ *|Revert\ *|fixup!\ *|squash!\ *) + exit 0 + ;; +esac + +pattern='^(build|chore|ci|docs|feat|fix|perf|refactor|release|revert|style|test)(\([a-z0-9][a-z0-9._/-]*\))?!?: [^ ].+$' +if ! [[ "${subject}" =~ ${pattern} ]]; then + cat >&2 <<'EOF' +commit-msg: subject must use: + (): + +Examples: + fix(tasks): standardize commit message template + docs: add commit message guide + +Allowed types: + build, chore, ci, docs, feat, fix, perf, refactor, release, revert, style, test + +See docs/guides/commit-messages.md for details. +EOF + exit 1 +fi + +if ((${#subject} > 72)); then + echo "commit-msg: subject is ${#subject} chars; keep it under 72" >&2 + exit 1 +fi + +if [[ "${subject}" == *. ]]; then + echo "commit-msg: subject should not end with a period" >&2 + exit 1 +fi + +nightshift_task_count=$(grep -c '^Nightshift-Task: ' "$msg_file" || true) +nightshift_ref_count=$(grep -c '^Nightshift-Ref: ' "$msg_file" || true) + +if ((nightshift_task_count + nightshift_ref_count == 0)); then + exit 0 +fi + +if ((nightshift_task_count != 1 || nightshift_ref_count != 1)); then + cat >&2 <<'EOF' +commit-msg: Nightshift commits must include both trailers: + Nightshift-Task: + Nightshift-Ref: https://github.com/marcus/nightshift +EOF + exit 1 +fi + +if ! grep -qx 'Nightshift-Ref: https://github.com/marcus/nightshift' "$msg_file"; then + echo "commit-msg: Nightshift-Ref must be https://github.com/marcus/nightshift" >&2 + exit 1 +fi + +first_trailer_line=$(grep -n '^Nightshift-\(Task\|Ref\): ' "$msg_file" | head -n 1 | cut -d: -f1) +if [[ -n "${first_trailer_line}" ]] && ((first_trailer_line > 1)); then + line_before=$(sed -n "$((first_trailer_line - 1))p" "$msg_file") + if [[ -n "${line_before}" ]]; then + echo "commit-msg: leave a blank line before Nightshift trailers" >&2 + exit 1 + fi +fi diff --git a/website/docs/tasks.md b/website/docs/tasks.md index fead41d..2da2426 100644 --- a/website/docs/tasks.md +++ b/website/docs/tasks.md @@ -56,6 +56,12 @@ nightshift task run lint-fix --provider claude --dry-run nightshift task run lint-fix --provider claude ``` +## Built-in Task Prompts + +Built-in task descriptions stay short in `nightshift task list`, previews, and JSON output. When a task needs extra guardrails, Nightshift uses richer agent instructions behind the scenes for `task show --prompt-only`, `task run`, and orchestrated runs. + +`commit-normalize` is the reference example: it remains a concise PR task in the catalog, but the agent prompt tells Nightshift to inspect existing repo conventions, standardize future commit behavior with minimal docs or enforcement changes, preserve Nightshift trailers, and avoid rewriting published history. + ## Skill Grooming Task Nightshift includes a built-in `skill-groom` task for keeping project-local skills aligned with the current codebase.