From c26c5fd368a6c536fab9149ee507f411f97e8bb1 Mon Sep 17 00:00:00 2001 From: Lasse Larsen Date: Sun, 28 Jun 2026 02:10:21 +0200 Subject: [PATCH 1/2] feat(commits): add Conventional Commits message normalizer Add internal/commits with pure Normalize/validate functions enforcing the project's Conventional Commits rules (known type set, lowercase type, lowercase subject, 72-char subject limit, whitespace trimming, and 72-column body wrapping). Wire it into the CLI as 'nightshift commit normalize' (positional, --file, and stdin sources; --check to validate only), ship a commit-msg git hook under scripts/, and document the format and installation in docs/commit-messages.md. Nightshift-Task: commit-normalize Nightshift-Ref: https://github.com/marcus/nightshift --- cmd/nightshift/commands/commit.go | 88 ++++++++++ docs/commit-messages.md | 47 +++++ internal/commits/normalizer.go | 255 ++++++++++++++++++++++++++++ internal/commits/normalizer_test.go | 133 +++++++++++++++ scripts/commit-msg.sh | 46 +++++ 5 files changed, 569 insertions(+) create mode 100644 cmd/nightshift/commands/commit.go create mode 100644 docs/commit-messages.md create mode 100644 internal/commits/normalizer.go create mode 100644 internal/commits/normalizer_test.go create mode 100755 scripts/commit-msg.sh 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/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/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 From 7d2b5d1d1cfa62c0194a94460cae13c77431d163 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 02:05:33 +0200 Subject: [PATCH 2/2] docs: backfill CLI reference, architecture overview, and package doc - Expand website/docs/cli-reference.md with sections for commit (normalize), busfactor, config (get/set/validate), init, doctor, daemon (start/stop/status), install/uninstall, logs, report, status, stats, and setup, captured from each command's help text - Add docs/architecture.md mapping the Go packages and the run flow; link it from README.md - Add the missing package-level doc comment to cmd/provider-calibration/main.go Nightshift-Task: docs-backfill Nightshift-Ref: https://github.com/marcus/nightshift --- README.md | 2 + cmd/provider-calibration/main.go | 8 + docs/architecture.md | 93 +++++++++++ website/docs/cli-reference.md | 263 +++++++++++++++++++++++++++++++ 4 files changed, 366 insertions(+) create mode 100644 docs/architecture.md diff --git a/README.md b/README.md index 84f92cd..52406ba 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,8 @@ Each task has a default cooldown interval to prevent the same task from running ## Development +New to the codebase? Start with the [architecture overview](docs/architecture.md) — a map of the Go packages and how a run flows through them. + ### Pre-commit hooks Install the git pre-commit hook to catch formatting and vet issues before pushing: diff --git a/cmd/provider-calibration/main.go b/cmd/provider-calibration/main.go index ee573b2..636d057 100644 --- a/cmd/provider-calibration/main.go +++ b/cmd/provider-calibration/main.go @@ -1,3 +1,11 @@ +// Package main implements the provider-calibration utility, a standalone +// diagnostic tool that analyzes local Claude and Codex session data to +// summarize per-provider token usage distributions (primary vs. alternate +// tokens, turns per session) and derive cross-provider budget ratios. +// +// The emitted statistics inform the budget calibration heuristics used by +// the nightshift daemon. See docs/guides/provider-calibration.md for usage +// details and interpretation of the output. package main import ( diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9375a34 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,93 @@ +# Architecture + +This document maps Nightshift's Go packages and shows how a run flows through +them. For the step-by-step run lifecycle (including where logs and reports are +written), see [guides/run-lifecycle.md](guides/run-lifecycle.md). + +## Overview + +Nightshift is a CLI + daemon that runs AI coding agents (Claude Code, Codex, +Copilot) on maintenance tasks across your projects, on a schedule, within +token budgets. The codebase is a single Go module: + +``` +cmd/ executable entry points + nightshift/ the main CLI binary + commands/ cobra command definitions (one file per command) + provider-calibration/ standalone calibration analysis tool +internal/ all library packages (not importable externally) +website/ documentation site (Docusaurus) +docs/ long-form guides and reference docs +``` + +## Command Layer + +| Package | Purpose | +|---------|---------| +| `cmd/nightshift` | CLI entry point: wires config, logging, and database into the commands. | +| `cmd/nightshift/commands` | Cobra command definitions — `run`, `preview`, `task`, `budget`, `commit`, `busfactor`, `config`, `init`, `doctor`, `daemon`, `install`/`uninstall`, `logs`, `report`, `status`, `stats`, `setup`. Thin layer: parses flags, then delegates to `internal/*`. | +| `cmd/provider-calibration` | Offline tool that summarizes per-provider token usage distributions from local session data; informs budget calibration heuristics. See [guides/provider-calibration.md](guides/provider-calibration.md). | + +## Core Runtime + +| Package | Purpose | +|---------|---------| +| `internal/config` | Loads, merges (global + project), and validates configuration. | +| `internal/scheduler` | Time-based job scheduling (cron or interval) used by the daemon. | +| `internal/orchestrator` | Coordinates agents working on tasks — the plan → implement → review loop. | +| `internal/agents` | Interfaces and implementations for spawning AI agent processes. | +| `internal/providers` | Provider abstraction and selection (Claude, Codex, Copilot): preference order, availability, and per-provider settings. | +| `internal/tasks` | Task registry, selection, priority scoring, and cooldown intervals. | +| `internal/projects` | Multi-project discovery, resolution, and per-project budget allocation. | +| `internal/tmux` | Scrapes tmux sessions to detect running agent processes and their usage output. | +| `internal/logging` | Structured logging with file rotation. | + +## Budget & Usage Data + +| Package | Purpose | +|---------|---------| +| `internal/budget` | Token budget calculation, allowance, and enforcement. | +| `internal/snapshots` | Collects and stores periodic usage snapshots from provider data files. | +| `internal/calibrator` | Tunes task budgets and scheduling from historical usage. | +| `internal/trends` | Analyzes snapshot history to surface usage patterns and anomalies. | +| `internal/stats` | Aggregate statistics over past runs (backs `nightshift stats`). | + +## Persistence & Output + +| Package | Purpose | +|---------|---------| +| `internal/db` | SQLite-backed storage for state and snapshots. | +| `internal/state` | Persistent run state (what ran, when, cooldowns). | +| `internal/reporting` | Run reports and morning summaries (backs `nightshift report`). | +| `internal/security` | Audit logging for nightshift operations. | +| `internal/setup` | Interactive onboarding (backs `nightshift setup`). | +| `internal/integrations` | Readers for external configuration and task sources. | + +## Analysis & Tooling + +| Package | Purpose | +|---------|---------| +| `internal/analysis` | Code ownership and bus-factor analysis (backs `nightshift busfactor`). | +| `internal/commits` | Conventional Commits normalization (backs `nightshift commit normalize`). | + +## How a Run Flows + +1. **Trigger** — cron/launchd/systemd executes `nightshift run`, or the daemon + (`internal/scheduler`) fires a scheduled tick. +2. **Setup** — `cmd/nightshift` loads config (`internal/config`) and initializes + logging (`internal/logging`), then opens the database (`internal/db`) and + loads run state (`internal/state`). +3. **Budget & provider** — `internal/budget` calculates the remaining + allowance from `internal/snapshots` data; `internal/providers` picks a + provider by preference and budget. +4. **Selection** — `internal/projects` resolves target projects; + `internal/tasks` scores and selects eligible tasks (respecting cooldowns). +5. **Execution** — `internal/orchestrator` drives the agent + (`internal/agents`) through plan → implement → review, inside tmux + (`internal/tmux`) when scraping is needed. +6. **Recording** — task and project results are written to the database; + `internal/reporting` saves the run report and (optionally) the morning + summary. `nightshift status`, `report`, and `stats` read this data back. + +The sequence diagram in [guides/run-lifecycle.md](guides/run-lifecycle.md) +traces the same flow end to end. diff --git a/website/docs/cli-reference.md b/website/docs/cli-reference.md index d5a2cd4..028cbbf 100644 --- a/website/docs/cli-reference.md +++ b/website/docs/cli-reference.md @@ -18,7 +18,14 @@ title: CLI Reference | `nightshift status` | View run history | | `nightshift logs` | Stream or export logs | | `nightshift stats` | Token usage statistics | +| `nightshift report` | View structured run reports | | `nightshift daemon` | Background scheduler | +| `nightshift config` | View and modify configuration | +| `nightshift init` | Create a config file | +| `nightshift commit` | Conventional Commits tools | +| `nightshift busfactor` | Code ownership analysis | +| `nightshift install` | Install a system service | +| `nightshift uninstall` | Remove the system service | ## Run Options @@ -83,6 +90,262 @@ nightshift budget history -n 10 nightshift budget calibrate ``` +### `budget snapshot` + +Capture a usage snapshot for budget calibration. Collects local token counts +(Claude: `stats-cache.json`; Codex: session JSONL files) and optionally scrapes +the CLI's usage display via tmux to get the provider's own usage percentage. +When both are available, nightshift infers the weekly budget: +`budget = local_tokens / (scraped% / 100)`. Tmux scraping requires tmux +installed and `calibrate_enabled: true` in the config. + +See [the provider calibration guide](https://github.com/marcus/nightshift/blob/main/docs/guides/provider-calibration.md) for details. + +| Flag | Default | Description | +|------|---------|-------------| +| `--local-only` | `false` | Skip tmux scraping and store a local-only snapshot | +| `--provider`, `-p` | | Provider to snapshot (claude, codex, copilot) | + +### `budget history` + +Show recent usage snapshots for budget calibration. + +| Flag | Default | Description | +|------|---------|-------------| +| `--n`, `-n` | `20` | Number of snapshots to show | +| `--provider`, `-p` | | Provider to show history for | + +### `budget calibrate` + +Show inferred budget calibration status for providers. Accepts `--provider`/`-p`. + +## Commit Commands + +Tools for working with Conventional Commits messages. See +[docs/commit-messages.md](https://github.com/marcus/nightshift/blob/main/docs/commit-messages.md) +for the full rule set. + +### `commit normalize` + +Validate and rewrite a commit message into canonical Conventional Commits form +(type prefix, lowercase type, subject length, wrapped body). The message is +read from a positional argument, from a file passed via `--file` (typically +`.git/COMMIT_EDITMSG` from a commit-msg hook), or from stdin when neither is given. + +```bash +nightshift commit normalize "feat: add login" +nightshift commit normalize --file .git/COMMIT_EDITMSG +git log -1 --pretty=%B | nightshift commit normalize +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--check`, `-c` | `false` | Only validate; do not rewrite (non-zero exit on non-conforming messages) | +| `--file`, `-f` | | Read the message from this file | + +## Busfactor Command + +Analyze code ownership concentration in a repository or directory. The bus +factor measures how many key contributors are critical to project continuity. + +Metrics reported: bus factor (minimum contributors for 50% of commits), +Herfindahl index (0 = diverse, 1 = concentrated), Gini coefficient +(0 = equal, 1 = unequal), and an overall risk level. + +```bash +nightshift busfactor # Analyze current directory +nightshift busfactor ~/code/myapp # Analyze a specific repo +nightshift busfactor --file '*.go' # Limit to a file pattern +nightshift busfactor --since 2026-01-01 --json +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--db` | config | Database path | +| `--file`, `-f` | | Analyze a specific file or pattern | +| `--json` | `false` | Output as JSON | +| `--path`, `-p` | | Repository or directory path | +| `--save` | `false` | Save results to the database | +| `--since` | | Start date (RFC3339 or YYYY-MM-DD) | +| `--until` | | End date (RFC3339 or YYYY-MM-DD) | + +## Config Commands + +View and modify nightshift configuration. The bare `nightshift config` +command shows the current configuration merged from global and project configs. + +```bash +nightshift config # Show merged config +nightshift config get budget.max_percent # Read one value +nightshift config set budget.max_percent 15 +nightshift config set logging.level debug +nightshift config set providers.claude.enabled false +nightshift config validate # Check for errors +``` + +| Subcommand | Description | +|------------|-------------| +| `config get KEY` | Get a value by key path | +| `config set KEY VALUE` | Set a value by key path (writes to project config if it exists; `--global`/`-g` forces global config) | +| `config validate` | Validate global and project configs | + +## Init Command + +Initialize a new nightshift configuration file. By default creates +`nightshift.yaml` in the current directory; use `--global` to create the +global config at `~/.config/nightshift/config.yaml`. + +```bash +nightshift init # Create nightshift.yaml here +nightshift init --global # Create the global config +nightshift init --force # Overwrite without prompting +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--force`, `-f` | `false` | Overwrite an existing config without prompting | +| `--global` | `false` | Create the global config instead of a project config | + +## Doctor Command + +Run diagnostics to detect configuration and environment issues. Checks config, +scheduling, providers, database health, and budget readiness. + +```bash +nightshift doctor +``` + +## Daemon Commands + +Start, stop, or check the nightshift background daemon. The daemon runs the +scheduler loop, executing tasks according to the configured schedule (cron or +interval) and respecting time windows. + +```bash +nightshift daemon start # Start in the background +nightshift daemon start -f # Run in the foreground +nightshift daemon status # Is it running? +nightshift daemon stop # Stop via SIGTERM +``` + +| Subcommand | Flags | +|------------|-------| +| `daemon start` | `--foreground`, `-f` (run in foreground); `--timeout` (per-agent execution timeout, default 30m) | +| `daemon status` | — | +| `daemon stop` | — | + +## Install / Uninstall Commands + +Generate and install a system service for nightshift, or remove it. +`install` supports `launchd` (macOS, creates a LaunchAgents plist), `systemd` +(Linux, creates a user unit), and `cron` (universal, creates a crontab entry); +with no argument it auto-detects based on OS. + +```bash +nightshift install # Auto-detect init system +nightshift install launchd # macOS only +nightshift install systemd # Linux only +nightshift install cron # Any OS +nightshift uninstall # Remove the installed service +``` + +## Logs Command + +View nightshift logs. Displays recent log entries; use `--follow` to stream +in real time. Logs live in `~/.local/share/nightshift/logs/`. + +```bash +nightshift logs # Last 50 entries +nightshift logs -n 200 # More entries +nightshift logs --follow # Stream +nightshift logs --level warn # Warnings and above +nightshift logs --component scheduler # Filter by component +nightshift logs --since 2026-08-01 --summary +nightshift logs --export logs.txt # Export to file +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--component` | | Filter by component substring | +| `--export`, `-e` | | Export logs to a file | +| `--follow`, `-f` | `false` | Follow log output | +| `--level` | | Minimum log level (debug\|info\|warn\|error) | +| `--match` | | Filter by message substring | +| `--no-color` | `false` | Disable ANSI colors | +| `--path` | | Override log directory | +| `--raw` | `false` | Show raw log lines without formatting | +| `--since` | | Start time (YYYY-MM-DD, YYYY-MM-DD HH:MM, or RFC3339) | +| `--summary` | `false` | Show a summary only | +| `--tail`, `-n` | `50` | Number of log lines to show | +| `--until` | | End time (YYYY-MM-DD, YYYY-MM-DD HH:MM, or RFC3339) | + +## Report Command + +View structured reports from recent nightshift runs. By default shows a +polished overview of what happened during the last night. + +```bash +nightshift report # Last night, fancy output +nightshift report --period last-24h # Different window +nightshift report --report tasks # Focus on task outcomes +nightshift report --report budget --json # Machine-readable +nightshift report --runs 10 --format markdown +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--format` | `fancy` | Output format: fancy \| plain \| markdown \| json | +| `--max-items` | `5` | Max highlights per run | +| `--no-color` | `false` | Disable ANSI colors | +| `--paths` | `false` | Include report/log file paths | +| `--period`, `-p` | `last-night` | last-night \| last-run \| last-24h \| last-7d \| today \| yesterday \| all | +| `--report`, `-r` | `overview` | Report type: overview \| tasks \| projects \| budget \| raw | +| `--runs`, `-n` | `3` | Max runs to include (0 = all) | +| `--since` | | Start time (YYYY-MM-DD, YYYY-MM-DD HH:MM, or RFC3339) | +| `--until` | | End time (YYYY-MM-DD, YYYY-MM-DD HH:MM, or RFC3339) | + +## Status Command + +Display nightshift run history and activity. Shows the last N runs +(default 5) or today's activity summary. + +```bash +nightshift status # Last 5 runs +nightshift status -n 20 # Last 20 runs +nightshift status --today # Today's activity summary +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--last`, `-n` | `5` | Show the last N runs | +| `--today` | `false` | Show today's activity summary | + +## Stats Command + +Display aggregate statistics from all nightshift runs: run counts, task +outcomes, token usage, budget projections, and per-project breakdowns. + +```bash +nightshift stats # All time +nightshift stats --period last-7d +nightshift stats --json # Machine-readable +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--json` | `false` | Output as JSON | +| `--period`, `-p` | `all` | all \| last-7d \| last-30d \| last-night | + +## Setup Command + +Interactive onboarding wizard that configures Nightshift end-to-end. Creates or +updates the global config, validates providers, runs a snapshot, previews the +next run, and optionally installs/enables the daemon. + +```bash +nightshift setup +``` + ## Global Flags | Flag | Description |