diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30a5790..6d07eaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ # Canonical CI workflow for hawk-eco Go repos. -# Source of truth: .shared-templates/workflows/go-ci.yml.tmpl +# Source of truth: https://github.com/GrayCodeAI/hawk/blob/main/.shared-templates/workflows/go-ci.yml.tmpl # # Two deployment models: # @@ -192,6 +192,24 @@ jobs: run: | npx jscpd --min-lines 5 --min-tokens 50 --reporters console --blame . 2>&1 | head -50 + # ------------------------------------------------------------------------- + # Fuzz the transcript parser, which consumes untrusted JSONL transcripts + # produced by external agents (Claude Code, Cursor, etc.). + # ------------------------------------------------------------------------- + fuzz: + name: fuzz (60s) + runs-on: ubuntu-latest + needs: [test] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + - name: Run fuzz targets + run: | + go test -fuzz=FuzzParseFromBytes -fuzztime=60s ./cli/transcript + # ------------------------------------------------------------------------- # Cross-platform build matrix — only for repos that produce a binary. # Repos that are pure libraries can keep this job (it'll just `go build ./...`) diff --git a/AGENTS.md b/AGENTS.md index fbab923..4976a45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,95 +1,168 @@ -# AGENTS.md — Trace +--- +description: Extending hawk-eco — how to write AGENTS.md files, custom specialists, skills, hooks, MCP servers, and plugins. +globs: "*.go, *.js, *.md, *.json, *.toml, *.yaml, *.yml" +alwaysApply: false +--- -Git-native session capture for AI coding agents. Hooks into your Git workflow to capture AI agent sessions as you work. Sessions are indexed alongside commits, creating a searchable record of how code was written. +# Extending hawk-eco -## Design Principles +hawk-eco is an open-source code intelligence platform. This document describes how to extend it with custom tools, skills, hooks, and integrations. -- **Git-native** — sessions stored as git objects, searchable with standard tools -- **Non-intrusive** — hooks into git without modifying your workflow -- **Replayable** — reconstruct any session from captured data +## 1. Drop a project `AGENTS.md` -## Build & Test +When hawk-eco starts in a directory, it looks for project-level instructions and injects them into the system prompt. The lookup walks from your current working directory **up to the nearest git root** and reads the first matching file at each level — general rules at the repo root, more specific rules in sub-trees. Files are labeled with their directory in the prompt (e.g. `## Project guidelines (services/api/AGENTS.md)`). + +Accepted file names, in priority order at each level: + +| Path | Notes | +| --- | --- | +| `./AGENTS.md` | The classic spot — committed to your repo, shared with the team. | +| `./ZERO.md` | Brand-specific alias. Same format, lower priority. | +| `./.zero/AGENTS.md` | Project-local, hidden, gitignored. Personal notes that stay out of git. | + +Matching is **case-insensitive** on the basename, so `AGENTS.md`, `Agents.md`, and `agents.md` resolve to the same file on Windows and macOS. The git-tracked filename in this repo is `AGENTS.md` — keep that on case-sensitive filesystems (Linux, the WSL filesystem, or a CI runner) to match what the loader looks for. + +Both files use the same format. YAML frontmatter is optional; the markdown body is loaded as instructions for the agent. hawk-eco reads the file once at session start, so changes take effect on the next launch — not mid-session. + +```markdown +# Project conventions for + +- Build with `make`, not `go build` directly. +- Tests live next to the source file (`foo_test.go` next to `foo.go`). +- Run `make lint` before opening a PR. +- Never edit files under `third_party/` — those are vendored. +``` + +Tips: + +- Keep each file under ~8 KiB. hawk-eco caps the **total** across all matched files at 32 KiB; everything past the cap is dropped. +- Re-state rules in the imperative voice: "Run `make lint`", not "you should consider running the linter". +- Don't put secrets, model IDs, or environment-specific paths in `AGENTS.md`. Use config files for those. +- In a monorepo, drop a narrower `AGENTS.md` in each sub-tree (e.g. `services/api/AGENTS.md`). hawk-eco picks those up automatically when you launch from inside the sub-tree. +- A YAML frontmatter block (`---\n...\n---`) at the top is preserved verbatim in the injected prompt but is not parsed for `globs:` or `alwaysApply:` scoping today — keep the body self-contained. + +### Personal guidelines, across every project + +For preferences that follow *you*, not a specific repo (tone, tooling habits, workflow), drop a `ZERO.md` in your user config directory: `~/.config/hawk-eco/ZERO.md` on Linux/macOS, `%AppData%\Roaming\hawk-eco\ZERO.md` on Windows — the same directory as config files and your personal specialists. Same format and 8 KiB cap as the project files above, and the same case-insensitive basename match. + +This file is injected as its own `## User guidelines` section, before the project's `AGENTS.md`/`ZERO.md`, and is labeled as personal preference in the prompt: project guidelines are the later, more specific instruction and take precedence over it when the two conflict. + +## 2. Custom specialists + +Specialists are hawk-eco's sub-agents. Three scopes, in priority order: + +| Scope | Path | Shared? | +| --- | --- | --- | +| Built-in | compiled into hawk-eco | yes | +| User | `~/.config/hawk-eco/specialists/*.md` | no — your machine only | +| Project | `./.zero/specialists/*.md` | yes — the repo team | + +Project overrides user overrides built-in when names collide. + +A specialist is a markdown manifest with frontmatter and a system prompt: + +```markdown +--- +description: Reviews API changes for breaking-change risk and missing tests. +tools: read-only,plan +--- + +You review API changes. For every changed hunk in `internal/api/` or any file +that ends in `_api.go`: + +1. Confirm the public signature is backward-compatible, or note the breaking + change explicitly with the migration path. +2. Confirm a corresponding test exists in `internal/api/*_test.go` and that + the new behaviour is exercised. +3. Flag any new exported symbol without a doc comment. + +Reply with one JSON object per finding: `{"file", "line", "severity", "message", "fix"}`. +``` + +CLI management: ```bash -go test ./... # Run all tests -go test -race ./... # Race detector -go test -coverprofile=c.out ./... # Coverage -go vet ./... # Static analysis -gofumpt -w . # Format +hawk-eco specialist list +hawk-eco specialist show api-reviewer +hawk-eco specialist create api-reviewer \ + --project \ + --description "Reviews API changes" \ + --tools read-only,plan \ + --prompt "$(cat api-reviewer.md)" +hawk-eco specialist edit api-reviewer --project +hawk-eco specialist delete api-reviewer --project +hawk-eco specialist path # prints the resolved specialists directory ``` -## Architecture - -- `cli/` — the cobra command tree (capture, list, search, replay, investigate, …), - built by `cli.NewRootCmd()` and mounted by Hawk as `hawk trace ...` -- `redact/` — secret/PII detection and redaction -- `internal/` — private support packages (git ops, agent launch tracking, etc.) -- `perf/` — performance benchmarks - -## Conventions - -- Go 1.26+, pure Go, no CGO -- Table-driven tests -- Conventional Commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:` -- No `Co-authored-by:` trailers (auto-stripped by githook) -- `gofumpt` formatting enforced in CI -- Investigate command has its own docs in `docs/` - -## Common Pitfalls - -- Agent launch tests need careful nil handling (nilnil pattern) -- Perfsprint linter is strict — use `fmt.Errorf` not `errors.New(fmt.Sprintf(...))` -- Session storage uses git objects — don't modify `.git/` directly in tests - -## Naming Conventions - -- **CLI commands use cobra**: `cli/` contains the command root (`NewRootCmd`) and subcommands -- **Error sentinels**: `Err` prefix, package-level `var` with `errors.New()` -- **JSON tags on all exported fields**: `json:"timestamp"`, `json:"session_id"` — snake_case in JSON, CamelCase in Go -- **Time formatting**: use `time.RFC3339` for display, `"20060102_150405"` for filenames -- **Redaction package**: `redact/` handles secret detection — pattern names use `Pattern` suffix: `secretPattern`, `credentialedURIPattern` - -## API Patterns - -- **Library, not a binary**: trace ships inside Hawk; there is no standalone `trace` binary -- **Git-native storage**: sessions stored as git objects on `trace/checkpoints/v1` orphan branch — never on the working branch -- **Config in `.trace/` directory**: `settings.json` (committed) + `settings.local.json` (gitignored) -- **Redaction is multi-layered**: entropy-based (`entropyThreshold = 4.5`), pattern-based (JWT, base64, DB URIs), keyword-based (password, secret) -- **Mise-based dev tooling**: `mise.toml` defines tasks — `mise run test`, `mise run test:ci`, `mise run lint` - -## Testing Patterns - -- **Integration tests**: `cli/integration_test/` (build tag `integration`) — full command flows -- **Nilnil pattern**: agent launch functions return `(T, error)` where both can be nil — test with explicit nil checks -- **Git-dependent tests**: some tests need a real git repo — use `t.TempDir()` + `git init` for isolation -- **Redaction tests**: `redact/redact_test.go` tests secret detection patterns — cover entropy edge cases, placeholder exclusion -- **Bench tests**: `redact/redact_bench_test.go` — performance-critical path, benchmark before changing regex patterns -- **Perfsprint linter**: enforced in CI — `errors.New(fmt.Sprintf(...))` must be `fmt.Errorf(...)` instead - -## Refactoring Guidelines - -- **Safe to refactor**: redaction patterns in `redact/` — add new patterns, tune entropy threshold -- **Safe to refactor**: commands in `cli/` — cobra command structure, add subcommands freely -- **Do not touch**: `trace/checkpoints/v1` branch name — hardcoded in consumers and git hooks -- **Do not touch**: `.trace/settings.json` schema — committed config, changing breaks existing repos -- **Do not touch**: the `cli` package import path — Hawk imports `github.com/GrayCodeAI/trace/cli` -- **When adding agents**: update `cli/agent/` — each agent has its own integration file -- **When adding redaction patterns**: add to `redact/redact.go` with corresponding test in `redact/redact_test.go` - -## Key File Locations - -| What | Where | -|---|---| -| Command tree root | `cli/` (`NewRootCmd`); mounted by Hawk as `hawk trace ...` | -| CLI commands | `cli/` (enable, disable, status, checkpoint, session, agent, doctor) | -| Secret redaction | `redact/redact.go` (entropy, patterns, DB URIs, JWT, base64) | -| Redaction pattern packs | `redact/packs.go` (vendor-specific patterns) | -| PII detection | `redact/pii.go` | -| Custom redaction rules | `redact/custom.go` | -| Agent launch detection | `internal/` subpackages | -| Git operations | `internal/` subdirectories | -| Integration tests | `cli/integration_test/` (build tag `integration`) | -| Perf benchmarks | `perf/` directory | -| Mise task definitions | `mise.toml` | -| Docs | `docs/` (investigate command, security, privacy) | -| Linter config | `.golangci.yaml` (very strict: 40+ linters including perfsprint, nilnil, wrapcheck, gosec) | +## 3. Skills + +Skills are markdown instruction files that extend agent capabilities. They can be: +- Project-scoped: dropped in `./.zero/skills/` or `./skills/` +- User-scoped: dropped in `~/.config/hawk-eco/skills/` + +A skill manifest: + +```markdown +--- +description: How to review Go code for security issues +globs: "*.go" +alwaysApply: true +--- + +When reviewing Go code for security: + +1. Check for SQL injection patterns +2. Verify error handling doesn't expose sensitive data +3. Confirm secrets are not hardcoded +4. Validate input sanitization +``` + +## 4. Hooks + +Hooks allow custom commands to run at specific lifecycle points: +- `beforeReview` — runs before code review starts +- `afterReview` — runs after code review completes +- `sessionStart` — runs at session initialization +- `sessionEnd` — runs at session teardown + +```bash +hawk-eco hook add beforeReview --command "lint-check" +hawk-eco hook remove beforeReview +hawk-eco hook list +``` + +## 5. MCP integration + +MCP (Model Context Protocol) servers can expose tools to hawk-eco: + +```bash +hawk-eco mcp add --name server --url http://localhost:8080 +hawk-eco mcp remove server +hawk-eco mcp list +``` + +## 6. Plugins + +Plugins extend hawk-eco with custom tools and capabilities: + +```bash +hawk-eco plugin add --name my-plugin --path ./my-plugin +hawk-eco plugin remove my-plugin +hawk-eco plugin list +``` + +## 7. Verification + +hawk-eco includes a self-verification system to validate local changes before contributing: + +```bash +hawk-eco verify +hawk-eco verify --fix +``` + +## Development + +```bash +make lint +hawk-eco verify +``` diff --git a/cli/auth/store.go b/cli/auth/store.go index 802395a..ba49f59 100644 --- a/cli/auth/store.go +++ b/cli/auth/store.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/GrayCodeAI/trace/cli/api" "github.com/entireio/auth-go/tokens" @@ -14,6 +15,28 @@ import ( const keyringService = "trace-cli" +// keyringTimeout bounds a single OS keyring operation. zalando/go-keyring +// does not accept a context or deadline, so a stuck keyring daemon (seen in +// practice with Linux secret-service backends) would otherwise hang login/ +// logout indefinitely. The goroutine is not killed when the timeout fires — +// its result is simply discarded — but the caller is freed to move on. +const keyringTimeout = 10 * time.Second + +// keyringDo runs fn on its own goroutine and returns its error, or +// context.DeadlineExceeded-equivalent behavior if it exceeds keyringTimeout. +func keyringDo(fn func() error) error { + ch := make(chan error, 1) + go func() { ch <- fn() }() + timer := time.NewTimer(keyringTimeout) + defer timer.Stop() + select { + case err := <-ch: + return err + case <-timer.C: + return fmt.Errorf("keyring operation timed out after %s", keyringTimeout) + } +} + // Store manages CLI authentication tokens in the OS keyring. // Implements tokenstore.Store for use with the tokenmanager library. type Store struct { @@ -38,7 +61,7 @@ func (s *Store) SaveToken(baseURL, token string) error { return errors.New("refusing to save empty token") } - if err := keyring.Set(s.service, baseURL, token); err != nil { + if err := keyringDo(func() error { return keyring.Set(s.service, baseURL, token) }); err != nil { return fmt.Errorf("save token to keyring: %w", err) } @@ -49,7 +72,12 @@ func (s *Store) SaveToken(baseURL, token string) error { // Returns an empty string (and no error) if no token is stored. // Legacy method for backward compatibility with plain-string tokens. func (s *Store) GetToken(baseURL string) (string, error) { - token, err := keyring.Get(s.service, baseURL) + var token string + err := keyringDo(func() error { + var innerErr error + token, innerErr = keyring.Get(s.service, baseURL) + return innerErr + }) if errors.Is(err, keyring.ErrNotFound) { return "", nil } @@ -63,7 +91,7 @@ func (s *Store) GetToken(baseURL string) (string, error) { // DeleteToken removes a stored token for the given base URL. // Legacy method for backward compatibility with plain-string tokens. func (s *Store) DeleteToken(baseURL string) error { - err := keyring.Delete(s.service, baseURL) + err := keyringDo(func() error { return keyring.Delete(s.service, baseURL) }) if errors.Is(err, keyring.ErrNotFound) { return nil } @@ -92,7 +120,7 @@ func (s *Store) SaveTokens(profile string, t tokens.TokenSet) error { return fmt.Errorf("marshal token set: %w", err) } - if err := keyring.Set(s.service, profile, string(data)); err != nil { + if err := keyringDo(func() error { return keyring.Set(s.service, profile, string(data)) }); err != nil { return fmt.Errorf("save tokens to keyring: %w", err) } @@ -104,7 +132,12 @@ func (s *Store) SaveTokens(profile string, t tokens.TokenSet) error { // Handles legacy plain-string entries by wrapping them in a TokenSet. // Implements tokenstore.Store. func (s *Store) LoadTokens(profile string) (tokens.TokenSet, error) { - raw, err := keyring.Get(s.service, profile) + var raw string + err := keyringDo(func() error { + var innerErr error + raw, innerErr = keyring.Get(s.service, profile) + return innerErr + }) if errors.Is(err, keyring.ErrNotFound) { return tokens.TokenSet{}, tokenstore.ErrNotFound } @@ -125,7 +158,7 @@ func (s *Store) LoadTokens(profile string) (tokens.TokenSet, error) { // DeleteTokens removes a stored TokenSet for the given profile. // Treats missing profiles as a no-op. Implements tokenstore.Store. func (s *Store) DeleteTokens(profile string) error { - err := keyring.Delete(s.service, profile) + err := keyringDo(func() error { return keyring.Delete(s.service, profile) }) if errors.Is(err, keyring.ErrNotFound) { return nil } diff --git a/cli/checkpoint/checkpoint_test.go b/cli/checkpoint/checkpoint_test.go index c795f83..e61d42f 100644 --- a/cli/checkpoint/checkpoint_test.go +++ b/cli/checkpoint/checkpoint_test.go @@ -451,9 +451,9 @@ func TestEnsureSessionsBranch_WritesVercelConfigWhenEnabled(t *testing.T) { if err != nil { t.Fatalf("repo.Worktree() error = %v", err) } - t.Chdir(worktree.Filesystem.Root()) + t.Chdir(worktree.Filesystem().Root()) - traceDir := filepath.Join(worktree.Filesystem.Root(), ".trace") + traceDir := filepath.Join(worktree.Filesystem().Root(), ".trace") if err := os.MkdirAll(traceDir, 0o755); err != nil { t.Fatalf("mkdir .trace: %v", err) } @@ -505,7 +505,7 @@ func TestWriteCommitted_MergesVercelConfigOnMetadataBranch(t *testing.T) { if err != nil { t.Fatalf("repo.Worktree() error = %v", err) } - repoRoot := worktree.Filesystem.Root() + repoRoot := worktree.Filesystem().Root() t.Chdir(repoRoot) traceDir := filepath.Join(repoRoot, ".trace") diff --git a/cli/checkpoint/shadow_ref.go b/cli/checkpoint/shadow_ref.go index 792cbc0..69ac966 100644 --- a/cli/checkpoint/shadow_ref.go +++ b/cli/checkpoint/shadow_ref.go @@ -40,7 +40,7 @@ func (s *GitStore) repoDirs(ctx context.Context) (worktreeRoot, commonDir string if err != nil { return "", "", fmt.Errorf("open worktree: %w", err) } - worktreeRoot = wt.Filesystem.Root() + worktreeRoot = wt.Filesystem().Root() if worktreeRoot == "" { return "", "", errors.New("repository worktree filesystem has no root path") } diff --git a/cli/checkpoint/store.go b/cli/checkpoint/store.go index 77f3d69..5ee182a 100644 --- a/cli/checkpoint/store.go +++ b/cli/checkpoint/store.go @@ -32,7 +32,7 @@ func NewGitStore(repo *git.Repository) *GitStore { wt, err := repo.Worktree() var repoPath string if err == nil { - repoPath = wt.Filesystem.Root() + repoPath = wt.Filesystem().Root() } return &GitStore{repo: repo, repoPath: repoPath} } diff --git a/cli/checkpoint/temporary_2.go b/cli/checkpoint/temporary_2.go index ad72b88..2aa0fba 100644 --- a/cli/checkpoint/temporary_2.go +++ b/cli/checkpoint/temporary_2.go @@ -451,7 +451,7 @@ func filterGitIgnoredFiles(ctx context.Context, repo *git.Repository, files []st slog.String("error", err.Error())) return nil } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() // Use git check-ignore to identify which files are ignored. // Pass files via stdin (-z for NUL-separated, --stdin) to handle special characters. @@ -520,7 +520,7 @@ func collectChangedFiles(ctx context.Context, repo *git.Repository) (changedFile if err != nil { return changedFilesResult{}, fmt.Errorf("failed to get worktree: %w", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() // Use -z for NUL-separated output (handles quoted filenames with spaces/special chars) // Use -uall to list individual untracked files instead of collapsed directories. diff --git a/cli/checkpoint/v2_generation_test.go b/cli/checkpoint/v2_generation_test.go index 6eea21b..e43cdd9 100644 --- a/cli/checkpoint/v2_generation_test.go +++ b/cli/checkpoint/v2_generation_test.go @@ -131,7 +131,7 @@ func TestAddGenerationJSONToTree(t *testing.T) { shardEntries["aa/bbccddeeff/0/"+paths.V2RawTranscriptFileName] = object.TreeEntry{ Name: paths.V2RawTranscriptFileName, Mode: 0o100644, - Hash: plumbing.ZeroHash, // dummy + Hash: storeBlob(t, repo, "dummy"), } rootTreeHash, err := BuildTreeFromEntries(context.Background(), repo, shardEntries) require.NoError(t, err) diff --git a/cli/checkpoint/v2_pending_rotation.go b/cli/checkpoint/v2_pending_rotation.go index 8a6b871..a906870 100644 --- a/cli/checkpoint/v2_pending_rotation.go +++ b/cli/checkpoint/v2_pending_rotation.go @@ -227,7 +227,7 @@ func resolveGitCommonDir(ctx context.Context, repo *git.Repository) (string, err if err != nil { return "", fmt.Errorf("open worktree for pending v2 full generation publications: %w", err) } - root := worktree.Filesystem.Root() + root := worktree.Filesystem().Root() if root == "" { return "", errors.New("resolve worktree root for pending v2 full generation publications") } diff --git a/cli/clean_2_test.go b/cli/clean_2_test.go index 1761e88..e86e7a2 100644 --- a/cli/clean_2_test.go +++ b/cli/clean_2_test.go @@ -75,7 +75,7 @@ func TestCleanCmd_All_InvalidSettingsWarnsAndContinues(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true,`) @@ -109,9 +109,9 @@ func TestCleanCmd_All_Subdirectory(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() subDir := filepath.Join(repoRoot, "subdir") - if err := wt.Filesystem.MkdirAll("subdir", 0o755); err != nil { + if err := wt.Filesystem().MkdirAll("subdir", 0o755); err != nil { t.Fatalf("failed to create subdir: %v", err) } @@ -144,7 +144,7 @@ func TestCleanCmd_All_FindsSessionWithShadowBranch(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) @@ -196,7 +196,7 @@ func TestCleanCmd_All_DryRunListsEligibleV2Generations(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRef(t, repo, "0000000000001", time.Now().AddDate(0, 0, -20), time.Now().AddDate(0, 0, -15)) @@ -227,7 +227,7 @@ func TestCleanCmd_All_DryRunListsRemoteOnlyEligibleV2Generations(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) addCleanBareOrigin(t, repoRoot) @@ -265,7 +265,7 @@ func TestCleanCmd_All_UsesRawTranscriptTimeForV2GenerationRetention(t *testing.T if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) @@ -301,7 +301,7 @@ func TestCleanCmd_All_ForceDeletesRemoteOnlyEligibleV2Generations(t *testing.T) if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) addCleanBareOrigin(t, repoRoot) @@ -336,7 +336,7 @@ func TestCleanCmd_All_ForceDeletesEligibleV2Generations(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createCleanV2Ref(t, repo, plumbing.ReferenceName(paths.V2MainRefName)) @@ -371,7 +371,7 @@ func TestCleanCmd_All_DryRunSkipsV2GenerationsWithinRetention(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRef(t, repo, "0000000000003", time.Now().AddDate(0, 0, -5), time.Now().AddDate(0, 0, -1)) @@ -402,7 +402,7 @@ func TestCleanCmd_All_ForceSkipsV2GenerationMissingMetadata(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRefWithoutMetadata(t, repo, "0000000000001") @@ -432,7 +432,7 @@ func TestCleanCmd_All_ForceSkipsV2GenerationWithInvalidTimestamps(t *testing.T) if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) createArchivedGenerationRef(t, repo, "0000000000004", time.Now().AddDate(0, 0, -1), time.Now().AddDate(0, 0, -20)) @@ -462,7 +462,7 @@ func TestCleanCmd_All_ForceWarnsWithErrorDetailsForUnreadableV2Ref(t *testing.T) if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) diff --git a/cli/clean_test.go b/cli/clean_test.go index a74c3d6..f0c2a19 100644 --- a/cli/clean_test.go +++ b/cli/clean_test.go @@ -155,7 +155,7 @@ func TestCleanLongDescription_DefaultIsGeneric(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {}}`) @@ -175,7 +175,7 @@ func TestCleanLongDescription_IncludesV2CleanupWhenEnabled(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - repoRoot := wt.Filesystem.Root() + repoRoot := wt.Filesystem().Root() writeCleanSettingsFile(t, repoRoot, `{"enabled": true, "strategy_options": {"checkpoints_v2": true, "full_transcript_generation_retention_days": 14}}`) @@ -460,7 +460,7 @@ func TestCleanCmd_DefaultMode_WithForce(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) @@ -506,7 +506,7 @@ func TestCleanCmd_DefaultMode_DryRun(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) @@ -581,7 +581,7 @@ func TestCleanCmd_DefaultMode_SessionsWithoutShadowBranch(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() // Create session state files WITHOUT a shadow branch sessionFile := createSessionStateFile(t, worktreePath, "2026-02-02-orphaned", commitHash) @@ -610,7 +610,7 @@ func TestCleanCmd_DefaultMode_MultipleSessions(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) diff --git a/cli/explain_test.go b/cli/explain_test.go index 5f05b88..46e03e5 100644 --- a/cli/explain_test.go +++ b/cli/explain_test.go @@ -352,7 +352,7 @@ func TestRunExplainAuto_CommitRefWithCheckpointTrailer(t *testing.T) { wt, err := repo.Worktree() require.NoError(t, err) - tmpDir := wt.Filesystem.Root() + tmpDir := wt.Filesystem().Root() require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "feature.txt"), []byte("feature"), 0o644)) _, err = wt.Add("feature.txt") require.NoError(t, err) diff --git a/cli/migrate_test.go b/cli/migrate_test.go index 7d0ca3b..c4c464f 100644 --- a/cli/migrate_test.go +++ b/cli/migrate_test.go @@ -590,7 +590,7 @@ func TestMigrateCmd_RepairsArchivedGenerationMetadata(t *testing.T) { repo := initMigrateTestRepo(t) wt, err := repo.Worktree() require.NoError(t, err) - t.Chdir(wt.Filesystem.Root()) + t.Chdir(wt.Filesystem().Root()) paths.ClearWorktreeRootCache() cpID := id.MustCheckpointID("123456789abc") diff --git a/cli/reset_test.go b/cli/reset_test.go index 64f1e8c..f1048f1 100644 --- a/cli/reset_test.go +++ b/cli/reset_test.go @@ -103,7 +103,7 @@ func TestResetCmd_WithForce(t *testing.T) { if err != nil { t.Fatalf("failed to get worktree: %v", err) } - worktreePath := wt.Filesystem.Root() + worktreePath := wt.Filesystem().Root() worktreeID, err := paths.GetWorktreeID(worktreePath) if err != nil { t.Fatalf("failed to get worktree ID: %v", err) diff --git a/cli/review/scope.go b/cli/review/scope.go index ffe7610..b56a080 100644 --- a/cli/review/scope.go +++ b/cli/review/scope.go @@ -276,7 +276,7 @@ func repoWorktreePath(repo *git.Repository) (string, error) { if err != nil { return "", fmt.Errorf("resolve worktree: %w", err) } - return wt.Filesystem.Root(), nil + return wt.Filesystem().Root(), nil } // fallbackScopeRef returns the first existing ref from the fallback chain: diff --git a/cli/strategy/content_overlap.go b/cli/strategy/content_overlap.go index 7ce4cef..114f322 100644 --- a/cli/strategy/content_overlap.go +++ b/cli/strategy/content_overlap.go @@ -471,7 +471,7 @@ func filesWithRemainingAgentChanges( // differs from shadow (distinguishes replacement from partial staging). var worktreeRoot string if wt, wtErr := repo.Worktree(); wtErr == nil { - worktreeRoot = wt.Filesystem.Root() + worktreeRoot = wt.Filesystem().Root() } var remaining []string diff --git a/cli/strategy/manual_commit_hooks_4.go b/cli/strategy/manual_commit_hooks_4.go index 068c92f..54a898f 100644 --- a/cli/strategy/manual_commit_hooks_4.go +++ b/cli/strategy/manual_commit_hooks_4.go @@ -105,7 +105,7 @@ func (s *ManualCommitStrategy) calculatePromptAttributionAtStart( return result } - worktreeRoot := worktree.Filesystem.Root() + worktreeRoot := worktree.Filesystem().Root() // Build map of changed files with their worktree content // IMPORTANT: We read from worktree (not staging area) to match what WriteTemporary diff --git a/cli/strategy/metadata_reconcile.go b/cli/strategy/metadata_reconcile.go index 596f835..1e39d70 100644 --- a/cli/strategy/metadata_reconcile.go +++ b/cli/strategy/metadata_reconcile.go @@ -614,5 +614,5 @@ func getRepoPath(repo *git.Repository) (string, error) { if err != nil { return "", fmt.Errorf("failed to get worktree: %w", err) } - return wt.Filesystem.Root(), nil + return wt.Filesystem().Root(), nil } diff --git a/cli/transcript/fuzz_test.go b/cli/transcript/fuzz_test.go index d96233f..0d5c225 100644 --- a/cli/transcript/fuzz_test.go +++ b/cli/transcript/fuzz_test.go @@ -3,8 +3,7 @@ package transcript import ( "bytes" "compress/gzip" - "os" - "path/filepath" + "io" "testing" ) @@ -46,10 +45,14 @@ func FuzzParseFromBytes(f *testing.F) { _ = SliceFromLine(data, 1) _ = SliceFromLine(data, len(data)+1) - // gz path: write the same bytes gzip-compressed and parse via the file - // reader, which transparently decompresses the .gz variant. Must not panic. - dir := t.TempDir() - base := filepath.Join(dir, "transcript.jsonl") + // gz path: exercise the same decompress-then-parse logic openTranscriptReader + // uses for a ".gz" transcript, entirely in memory. Must not panic or hang. + // (The thin disk-based path lookup/fallback in openTranscriptReader itself + // is covered by TestParseFromFileAtLine_* in parse_test.go; doing real + // filesystem I/O on every one of millions of fuzz iterations here made this + // target orders of magnitude slower than the other Fuzz* targets in this + // repo, up to the point of colliding with the fuzz engine's own per-run + // deadline and failing with a spurious "context deadline exceeded".) var buf bytes.Buffer gw := gzip.NewWriter(&buf) if _, werr := gw.Write(data); werr != nil { @@ -58,12 +61,16 @@ func FuzzParseFromBytes(f *testing.F) { if cerr := gw.Close(); cerr != nil { t.Fatalf("gzip close: %v", cerr) } - if werr := os.WriteFile(base+".gz", buf.Bytes(), 0o600); werr != nil { - t.Fatalf("write gz file: %v", werr) + gzReader, gzErr := gzip.NewReader(&buf) + if gzErr != nil { + t.Fatalf("gzip.NewReader on our own compressed output: %v", gzErr) } - // base does not exist, so openTranscriptReader falls back to base+".gz". - if _, gerr := ParseFromFileAtLine(base, 0); gerr != nil { - t.Fatalf("ParseFromFileAtLine (gz) returned error: %v", gerr) + decompressed, readErr := io.ReadAll(gzReader) + if readErr != nil { + t.Fatalf("decompressing our own gzip output: %v", readErr) + } + if _, gerr := ParseFromBytes(decompressed); gerr != nil { + t.Fatalf("ParseFromBytes (decompressed) returned error on arbitrary input: %v", gerr) } }) } diff --git a/go.mod b/go.mod index 6a1b7bc..42fae97 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/entireio/auth-go v0.4.0 github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 - github.com/go-git/go-git/v6 v6.0.0-alpha.2 + github.com/go-git/go-git/v6 v6.0.0-alpha.4 github.com/go-git/x/plugin/objectsigner/auto v0.1.0 github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260509055934-990a63433b45 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index 6552d8a..5ed3c39 100644 --- a/go.sum +++ b/go.sum @@ -126,10 +126,10 @@ github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 h1:AaQOU2NVLxnBGWkv5YSoxomcDCqlaqfCW0t00pNKtnk= github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0 h1:XoTsdvaghuVfIr7HpNTmFDLu2nz3I2iGqyn6Uk6MkJc= -github.com/go-git/go-git-fixtures/v6 v6.0.0-20260405195209-b16dd39735e0/go.mod h1:1Lr7/vYEYyl6Ir9Ku0tKrCIRreM5zovv0Jdx2MPSM4s= -github.com/go-git/go-git/v6 v6.0.0-alpha.2 h1:T3loNtDuAixNzXtlQxZhnYiYpaQ3CA4vn9RssAniEeI= -github.com/go-git/go-git/v6 v6.0.0-alpha.2/go.mod h1:oCD3i19CTz7gBpeb11ZZqL91WzqbMq9avn5KpUYy/Ak= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4 h1:aDTc2UGanmaE7FkGLSlBEB9nohMnQ+RKXcfq/D+esDQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4/go.mod h1:4ODa/G7hPWrh4Y+7lmt59Ij3zW38IEfvRoAZxLYYBhc= github.com/go-git/x/plugin/objectsigner/auto v0.1.0 h1:RcLW29RgwSCmqrNSs7QOxvWkRbM1vPu0Vp9TCECZjMs= github.com/go-git/x/plugin/objectsigner/auto v0.1.0/go.mod h1:iP2cXPyXc//9v9THS3y/MLi0jnt7vEqwUDj11qQfFPg= github.com/go-git/x/plugin/objectsigner/gpg v0.1.0 h1:NEGVSOD+LPnus6j4iNkAZaHVTc4DNY223y1/I2Jq2yI= diff --git a/lefthook.yml b/lefthook.yml index 7d5bdaf..edab577 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,5 +1,5 @@ # Canonical lefthook config for hawk-eco Go repos. -# Source of truth: .shared-templates/lefthook.yml.tmpl +# Source of truth: https://github.com/GrayCodeAI/hawk/blob/main/.shared-templates/lefthook.yml.tmpl # # Install lefthook: # brew install lefthook (macOS) @@ -75,6 +75,9 @@ pre-commit: pre-push: commands: + boundaries: + run: bash ./scripts/check-ecosystem-boundaries.sh + test: run: go test ./... -count=1 -timeout=60s diff --git a/version.go b/version.go new file mode 100644 index 0000000..a96fbe1 --- /dev/null +++ b/version.go @@ -0,0 +1,15 @@ +// Package trace provides distributed tracing and observability. +// +// The Version variable is sourced from the VERSION file at the repo root. +package trace + +import ( + _ "embed" + "strings" +) + +//go:embed VERSION +var versionFile string + +// Version of the trace library. Single source of truth: VERSION file. +var Version = strings.TrimSpace(versionFile)