Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions cmd/nightshift/commands/silo.go
Original file line number Diff line number Diff line change
@@ -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)
}
203 changes: 203 additions & 0 deletions internal/analysis/silo/silo.go
Original file line number Diff line number Diff line change
@@ -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)

Check failure on line 95 in internal/analysis/silo/silo.go

View workflow job for this annotation

GitHub Actions / Lint

Error return value of `fmt.Sscanf` is not checked (errcheck)
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 "."
}
Loading
Loading