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/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/agent.go b/internal/agents/agent.go index 97894a9..a27f1e5 100644 --- a/internal/agents/agent.go +++ b/internal/agents/agent.go @@ -1,5 +1,3 @@ -// Package agents provides interfaces and implementations for spawning AI agents. -// Unlike providers (which track usage), agents execute tasks autonomously. package agents import ( diff --git a/internal/agents/doc.go b/internal/agents/doc.go new file mode 100644 index 0000000..85a15ea --- /dev/null +++ b/internal/agents/doc.go @@ -0,0 +1,22 @@ +// Package agents provides interfaces and implementations for spawning and +// driving AI coding agents. +// +// Unlike the [providers] package, which is concerned with tracking token usage +// and cost, agents are responsible for autonomously executing a task to +// completion: running a prompt against a CLI, handling timeouts, and returning +// structured output. The orchestrator uses agents to perform the implement and +// review phases of the plan-implement-review loop. +// +// The core abstraction is the [Agent] interface, implemented per backend: +// +// - [ClaudeAgent] wraps the Claude Code CLI (claude --print). +// - CodexAgent wraps the OpenAI Codex CLI. +// - CopilotAgent wraps the GitHub Copilot CLI. +// +// Agents are constructed with functional options (e.g. [WithBinaryPath], +// [WithDangerouslySkipPermissions]) and report availability via Available so +// the orchestrator can skip backends that are not installed. Execution honours +// [DefaultTimeout] unless overridden by the caller's context. +// +// [providers]: github.com/marcus/nightshift/internal/providers +package agents diff --git a/internal/budget/budget.go b/internal/budget/budget.go index 31a718c..5381f13 100644 --- a/internal/budget/budget.go +++ b/internal/budget/budget.go @@ -1,5 +1,3 @@ -// Package budget implements token budget calculation and allocation for nightshift. -// Supports daily and weekly modes with reserve and aggressive end-of-week options. package budget import ( diff --git a/internal/budget/doc.go b/internal/budget/doc.go new file mode 100644 index 0000000..413bc02 --- /dev/null +++ b/internal/budget/doc.go @@ -0,0 +1,24 @@ +// Package budget implements token budget calculation and allocation for +// nightshift runs. +// +// Given a provider and its recent usage, the package computes how many tokens +// may be safely spent on the next run. It supports two modes: +// +// - daily: a fixed per-day allowance. +// - weekly: a rolling weekly budget with reserve holding and an aggressive +// end-of-week multiplier so leftover budget is spent down before reset. +// +// The primary result type is [AllowanceResult], which carries the final token +// allowance together with the metadata used to derive it (used percentage and +// its source, predicted remaining daytime usage, reserve amount, mode, and the +// confidence/sample count of the underlying budget estimate). +// +// Usage data is supplied through provider-specific interfaces +// ([ClaudeUsageProvider], [CodexUsageProvider], [CopilotUsageProvider]), all +// extending the common [UsageProvider]. Budget estimates may come from config, +// the API, or the [calibrator] via the [BudgetSource] interface, which returns +// a [BudgetEstimate] annotated with source, confidence, sample count, and +// variance. +// +// [calibrator]: github.com/marcus/nightshift/internal/calibrator +package budget diff --git a/internal/calibrator/calibrator.go b/internal/calibrator/calibrator.go index d98be7d..0d2143f 100644 --- a/internal/calibrator/calibrator.go +++ b/internal/calibrator/calibrator.go @@ -1,4 +1,3 @@ -// Package calibrator tunes task budgets and scheduling based on historical usage data. package calibrator import ( diff --git a/internal/calibrator/doc.go b/internal/calibrator/doc.go new file mode 100644 index 0000000..7723fa5 --- /dev/null +++ b/internal/calibrator/doc.go @@ -0,0 +1,22 @@ +// Package calibrator infers provider subscription budgets from historical +// usage and feeds them back into budget planning. +// +// Rather than requiring the user to configure an exact weekly token budget, +// the calibrator mines the snapshot history collected by the [snapshots] +// package to estimate the effective subscription limit for each provider. It +// implements the [budget.BudgetSource] interface so its estimates flow +// transparently into the budget manager. +// +// [Calibrate] returns a [CalibrationResult] holding the inferred weekly +// budget plus a confidence level (none/low/medium/high), the sample count it +// was derived from, the variance across samples, and a human-readable source +// label. [GetBudget] wraps that result as a [budget.BudgetEstimate] for direct +// consumption by the budget package. +// +// A [Calibrator] is constructed with [New], taking the [db.DB] it reads +// snapshots from and the project [config.Config]. +// +// [snapshots]: github.com/marcus/nightshift/internal/snapshots +// [db]: github.com/marcus/nightshift/internal/db +// [config]: github.com/marcus/nightshift/internal/config +package calibrator 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/db/db.go b/internal/db/db.go index 2f846a8..6afd1d7 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1,4 +1,3 @@ -// Package db provides SQLite-backed storage for nightshift state and snapshots. package db import ( diff --git a/internal/db/doc.go b/internal/db/doc.go new file mode 100644 index 0000000..2a8bbb5 --- /dev/null +++ b/internal/db/doc.go @@ -0,0 +1,22 @@ +// Package db provides SQLite-backed persistence for nightshift state and +// usage snapshots. +// +// It is the storage layer shared by several subsystems — [state] for run +// history and task assignments, [snapshots] for periodic usage samples, and +// [stats]/[trends] for read-only aggregation — all of which operate against a +// single database file. +// +// The entry point is [Open], which opens (or creates) the database at the +// given path, applies connection pragmas, and runs any pending [Migrate] +// migrations inside transactions. [DefaultPath] returns the conventional +// database location. Callers obtain the underlying handle through [DB.SQL] +// when they need direct query access; [DB.Close] releases the connection. +// +// Schema evolution is handled by the [Migration] type and the version tracking +// exposed by [CurrentVersion]. +// +// [state]: github.com/marcus/nightshift/internal/state +// [snapshots]: github.com/marcus/nightshift/internal/snapshots +// [stats]: github.com/marcus/nightshift/internal/stats +// [trends]: github.com/marcus/nightshift/internal/trends +package db diff --git a/internal/integrations/doc.go b/internal/integrations/doc.go new file mode 100644 index 0000000..61a0bf2 --- /dev/null +++ b/internal/integrations/doc.go @@ -0,0 +1,20 @@ +// Package integrations provides readers for external configuration and task +// sources so nightshift can incorporate context that lives outside its own +// database. +// +// Each reader implements a small, uniform surface (Enabled, Name, and Read) +// and is constructed from the project [config]. The built-in readers are: +// +// - [ClaudeMDReader] extracts project context from claude.md files. +// - [AgentsMDReader] extracts agent behavior preferences from agents.md / +// AGENTS.md files. +// - GitHubReader surfaces GitHub issues as actionable tasks. +// - TDReader imports tasks from a td task-management store. +// +// Results from individual readers are combined into an [AggregatedResult]: +// per-reader [Result]s, the union of [TaskItem]s and [Hint]s, a combined +// context string suitable for injection into agent prompts, and any non-fatal +// [ReaderError]s encountered along the way. +// +// [config]: github.com/marcus/nightshift/internal/config +package integrations diff --git a/internal/integrations/integrations.go b/internal/integrations/integrations.go index 5b7823b..17758d5 100644 --- a/internal/integrations/integrations.go +++ b/internal/integrations/integrations.go @@ -1,5 +1,3 @@ -// Package integrations provides readers for external configuration and task sources. -// Supports claude.md, agents.md, td task management, and GitHub issues. package integrations import ( diff --git a/internal/logging/doc.go b/internal/logging/doc.go new file mode 100644 index 0000000..f08c64b --- /dev/null +++ b/internal/logging/doc.go @@ -0,0 +1,17 @@ +// Package logging provides structured logging with file rotation for +// nightshift. +// +// It wraps zerolog with nightshift-specific conventions: JSON or text output, +// a configurable level, and date-based log-file naming with retention so old +// logs are pruned automatically. The package exposes both a process-wide +// global logger and per-component loggers for emitting scoped output. +// +// [Init] configures and installs the global logger from a [Config]; the +// package-level [Info], [Warn], [Error], and [Debug] functions then log to it. +// For component-scoped output, [Component] returns a [*Logger] that tags every +// record, and [Get] returns the global logger directly. Each [Logger] supports +// formatted and context-field variants (e.g. DebugCtx). +// +// [New] constructs a standalone logger when a non-global instance is required; +// [Logger.Close] flushes and closes the underlying file. +package logging diff --git a/internal/logging/logging.go b/internal/logging/logging.go index 2f870ba..c3f071c 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -1,5 +1,3 @@ -// Package logging provides structured logging with file rotation for nightshift. -// Supports JSON and text formats with date-based log file naming. package logging import ( diff --git a/internal/orchestrator/doc.go b/internal/orchestrator/doc.go new file mode 100644 index 0000000..541d635 --- /dev/null +++ b/internal/orchestrator/doc.go @@ -0,0 +1,20 @@ +// Package orchestrator is the run-orchestration core of nightshift. +// +// It coordinates AI agents working on tasks by driving the +// plan-implement-review loop to convergence: an agent plans, an agent +// implements, and a review pass decides whether another iteration is needed, +// up to [DefaultMaxIterations]. Per-agent work is bounded by +// [DefaultAgentTimeout]. +// +// A run is configured with [Config] (iteration limit, per-agent timeout, and +// working directory). Lifecycle progress is published as [Event] values to +// registered [EventHandler] callbacks, allowing the CLI and other observers to +// react to phase transitions, task start/end, and errors without coupling to +// the orchestration internals. +// +// The package also offers a few git- and PR-oriented helpers used while +// orchestrating runs: [CurrentBranch] resolves the active branch in a working +// directory, [ExtractPRURL] recovers a GitHub PR URL from agent output, and +// [ParseMetadataBlock] reads the structured metadata comment embedded in a PR +// body. +package orchestrator diff --git a/internal/orchestrator/orchestrator.go b/internal/orchestrator/orchestrator.go index 6141c95..9c504b9 100644 --- a/internal/orchestrator/orchestrator.go +++ b/internal/orchestrator/orchestrator.go @@ -1,5 +1,3 @@ -// Package orchestrator coordinates AI agents working on tasks. -// Implements the plan-implement-review loop for task execution. package orchestrator import ( diff --git a/internal/projects/doc.go b/internal/projects/doc.go new file mode 100644 index 0000000..fad279b --- /dev/null +++ b/internal/projects/doc.go @@ -0,0 +1,21 @@ +// Package projects handles multi-project discovery, resolution, and budget +// allocation for nightshift. +// +// A nightshift invocation can target more than one repository. This package +// discovers candidate projects ([DiscoverProjectsInDir], [IsProjectPath]), +// expands glob patterns while honouring excludes ([ExpandGlobPatterns]), and +// merges per-project configuration over the global [config.Config] +// ([MergeProjectConfig]). +// +// A resolved [Project] carries its absolute path, priority, merged config, and +// a normalized budget weight. Selection helpers choose what to work on next +// based on priority and staleness against the [state] store: [SelectNext], +// [SortByPriority], [FilterProcessedToday], and [FilterNotProcessedSince]. +// +// Budget is distributed across the selected projects by [AllocateBudget], +// which returns a [BudgetAllocation] per project describing its token share +// and percentage of the total. +// +// [config]: github.com/marcus/nightshift/internal/config +// [state]: github.com/marcus/nightshift/internal/state +package projects diff --git a/internal/projects/projects.go b/internal/projects/projects.go index fb0a868..53dbb3e 100644 --- a/internal/projects/projects.go +++ b/internal/projects/projects.go @@ -1,5 +1,3 @@ -// Package projects handles multi-project discovery, resolution, and budget allocation. -// Supports explicit project paths, glob patterns, and priority-based budget splitting. package projects import ( diff --git a/internal/providers/doc.go b/internal/providers/doc.go new file mode 100644 index 0000000..83a6070 --- /dev/null +++ b/internal/providers/doc.go @@ -0,0 +1,24 @@ +// Package providers defines the abstraction over the AI coding backends that +// nightshift tracks for usage, cost, and budgeting. +// +// Where the [agents] package executes prompts, providers are the accounting +// layer: each implementation reports how many tokens have been consumed, at +// what cost, and how close a subscription is to its limit. This is the data +// the [budget], [calibrator], [stats], and [trends] packages build on. +// +// The built-in backends wrap their respective CLIs: +// +// - [Claude] wraps Claude Code, reading usage primarily from its +// stats-cache.json with a JSONL fallback. +// - Codex wraps the OpenAI Codex CLI. +// - Copilot wraps GitHub Copilot, which is metered by monthly request limits +// rather than weekly tokens. +// +// Common functionality — daily/weekly usage ([GetTodayUsage], [GetWeeklyUsage]), +// used-percentage against a budget ([GetUsedPercent]), daily statistics, and +// per-token cost ([Cost]) — is exposed uniformly so callers can treat providers +// interchangeably. +// +// [agents]: github.com/marcus/nightshift/internal/agents +// [budget]: github.com/marcus/nightshift/internal/budget +package providers diff --git a/internal/providers/provider.go b/internal/providers/provider.go index 07f53a7..4719b73 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -1,5 +1,3 @@ -// Package providers defines interfaces and implementations for AI coding agents. -// Supports multiple backends: Claude Code, Codex CLI, etc. package providers import "context" diff --git a/internal/scheduler/doc.go b/internal/scheduler/doc.go new file mode 100644 index 0000000..ccf4026 --- /dev/null +++ b/internal/scheduler/doc.go @@ -0,0 +1,17 @@ +// Package scheduler handles time-based job scheduling for nightshift runs. +// +// A [Scheduler] decides when registered [Job]s execute, supporting three +// complementary ways to express timing: cron expressions, fixed intervals, +// and one-shot [Schedule] calls. Execution can additionally be gated by a time +// window via [IsInWindow] so runs only happen during allowed hours. +// +// Schedulers are typically built from configuration with [NewFromConfig], or +// assembled manually with [New] plus [SetCron]/[SetInterval] and [AddJob]. +// Inspection helpers — [NextRun], [NextRuns], [IsRunning] — support dry-run +// planning and status reporting without starting the loop. +// +// The package defines typed errors for malformed configuration +// ([ErrInvalidCron], [ErrInvalidInterval], [ErrInvalidWindow], +// [ErrInvalidTimezone]) and for lifecycle misuse ([ErrNoSchedule], +// [ErrAlreadyRunning], [ErrNotRunning]). +package scheduler diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 278ca71..241c886 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -1,5 +1,3 @@ -// Package scheduler handles time-based job scheduling. -// Supports cron expressions, intervals, and time window constraints. package scheduler import ( diff --git a/internal/setup/doc.go b/internal/setup/doc.go new file mode 100644 index 0000000..4cc07a4 --- /dev/null +++ b/internal/setup/doc.go @@ -0,0 +1,16 @@ +// Package setup provides interactive configuration and task-preset selection +// for new nightshift projects. +// +// When a project is initialized, setup helps the user choose which task types +// to enable by offering curated profiles. A [Preset] names one of these +// profiles — [PresetSafe], [PresetBalanced], or [PresetAggressive] — trading +// autonomy for caution. +// +// [PresetTasks] resolves a preset (combined with the available +// [tasks.TaskDefinition]s and detected [RepoSignals]) into the concrete set of +// enabled task types. [DetectRepoSignals] inspects project roots for signals +// such as release notes or architecture-decision records (ADR) that should +// influence which tasks make sense, and [RepoSignals] carries the result. +// +// [tasks]: github.com/marcus/nightshift/internal/tasks +package setup diff --git a/internal/setup/presets.go b/internal/setup/presets.go index 4927c06..aa52715 100644 --- a/internal/setup/presets.go +++ b/internal/setup/presets.go @@ -1,4 +1,3 @@ -// Package setup provides interactive configuration and task preset selection for new projects. package setup import ( diff --git a/internal/state/doc.go b/internal/state/doc.go new file mode 100644 index 0000000..3a400b1 --- /dev/null +++ b/internal/state/doc.go @@ -0,0 +1,21 @@ +// Package state manages the persistent state of nightshift runs. +// +// Backed by the [db] package, it records run history and in-flight task +// assignments so that nightshift can reason about recency across restarts. +// This state underpins three behaviours: staleness calculation (how long since +// a task type last ran for a project), duplicate-run prevention (skip work +// already done today), and task-assignment tracking (knowing what is +// currently in progress). +// +// A [State] manager is created with [New] over a [db.DB]. Per-project +// recency is tracked through [ProjectState] and queried with helpers such as +// [DaysSinceLastRun] and [LastRun]. Completed runs are recorded as [RunRecord] +// entries via [AddRunRecord]. +// +// In-flight work is modelled by [AssignedTask]. Assignments are added, cleared +// individually ([ClearAssigned]), cleared in bulk ([ClearAllAssigned], e.g. on +// daemon restart), and pruned by age ([ClearStaleAssignments]) to recover from +// abandoned tasks. +// +// [db]: github.com/marcus/nightshift/internal/db +package state diff --git a/internal/state/state.go b/internal/state/state.go index 8b21e11..59a429d 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -1,6 +1,3 @@ -// Package state manages persistent state for nightshift runs. -// Tracks run history per project and task to support staleness calculation, -// duplicate run prevention, and task assignment tracking. package state import ( diff --git a/internal/stats/doc.go b/internal/stats/doc.go new file mode 100644 index 0000000..6c5e17c --- /dev/null +++ b/internal/stats/doc.go @@ -0,0 +1,19 @@ +// Package stats computes aggregate statistics from nightshift's run and usage +// data. +// +// It is a read-only aggregation layer that pulls together several data +// sources — saved report JSONs, the run history held by [state], periodic +// usage [snapshots], and the projects table — and folds them into a single +// [StatsResult] via [Stats.Compute]. +// +// A [Stats] instance is created with [New], or with [NewWithBudgetSource] when +// a calibrated [budget.BudgetSource] should inform the projections. The +// package surfaces several supporting types: [BudgetProjection] estimates how +// many days of budget remain and whether exhaustion precedes the subscription +// reset; [ProjectStats] summarizes per-project activity; and [Duration] wraps +// [time.Duration] for clean JSON serialization as integer seconds. +// +// [state]: github.com/marcus/nightshift/internal/state +// [snapshots]: github.com/marcus/nightshift/internal/snapshots +// [budget]: github.com/marcus/nightshift/internal/budget +package stats diff --git a/internal/stats/stats.go b/internal/stats/stats.go index 0c93fe9..b1f67f4 100644 --- a/internal/stats/stats.go +++ b/internal/stats/stats.go @@ -1,5 +1,3 @@ -// Package stats computes aggregate statistics from nightshift run data. -// It reads from existing report JSONs, run_history, snapshots, and projects tables. package stats import ( diff --git a/internal/tmux/doc.go b/internal/tmux/doc.go new file mode 100644 index 0000000..31b4872 --- /dev/null +++ b/internal/tmux/doc.go @@ -0,0 +1,17 @@ +// Package tmux provides a thin wrapper around tmux for driving and inspecting +// terminal sessions. +// +// nightshift uses tmux to run AI agents in detached panes and to scrape their +// output. The [Session] type wraps a named tmux session and exposes the +// operations needed for this workflow: [Session.Start] creates the session, +// [Session.SendKeys] sends input, [Session.CapturePane] reads the current pane +// contents, [Session.Resize] sets the pane size, [Session.Kill] tears it down, +// and [Session.WaitForPattern] polls capture-pane until a regex matches or the +// timeout elapses. +// +// Sessions are constructed with [NewSession] and functional [SessionOption]s. +// Command execution is abstracted behind the [CommandRunner] interface (with +// [ExecRunner] as the os/exec-based default) so behaviour can be tested +// without a real tmux. [StripANSI] strips escape codes from captured output, +// and [ErrTmuxNotFound] is returned when tmux is not installed. +package tmux diff --git a/internal/tmux/tmux.go b/internal/tmux/tmux.go index ac3f263..23e9ab8 100644 --- a/internal/tmux/tmux.go +++ b/internal/tmux/tmux.go @@ -1,4 +1,3 @@ -// Package tmux scrapes tmux sessions to detect running AI agent processes and their usage. package tmux import ( diff --git a/internal/trends/analyzer.go b/internal/trends/analyzer.go index ddfbaa5..1e2a4b2 100644 --- a/internal/trends/analyzer.go +++ b/internal/trends/analyzer.go @@ -1,4 +1,3 @@ -// Package trends analyzes historical snapshot data to surface usage patterns and anomalies. package trends import ( diff --git a/internal/trends/doc.go b/internal/trends/doc.go new file mode 100644 index 0000000..ae86b6f --- /dev/null +++ b/internal/trends/doc.go @@ -0,0 +1,18 @@ +// Package trends analyzes historical snapshot data to surface usage patterns +// and predict near-term consumption. +// +// Working from the periodic samples collected by the [snapshots] package, an +// [Analyzer] builds a usage profile for a provider over a bounded lookback +// window and uses it to anticipate remaining demand. This feeds the budget +// package's predicted-usage reserve, so runs account for expected daytime +// consumption rather than only what has already been spent. +// +// [NewAnalyzer] constructs an analyzer bound to a [db.DB] with a default +// lookback. [BuildProfile] aggregates hourly averages into a [Profile] (per- +// hour averages, daily total, and the window length), and +// [PredictDaytimeUsage] estimates the tokens still expected to be consumed +// today given the current time and weekly budget. +// +// [snapshots]: github.com/marcus/nightshift/internal/snapshots +// [db]: github.com/marcus/nightshift/internal/db +package trends 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 diff --git a/website/sidebars.js b/website/sidebars.js index e516e2c..985dfaa 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -1,21 +1,21 @@ /** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */ const sidebars = { docsSidebar: [ - 'intro', + "intro", { - type: 'category', - label: 'Getting Started', - items: ['installation', 'quick-start', 'configuration'], + type: "category", + label: "Getting Started", + items: ["installation", "quick-start", "configuration"], }, { - type: 'category', - label: 'Usage', - items: ['tasks', 'task-reference', 'budget', 'scheduling'], + type: "category", + label: "Usage", + items: ["tasks", "task-reference", "budget", "scheduling"], }, { - type: 'category', - label: 'Reference', - items: ['cli-reference', 'integrations'], + type: "category", + label: "Reference", + items: ["cli-reference", "integrations", "troubleshooting"], }, ], };