diff --git a/CHANGELOG.md b/CHANGELOG.md index 59afa7d..cb536f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to nightshift are documented in this file. +## [v0.3.4] - 2026-02-28 + +### Features +- **Configurable agent timeouts** — add `--timeout` to `nightshift run` and `nightshift daemon`, with daemon re-exec forwarding the flag (#27) +- **Expanded PII scanner guidance** — add detailed instructions for detecting hardcoded PII, leaked env files, unsafe storage, and related exposure patterns in the built-in task (#34) + +### Fixes +- **Timeout handling and diagnostics** — preserve partial output on timeout, terminate full process groups, and surface partial logs from failed plan/implement/review steps (#33) +- **Copilot CLI integration** — improve binary resolution, permission gating, and CLI flag handling for Copilot runs (#39) +- **Provider config YAML serialization** — write provider settings with the correct YAML keys during setup (#43) +- **Configured run limits and budget fallback** — honor `schedule.max_projects` and `schedule.max_tasks`, improve budget calibration at day and week boundaries, and preserve Codex fallback permissions in headless runs (#42) + +### Other +- **Task reference docs** — add a comprehensive reference page for all 59 built-in tasks and refresh related task documentation (#30) +- **Docs cleanup** — remove auto-generated implementation docs from the repository (#40) +- **Low-risk cleanup** — resolve Copilot helper lint warnings and replace `WriteString(fmt.Sprintf(...))` patterns with `fmt.Fprintf` in reporting and setup code (#38, #41) + ## [v0.3.3] - 2026-02-19 ### Features diff --git a/cmd/nightshift/commands/silo.go b/cmd/nightshift/commands/silo.go new file mode 100644 index 0000000..e8554f2 --- /dev/null +++ b/cmd/nightshift/commands/silo.go @@ -0,0 +1,114 @@ +package commands + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + + "github.com/marcus/nightshift/internal/analysis" + "github.com/marcus/nightshift/internal/analysis/silo" +) + +var siloCmd = &cobra.Command{ + Use: "silo [path]", + Short: "Detect knowledge silos in git history", + Long: `Analyze git history to find files or directories where one author +dominates recent commits, surfacing knowledge silos and bus-factor risk.`, + RunE: runSilo, +} + +func init() { + siloCmd.Flags().String("since", "180d", "Window to analyze (e.g. 180d, 30d, 2024-01-01)") + siloCmd.Flags().Float64("threshold", 0.8, "Dominance ratio above which a path is flagged a silo") + siloCmd.Flags().String("path", "", "Restrict analysis to a path prefix") + siloCmd.Flags().String("format", "table", "Output format: table|json") + siloCmd.Flags().Bool("dirs", false, "Aggregate by top-level directory instead of files") + siloCmd.Flags().Int("limit", 25, "Max rows to display in table output") + rootCmd.AddCommand(siloCmd) +} + +func runSilo(cmd *cobra.Command, args []string) error { + repo, err := os.Getwd() + if err != nil { + return err + } + if len(args) > 0 { + repo = args[0] + } + abs, err := filepath.Abs(repo) + if err != nil { + return err + } + if !analysis.RepositoryExists(abs) { + return fmt.Errorf("not a git repository: %s", abs) + } + + sinceStr, _ := cmd.Flags().GetString("since") + threshold, _ := cmd.Flags().GetFloat64("threshold") + pathFilter, _ := cmd.Flags().GetString("path") + format, _ := cmd.Flags().GetString("format") + dirs, _ := cmd.Flags().GetBool("dirs") + limit, _ := cmd.Flags().GetInt("limit") + + since, err := parseSince(sinceStr) + if err != nil { + return err + } + + src := silo.NewGitCommitSource(abs, since) + results, err := silo.Analyze(src, silo.Options{ + Since: since, + PathFilter: pathFilter, + DominanceThreshold: threshold, + GroupByDir: dirs, + }) + if err != nil { + return err + } + + if format == "json" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(results) + } + + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "RISK\tSILO\tCOMMITS\tAUTHORS\tDOMINANCE\tOWNER\tPATH") + for i, r := range results { + if i >= limit { + break + } + flag := " " + if r.IsSilo { + flag = "*" + } + fmt.Fprintf(tw, "%.2f\t%s\t%d\t%d\t%.0f%%\t%s\t%s\n", + r.Risk, flag, r.Commits, r.Authors, r.Dominance*100, r.TopAuthor, r.Path) + } + return tw.Flush() +} + +func parseSince(s string) (time.Time, error) { + if s == "" { + return time.Time{}, nil + } + if t, err := time.Parse("2006-01-02", s); err == nil { + return t, nil + } + // support "180d", "30d" + if len(s) > 1 && s[len(s)-1] == 'd' { + var days int + if _, err := fmt.Sscanf(s, "%dd", &days); err == nil { + return time.Now().AddDate(0, 0, -days), nil + } + } + if d, err := time.ParseDuration(s); err == nil { + return time.Now().Add(-d), nil + } + return time.Time{}, fmt.Errorf("invalid --since: %s", s) +} diff --git a/internal/analysis/silo/silo.go b/internal/analysis/silo/silo.go new file mode 100644 index 0000000..137ea88 --- /dev/null +++ b/internal/analysis/silo/silo.go @@ -0,0 +1,203 @@ +// Package silo identifies knowledge silos in a git repository: files or +// directories where commit activity is dominated by a single author. +package silo + +import ( + "fmt" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" +) + +// Commit represents a single commit touching one or more files. +type Commit struct { + Author string + When time.Time + Files []string +} + +// CommitSource yields commits for analysis. Injectable for testing. +type CommitSource interface { + Commits() ([]Commit, error) +} + +// Options configure silo detection. +type Options struct { + Since time.Time + PathFilter string // optional path prefix filter + DominanceThreshold float64 // e.g. 0.8 -> 80% + GroupByDir bool // aggregate by top-level dir instead of files +} + +// PathStats holds aggregated stats for one file or directory. +type PathStats struct { + Path string + Commits int + Authors int + TopAuthor string + TopCommits int + Dominance float64 + LastTouch time.Time + OtherLatest time.Time // most recent commit by a non-dominant author + Risk float64 + IsSilo bool +} + +// gitCommitSource is the default implementation backed by `git log`. +type gitCommitSource struct { + repo string + since time.Time +} + +// NewGitCommitSource creates a source that reads commits from a repo via git. +func NewGitCommitSource(repo string, since time.Time) CommitSource { + return &gitCommitSource{repo: repo, since: since} +} + +func (g *gitCommitSource) Commits() ([]Commit, error) { + args := []string{"log", "--name-only", "--no-merges", "--format=__COMMIT__%n%ae%n%at"} + if !g.since.IsZero() { + args = append(args, "--since="+g.since.Format(time.RFC3339)) + } + cmd := exec.Command("git", args...) + cmd.Dir = g.repo + out, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("git log: %w", err) + } + return parseGitLog(string(out)), nil +} + +func parseGitLog(out string) []Commit { + var commits []Commit + var cur *Commit + state := 0 // 0=expect marker, 1=author, 2=time, 3=files + for _, line := range strings.Split(out, "\n") { + if line == "__COMMIT__" { + if cur != nil { + commits = append(commits, *cur) + } + cur = &Commit{} + state = 1 + continue + } + if cur == nil { + continue + } + switch state { + case 1: + cur.Author = strings.ToLower(strings.TrimSpace(line)) + state = 2 + case 2: + var ts int64 + fmt.Sscanf(strings.TrimSpace(line), "%d", &ts) + cur.When = time.Unix(ts, 0) + state = 3 + case 3: + line = strings.TrimSpace(line) + if line != "" { + cur.Files = append(cur.Files, line) + } + } + } + if cur != nil { + commits = append(commits, *cur) + } + return commits +} + +// Analyze aggregates commits and identifies silos according to opts. +func Analyze(src CommitSource, opts Options) ([]PathStats, error) { + commits, err := src.Commits() + if err != nil { + return nil, err + } + if opts.DominanceThreshold <= 0 { + opts.DominanceThreshold = 0.8 + } + + type bucket struct { + commits map[string]int // author -> count + last time.Time + } + buckets := map[string]*bucket{} + + bump := func(path, author string, when time.Time) { + b, ok := buckets[path] + if !ok { + b = &bucket{commits: map[string]int{}} + buckets[path] = b + } + b.commits[author]++ + if when.After(b.last) { + b.last = when + } + } + + for _, c := range commits { + if !opts.Since.IsZero() && c.When.Before(opts.Since) { + continue + } + for _, f := range c.Files { + if opts.PathFilter != "" && !strings.HasPrefix(f, opts.PathFilter) { + continue + } + key := f + if opts.GroupByDir { + key = topDir(f) + } + bump(key, c.Author, c.When) + } + } + + results := make([]PathStats, 0, len(buckets)) + for path, b := range buckets { + total := 0 + topAuthor := "" + topN := 0 + for a, n := range b.commits { + total += n + if n > topN { + topN = n + topAuthor = a + } + } + if total == 0 { + continue + } + dominance := float64(topN) / float64(total) + authors := len(b.commits) + isSilo := authors <= 1 || dominance >= opts.DominanceThreshold + // Risk: weight dominance and bus-factor; small floor for recency. + risk := dominance*0.6 + (1.0/float64(authors))*0.4 + results = append(results, PathStats{ + Path: path, + Commits: total, + Authors: authors, + TopAuthor: topAuthor, + TopCommits: topN, + Dominance: dominance, + LastTouch: b.last, + Risk: risk, + IsSilo: isSilo, + }) + } + + sort.Slice(results, func(i, j int) bool { + if results[i].Risk != results[j].Risk { + return results[i].Risk > results[j].Risk + } + return results[i].Commits > results[j].Commits + }) + return results, nil +} + +func topDir(p string) string { + p = filepath.ToSlash(p) + if i := strings.Index(p, "/"); i >= 0 { + return p[:i] + } + return "." +} diff --git a/internal/analysis/silo/silo_test.go b/internal/analysis/silo/silo_test.go new file mode 100644 index 0000000..1584362 --- /dev/null +++ b/internal/analysis/silo/silo_test.go @@ -0,0 +1,86 @@ +package silo + +import ( + "testing" + "time" +) + +type fakeSource struct{ commits []Commit } + +func (f *fakeSource) Commits() ([]Commit, error) { return f.commits, nil } + +func TestSingleAuthorFileIsSilo(t *testing.T) { + now := time.Now() + src := &fakeSource{commits: []Commit{ + {Author: "alice", When: now, Files: []string{"a.go"}}, + {Author: "alice", When: now, Files: []string{"a.go"}}, + }} + res, err := Analyze(src, Options{}) + if err != nil { + t.Fatal(err) + } + if len(res) != 1 || !res[0].IsSilo || res[0].TopAuthor != "alice" { + t.Fatalf("expected single-author silo, got %+v", res) + } + if res[0].Dominance != 1.0 { + t.Fatalf("expected dominance 1.0, got %v", res[0].Dominance) + } +} + +func TestBalancedFileNotSilo(t *testing.T) { + now := time.Now() + src := &fakeSource{commits: []Commit{ + {Author: "a", When: now, Files: []string{"b.go"}}, + {Author: "b", When: now, Files: []string{"b.go"}}, + {Author: "c", When: now, Files: []string{"b.go"}}, + {Author: "d", When: now, Files: []string{"b.go"}}, + }} + res, _ := Analyze(src, Options{DominanceThreshold: 0.8}) + if res[0].IsSilo { + t.Fatalf("expected non-silo, got %+v", res[0]) + } +} + +func TestThresholdEdge(t *testing.T) { + now := time.Now() + // 4 of 5 commits = 0.8 dominance — at threshold + src := &fakeSource{commits: []Commit{ + {Author: "a", When: now, Files: []string{"f"}}, + {Author: "a", When: now, Files: []string{"f"}}, + {Author: "a", When: now, Files: []string{"f"}}, + {Author: "a", When: now, Files: []string{"f"}}, + {Author: "b", When: now, Files: []string{"f"}}, + }} + res, _ := Analyze(src, Options{DominanceThreshold: 0.8}) + if !res[0].IsSilo { + t.Fatalf("expected silo at 0.8 threshold, got %+v", res[0]) + } + res2, _ := Analyze(src, Options{DominanceThreshold: 0.9}) + if res2[0].IsSilo { + t.Fatalf("expected not-silo at 0.9 threshold, got %+v", res2[0]) + } +} + +func TestPathFilter(t *testing.T) { + now := time.Now() + src := &fakeSource{commits: []Commit{ + {Author: "a", When: now, Files: []string{"keep/x.go", "skip/y.go"}}, + }} + res, _ := Analyze(src, Options{PathFilter: "keep/"}) + if len(res) != 1 || res[0].Path != "keep/x.go" { + t.Fatalf("path filter failed: %+v", res) + } +} + +func TestGroupByDir(t *testing.T) { + now := time.Now() + src := &fakeSource{commits: []Commit{ + {Author: "a", When: now, Files: []string{"pkg/a.go"}}, + {Author: "a", When: now, Files: []string{"pkg/b.go"}}, + {Author: "b", When: now, Files: []string{"pkg/c.go"}}, + }} + res, _ := Analyze(src, Options{GroupByDir: true}) + if len(res) != 1 || res[0].Path != "pkg" || res[0].Commits != 3 { + t.Fatalf("group-by-dir failed: %+v", res) + } +}