diff --git a/README.md b/README.md index 84f92cd..6939f84 100644 --- a/README.md +++ b/README.md @@ -106,16 +106,16 @@ selected provider, budget status, projects, and planned tasks. In interactive terminals you are prompted for confirmation; in non-TTY environments (cron, daemon, CI) confirmation is auto-skipped. -| Flag | Default | Description | -|------|---------|-------------| -| `--dry-run` | `false` | Show preflight summary and exit without executing | -| `--project`, `-p` | _(all configured)_ | Target a single project directory | -| `--task`, `-t` | _(auto-select)_ | Run a specific task by name | -| `--max-projects` | `1` | Max projects to process (ignored when `--project` is set) | -| `--max-tasks` | `1` | Max tasks per project (ignored when `--task` is set) | -| `--random-task` | `false` | Pick a random task from eligible tasks instead of the highest-scored one | -| `--ignore-budget` | `false` | Bypass budget checks (use with caution) | -| `--yes`, `-y` | `false` | Skip the confirmation prompt | +| Flag | Default | Description | +| ----------------- | ------------------ | ------------------------------------------------------------------------ | +| `--dry-run` | `false` | Show preflight summary and exit without executing | +| `--project`, `-p` | _(all configured)_ | Target a single project directory | +| `--task`, `-t` | _(auto-select)_ | Run a specific task by name | +| `--max-projects` | `1` | Max projects to process (ignored when `--project` is set) | +| `--max-tasks` | `1` | Max tasks per project (ignored when `--task` is set) | +| `--random-task` | `false` | Pick a random task from eligible tasks instead of the highest-scored one | +| `--ignore-budget` | `false` | Bypass budget checks (use with caution) | +| `--yes`, `-y` | `false` | Skip the confirmation prompt | ```bash # Interactive run with preflight summary + confirmation prompt @@ -141,6 +141,7 @@ nightshift run -p ./my-project -t lint-fix ``` Other useful flags: + - `nightshift status --today` to see today's activity summary - `nightshift daemon start --foreground` for debug - `--category` — filter tasks by category (pr, analysis, options, safe, map, emergency) @@ -153,8 +154,9 @@ Other useful flags: ## Authentication (Subscriptions) Nightshift supports three AI providers: + - **Claude Code** - Anthropic's Claude via local CLI -- **Codex** - OpenAI's GPT via local CLI +- **Codex** - OpenAI's GPT via local CLI - **GitHub Copilot** - GitHub's Copilot via GitHub CLI ### Claude Code @@ -258,6 +260,40 @@ Each task has a default cooldown interval to prevent the same task from running ## Development +### Package layout + +Nightshift is organized as a small set of commands on top of focused internal +packages. Browse the godoc for any package with `go doc ` (or +`go doc ./...` for all of them). + +| Package | Responsibility | +| -------------------------- | ---------------------------------------------------------------------------------- | +| `cmd/nightshift` | CLI entry point (`main`). | +| `cmd/nightshift/commands` | Cobra subcommands (`run`, `report`, `budget`, `setup`, `stats`, etc.). | +| `cmd/provider-calibration` | Standalone tool that estimates per-repo token usage from local agent session logs. | +| `internal/orchestrator` | The plan-implement-review loop that drives task execution. | +| `internal/agents` | Agent interface and CLI implementations (Claude, Codex, Copilot). | +| `internal/providers` | Provider interface for coding-agent backends and per-token cost. | +| `internal/tasks` | Task structures, cost/risk tiers, loading, and selection. | +| `internal/budget` | Token budget calculation and allocation across daily/weekly modes. | +| `internal/calibrator` | Infers weekly subscription budgets from usage history. | +| `internal/scheduler` | Cron, fixed-interval, and time-window job scheduling. | +| `internal/projects` | Multi-project discovery, config merging, and priority weighting. | +| `internal/integrations` | Readers for claude.md, agents.md, td, and GitHub issues. | +| `internal/config` | YAML config loading with environment overrides. | +| `internal/db` | SQLite storage, migrations, and legacy state import. | +| `internal/state` | Persistent run history driving staleness and de-duplication. | +| `internal/snapshots` | Periodic usage-snapshot collection. | +| `internal/trends` | Historical usage profiling, anomaly detection, and forecasting. | +| `internal/stats` | Aggregate statistics for the stats CLI. | +| `internal/reporting` | Markdown run reports and summaries. | +| `internal/security` | Credentials, sandboxing, audit logging, and safe defaults. | +| `internal/logging` | Structured zerolog logging with file rotation. | +| `internal/commits` | Conventional Commits message normalization. | +| `internal/tmux` | tmux session control and token-usage scraping. | +| `internal/analysis` | Git-based ownership and bus-factor analysis. | +| `internal/setup` | Interactive config and task-preset selection. | + ### Pre-commit hooks Install the git pre-commit hook to catch formatting and vet issues before pushing: @@ -267,6 +303,7 @@ make install-hooks ``` This symlinks `scripts/pre-commit.sh` into `.git/hooks/pre-commit`. The hook runs: + - **gofmt** — flags any staged `.go` files that need formatting - **go vet** — catches common correctness issues - **go build** — ensures the project compiles diff --git a/cmd/nightshift/commands/commit.go b/cmd/nightshift/commands/commit.go new file mode 100644 index 0000000..7e018fb --- /dev/null +++ b/cmd/nightshift/commands/commit.go @@ -0,0 +1,88 @@ +package commands + +import ( + "fmt" + "io" + "os" + + "github.com/marcus/nightshift/internal/commits" + "github.com/spf13/cobra" +) + +var commitCmd = &cobra.Command{ + Use: "commit", + Short: "Conventional Commits helpers", + Long: `Tools for working with Conventional Commits messages. + +Use "commit normalize" to validate and reformat a commit message so it +follows the project's rules (type prefix, lowercase type, subject length, +and wrapped body).`, +} + +var commitNormalizeCmd = &cobra.Command{ + Use: "normalize [MESSAGE]", + Short: "Normalize a commit message to Conventional Commits format", + Long: `Validate and rewrite a commit message into canonical Conventional +Commits form. + +The message is read from a positional argument, from a file passed via +--file (typically .git/COMMIT_EDITMSG by a commit-msg hook), or from stdin +when no argument and no --file are given. + + nightshift commit normalize "feat: add login" + nightshift commit normalize --file .git/COMMIT_EDITMSG + git log -1 --pretty=%B | nightshift commit normalize + +Use --check to only validate without rewriting; the exit code is non-zero +when the message does not conform.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + check, _ := cmd.Flags().GetBool("check") + file, _ := cmd.Flags().GetString("file") + + raw, err := readCommitMessage(args, file) + if err != nil { + return err + } + + normalized, err := commits.Normalize(raw) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return err + } + + if check { + fmt.Fprintln(os.Stdout, normalized) + return nil + } + fmt.Fprintln(os.Stdout, normalized) + return nil + }, +} + +func init() { + commitNormalizeCmd.Flags().BoolP("check", "c", false, "Only validate; do not rewrite") + commitNormalizeCmd.Flags().StringP("file", "f", "", "Read the message from this file (use by the commit-msg hook)") + commitCmd.AddCommand(commitNormalizeCmd) + rootCmd.AddCommand(commitCmd) +} + +// readCommitMessage resolves the message source in order: positional arg, +// --file, then stdin. +func readCommitMessage(args []string, file string) (string, error) { + if len(args) == 1 { + return args[0], nil + } + if file != "" { + b, err := os.ReadFile(file) + if err != nil { + return "", fmt.Errorf("read %s: %w", file, err) + } + return string(b), nil + } + b, err := io.ReadAll(os.Stdin) + if err != nil { + return "", fmt.Errorf("read stdin: %w", err) + } + return string(b), nil +} diff --git a/cmd/provider-calibration/main.go b/cmd/provider-calibration/main.go index ee573b2..c1f4401 100644 --- a/cmd/provider-calibration/main.go +++ b/cmd/provider-calibration/main.go @@ -1,3 +1,5 @@ +// Command provider-calibration analyzes local Codex and Claude session logs to +// estimate per-repository token usage, producing input for budget calibration. package main import ( diff --git a/docs/commit-messages.md b/docs/commit-messages.md new file mode 100644 index 0000000..ab6e009 --- /dev/null +++ b/docs/commit-messages.md @@ -0,0 +1,47 @@ +# Commit Messages + +Nightshift uses [Conventional Commits](https://www.conventionalcommits.org/) +for all commit messages. This keeps the history readable and lets tooling +derive changelogs automatically. + +## Format + +``` +(): + + +``` + +- **type** — one of `feat`, `fix`, `docs`, `style`, `refactor`, `test`, + `chore`, `perf`, `build`, `ci`. +- **scope** — optional, e.g. `fix(api): ...`. +- **subject** — lowercase, imperative mood, no trailing period, max 72 chars. +- **body** — optional, wrapped at 72 columns, separated from the subject by a + blank line. + +## The `commit normalize` command + +Validate and reformat a message: + +```sh +nightshift commit normalize "feat: add login screen" +nightshift commit normalize --file .git/COMMIT_EDITMSG +git log -1 --pretty=%B | nightshift commit normalize +``` + +Add `--check` to validate only. The command exits non-zero when a message +cannot be normalized (missing/unknown type, capitalized or overlong subject). + +## commit-msg hook + +To enforce the rules locally, install the hook: + +```sh +make install-hooks +# or manually: +ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +``` + +The hook normalizes your message file in place before the commit is created and +rejects messages that cannot be fixed automatically. Bypass it with +`git commit --no-verify`. diff --git a/internal/agents/claude.go b/internal/agents/claude.go index c34d513..d1db49c 100644 --- a/internal/agents/claude.go +++ b/internal/agents/claude.go @@ -1,4 +1,5 @@ -// claude.go implements the Agent interface for Claude Code CLI. +// This file implements the Agent interface for the Claude Code CLI. + package agents import ( diff --git a/internal/agents/codex.go b/internal/agents/codex.go index c366400..f3209e9 100644 --- a/internal/agents/codex.go +++ b/internal/agents/codex.go @@ -1,4 +1,5 @@ -// codex.go implements the Agent interface for OpenAI Codex CLI. +// This file implements the Agent interface for the OpenAI Codex CLI. + package agents import ( diff --git a/internal/agents/copilot.go b/internal/agents/copilot.go index fd94226..56347a0 100644 --- a/internal/agents/copilot.go +++ b/internal/agents/copilot.go @@ -1,4 +1,5 @@ -// copilot.go implements the Agent interface for GitHub Copilot CLI. +// This file implements the Agent interface for the GitHub Copilot CLI. + package agents import ( diff --git a/internal/commits/normalizer.go b/internal/commits/normalizer.go new file mode 100644 index 0000000..62526b3 --- /dev/null +++ b/internal/commits/normalizer.go @@ -0,0 +1,255 @@ +// Package commits implements Conventional Commits message normalization and +// validation. It exposes pure, well-tested functions used by the CLI and by the +// commit-msg git hook to keep the project's history consistent. +// +// The supported format follows the Conventional Commits 1.0.0 specification: +// +// (): +// +// +// +// The normalizer is intentionally strict but constructive: rather than silently +// accepting malformed input it fixes the trivially fixable (whitespace, type +// casing, trailing punctuation, body wrapping) and rejects anything that needs +// a human decision (missing type, unknown type, missing subject). +package commits + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +// MaxSubjectLength is the maximum number of runes allowed in a commit subject. +const MaxSubjectLength = 72 + +// BodyWrapWidth is the column at which the commit body is wrapped. +const BodyWrapWidth = 72 + +// allowedTypes is the set of Conventional Commit types this project accepts. +var allowedTypes = map[string]struct{}{ + "feat": {}, + "fix": {}, + "docs": {}, + "style": {}, + "refactor": {}, + "test": {}, + "chore": {}, + "perf": {}, + "build": {}, + "ci": {}, +} + +// Errors returned by the normalizer. They are wrapped so callers can match on +// the underlying cause with errors.Is. +var ( + // ErrEmptyMessage is returned when the message contains no non-comment, + // non-whitespace content. + ErrEmptyMessage = errors.New("commit message is empty") + // ErrMissingType is returned when the subject line is not a Conventional + // Commit (no type prefix before the colon). + ErrMissingType = errors.New("commit message must start with a conventional commit type") + // ErrUnknownType is returned when the type prefix is not in the allowed set. + ErrUnknownType = errors.New("commit type is not in the allowed set") + // ErrMissingSubject is returned when the type prefix is present but no + // subject text follows the colon. + ErrMissingSubject = errors.New("commit subject is missing") + // ErrSubjectTooLong is returned when the subject exceeds MaxSubjectLength. + ErrSubjectTooLong = fmt.Errorf("commit subject exceeds %d characters", MaxSubjectLength) + // ErrSubjectLowercase is returned when the subject starts with an uppercase + // letter (the rule is "do not capitalize the subject"). + ErrSubjectLowercase = errors.New("commit subject must not be capitalized") +) + +// Normalize parses, validates, and rewrites a raw commit message so that it +// conforms to the project's Conventional Commits rules. It returns the +// canonical form and a non-nil error describing the first unrecoverable +// problem when the message cannot be normalized. +// +// Normalization is idempotent: Normalize(Normalize(m)) == Normalize(m). +func Normalize(msg string) (string, error) { + lines := stripComments(msg) + if len(lines) == 0 { + return "", ErrEmptyMessage + } + + header := lines[0] + body := lines[1:] + + typ, scope, subject, err := parseHeader(header) + if err != nil { + return "", err + } + + subject = cleanSubject(subject) + + var b strings.Builder + b.WriteString(formatHeader(typ, scope, subject)) + + wrapped := wrapBody(body, BodyWrapWidth) + if wrapped != "" { + b.WriteString("\n\n") + b.WriteString(wrapped) + } + + return b.String(), nil +} + +// stripComments removes git's commented-out lines (those beginning with "#"), +// trims trailing whitespace from every line, and drops leading/trailing blank +// lines. It returns the meaningful lines of the message. +func stripComments(msg string) []string { + rawLines := strings.Split(msg, "\n") + out := make([]string, 0, len(rawLines)) + for _, l := range rawLines { + l = strings.TrimRight(l, " \t\r") + if strings.HasPrefix(strings.TrimSpace(l), "#") { + continue + } + out = append(out, l) + } + // Drop leading and trailing blank lines. + for len(out) > 0 && strings.TrimSpace(out[0]) == "" { + out = out[1:] + } + for len(out) > 0 && strings.TrimSpace(out[len(out)-1]) == "" { + out = out[:len(out)-1] + } + return out +} + +// parseHeader splits the first line into its Conventional Commit components and +// validates them. The returned type is lower-cased to match the allowed set. +func parseHeader(header string) (typ, scope, subject string, err error) { + header = strings.TrimSpace(header) + colon := strings.Index(header, ":") + if colon <= 0 { + return "", "", "", ErrMissingType + } + prefix := header[:colon] + subject = strings.TrimSpace(header[colon+1:]) + + // Split an optional "(scope)" from the type. + prefix = strings.TrimSpace(prefix) + if strings.HasPrefix(prefix, "(") { + // A leading "(" with no type is not a valid conventional header. + return "", "", "", ErrMissingType + } + if open := strings.Index(prefix, "("); open > 0 && strings.HasSuffix(prefix, ")") { + typ = prefix[:open] + scope = prefix[open+1 : len(prefix)-1] + } else { + typ = prefix + } + typ = strings.ToLower(strings.TrimSpace(typ)) + scope = strings.TrimSpace(scope) + + if typ == "" { + return "", "", "", ErrMissingType + } + if !isAllowedType(typ) { + return "", "", "", fmt.Errorf("%w: %q", ErrUnknownType, typ) + } + if strings.TrimSpace(subject) == "" { + return "", "", "", ErrMissingSubject + } + if utf8.RuneCountInString(subject) > MaxSubjectLength { + return "", "", "", ErrSubjectTooLong + } + if startsUpper(subject) { + return "", "", "", ErrSubjectLowercase + } + return typ, scope, subject, nil +} + +// cleanSubject normalizes the subject text: lowercases a leading uppercase +// letter is *not* done here (capitalization is a hard error, not a fix), but +// surrounding whitespace and a trailing period are removed. +func cleanSubject(subject string) string { + s := strings.TrimSpace(subject) + s = strings.TrimRight(s, ".") + return s +} + +// formatHeader reassembles a canonical header line from its components. +func formatHeader(typ, scope, subject string) string { + if scope != "" { + return typ + "(" + scope + "): " + subject + } + return typ + ": " + subject +} + +// wrapBody collapses runs of blank lines, preserves non-blank paragraphs, and +// hard-wraps each paragraph line to width. Paragraph breaks (a single blank +// line) are preserved. +func wrapBody(body []string, width int) string { + var paragraphs [][]string + var cur []string + for _, l := range body { + if strings.TrimSpace(l) == "" { + if len(cur) > 0 { + paragraphs = append(paragraphs, cur) + cur = nil + } + continue + } + cur = append(cur, strings.TrimSpace(l)) + } + if len(cur) > 0 { + paragraphs = append(paragraphs, cur) + } + + var b strings.Builder + for i, p := range paragraphs { + if i > 0 { + b.WriteString("\n\n") + } + b.WriteString(wrapParagraph(strings.Join(p, " "), width)) + } + return b.String() +} + +// wrapParagraph hard-wraps a single-line paragraph at width, breaking on word +// boundaries. A word longer than width is left intact rather than split. +func wrapParagraph(text string, width int) string { + words := strings.Fields(text) + if len(words) == 0 { + return "" + } + var b strings.Builder + lineLen := 0 + for i, w := range words { + if i == 0 { + b.WriteString(w) + lineLen = len(w) + continue + } + if lineLen+1+len(w) <= width { + b.WriteByte(' ') + b.WriteString(w) + lineLen += 1 + len(w) + } else { + b.WriteByte('\n') + b.WriteString(w) + lineLen = len(w) + } + } + return b.String() +} + +// isAllowedType reports whether typ is one of the accepted Conventional Commit +// types. +func isAllowedType(typ string) bool { + _, ok := allowedTypes[typ] + return ok +} + +// startsUpper reports whether the first rune of s is an ASCII uppercase letter. +func startsUpper(s string) bool { + if s == "" { + return false + } + r, _ := utf8.DecodeRuneInString(s) + return r >= 'A' && r <= 'Z' +} diff --git a/internal/commits/normalizer_test.go b/internal/commits/normalizer_test.go new file mode 100644 index 0000000..2121633 --- /dev/null +++ b/internal/commits/normalizer_test.go @@ -0,0 +1,133 @@ +package commits + +import ( + "errors" + "strings" + "testing" +) + +func TestNormalize(t *testing.T) { + tests := []struct { + name string + in string + want string + wantErr error + }{ + { + name: "valid simple feat", + in: "feat: add login screen", + want: "feat: add login screen", + }, + { + name: "valid with scope", + in: "fix(api): handle nil response", + want: "fix(api): handle nil response", + }, + { + name: "trims surrounding whitespace and trailing period", + in: " docs: update README. ", + want: "docs: update README", + }, + { + name: "lowercases an uppercased type", + in: "FEAT(ui): render button", + want: "feat(ui): render button", + }, + { + name: "preserves body and wraps long lines", + in: "feat: add thing\n\nthis is a body paragraph that is intentionally far longer than the configured wrap width so it must be hard wrapped onto multiple lines by the normalizer function", + want: "feat: add thing\n\n" + + "this is a body paragraph that is intentionally far longer than the\n" + + "configured wrap width so it must be hard wrapped onto multiple lines by\n" + + "the normalizer function", + }, + { + name: "strips git comment lines", + in: "chore: tidy\n# please enter the commit message\n\nbody here", + want: "chore: tidy\n\nbody here", + }, + { + name: "missing type rejected", + in: "just a plain message", + wantErr: ErrMissingType, + }, + { + name: "unknown type rejected", + in: "wip: halfway done", + wantErr: ErrUnknownType, + }, + { + name: "missing subject rejected", + in: "feat:", + wantErr: ErrMissingSubject, + }, + { + name: "capitalized subject rejected", + in: "feat: Add login screen", + wantErr: ErrSubjectLowercase, + }, + { + name: "overlong subject rejected", + in: "feat: " + strings.Repeat("a", MaxSubjectLength+1), + wantErr: ErrSubjectTooLong, + }, + { + name: "empty message rejected", + in: "\n\n# only comments\n \n", + wantErr: ErrEmptyMessage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Normalize(tc.in) + if tc.wantErr != nil { + if err == nil { + t.Fatalf("Normalize(%q): expected error %v, got nil (result %q)", tc.in, tc.wantErr, got) + } + if !errors.Is(err, tc.wantErr) { + t.Fatalf("Normalize(%q): expected error to wrap %v, got %v", tc.in, tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("Normalize(%q): unexpected error: %v", tc.in, err) + } + if got != tc.want { + t.Errorf("Normalize(%q):\n got: %q\nwant: %q", tc.in, got, tc.want) + } + }) + } +} + +func TestNormalizeIdempotent(t *testing.T) { + cases := []string{ + "feat: add login screen", + "fix(api): handle nil response\n\nLong body that explains the fix in more detail than the subject alone can manage so that we exercise the wrapping path too and then some more words here.", + "docs: update README\n\nfirst paragraph\n\nsecond paragraph stays separate", + } + for _, in := range cases { + once, err := Normalize(in) + if err != nil { + t.Fatalf("first Normalize(%q) errored: %v", in, err) + } + twice, err := Normalize(once) + if err != nil { + t.Fatalf("second Normalize(%q) errored: %v", once, err) + } + if once != twice { + t.Errorf("not idempotent for %q\n once: %q\n twice: %q", in, once, twice) + } + } +} + +func TestAllowedTypes(t *testing.T) { + for _, typ := range []string{"feat", "fix", "docs", "style", "refactor", "test", "chore", "perf", "build", "ci"} { + if !isAllowedType(typ) { + t.Errorf("expected %q to be an allowed type", typ) + } + } + if isAllowedType("wip") { + t.Error("did not expect wip to be allowed") + } +} diff --git a/internal/integrations/integrations.go b/internal/integrations/integrations.go index 5b7823b..2186fbc 100644 --- a/internal/integrations/integrations.go +++ b/internal/integrations/integrations.go @@ -57,6 +57,8 @@ type Hint struct { // HintType categorizes hints. type HintType int +// HintType values enumerate the kinds of lightweight suggestions that a +// Reader can extract from configuration files. const ( HintTaskSuggestion HintType = iota // Suggested task to run HintConvention // Coding convention diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 2f870ba..0f194fe 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -370,7 +370,7 @@ func Warn(msg string) { Get().Warn(msg) } -// Error(msg string) logs an error message to the global logger. +// Error logs an error message to the global logger. func Error(msg string) { Get().Error(msg) } diff --git a/internal/orchestrator/events.go b/internal/orchestrator/events.go index 7399672..682a776 100644 --- a/internal/orchestrator/events.go +++ b/internal/orchestrator/events.go @@ -5,6 +5,8 @@ import "time" // EventType classifies orchestrator lifecycle events. type EventType int +// EventType values describe the lifecycle stages of a task as it moves through +// the orchestrator's plan-implement-review loop. const ( EventTaskStart EventType = iota // task execution begins EventPhaseStart // entering a phase (plan/implement/review) diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 6141c95..ee3ff2a 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -29,6 +29,8 @@ const ( // TaskStatus represents the outcome of task execution. type TaskStatus string +// TaskStatus values enumerate the lifecycle states a task may be in, from +// initial planning through execution, review, and a terminal outcome. const ( StatusPending TaskStatus = "pending" StatusPlanning TaskStatus = "planning" diff --git a/internal/providers/claude.go b/internal/providers/claude.go index 3f78e89..1d97a16 100644 --- a/internal/providers/claude.go +++ b/internal/providers/claude.go @@ -1,4 +1,5 @@ -// claude.go implements the Provider interface for Claude Code CLI. +// This file implements the Provider interface for the Claude Code CLI. + package providers import ( diff --git a/internal/providers/codex.go b/internal/providers/codex.go index eb948ed..877e327 100644 --- a/internal/providers/codex.go +++ b/internal/providers/codex.go @@ -1,4 +1,5 @@ -// codex.go implements the Provider interface for OpenAI Codex CLI. +// This file implements the Provider interface for the OpenAI Codex CLI. + package providers import ( diff --git a/internal/providers/copilot.go b/internal/providers/copilot.go index fe69acf..85e1a58 100644 --- a/internal/providers/copilot.go +++ b/internal/providers/copilot.go @@ -1,4 +1,5 @@ -// copilot.go implements the Provider interface for GitHub Copilot CLI. +// This file implements the Provider interface for the GitHub Copilot CLI. + package providers import ( diff --git a/internal/security/audit.go b/internal/security/audit.go index 69e93c6..2adfdf4 100644 --- a/internal/security/audit.go +++ b/internal/security/audit.go @@ -14,6 +14,9 @@ import ( // AuditEventType categorizes audit events. type AuditEventType string +// AuditEventType values enumerate the kinds of operations recorded in the +// append-only audit log, covering agent activity, file and git operations, +// security decisions, and configuration or budget checks. const ( AuditAgentStart AuditEventType = "agent_start" AuditAgentComplete AuditEventType = "agent_complete" diff --git a/internal/security/security.go b/internal/security/security.go index 1166e57..08686cb 100644 --- a/internal/security/security.go +++ b/internal/security/security.go @@ -237,9 +237,13 @@ func ValidateProjectPath(path string) error { return nil } -// Operation types for safety checks. +// OperationType classifies an operation that the Manager evaluates against the +// configured safety policy before allowing it to proceed. type OperationType string +// OperationType values enumerate the operations that are subject to safety +// checks, including agent invocation, file access, git mutations, and network +// calls. const ( OpAgentInvoke OperationType = "agent_invoke" OpFileRead OperationType = "file_read" diff --git a/internal/setup/presets.go b/internal/setup/presets.go index 4927c06..d31dc7d 100644 --- a/internal/setup/presets.go +++ b/internal/setup/presets.go @@ -12,6 +12,8 @@ import ( // Preset identifies a task selection profile (safe, balanced, aggressive). type Preset string +// Preset values name the built-in task selection profiles, ranging from the +// conservative safe preset to the more permissive aggressive one. const ( PresetBalanced Preset = "balanced" PresetSafe Preset = "safe" diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 2c7dabb..1cb7682 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -12,6 +12,8 @@ import ( // CostTier represents the estimated token cost for a task. type CostTier int +// CostTier values classify tasks by their estimated token consumption, from +// the low tens of thousands up to half a million tokens or more. const ( CostLow CostTier = iota // 10-50k tokens CostMedium // 50-150k tokens @@ -54,6 +56,8 @@ func (c CostTier) TokenRange() (min, max int) { // RiskLevel represents the risk associated with a task. type RiskLevel int +// RiskLevel values classify tasks by the level of risk they carry, used to +// gate which tasks are selected under a given preset. const ( RiskLow RiskLevel = iota RiskMedium diff --git a/scripts/commit-msg.sh b/scripts/commit-msg.sh new file mode 100755 index 0000000..3320a40 --- /dev/null +++ b/scripts/commit-msg.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# commit-msg hook for nightshift +# +# Enforces Conventional Commits on every commit message and rewrites the +# message file into canonical form before the commit is created. Messages that +# cannot be normalized (missing/unknown type, capitalized or overlong subject) +# are rejected with a non-zero exit so the commit is aborted. +# +# Install: +# make install-hooks +# # or manually: +# ln -sf ../../scripts/commit-msg.sh .git/hooks/commit-msg +# chmod +x scripts/commit-msg.sh +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: commit-msg " >&2 + exit 1 +fi + +MSG_FILE="$1" + +# Resolve the nightshift binary: prefer the one on $PATH, fall back to +# building the current source tree. +NIGHTSHIFT="$(command -v nightshift || true)" +if [[ -z "$NIGHTSHIFT" ]]; then + NIGHTSHIFT="go run github.com/marcus/nightshift/cmd/nightshift" +fi + +NORMALIZED="$($NIGHTSHIFT commit normalize --file "$MSG_FILE" 2>/tmp/nightshift-commit-msg.err)" +STATUS=$? + +if [[ $STATUS -ne 0 ]]; then + echo "🪡 commit-msg: message does not follow Conventional Commits" >&2 + sed 's/^/ /' /tmp/nightshift-commit-msg.err >&2 || true + echo "" >&2 + echo " Expected format: (): " >&2 + echo " Types: feat fix docs style refactor test chore perf build ci" >&2 + echo " (rewrite your message, or bypass with: git commit --no-verify)" >&2 + exit 1 +fi + +# Rewrite the message file into canonical form. +printf '%s\n' "$NORMALIZED" > "$MSG_FILE" +echo "🪡 commit-msg: normalized" +exit 0