From bd4d43fd8b67161e53c029a90092e3eaa6ef3eb6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 06:05:45 +0530 Subject: [PATCH 01/23] feat: implement core contracts architecture Add the hawk-core-contracts module to the local workspace and migrate Hawk runtime, session, policy, event, review, and verification boundaries to consume neutral contracts instead of leaking Hawk internals or eyrie/client types. Also add import guardrails, architecture docs, and the sight/inspect submodule adapter commits required by the Hawk bridge layer. --- .github/actions/setup-deps/action.yml | 2 +- .github/workflows/ci.yml | 6 + .gitignore | 1 + AGENTS.md | 19 +- CHANGELOG.md | 2 + Makefile | 16 +- README.md | 11 +- cmd/chat.go | 2 +- cmd/chat_commands_session.go | 31 +- cmd/chat_print.go | 27 +- cmd/chat_welcome.go | 4 +- cmd/exec.go | 2 +- cmd/inspect_pipeline.go | 18 +- cmd/inspect_pipeline_test.go | 31 +- cmd/options.go | 6 +- cmd/review_analyze.go | 11 +- cmd/review_run.go | 11 +- cmd/review_store.go | 6 +- cmd/review_test.go | 17 +- docs/architecture.md | 8 +- docs/architecture/README.md | 18 + .../hawk-contract-migration-inventory.md | 192 ++++++ docs/architecture/hawk-core-contracts-spec.md | 106 +++ docs/architecture/hawk-dependency-rules.md | 65 ++ .../architecture/hawk-product-architecture.md | 176 +++++ .../architecture/hawk-provider-abstraction.md | 65 ++ docs/architecture/hawk-repo-roles.md | 61 ++ .../hawk-review-verify-lifecycle.md | 81 +++ docs/architecture/hawk-trace-event-model.md | 83 +++ .../plans/hawk-contracts-migration-backlog.md | 180 +++++ external/hawk-core-contracts/README.md | 68 ++ external/hawk-core-contracts/events/events.go | 34 + .../hawk-core-contracts/events/events_test.go | 73 ++ external/hawk-core-contracts/go.mod | 3 + external/hawk-core-contracts/policy/policy.go | 136 ++++ .../hawk-core-contracts/policy/policy_test.go | 77 +++ external/hawk-core-contracts/review/review.go | 89 +++ .../hawk-core-contracts/review/review_test.go | 38 + external/hawk-core-contracts/tools/tool.go | 15 + .../hawk-core-contracts/tools/tool_test.go | 63 ++ external/hawk-core-contracts/types/finding.go | 155 +++++ .../hawk-core-contracts/types/finding_test.go | 20 + .../hawk-core-contracts/types/severity.go | 63 ++ .../types/severity_test.go | 26 + external/hawk-core-contracts/verify/verify.go | 64 ++ .../hawk-core-contracts/verify/verify_test.go | 38 + external/inspect | 2 +- external/sight | 2 +- go.mod | 3 + go.work | 2 + internal/bridge/inspect/bridge.go | 10 + internal/bridge/sight/bridge.go | 35 +- internal/engine/compact_strategy_test.go | 13 +- internal/engine/context_compaction.go | 13 +- internal/engine/engine_integration_test.go | 9 +- internal/engine/provider_chat_client.go | 4 +- internal/engine/safety/permission.go | 5 +- internal/engine/safety/permission_engine.go | 9 +- internal/engine/session.go | 4 +- internal/engine/session_factory.go | 6 +- internal/engine/subagent_synthesis_test.go | 17 +- internal/hooks/audit/detectors.go | 12 +- internal/observability/oteltrace/langfuse.go | 21 +- internal/permissions/guardian.go | 8 +- internal/permissions/verdict.go | 136 +--- internal/session/conversion.go | 101 +++ internal/session/conversion_test.go | 40 ++ internal/session/session.go | 14 +- internal/testaudit/audit_test.go | 63 ++ internal/testaudit/docs_audit_test.go | 100 +++ internal/testaudit/helpers.go | 6 + internal/tool/git_commit.go | 12 +- internal/tool/git_commit_test.go | 36 + internal/tool/tool.go | 36 +- internal/types/client.go | 651 +++++++++++++++++- internal/types/client_test.go | 127 ++++ internal/types/severity.go | 47 +- scripts/check-ecosystem-boundaries.sh | 36 + scripts/check-eyrie-client-imports.sh | 22 + scripts/check-shared-types-imports.sh | 22 + shared/types/finding.go | 157 +---- shared/types/severity.go | 70 +- 82 files changed, 3476 insertions(+), 595 deletions(-) create mode 100644 docs/architecture/README.md create mode 100644 docs/architecture/hawk-contract-migration-inventory.md create mode 100644 docs/architecture/hawk-core-contracts-spec.md create mode 100644 docs/architecture/hawk-dependency-rules.md create mode 100644 docs/architecture/hawk-product-architecture.md create mode 100644 docs/architecture/hawk-provider-abstraction.md create mode 100644 docs/architecture/hawk-repo-roles.md create mode 100644 docs/architecture/hawk-review-verify-lifecycle.md create mode 100644 docs/architecture/hawk-trace-event-model.md create mode 100644 docs/plans/hawk-contracts-migration-backlog.md create mode 100644 external/hawk-core-contracts/README.md create mode 100644 external/hawk-core-contracts/events/events.go create mode 100644 external/hawk-core-contracts/events/events_test.go create mode 100644 external/hawk-core-contracts/go.mod create mode 100644 external/hawk-core-contracts/policy/policy.go create mode 100644 external/hawk-core-contracts/policy/policy_test.go create mode 100644 external/hawk-core-contracts/review/review.go create mode 100644 external/hawk-core-contracts/review/review_test.go create mode 100644 external/hawk-core-contracts/tools/tool.go create mode 100644 external/hawk-core-contracts/tools/tool_test.go create mode 100644 external/hawk-core-contracts/types/finding.go create mode 100644 external/hawk-core-contracts/types/finding_test.go create mode 100644 external/hawk-core-contracts/types/severity.go create mode 100644 external/hawk-core-contracts/types/severity_test.go create mode 100644 external/hawk-core-contracts/verify/verify.go create mode 100644 external/hawk-core-contracts/verify/verify_test.go create mode 100644 internal/session/conversion.go create mode 100644 internal/session/conversion_test.go create mode 100644 internal/testaudit/docs_audit_test.go create mode 100644 internal/types/client_test.go create mode 100644 scripts/check-ecosystem-boundaries.sh create mode 100755 scripts/check-eyrie-client-imports.sh create mode 100644 scripts/check-shared-types-imports.sh diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index 777735ef..40b98197 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -41,4 +41,4 @@ runs: - name: Create workspace shell: bash run: | - printf 'go 1.26.4\n\nuse (\n\t.\n\t./external/eyrie\n\t./external/inspect\n\t./external/sight\n\t./external/tok\n\t./external/trace\n\t./external/yaad\n)\n' > go.work + printf 'go 1.26.4\n\nuse (\n\t.\n\t./external/eyrie\n\t./external/hawk-core-contracts\n\t./external/inspect\n\t./external/sight\n\t./external/tok\n\t./external/trace\n\t./external/yaad\n)\n' > go.work diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05faa6c2..60dd2019 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,6 +58,12 @@ jobs: echo "$out" | head -20 exit 1 fi + - name: shared types import guard + run: bash ./scripts/check-shared-types-imports.sh + - name: ecosystem boundary guard + run: bash ./scripts/check-ecosystem-boundaries.sh + - name: eyrie client boundary guard + run: bash ./scripts/check-eyrie-client-imports.sh # ------------------------------------------------------------------------- # 2. Module hygiene — tidy, verify (hawk + external ecosystem repos via go.work). diff --git a/.gitignore b/.gitignore index f316d874..948ae4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ hawk .gocache-*/ .gomodcache/ .gomodcache-*/ +.tmp/ *.codegraph.db .DS_Store diff --git a/AGENTS.md b/AGENTS.md index 0e5d18e8..1fae5a5a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,7 @@ hawk/ │ ├── daemon/ # Background HTTP/SSE server │ ├── resilience/ # Circuit breaker, rate limiting, health checks │ └── feature/ # Eval, fingerprint, scaffolding -├── shared/types/ # Cross-repo exported types (severity, etc.) +├── shared/types/ # Deprecated compatibility layer; migrate to hawk-core-contracts ├── docs/ # Architecture docs, research notes └── testdata/ # Test fixtures ``` @@ -70,13 +70,17 @@ hawk/ ## Key Design Decisions - **Zero CGO:** Pure Go, cross-compilable. Tree-sitter is optional. -- **`internal/` is private:** Other repos import `shared/types/` only. +- **`internal/` is private:** Cross-repo contracts belong in `hawk-core-contracts`, not `internal/`. - **Tool safety layer:** Every tool call goes through permissions (guardian, rules DSL, boundary checker) before execution. - **Engine-first:** The agent loop in `internal/engine/` orchestrates context packing, tool dispatch, streaming, and session persistence. - **Ecosystem integration:** eyrie handles all LLM API communication. hawk - never talks to LLM APIs directly. + never talks to LLM APIs directly, and production code should go through + `internal/types` transport adapters instead of importing `eyrie/client`. +- **Shared contracts:** cross-repo types now live in `hawk-core-contracts` + (`types`, `review`, `verify`, `tools`, `events`, `policy`). Hawk production + code should not add new imports of `hawk/shared/types`. ## Development Guidelines @@ -146,7 +150,7 @@ test: add coverage for guardian - `CONTRIBUTING.md` — PR process, commit conventions - `docs/` — Architecture details, security model, ecosystem message flow - `external/` — Ecosystem repo checkouts for `go.work` development -- `shared/types/` — Types exported for sight/inspect/tok (they must not import `internal/`) +- `shared/types/` — Deprecated compatibility types; new cross-repo contracts belong in `hawk-core-contracts` ## Testing Philosophy @@ -157,7 +161,7 @@ test: add coverage for guardian ## Common Pitfalls -- Do not import `internal/` from other ecosystem repos — use `shared/types/` +- Do not import `internal/` from other ecosystem repos — use `hawk-core-contracts` - Do not put API keys in `.env` or shell env for hawk — use `/config` (OS keychain) - The `external/` directory is for local dev only; CI clones repos separately - `go.work` and `go.work.sum` are committed — CI's `module hygiene` job @@ -202,6 +206,7 @@ test: add coverage for guardian - **Fuzz tests** for input parsing robustness: `func FuzzFoo(f *testing.F) { ... }` - **No mocks framework** — use concrete types and test doubles - **Meta-audit tests** in `internal/testaudit/` enforce architectural invariants via go/ast + including transport-boundary and deprecated-compatibility-package checks. ## Refactoring Guidelines @@ -211,7 +216,7 @@ test: add coverage for guardian - **Caution**: `internal/engine/session.go` Session struct — widely referenced across 30+ sub-packages - **Caution**: `internal/config/settings.go` Settings struct — serialized to JSON, dual-format (snake_case + camelCase) - **Caution**: `internal/tool/` Tool interface — implemented by 40+ tools -- **Blocked**: `shared/types/` — exported to eyrie, sight, inspect, tok; changes break ecosystem +- **Migration-sensitive**: `shared/types/` remains only as a deprecated compatibility layer for legacy downstreams; do not add new imports. ## Key File Locations @@ -250,6 +255,6 @@ test: add coverage for guardian - **No `panic()` for error handling** — return `error` values. Exception: `init()` functions for package-level assertions. - **No `fmt.Print` for logging** — use `logger.Logger` with structured fields. Exception: `internal/onboarding/` and `internal/engine/scaffold/` for user-facing CLI output. - **No API keys in settings.json** — use OS secret store via `credentials` package and `/config` command. -- **No importing `internal/` from other ecosystem repos** — use `shared/types/` for cross-repo types. +- **No importing `internal/` from other ecosystem repos** — use `hawk-core-contracts` for cross-repo types. - **No global mutable state** — prefer dependency injection via `deps` structs or `context.WithValue`. - **No `t.Skip()` without a tracking issue** — every skipped test needs a GitHub issue number. diff --git a/CHANGELOG.md b/CHANGELOG.md index b3805493..e1f4ccc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Version re-baselined to `0.1.0`** across `cmd/hawk/main.go`, `cmd/daemon.go`, `flake.nix`, `.github/workflows/release.yml`, and the `update`/daemon test suites, aligning hawk with the rest of the GrayCodeAI ecosystem (`eyrie`, `tok`, `yaad`, `sight`, `inspect`). +- **Architecture boundary hardening**: Hawk now owns runtime request/response DTOs, transport config/provider seams, and review/verification product-boundary contracts, with `eyrie/client` usage restricted to internal adapters and guarded in CI. +- **`shared/types` deprecation tightened**: compatibility symbols are now explicitly marked deprecated in code, and local boundary checks enforce migration away from that package. ### Added - **Watch mode (`--watch`)**: file-watcher loop that acts on `AI!` (do-now) and `AI?` (answer) code comments. Off by default. diff --git a/Makefile b/Makefile index f52bdc08..476c64e1 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ GORELEASER := $(GOBIN_DIR)/goreleaser # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- -.PHONY: all bench build ci clean cover cover-new fmt help install lint lint-fix \ +.PHONY: all bench build ci clean contracts-guard ecosystem-guard eyrie-client-guard cover cover-new fmt help install lint lint-fix \ release security setup smoke path test test-10x test-live test-new test-race tidy version vet # --------------------------------------------------------------------------- @@ -99,6 +99,15 @@ fmt: ## Format source files (gofumpt + goimports). vet: ## Run go vet. go vet ./... +contracts-guard: ## Fail on new imports of hawk/shared/types outside compatibility paths. + bash ./scripts/check-shared-types-imports.sh + +ecosystem-guard: ## Fail if external ecosystem repos import hawk/internal or deprecated hawk/shared/types. + bash ./scripts/check-ecosystem-boundaries.sh + +eyrie-client-guard: ## Fail on new direct eyrie/client imports outside Hawk transport adapters. + bash ./scripts/check-eyrie-client-imports.sh + lint: ## Run golangci-lint. @command -v $(GOLANGCI) >/dev/null 2>&1 || (echo "install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1) $(GOLANGCI) run ./... --timeout=5m @@ -118,7 +127,7 @@ tidy: ## Sync workspace modules and verify checksums. # --------------------------------------------------------------------------- # Composite gate used by CI and pre-push. # --------------------------------------------------------------------------- -ci: tidy fmt vet lint test-race security ## Run everything CI runs. +ci: tidy fmt vet contracts-guard ecosystem-guard eyrie-client-guard lint test-race security ## Run everything CI runs. @echo "All CI checks passed." smoke: ## Quick build + doctor + ecosystem verification. @@ -161,6 +170,9 @@ setup: ## Set up local development environment (go.work + external repos). @echo "" >> go.work @echo "use (" >> go.work @echo " ." >> go.work + @if [ -d "external/hawk-core-contracts" ]; then \ + echo " ./external/hawk-core-contracts" >> go.work; \ + fi @for repo in $(ECO_REPOS); do \ if [ -d "external/$$repo" ]; then \ echo " ./external/$$repo" >> go.work; \ diff --git a/README.md b/README.md index 61e0b6b1..4d6a7ba3 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,16 @@ hawk integrates these GrayCodeAI repos in three ways: - **External checkout + `go.work`:** **eyrie**, **sight**, **inspect**, **tok**, **yaad**, **trace** — clone ecosystem repos under `external/`. `go.work` lists the local external checkouts. CI clones the same layout via **`.github/actions/checkout-eyrie`**. - **Optional CLI (no Go import):** **trace** — installed separately; `hawk` shells into `trace` for session capture when present. -Cross-repo types (severity, etc.) are exported from **`github.com/GrayCodeAI/hawk/shared/types`** so **sight** / **inspect** / **tok** do not import **`internal/`**. +Cross-repo contracts now live in **`github.com/GrayCodeAI/hawk-core-contracts`** so support repos do not depend on Hawk internals. The legacy `hawk/shared/types` package is now a deprecated compatibility layer and should not be used for new code. + +Current contract packages: + +- `hawk-core-contracts/types` — severity, findings +- `hawk-core-contracts/review` — normalized review findings, comments, stats, results +- `hawk-core-contracts/verify` — normalized verification findings, stats, reports +- `hawk-core-contracts/tools` — tool call/result contracts +- `hawk-core-contracts/events` — normalized tool/trace events +- `hawk-core-contracts/policy` — permission and policy verdict contracts You may keep a **personal** parent **`go.work`** that lists alternate clones on disk for multi-repo development. diff --git a/cmd/chat.go b/cmd/chat.go index 2f658f3c..d065befb 100644 --- a/cmd/chat.go +++ b/cmd/chat.go @@ -76,7 +76,7 @@ func prepareSession(sess *engine.Session) (string, *session.Session, error) { if err != nil { return "", nil, err } - sess.LoadMessages(toEyrieMessages(saved.Messages)) + sess.LoadMessages(session.ToRuntimeMessages(saved.Messages)) if forkSessionFlag { if sessionIDFlag != "" { id = sessionIDFlag diff --git a/cmd/chat_commands_session.go b/cmd/chat_commands_session.go index e2ea8680..3afbc409 100644 --- a/cmd/chat_commands_session.go +++ b/cmd/chat_commands_session.go @@ -11,7 +11,6 @@ import ( tea "github.com/charmbracelet/bubbletea" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/storage" ) @@ -22,19 +21,9 @@ func (m *chatModel) saveSession() { if len(raw) == 0 { return } - var msgs []session.Message - for _, rm := range raw { - sm := session.Message{Role: rm.Role, Content: rm.Content} - sm.ToolUse = append(sm.ToolUse, rm.ToolUse...) - if len(rm.ToolResults) > 0 { - sm.ToolResults = make([]session.ToolResult, len(rm.ToolResults)) - copy(sm.ToolResults, rm.ToolResults) - } - msgs = append(msgs, sm) - } err := session.Save(&session.Session{ ID: m.sessionID, Model: m.session.Model(), Provider: m.session.Provider(), - Messages: msgs, CreatedAt: time.Now(), + Messages: session.FromRuntimeMessages(raw), CreatedAt: time.Now(), }) // On successful save, WAL is no longer needed (session file has everything) if err == nil && m.wal != nil { @@ -132,15 +121,8 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.sessionID = s.ID m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} - var msgs []client.EyrieMessage + msgs := session.ToRuntimeMessages(s.Messages) for _, sm := range s.Messages { - em := client.EyrieMessage{Role: sm.Role, Content: sm.Content} - em.ToolUse = append(em.ToolUse, sm.ToolUse...) - if len(sm.ToolResults) > 0 { - em.ToolResults = make([]client.ToolResult, len(sm.ToolResults)) - copy(em.ToolResults, sm.ToolResults) - } - msgs = append(msgs, em) if sm.Role == "user" || sm.Role == "assistant" { m.messages = append(m.messages, displayMsg{role: sm.Role, content: sm.Content}) } @@ -179,15 +161,8 @@ func (m *chatModel) handleSessionCommand(cmd string, parts []string, text string m.sessionID = saved.ID m.invalidateViewportCache() m.messages = []displayMsg{{role: "welcome", content: m.welcomeCache}} - var msgs []client.EyrieMessage + msgs := session.ToRuntimeMessages(saved.Messages) for _, sm := range saved.Messages { - em := client.EyrieMessage{Role: sm.Role, Content: sm.Content} - em.ToolUse = append(em.ToolUse, sm.ToolUse...) - if len(sm.ToolResults) > 0 { - em.ToolResults = make([]client.ToolResult, len(sm.ToolResults)) - copy(em.ToolResults, sm.ToolResults) - } - msgs = append(msgs, em) if sm.Role == "user" || sm.Role == "assistant" { m.messages = append(m.messages, displayMsg{role: sm.Role, content: sm.Content}) } diff --git a/cmd/chat_print.go b/cmd/chat_print.go index 2baae951..d43e1f25 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -11,7 +11,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/hawk/internal/engine" aiwatch "github.com/GrayCodeAI/hawk/internal/engine/io" "github.com/GrayCodeAI/hawk/internal/engine/lifecycle" @@ -205,39 +204,15 @@ func saveEyrieSession(id string, sess *engine.Session) { if len(raw) == 0 { return } - var msgs []session.Message - for _, rm := range raw { - sm := session.Message{Role: rm.Role, Content: rm.Content} - sm.ToolUse = append(sm.ToolUse, rm.ToolUse...) - if len(rm.ToolResults) > 0 { - sm.ToolResults = make([]session.ToolResult, len(rm.ToolResults)) - copy(sm.ToolResults, rm.ToolResults) - } - msgs = append(msgs, sm) - } _ = session.Save(&session.Session{ ID: id, Model: sess.Model(), Provider: sess.Provider(), - Messages: msgs, + Messages: session.FromRuntimeMessages(raw), CreatedAt: time.Now(), }) } -func toEyrieMessages(saved []session.Message) []client.EyrieMessage { - msgs := make([]client.EyrieMessage, 0, len(saved)) - for _, sm := range saved { - em := client.EyrieMessage{Role: sm.Role, Content: sm.Content} - em.ToolUse = append(em.ToolUse, sm.ToolUse...) - if len(sm.ToolResults) > 0 { - em.ToolResults = make([]client.ToolResult, len(sm.ToolResults)) - copy(em.ToolResults, sm.ToolResults) - } - msgs = append(msgs, em) - } - return msgs -} - // runRepl starts an interactive REPL mode for multi-turn conversation without TUI. func runRepl() error { fmt.Fprintln(os.Stderr, "Hawk REPL — type 'exit' or 'quit' to leave, 'help' for commands") diff --git a/cmd/chat_welcome.go b/cmd/chat_welcome.go index faa643d2..25ea09ce 100644 --- a/cmd/chat_welcome.go +++ b/cmd/chat_welcome.go @@ -9,7 +9,6 @@ import ( "github.com/mattn/go-runewidth" "github.com/GrayCodeAI/eyrie/catalog" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/eyrie/credentials" "github.com/GrayCodeAI/eyrie/setup" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" @@ -17,6 +16,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/session" "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -357,7 +357,7 @@ func indentedAPIKeyLines() string { } func apiKeyStatusLines() []string { - providers := client.Client(nil).GetProviders() + providers := types.NewClient(nil).GetProviders() sort.Strings(providers) var lines []string for _, provider := range providers { diff --git a/cmd/exec.go b/cmd/exec.go index 5947c246..2adbeda5 100644 --- a/cmd/exec.go +++ b/cmd/exec.go @@ -235,7 +235,7 @@ func runExec(_ *cobra.Command, args []string) error { if lookupErr != nil { return fmt.Errorf("resume session %s: %w", execSessionID, lookupErr) } - sess.LoadMessages(toEyrieMessages(saved.Messages)) + sess.LoadMessages(session.ToRuntimeMessages(saved.Messages)) } // Add the user prompt diff --git a/cmd/inspect_pipeline.go b/cmd/inspect_pipeline.go index 92351c50..a283b1a8 100644 --- a/cmd/inspect_pipeline.go +++ b/cmd/inspect_pipeline.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" + verifycontracts "github.com/GrayCodeAI/hawk-core-contracts/verify" hawkInspect "github.com/GrayCodeAI/hawk/internal/bridge/inspect" inspectLib "github.com/GrayCodeAI/inspect" ) @@ -31,7 +33,7 @@ func DefaultInspectPipelineConfig(target string) InspectPipelineConfig { // InspectToReviewFindings converts an inspect report into ReviewFindings // so they can be displayed alongside code review findings. -func InspectToReviewFindings(report *inspectLib.Report) []ReviewFinding { +func InspectToReviewFindings(report *verifycontracts.Report) []ReviewFinding { if report == nil { return nil } @@ -52,15 +54,15 @@ func InspectToReviewFindings(report *inspectLib.Report) []ReviewFinding { } // mapInspectSeverity converts inspect severity to review severity. -func mapInspectSeverity(sev inspectLib.Severity) string { +func mapInspectSeverity(sev contracts.Severity) string { switch sev { - case inspectLib.SeverityCritical: + case contracts.SeverityCritical: return "critical" - case inspectLib.SeverityHigh: + case contracts.SeverityHigh: return "high" - case inspectLib.SeverityMedium: + case contracts.SeverityMedium: return "medium" - case inspectLib.SeverityLow: + case contracts.SeverityLow: return "low" default: return "low" @@ -89,7 +91,7 @@ func RunInspectPipeline(ctx context.Context, cfg InspectPipelineConfig) ([]Revie return nil, "", fmt.Errorf("inspect bridge failed to initialize") } - report, err := bridge.Run(ctx, cfg.Target) + report, err := bridge.RunContracts(ctx, cfg.Target) if err != nil { return nil, "", fmt.Errorf("inspect scan failed: %w", err) } @@ -123,7 +125,7 @@ func MergeInspectWithReview(reviewFindings, inspectFindings []ReviewFinding) []R } // formatInspectReport creates a concise text summary of an inspect report. -func formatInspectReport(report *inspectLib.Report) string { +func formatInspectReport(report *verifycontracts.Report) string { if report == nil { return "No inspect report." } diff --git a/cmd/inspect_pipeline_test.go b/cmd/inspect_pipeline_test.go index 61925145..0437f2f6 100644 --- a/cmd/inspect_pipeline_test.go +++ b/cmd/inspect_pipeline_test.go @@ -3,7 +3,8 @@ package cmd import ( "testing" - inspectLib "github.com/GrayCodeAI/inspect" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" + verifycontracts "github.com/GrayCodeAI/hawk-core-contracts/verify" ) func TestInspectToReviewFindings_NilReport(t *testing.T) { @@ -16,7 +17,7 @@ func TestInspectToReviewFindings_NilReport(t *testing.T) { func TestInspectToReviewFindings_EmptyReport(t *testing.T) { t.Parallel() - report := &inspectLib.Report{ + report := &verifycontracts.Report{ Target: "https://example.com", } findings := InspectToReviewFindings(report) @@ -27,20 +28,20 @@ func TestInspectToReviewFindings_EmptyReport(t *testing.T) { func TestInspectToReviewFindings_WithFindings(t *testing.T) { t.Parallel() - report := &inspectLib.Report{ + report := &verifycontracts.Report{ Target: "https://example.com", - Findings: []inspectLib.Finding{ + Findings: []verifycontracts.Finding{ { Check: "security", URL: "https://example.com/login", - Severity: inspectLib.SeverityHigh, + Severity: contracts.SeverityHigh, Message: "Missing CSP header", Fix: "Add Content-Security-Policy header", }, { Check: "a11y", URL: "https://example.com/about", - Severity: inspectLib.SeverityMedium, + Severity: contracts.SeverityMedium, Message: "Missing alt text on image", }, }, @@ -68,14 +69,14 @@ func TestInspectToReviewFindings_WithFindings(t *testing.T) { func TestMapInspectSeverity(t *testing.T) { t.Parallel() tests := []struct { - input inspectLib.Severity + input contracts.Severity want string }{ - {inspectLib.SeverityCritical, "critical"}, - {inspectLib.SeverityHigh, "high"}, - {inspectLib.SeverityMedium, "medium"}, - {inspectLib.SeverityLow, "low"}, - {inspectLib.Severity(999), "low"}, + {contracts.SeverityCritical, "critical"}, + {contracts.SeverityHigh, "high"}, + {contracts.SeverityMedium, "medium"}, + {contracts.SeverityLow, "low"}, + {contracts.Severity(999), "low"}, } for _, tt := range tests { @@ -141,12 +142,12 @@ func TestFormatInspectReport_Nil(t *testing.T) { func TestFormatInspectReport_WithData(t *testing.T) { t.Parallel() - report := &inspectLib.Report{ + report := &verifycontracts.Report{ Target: "https://example.com", CrawledURLs: 10, - Stats: inspectLib.Stats{ + Stats: verifycontracts.Stats{ FindingsTotal: 5, - BySeverity: map[inspectLib.Severity]int{inspectLib.SeverityHigh: 2, inspectLib.SeverityMedium: 3}, + BySeverity: map[contracts.Severity]int{contracts.SeverityHigh: 2, contracts.SeverityMedium: 3}, }, } output := formatInspectReport(report) diff --git a/cmd/options.go b/cmd/options.go index e387f7fe..6f1d4bad 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -10,7 +10,6 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" ctxrepomap "github.com/GrayCodeAI/hawk/internal/context/repomap" "github.com/GrayCodeAI/hawk/internal/engine" @@ -23,6 +22,7 @@ import ( hawkmodel "github.com/GrayCodeAI/hawk/internal/provider/routing" "github.com/GrayCodeAI/hawk/internal/snapshot" "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" ) func buildSystemPrompt() (string, error) { @@ -156,7 +156,7 @@ func loadEffectiveSettings() (hawkconfig.Settings, error) { if cp.Name == "" || cp.BaseURL == "" { continue } - _ = client.RegisterDynamicProvider(cp.Name, cp.BaseURL, cp.APIKeyEnv) + _ = types.RegisterDynamicProvider(cp.Name, cp.BaseURL, cp.APIKeyEnv) } return settings, nil } @@ -185,7 +185,7 @@ func effectiveModelAndProvider(settings hawkconfig.Settings) (string, string) { // so users with ANTHROPIC_API_KEY don't get confusing errors about canopywave. normalized := hawkconfig.NormalizeProviderForEngine(effectiveProvider) if normalized != "" && hawkconfig.APIKeyForProvider(normalized) == "" { - detected := client.DetectProvider() + detected := types.DetectProvider() if detected != "" && hawkconfig.APIKeyForProvider(detected) != "" { normalized = detected effectiveModel = "" diff --git a/cmd/review_analyze.go b/cmd/review_analyze.go index 25aa8c30..1844c235 100644 --- a/cmd/review_analyze.go +++ b/cmd/review_analyze.go @@ -8,8 +8,9 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" hawkSight "github.com/GrayCodeAI/hawk/internal/bridge/sight" + "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" sightLib "github.com/GrayCodeAI/sight" "github.com/spf13/cobra" @@ -125,9 +126,9 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { // Build sight bridge for analysis. prov := provider if prov == "" { - prov = client.DetectProvider() + prov = types.DetectProvider() } - eyrieClient := client.Client(&client.EyrieConfig{Provider: prov}) + eyrieClient := types.NewClient(&types.ClientConfig{Provider: prov}) var opts []sightLib.Option if analyzeModel != "" { @@ -151,7 +152,7 @@ func runReviewAnalyze(_ *cobra.Command, args []string) error { analysisInput := fmt.Sprintf("# Analysis Type: %s\n\n%s\n\n---\n\n%s", analysisType, prompt, content) fmt.Printf("Analyzing (%s)...\n", analysisType) - result, err := bridge.Review(ctx, analysisInput) + result, err := bridge.ReviewContracts(ctx, analysisInput) if err != nil { return fmt.Errorf("analysis failed: %w", err) } @@ -229,7 +230,7 @@ func getAnalysisContent(patterns []string) (string, error) { return b.String(), nil } -func autoFixAnalysis(result *sightLib.Result) error { +func autoFixAnalysis(result *reviewcontracts.Result) error { var b strings.Builder b.WriteString("Fix the following analysis findings:\n\n") for i, f := range result.Findings { diff --git a/cmd/review_run.go b/cmd/review_run.go index 32300411..f9783b21 100644 --- a/cmd/review_run.go +++ b/cmd/review_run.go @@ -8,8 +8,9 @@ import ( "strings" "time" - "github.com/GrayCodeAI/eyrie/client" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" hawkSight "github.com/GrayCodeAI/hawk/internal/bridge/sight" + "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" sightLib "github.com/GrayCodeAI/sight" "github.com/spf13/cobra" @@ -87,9 +88,9 @@ func runReviewRun(_ *cobra.Command, args []string) error { // Build sight bridge. prov := provider if prov == "" { - prov = client.DetectProvider() + prov = types.DetectProvider() } - eyrieClient := client.Client(&client.EyrieConfig{Provider: prov}) + eyrieClient := types.NewClient(&types.ClientConfig{Provider: prov}) var opts []sightLib.Option if reviewRunModel != "" { @@ -117,7 +118,7 @@ func runReviewRun(_ *cobra.Command, args []string) error { } // Run review. - result, err := bridge.Review(ctx, diff) + result, err := bridge.ReviewContracts(ctx, diff) if err != nil { _ = store.SetStatus(id, ReviewStatusFailed) return silentErr(err, "sight review") @@ -152,7 +153,7 @@ func getCommitDiff(sha string) (string, error) { return string(out), nil } -func printReviewSummary(sha string, result *sightLib.Result) { +func printReviewSummary(sha string, result *reviewcontracts.Result) { if len(result.Findings) == 0 { fmt.Printf("%s %s — no issues found (%d files reviewed)\n", icons.CheckBold(), sha[:8], result.Stats.FilesReviewed) return diff --git a/cmd/review_store.go b/cmd/review_store.go index 153b7174..5b4f0faa 100644 --- a/cmd/review_store.go +++ b/cmd/review_store.go @@ -10,8 +10,8 @@ import ( "sync" "time" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" "github.com/GrayCodeAI/hawk/internal/storage" - sightLib "github.com/GrayCodeAI/sight" ) // ReviewStatus represents the state of a review. @@ -32,7 +32,7 @@ type ReviewRecord struct { ID int64 SHA string Status ReviewStatus - Findings []sightLib.Finding + Findings []reviewcontracts.Finding Report string MaxSeverity string TokensUsed int @@ -142,7 +142,7 @@ func (s *ReviewStore) Create(sha string) (int64, error) { } // Update sets the review result after completion. -func (s *ReviewStore) Update(id int64, status ReviewStatus, result *sightLib.Result) error { +func (s *ReviewStore) Update(id int64, status ReviewStatus, result *reviewcontracts.Result) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/cmd/review_test.go b/cmd/review_test.go index a196d27f..42bfc76a 100644 --- a/cmd/review_test.go +++ b/cmd/review_test.go @@ -6,7 +6,8 @@ import ( "strings" "testing" - sightLib "github.com/GrayCodeAI/sight" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" ) @@ -53,12 +54,12 @@ func TestReviewStore_Update(t *testing.T) { id, _ := store.Create("sha123") - result := &sightLib.Result{ - Findings: []sightLib.Finding{ - {Concern: "security", Severity: sightLib.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection"}, - {Concern: "bugs", Severity: sightLib.SeverityMedium, File: "handler.go", Line: 20, Message: "nil deref"}, + result := &reviewcontracts.Result{ + Findings: []reviewcontracts.Finding{ + {Concern: "security", Severity: contracts.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection"}, + {Concern: "bugs", Severity: contracts.SeverityMedium, File: "handler.go", Line: 20, Message: "nil deref"}, }, - Stats: sightLib.Stats{TokensUsed: 500}, + Stats: reviewcontracts.Stats{TokensUsed: 500}, } err = store.Update(id, ReviewStatusOpen, result) @@ -183,8 +184,8 @@ func TestReviewStore_SetStatus(t *testing.T) { func TestBuildFixPrompt(t *testing.T) { r := &ReviewRecord{ SHA: "abc12345deadbeef0000000000000000000000ff", - Findings: []sightLib.Finding{ - {Severity: sightLib.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection", Fix: "use parameterized query"}, + Findings: []reviewcontracts.Finding{ + {Severity: contracts.SeverityHigh, File: "main.go", Line: 10, Message: "SQL injection", Fix: "use parameterized query"}, }, } diff --git a/docs/architecture.md b/docs/architecture.md index 017dfc88..42cb9935 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -16,6 +16,8 @@ hawk is an AI-powered coding agent for the terminal. It reads codebases, writes and edits files, runs tests, and manages git — all through natural language. Zero CGO, single static binary for linux/darwin/windows on amd64/arm64. +Detailed planning docs for the Hawk product architecture live in [`docs/architecture/`](architecture/README.md). + --- ## blocks Layered Architecture @@ -49,7 +51,7 @@ hawk/ │ ├── mcp/ plug MCP client and server │ ├── bridge/ link Bridges to ecosystem services │ └── resilience/ refresh-cw Circuit breaker, retry, rate limit -├── shared/types/ share-2 Cross-repo exported types +├── shared/types/ share-2 Deprecated compatibility layer; keep only for legacy downstreams ├── docs/ book-open Architecture docs └── external/ link Local go.work checkouts ``` @@ -114,5 +116,7 @@ Tool Call → = 'A' && c <= 'Z' { + c += 'a' - 'A' + } + b[i] = c + } + return string(b) +} diff --git a/external/hawk-core-contracts/policy/policy_test.go b/external/hawk-core-contracts/policy/policy_test.go new file mode 100644 index 00000000..1c6daf15 --- /dev/null +++ b/external/hawk-core-contracts/policy/policy_test.go @@ -0,0 +1,77 @@ +package policy_test + +import ( + "strings" + "testing" + + "github.com/GrayCodeAI/hawk-core-contracts/policy" +) + +func TestParseRisk(t *testing.T) { + tests := []struct { + in string + want policy.Risk + }{ + {in: "low", want: policy.RiskLow}, + {in: "MED", want: policy.RiskMedium}, + {in: "moderate", want: policy.RiskMedium}, + {in: "hi", want: policy.RiskHigh}, + {in: "forbidden", want: policy.RiskBlocked}, + } + + for _, tt := range tests { + got, err := policy.ParseRisk(tt.in) + if err != nil { + t.Fatalf("ParseRisk(%q) unexpected error = %v", tt.in, err) + } + if got != tt.want { + t.Fatalf("ParseRisk(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +func TestParseRiskUnknown(t *testing.T) { + got, err := policy.ParseRisk("mystery") + if err == nil { + t.Fatal("ParseRisk() error = nil, want non-nil") + } + if got != policy.RiskMedium { + t.Fatalf("ParseRisk() risk = %v, want %v", got, policy.RiskMedium) + } +} + +func TestPermissionVerdictHelpers(t *testing.T) { + allow := policy.Allow("safe") + if !allow.Allowed || allow.Risk != policy.RiskLow || allow.Source != "default" { + t.Fatalf("Allow() = %#v", allow) + } + + deny := policy.Deny("blocked", "rule.exec") + if deny.Allowed || deny.Risk != policy.RiskBlocked || deny.Rule != "rule.exec" { + t.Fatalf("Deny() = %#v", deny) + } + + approval := policy.RequireApproval("needs review", "rule.write", policy.RiskHigh) + if approval.Allowed || approval.Risk != policy.RiskHigh || approval.Source != "guardian" { + t.Fatalf("RequireApproval() = %#v", approval) + } + if approval.IsZero() { + t.Fatalf("RequireApproval() should not be zero: %#v", approval) + } + if !(policy.PermissionVerdict{}).IsZero() { + t.Fatal("zero PermissionVerdict should report IsZero() = true") + } +} + +func TestPermissionVerdictString(t *testing.T) { + got := policy.Deny("dangerous command", "rule.shell").String() + if !strings.Contains(got, "DENY") { + t.Fatalf("String() = %q, want DENY marker", got) + } + if !strings.Contains(got, "rule.shell") { + t.Fatalf("String() = %q, want rule name", got) + } + if !strings.Contains(got, "risk=blocked") { + t.Fatalf("String() = %q, want blocked risk", got) + } +} diff --git a/external/hawk-core-contracts/review/review.go b/external/hawk-core-contracts/review/review.go new file mode 100644 index 00000000..ecbccc60 --- /dev/null +++ b/external/hawk-core-contracts/review/review.go @@ -0,0 +1,89 @@ +package review + +import ( + "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" +) + +// Finding is the neutral review finding contract shared across Hawk and review engines. +type Finding struct { + Concern string `json:"concern"` + Severity contracts.Severity `json:"severity"` + File string `json:"file"` + Line int `json:"line"` + EndLine int `json:"end_line,omitempty"` + Message string `json:"message"` + Fix string `json:"fix,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + CWE string `json:"cwe,omitempty"` + Confidence float64 `json:"confidence"` + SASTSource bool `json:"sast_source,omitempty"` +} + +// InlineComment is a review finding mapped to a concrete diff position. +type InlineComment struct { + Path string `json:"path"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line,omitempty"` + Body string `json:"body"` + Suggestion string `json:"suggestion,omitempty"` +} + +// Stats captures review execution metrics. +type Stats struct { + FilesReviewed int `json:"files_reviewed"` + HunksAnalyzed int `json:"hunks_analyzed"` + FindingsTotal int `json:"findings_total"` + BySeverity map[contracts.Severity]int `json:"by_severity"` + ByConcern map[string]int `json:"by_concern"` + TokensUsed int `json:"tokens_used"` + DurationPerConcern map[string]time.Duration `json:"duration_per_concern"` + AverageConfidence float64 `json:"average_confidence"` + HighConfidenceCount int `json:"high_confidence_count"` + LowConfidenceCount int `json:"low_confidence_count"` +} + +// ConfidenceBreakdown groups review findings by confidence band. +type ConfidenceBreakdown struct { + High []Finding `json:"high"` + Medium []Finding `json:"medium"` + Low []Finding `json:"low"` +} + +// Result is the neutral review result contract. +type Result struct { + Findings []Finding `json:"findings"` + Comments []InlineComment `json:"comments"` + Stats Stats `json:"stats"` + Report string `json:"report"` + FailOn contracts.Severity `json:"fail_on"` + ConfidenceBreakdown *ConfidenceBreakdown `json:"confidence_breakdown,omitempty"` +} + +// Failed reports whether any finding meets or exceeds the configured fail threshold. +func (r *Result) Failed() bool { + if r == nil { + return false + } + for _, f := range r.Findings { + if f.Severity.AtLeast(r.FailOn) { + return true + } + } + return false +} + +// MaxSeverity returns the highest severity present in the result. +func (r *Result) MaxSeverity() contracts.Severity { + if r == nil { + return contracts.SeverityInfo + } + max := contracts.SeverityInfo + for _, f := range r.Findings { + if f.Severity > max { + max = f.Severity + } + } + return max +} diff --git a/external/hawk-core-contracts/review/review_test.go b/external/hawk-core-contracts/review/review_test.go new file mode 100644 index 00000000..4718e124 --- /dev/null +++ b/external/hawk-core-contracts/review/review_test.go @@ -0,0 +1,38 @@ +package review + +import ( + "testing" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" +) + +func TestResultFailedAndMaxSeverity(t *testing.T) { + t.Parallel() + + result := &Result{ + FailOn: contracts.SeverityHigh, + Findings: []Finding{ + {Severity: contracts.SeverityMedium}, + {Severity: contracts.SeverityCritical}, + }, + } + + if !result.Failed() { + t.Fatal("expected result to fail at high threshold") + } + if got := result.MaxSeverity(); got != contracts.SeverityCritical { + t.Fatalf("MaxSeverity = %v, want %v", got, contracts.SeverityCritical) + } +} + +func TestNilResultMethods(t *testing.T) { + t.Parallel() + + var result *Result + if result.Failed() { + t.Fatal("nil result should not fail") + } + if got := result.MaxSeverity(); got != contracts.SeverityInfo { + t.Fatalf("MaxSeverity(nil) = %v, want %v", got, contracts.SeverityInfo) + } +} diff --git a/external/hawk-core-contracts/tools/tool.go b/external/hawk-core-contracts/tools/tool.go new file mode 100644 index 00000000..87545a14 --- /dev/null +++ b/external/hawk-core-contracts/tools/tool.go @@ -0,0 +1,15 @@ +package tools + +// ToolCall represents a provider-neutral tool invocation contract. +type ToolCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +// ToolResult represents a provider-neutral tool execution result contract. +type ToolResult struct { + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` + IsError bool `json:"is_error,omitempty"` +} diff --git a/external/hawk-core-contracts/tools/tool_test.go b/external/hawk-core-contracts/tools/tool_test.go new file mode 100644 index 00000000..448ea95e --- /dev/null +++ b/external/hawk-core-contracts/tools/tool_test.go @@ -0,0 +1,63 @@ +package tools_test + +import ( + "encoding/json" + "testing" + + "github.com/GrayCodeAI/hawk-core-contracts/tools" +) + +func TestToolCallJSONRoundTrip(t *testing.T) { + in := tools.ToolCall{ + ID: "call_123", + Name: "exec", + Arguments: map[string]interface{}{ + "cmd": "go test ./...", + "tty": true, + }, + } + + data, err := json.Marshal(in) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var out tools.ToolCall + if err := json.Unmarshal(data, &out); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if out.ID != in.ID { + t.Fatalf("ID = %q, want %q", out.ID, in.ID) + } + if out.Name != in.Name { + t.Fatalf("Name = %q, want %q", out.Name, in.Name) + } + if got, ok := out.Arguments["cmd"].(string); !ok || got != "go test ./..." { + t.Fatalf("Arguments[cmd] = %#v, want %q", out.Arguments["cmd"], "go test ./...") + } + if got, ok := out.Arguments["tty"].(bool); !ok || !got { + t.Fatalf("Arguments[tty] = %#v, want true", out.Arguments["tty"]) + } +} + +func TestToolResultJSONOmitsFalseIsError(t *testing.T) { + result := tools.ToolResult{ + ToolUseID: "toolu_123", + Content: "ok", + } + + data, err := json.Marshal(result) + if err != nil { + t.Fatalf("Marshal() error = %v", err) + } + + var got map[string]interface{} + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + if _, exists := got["is_error"]; exists { + t.Fatalf("unexpected is_error field in %#v", got) + } +} diff --git a/external/hawk-core-contracts/types/finding.go b/external/hawk-core-contracts/types/finding.go new file mode 100644 index 00000000..d7aa928f --- /dev/null +++ b/external/hawk-core-contracts/types/finding.go @@ -0,0 +1,155 @@ +package types + +import ( + "fmt" + "time" +) + +// Finding represents a unified analysis concern sourced from Hawk support engines. +type Finding struct { + ID string `json:"id"` + Source string `json:"source"` + Concern string `json:"concern"` + Severity Severity `json:"severity"` + File string `json:"file,omitempty"` + URL string `json:"url,omitempty"` + Line int `json:"line,omitempty"` + EndLine int `json:"end_line,omitempty"` + Message string `json:"message"` + CWE string `json:"cwe,omitempty"` + Confidence float64 `json:"confidence"` + Fix string `json:"fix,omitempty"` + Reasoning string `json:"reasoning,omitempty"` + Tags []string `json:"tags,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// FindingSlice is sortable by severity descending and confidence descending. +type FindingSlice []Finding + +func (s FindingSlice) Len() int { return len(s) } + +func (s FindingSlice) Less(i, j int) bool { + if s[i].Severity != s[j].Severity { + return s[i].Severity > s[j].Severity + } + return s[i].Confidence > s[j].Confidence +} + +func (s FindingSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +// FilterBySource returns findings whose Source matches the given value. +func (s FindingSlice) FilterBySource(source string) FindingSlice { + out := make(FindingSlice, 0, len(s)) + for _, f := range s { + if f.Source == source { + out = append(out, f) + } + } + return out +} + +// FilterBySeverity returns findings whose Severity is at least min. +func (s FindingSlice) FilterBySeverity(min Severity) FindingSlice { + out := make(FindingSlice, 0, len(s)) + for _, f := range s { + if f.Severity.AtLeast(min) { + out = append(out, f) + } + } + return out +} + +// FilterByConfidence returns findings whose Confidence is >= min. +func (s FindingSlice) FilterByConfidence(min float64) FindingSlice { + out := make(FindingSlice, 0, len(s)) + for _, f := range s { + if f.Confidence >= min { + out = append(out, f) + } + } + return out +} + +// ByFile groups findings by their File field. +func (s FindingSlice) ByFile() map[string]FindingSlice { + m := make(map[string]FindingSlice, len(s)) + for _, f := range s { + m[f.File] = append(m[f.File], f) + } + return m +} + +// FindingSummary provides aggregate counts over a set of findings. +type FindingSummary struct { + Total int `json:"total"` + BySource map[string]int `json:"by_source"` + BySeverity map[string]int `json:"by_severity"` + AvgConfidence float64 `json:"avg_confidence"` +} + +// Summary returns a FindingSummary for the slice. +func (s FindingSlice) Summary() FindingSummary { + bySrc := make(map[string]int) + bySev := make(map[string]int) + var confSum float64 + + for _, f := range s { + bySrc[f.Source]++ + bySev[f.Severity.String()]++ + confSum += f.Confidence + } + + avg := 0.0 + if len(s) > 0 { + avg = confSum / float64(len(s)) + } + + return FindingSummary{ + Total: len(s), + BySource: bySrc, + BySeverity: bySev, + AvgConfidence: avg, + } +} + +// FindingFromSight constructs a Finding from a sight review result. +func FindingFromSight( + concern, file string, + line int, + message, cwe string, + sev Severity, + confidence float64, +) Finding { + return Finding{ + ID: fmt.Sprintf("sight:%s:%s:%d", concern, file, line), + Source: "sight", + Concern: concern, + Severity: sev, + File: file, + Line: line, + Message: message, + CWE: cwe, + Confidence: confidence, + CreatedAt: time.Now(), + } +} + +// FindingFromInspect constructs a Finding from an inspect analysis result. +func FindingFromInspect( + concern, url, message string, + sev Severity, + tags []string, +) Finding { + return Finding{ + ID: fmt.Sprintf("inspect:%s:%s", concern, url), + Source: "inspect", + Concern: concern, + Severity: sev, + URL: url, + Message: message, + Tags: tags, + CreatedAt: time.Now(), + } +} diff --git a/external/hawk-core-contracts/types/finding_test.go b/external/hawk-core-contracts/types/finding_test.go new file mode 100644 index 00000000..6bf95722 --- /dev/null +++ b/external/hawk-core-contracts/types/finding_test.go @@ -0,0 +1,20 @@ +package types_test + +import ( + "testing" + + "github.com/GrayCodeAI/hawk-core-contracts/types" +) + +func TestFindingSliceFilterBySeverity(t *testing.T) { + findings := types.FindingSlice{ + {ID: "a", Severity: types.SeverityLow, Confidence: 0.2}, + {ID: "b", Severity: types.SeverityHigh, Confidence: 0.8}, + {ID: "c", Severity: types.SeverityCritical, Confidence: 0.9}, + } + + got := findings.FilterBySeverity(types.SeverityHigh) + if len(got) != 2 { + t.Fatalf("FilterBySeverity() len = %d, want 2", len(got)) + } +} diff --git a/external/hawk-core-contracts/types/severity.go b/external/hawk-core-contracts/types/severity.go new file mode 100644 index 00000000..921bc827 --- /dev/null +++ b/external/hawk-core-contracts/types/severity.go @@ -0,0 +1,63 @@ +package types + +import "strings" + +// Severity represents the impact level of a finding. +type Severity int + +const ( + SeverityInfo Severity = iota + SeverityLow + SeverityMedium + SeverityHigh + SeverityCritical +) + +var severityNames = [...]string{"info", "low", "medium", "high", "critical"} + +func (s Severity) String() string { + if int(s) < len(severityNames) { + return severityNames[s] + } + return "unknown" +} + +// ParseSeverity converts a string to a Severity. +func ParseSeverity(s string) Severity { + switch strings.ToLower(strings.TrimSpace(s)) { + case "critical": + return SeverityCritical + case "high": + return SeverityHigh + case "medium": + return SeverityMedium + case "low": + return SeverityLow + default: + return SeverityInfo + } +} + +// AtLeast returns true if s >= threshold. +func (s Severity) AtLeast(threshold Severity) bool { + return s >= threshold +} + +// TokenSeverity defines rule severity for compression error patterns. +type TokenSeverity string + +const ( + TokenSeverityCritical TokenSeverity = "critical" + TokenSeverityHigh TokenSeverity = "high" + TokenSeverityMedium TokenSeverity = "medium" + TokenSeverityLow TokenSeverity = "low" +) + +// AuditSeverity indicates how dangerous a security audit finding is. +type AuditSeverity string + +const ( + AuditSeverityCritical AuditSeverity = "CRITICAL" + AuditSeverityWarning AuditSeverity = "WARNING" + AuditSeverityInfo AuditSeverity = "INFO" +) diff --git a/external/hawk-core-contracts/types/severity_test.go b/external/hawk-core-contracts/types/severity_test.go new file mode 100644 index 00000000..e532712a --- /dev/null +++ b/external/hawk-core-contracts/types/severity_test.go @@ -0,0 +1,26 @@ +package types_test + +import ( + "testing" + + "github.com/GrayCodeAI/hawk-core-contracts/types" +) + +func TestParseSeverity(t *testing.T) { + tests := []struct { + in string + want types.Severity + }{ + {in: "critical", want: types.SeverityCritical}, + {in: "HIGH", want: types.SeverityHigh}, + {in: " medium ", want: types.SeverityMedium}, + {in: "low", want: types.SeverityLow}, + {in: "unknown", want: types.SeverityInfo}, + } + + for _, tt := range tests { + if got := types.ParseSeverity(tt.in); got != tt.want { + t.Fatalf("ParseSeverity(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} diff --git a/external/hawk-core-contracts/verify/verify.go b/external/hawk-core-contracts/verify/verify.go new file mode 100644 index 00000000..0254d007 --- /dev/null +++ b/external/hawk-core-contracts/verify/verify.go @@ -0,0 +1,64 @@ +package verify + +import ( + "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" +) + +// Finding is the neutral verification finding contract shared across Hawk and verification engines. +type Finding struct { + Check string `json:"check"` + Severity contracts.Severity `json:"severity"` + URL string `json:"url"` + Element string `json:"element,omitempty"` + Message string `json:"message"` + Fix string `json:"fix,omitempty"` + Evidence string `json:"evidence,omitempty"` +} + +// Stats captures verification execution metrics. +type Stats struct { + PagesScanned int `json:"pages_scanned"` + FindingsTotal int `json:"findings_total"` + BySeverity map[contracts.Severity]int `json:"by_severity"` + ByCheck map[string]int `json:"by_check"` + DurationPerCheck map[string]time.Duration `json:"duration_per_check"` +} + +// Report is the neutral verification report contract. +type Report struct { + Target string `json:"target"` + Findings []Finding `json:"findings"` + Stats Stats `json:"stats"` + CrawledURLs int `json:"crawled_urls"` + Duration time.Duration `json:"duration"` + FailOn contracts.Severity `json:"fail_on"` +} + +// Failed reports whether any finding meets or exceeds the configured fail threshold. +func (r *Report) Failed() bool { + if r == nil { + return false + } + for _, f := range r.Findings { + if f.Severity.AtLeast(r.FailOn) { + return true + } + } + return false +} + +// MaxSeverity returns the highest severity present in the report. +func (r *Report) MaxSeverity() contracts.Severity { + if r == nil { + return contracts.SeverityInfo + } + max := contracts.SeverityInfo + for _, f := range r.Findings { + if f.Severity > max { + max = f.Severity + } + } + return max +} diff --git a/external/hawk-core-contracts/verify/verify_test.go b/external/hawk-core-contracts/verify/verify_test.go new file mode 100644 index 00000000..a8416f2f --- /dev/null +++ b/external/hawk-core-contracts/verify/verify_test.go @@ -0,0 +1,38 @@ +package verify + +import ( + "testing" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" +) + +func TestReportFailedAndMaxSeverity(t *testing.T) { + t.Parallel() + + report := &Report{ + FailOn: contracts.SeverityMedium, + Findings: []Finding{ + {Severity: contracts.SeverityLow}, + {Severity: contracts.SeverityHigh}, + }, + } + + if !report.Failed() { + t.Fatal("expected report to fail at medium threshold") + } + if got := report.MaxSeverity(); got != contracts.SeverityHigh { + t.Fatalf("MaxSeverity = %v, want %v", got, contracts.SeverityHigh) + } +} + +func TestNilReportMethods(t *testing.T) { + t.Parallel() + + var report *Report + if report.Failed() { + t.Fatal("nil report should not fail") + } + if got := report.MaxSeverity(); got != contracts.SeverityInfo { + t.Fatalf("MaxSeverity(nil) = %v, want %v", got, contracts.SeverityInfo) + } +} diff --git a/external/inspect b/external/inspect index 4f2b7272..6175bc18 160000 --- a/external/inspect +++ b/external/inspect @@ -1 +1 @@ -Subproject commit 4f2b72720b112dc7f2d52a19eea2066944c40164 +Subproject commit 6175bc1846edc885250919171a4b69da8cea4a39 diff --git a/external/sight b/external/sight index 0d98d703..30121d37 160000 --- a/external/sight +++ b/external/sight @@ -1 +1 @@ -Subproject commit 0d98d70308f73126f8362a7996fc865f98a742e3 +Subproject commit 30121d37685b89aa8fef5b1c9d215fea907609f2 diff --git a/go.mod b/go.mod index 3d1ec1c5..bd0daa6c 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,11 @@ module github.com/GrayCodeAI/hawk go 1.26.4 +replace github.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts + require ( github.com/GrayCodeAI/eyrie v0.1.0 + github.com/GrayCodeAI/hawk-core-contracts v0.0.0 github.com/GrayCodeAI/inspect v0.1.0 github.com/GrayCodeAI/sight v0.1.0 github.com/GrayCodeAI/tok v0.1.0 diff --git a/go.work b/go.work index 521ced11..391521a9 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,8 @@ go 1.26.4 use . +use ./external/hawk-core-contracts + replace ( github.com/GrayCodeAI/eyrie => ./external/eyrie github.com/GrayCodeAI/inspect => ./external/inspect diff --git a/internal/bridge/inspect/bridge.go b/internal/bridge/inspect/bridge.go index 3b5387d3..d6ec5cac 100644 --- a/internal/bridge/inspect/bridge.go +++ b/internal/bridge/inspect/bridge.go @@ -4,6 +4,7 @@ import ( "context" "sync" + verifycontracts "github.com/GrayCodeAI/hawk-core-contracts/verify" inspectLib "github.com/GrayCodeAI/inspect" ) @@ -52,3 +53,12 @@ func (b *Bridge) Run(ctx context.Context, target string, opts ...inspectLib.Opti } return b.scanner.Scan(ctx, target) } + +// RunContracts performs a verification scan and returns the neutral verification contract. +func (b *Bridge) RunContracts(ctx context.Context, target string, opts ...inspectLib.Option) (*verifycontracts.Report, error) { + report, err := b.Run(ctx, target, opts...) + if err != nil { + return nil, err + } + return inspectLib.ToContractReport(report), nil +} diff --git a/internal/bridge/sight/bridge.go b/internal/bridge/sight/bridge.go index d05a0985..44c638f5 100644 --- a/internal/bridge/sight/bridge.go +++ b/internal/bridge/sight/bridge.go @@ -4,30 +4,30 @@ import ( "context" "sync" - "github.com/GrayCodeAI/eyrie/client" + reviewcontracts "github.com/GrayCodeAI/hawk-core-contracts/review" + "github.com/GrayCodeAI/hawk/internal/types" sightLib "github.com/GrayCodeAI/sight" ) // EyrieAdapter implements sight's Provider interface using hawk's eyrie client. -// It translates between sight.Message/sight.ChatOpts and eyrie's -// client.EyrieMessage/client.ChatOptions. +// It translates between sight.Message/sight.ChatOpts and Hawk runtime DTOs. type EyrieAdapter struct { - client *client.EyrieClient + client *types.EyrieClient provider string } // NewEyrieAdapter creates an adapter that satisfies sight.Provider using // the given eyrie client and provider name (e.g. "anthropic", "openai"). -func NewEyrieAdapter(c *client.EyrieClient, provider string) *EyrieAdapter { +func NewEyrieAdapter(c *types.EyrieClient, provider string) *EyrieAdapter { return &EyrieAdapter{client: c, provider: provider} } // Chat translates a sight LLM request into an eyrie call and returns the // result in sight's Response format. func (a *EyrieAdapter) Chat(ctx context.Context, messages []sightLib.Message, opts sightLib.ChatOpts) (*sightLib.Response, error) { - eyrieMessages := make([]client.EyrieMessage, len(messages)) + eyrieMessages := make([]types.EyrieMessage, len(messages)) for i, m := range messages { - eyrieMessages[i] = client.EyrieMessage{ + eyrieMessages[i] = types.EyrieMessage{ Role: m.Role, Content: m.Content, } @@ -39,7 +39,7 @@ func (a *EyrieAdapter) Chat(ctx context.Context, messages []sightLib.Message, op temp = &t } - eyrieOpts := client.ChatOptions{ + eyrieOpts := types.ChatOptions{ Provider: a.provider, Model: opts.Model, MaxTokens: opts.MaxTokens, @@ -74,16 +74,16 @@ type Bridge struct { ready bool } -// NewBridge creates a bridge to the sight library using the given eyrie -// client and provider name. Additional sight options (model, concerns, etc.) -// are applied to all operations. -func NewBridge(c *client.EyrieClient, provider string, opts ...sightLib.Option) *Bridge { +// NewBridge creates a bridge to the sight library using the given Hawk +// transport client and provider name. Additional sight options (model, +// concerns, etc.) are applied to all operations. +func NewBridge(c *types.EyrieClient, provider string, opts ...sightLib.Option) *Bridge { b := &Bridge{} b.init(c, provider, opts...) return b } -func (b *Bridge) init(c *client.EyrieClient, provider string, opts ...sightLib.Option) { +func (b *Bridge) init(c *types.EyrieClient, provider string, opts ...sightLib.Option) { if c == nil { return } @@ -111,6 +111,15 @@ func (b *Bridge) Review(ctx context.Context, diff string) (*sightLib.Result, err return b.reviewer.Review(ctx, diff) } +// ReviewContracts performs a review and returns the neutral review contract. +func (b *Bridge) ReviewContracts(ctx context.Context, diff string) (*reviewcontracts.Result, error) { + result, err := b.Review(ctx, diff) + if err != nil { + return nil, err + } + return sightLib.ToContractResult(result), nil +} + // Describe generates a PR description from a unified diff string. // Falls back silently if the bridge is not initialized. func (b *Bridge) Describe(ctx context.Context, diff string) (*sightLib.Description, error) { diff --git a/internal/engine/compact_strategy_test.go b/internal/engine/compact_strategy_test.go index 17b83799..8298b77f 100644 --- a/internal/engine/compact_strategy_test.go +++ b/internal/engine/compact_strategy_test.go @@ -5,10 +5,9 @@ import ( "strings" "testing" - "github.com/GrayCodeAI/eyrie/client" - "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/observability/metrics" + "github.com/GrayCodeAI/hawk/internal/types" ) func TestSessionMemoryStrategy_ShouldTrigger(t *testing.T) { @@ -61,7 +60,7 @@ func TestTruncateStrategy(t *testing.T) { messages: makeMessages(100), log: newTestLogger(), metrics: newTestMetrics(), - client: client.Client(&client.EyrieConfig{Provider: "test"}), + client: types.NewClient(&types.ClientConfig{Provider: "test"}), } s := &TruncateStrategy{} @@ -77,13 +76,13 @@ func TestTruncateStrategy(t *testing.T) { } } -func makeMessages(n int) []client.EyrieMessage { - msgs := make([]client.EyrieMessage, n) +func makeMessages(n int) []types.EyrieMessage { + msgs := make([]types.EyrieMessage, n) for i := range msgs { if i%2 == 0 { - msgs[i] = client.EyrieMessage{Role: "user", Content: strings.Repeat("message ", 50)} + msgs[i] = types.EyrieMessage{Role: "user", Content: strings.Repeat("message ", 50)} } else { - msgs[i] = client.EyrieMessage{Role: "assistant", Content: strings.Repeat("response ", 50)} + msgs[i] = types.EyrieMessage{Role: "assistant", Content: strings.Repeat("response ", 50)} } } return msgs diff --git a/internal/engine/context_compaction.go b/internal/engine/context_compaction.go index fce872c0..53b4f905 100644 --- a/internal/engine/context_compaction.go +++ b/internal/engine/context_compaction.go @@ -118,18 +118,7 @@ func (s *Session) checkpointManager() *session.CheckpointManager { } func rawToSessionMessages(raw []types.EyrieMessage) []session.Message { - out := make([]session.Message, 0, len(raw)) - for _, rm := range raw { - sm := session.Message{Role: rm.Role, Content: rm.Content} - if len(rm.ToolUse) > 0 { - sm.ToolUse = append(sm.ToolUse, rm.ToolUse...) - } - if len(rm.ToolResults) > 0 { - sm.ToolResults = append(sm.ToolResults, rm.ToolResults...) - } - out = append(out, sm) - } - return out + return session.FromRuntimeMessages(raw) } func (s *Session) recordCompaction(strategy string, tokensBefore, tokensAfter int, manual bool) { diff --git a/internal/engine/engine_integration_test.go b/internal/engine/engine_integration_test.go index d21c0626..8d14d508 100644 --- a/internal/engine/engine_integration_test.go +++ b/internal/engine/engine_integration_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/session" @@ -280,9 +281,11 @@ func TestIntegration_PermissionFlow(t *testing.T) { // Simulate calling the permission function. resp := make(chan bool, 1) sess.PermissionFn(PermissionRequest{ - ToolName: "Write", - ToolID: "test-id", - Summary: "/tmp/new-file.txt", + PermissionRequest: contracts.PermissionRequest{ + ToolName: "Write", + ToolID: "test-id", + Summary: "/tmp/new-file.txt", + }, Response: resp, }) if !permCalled { diff --git a/internal/engine/provider_chat_client.go b/internal/engine/provider_chat_client.go index 4334d21d..70ec6093 100644 --- a/internal/engine/provider_chat_client.go +++ b/internal/engine/provider_chat_client.go @@ -8,11 +8,11 @@ import ( // providerChatClient adapts any eyrie types.Provider to ChatClient (continuations + streaming). type providerChatClient struct { - p types.Provider + p types.ChatProvider } // NewProviderChatClient wraps a catalog-backed provider (e.g. DeploymentRouter) for Session use. -func NewProviderChatClient(p types.Provider) ChatClient { +func NewProviderChatClient(p types.ChatProvider) ChatClient { return &providerChatClient{p: p} } diff --git a/internal/engine/safety/permission.go b/internal/engine/safety/permission.go index d09b97ab..14c180a8 100644 --- a/internal/engine/safety/permission.go +++ b/internal/engine/safety/permission.go @@ -6,14 +6,13 @@ import ( "strings" "sync" + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/tool" ) // PermissionRequest is sent from engine to TUI when a tool needs approval. type PermissionRequest struct { - ToolName string - ToolID string - Summary string + contracts.PermissionRequest Response chan bool } diff --git a/internal/engine/safety/permission_engine.go b/internal/engine/safety/permission_engine.go index 93f83406..54350832 100644 --- a/internal/engine/safety/permission_engine.go +++ b/internal/engine/safety/permission_engine.go @@ -4,6 +4,7 @@ import ( "context" "time" + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" "github.com/GrayCodeAI/hawk/internal/permissions" ) @@ -81,9 +82,11 @@ func (pe *PermissionEngine) CheckTool(ctx context.Context, tc ToolCallInfo) (boo // Ask user resp := make(chan bool, 1) pe.PromptFn(PermissionRequest{ - ToolName: tc.Name, - ToolID: tc.ID, - Summary: summary, + PermissionRequest: contracts.PermissionRequest{ + ToolName: tc.Name, + ToolID: tc.ID, + Summary: summary, + }, Response: resp, }) select { diff --git a/internal/engine/session.go b/internal/engine/session.go index 431c67c1..53ec7bd7 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -240,7 +240,7 @@ type Session struct { // NewSession creates a new conversation session with a legacy string-named provider. func NewSession(provider, model, systemPrompt string, registry *tool.Registry) *Session { - return NewSessionWithClient(types.NewClient(&types.EyrieConfig{Provider: provider}), provider, model, systemPrompt, registry, false) + return NewSessionWithClient(types.NewClient(&types.ClientConfig{Provider: provider}), provider, model, systemPrompt, registry, false) } // NewSessionWithClient constructs a session with an explicit LLM client (e.g. deployment router). @@ -467,7 +467,7 @@ func (s *Session) SetProvider(provider string) { if s.DeploymentRouting { return } - s.client = types.NewClient(&types.EyrieConfig{Provider: p}) + s.client = types.NewClient(&types.ClientConfig{Provider: p}) // Copy keys to avoid map iteration race with concurrent SetAPIKey calls. keys := make(map[string]string, len(s.apiKeys)) for k, v := range s.apiKeys { diff --git a/internal/engine/session_factory.go b/internal/engine/session_factory.go index bbd932e5..9029135e 100644 --- a/internal/engine/session_factory.go +++ b/internal/engine/session_factory.go @@ -4,11 +4,11 @@ import ( "context" "fmt" - "github.com/GrayCodeAI/eyrie/client" eyriecfg "github.com/GrayCodeAI/eyrie/config" "github.com/GrayCodeAI/eyrie/setup" "github.com/GrayCodeAI/hawk/internal/tool" + "github.com/GrayCodeAI/hawk/internal/types" ) // BuildChatClient returns an LLM client and whether deployment routing is active. @@ -17,10 +17,10 @@ func BuildChatClient(ctx context.Context, useDeploymentRouting bool, legacyProvi if useDeploymentRouting { p, err := setup.DeploymentProvider(ctx, cfg) if err == nil { - return NewProviderChatClient(p), legacyProvider, true + return NewProviderChatClient(types.WrapClientProvider(p)), legacyProvider, true } } - c := client.Client(&client.EyrieConfig{Provider: legacyProvider}) + c := types.NewClient(&types.ClientConfig{Provider: legacyProvider}) return c, legacyProvider, false } diff --git a/internal/engine/subagent_synthesis_test.go b/internal/engine/subagent_synthesis_test.go index 6f9460a6..f038e289 100644 --- a/internal/engine/subagent_synthesis_test.go +++ b/internal/engine/subagent_synthesis_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/GrayCodeAI/eyrie/client" + "github.com/GrayCodeAI/hawk/internal/types" ) func TestSynthesizeSubAgent(t *testing.T) { @@ -14,7 +15,7 @@ func TestSynthesizeSubAgent(t *testing.T) { mockClient := &mockLLMForSynthesis{provider: mock} - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "Find all Go files with errors"}, {Role: "assistant", Content: "I'll search for Go files."}, } @@ -62,7 +63,7 @@ func TestSynthesizeSubAgent(t *testing.T) { }) t.Run("nil client returns error", func(t *testing.T) { - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "test"}, } _, err := SynthesizeSubAgent(context.Background(), nil, "model", conversation) @@ -75,7 +76,7 @@ func TestSynthesizeSubAgent(t *testing.T) { mock := client.NewMockProvider(client.MockModeError) mockClient := &mockLLMForSynthesis{provider: mock} - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "test"}, } @@ -90,7 +91,7 @@ func TestSynthesizeSubAgent(t *testing.T) { mock.Response = "" mockClient := &mockLLMForSynthesis{provider: mock} - conversation := []client.EyrieMessage{ + conversation := []types.EyrieMessage{ {Role: "user", Content: "test"}, } @@ -106,6 +107,10 @@ type mockLLMForSynthesis struct { provider *client.MockProvider } -func (m *mockLLMForSynthesis) Chat(ctx context.Context, msgs []client.EyrieMessage, opts client.ChatOptions) (*client.EyrieResponse, error) { - return m.provider.Chat(ctx, msgs, opts) +func (m *mockLLMForSynthesis) Chat(ctx context.Context, msgs []types.EyrieMessage, opts types.ChatOptions) (*types.EyrieResponse, error) { + resp, err := m.provider.Chat(ctx, types.ToClientMessages(msgs), types.ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return types.FromClientResponse(resp), nil } diff --git a/internal/hooks/audit/detectors.go b/internal/hooks/audit/detectors.go index c30eb630..5fcf6ff3 100644 --- a/internal/hooks/audit/detectors.go +++ b/internal/hooks/audit/detectors.go @@ -10,7 +10,8 @@ import ( "regexp" "strconv" "strings" - "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/events" ) // DetectorHit represents one detected occurrence of wasteful behavior. @@ -22,14 +23,7 @@ type DetectorHit struct { } // ToolEvent represents a normalized tool event from a transcript. -type ToolEvent struct { - ToolName string - ToolInput map[string]interface{} - CWD string - Timestamp time.Time - SessionID string - Transcript string -} +type ToolEvent = contracts.ToolEvent // DetectorSessionState holds per-session state for stateful detectors. type DetectorSessionState = map[string]interface{} diff --git a/internal/observability/oteltrace/langfuse.go b/internal/observability/oteltrace/langfuse.go index b280cd2b..7c464e5d 100644 --- a/internal/observability/oteltrace/langfuse.go +++ b/internal/observability/oteltrace/langfuse.go @@ -10,6 +10,8 @@ import ( "os" "sync" "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/events" ) // LangfuseClient sends traces to Langfuse for LLM observability. @@ -29,25 +31,10 @@ type event struct { } // TraceEvent represents a single LLM call trace. -type TraceEvent struct { - ID string `json:"id"` - Name string `json:"name"` - Input string `json:"input,omitempty"` - Output string `json:"output,omitempty"` - Model string `json:"model,omitempty"` - StartTime time.Time `json:"startTime"` - EndTime time.Time `json:"endTime,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Usage *UsageInfo `json:"usage,omitempty"` -} +type TraceEvent = contracts.TraceEvent // UsageInfo tracks token usage. -type UsageInfo struct { - PromptTokens int `json:"promptTokens"` - CompletionTokens int `json:"completionTokens"` - TotalTokens int `json:"totalTokens"` - CostUSD float64 `json:"costUSD,omitempty"` -} +type UsageInfo = contracts.UsageInfo // NewLangfuseClient creates a client from environment variables. // Requires LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY, and optionally LANGFUSE_HOST. diff --git a/internal/permissions/guardian.go b/internal/permissions/guardian.go index 94a51219..8f2d4d5a 100644 --- a/internal/permissions/guardian.go +++ b/internal/permissions/guardian.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" ) // ErrCircuitBreakerOpen is returned when the guardian has denied too many @@ -59,11 +61,7 @@ type GuardianRequest struct { } // GuardianDecision represents the guardian's decision on a permission request. -type GuardianDecision struct { - Allowed bool `json:"allowed"` - Reason string `json:"reason"` - Confidence float64 `json:"confidence"` -} +type GuardianDecision = contracts.GuardianDecision // NewGuardian creates a new Guardian with sensible defaults. func NewGuardian(chatFn func(context.Context, string) (string, error)) *Guardian { diff --git a/internal/permissions/verdict.go b/internal/permissions/verdict.go index 5e318d3a..330053af 100644 --- a/internal/permissions/verdict.go +++ b/internal/permissions/verdict.go @@ -12,147 +12,33 @@ // (PermissionVerdict). Ported to native Go. package permissions -import "fmt" +import contracts "github.com/GrayCodeAI/hawk-core-contracts/policy" // Risk is the severity of a permission verdict. -type Risk int +type Risk = contracts.Risk const ( - // RiskLow: read-only, side-effect-free operations. - RiskLow Risk = iota - // RiskMedium: local writes, network reads, subprocess spawns. - RiskMedium - // RiskHigh: network writes, credential access, system changes. - RiskHigh - // RiskBlocked: definitely should not run; security or - // compliance violation. - RiskBlocked + RiskLow = contracts.RiskLow + RiskMedium = contracts.RiskMedium + RiskHigh = contracts.RiskHigh + RiskBlocked = contracts.RiskBlocked ) -// String returns a human-readable risk name. -func (r Risk) String() string { - switch r { - case RiskLow: - return "low" - case RiskMedium: - return "medium" - case RiskHigh: - return "high" - case RiskBlocked: - return "blocked" - default: - return fmt.Sprintf("Risk(%d)", int(r)) - } -} - // ParseRisk parses a risk name (case-insensitive) into a Risk value. -// Returns RiskMedium and a non-nil error if the name is unknown. -func ParseRisk(s string) (Risk, error) { - switch lower(s) { - case "low": - return RiskLow, nil - case "medium", "med", "moderate": - return RiskMedium, nil - case "high", "hi": - return RiskHigh, nil - case "blocked", "block", "deny", "denied", "forbidden": - return RiskBlocked, nil - default: - return RiskMedium, fmt.Errorf("permissions: unknown risk %q", s) - } -} +var ParseRisk = contracts.ParseRisk // PermissionVerdict is the unified outcome type for any permission // subsystem. It is constructed by helpers (Allow, Deny, RequireApproval) // and consumed by the tool dispatcher. -type PermissionVerdict struct { - // Allowed is the final accept/reject decision. - Allowed bool - // Reason is a human-readable explanation. - Reason string - // Rule is the identifier of the rule or check that fired - // (e.g. "boundary:write-outside-workspace", "guardian:rm-rf"). - // Empty when no specific rule matched. - Rule string - // Risk is the severity assessment. Even if Allowed=true, a - // non-low risk should be surfaced in logs/UI. - Risk Risk - // Confidence is 0.0-1.0; only meaningful for LLM-derived - // verdicts. Static rule verdicts should set this to 1.0. - Confidence float64 - // Source identifies the subsystem that produced the verdict - // (e.g. "rules", "guardian", "boundary", "hook", "default"). - Source string -} +type PermissionVerdict = contracts.PermissionVerdict // Allow returns a permissive verdict. -func Allow(reason string) PermissionVerdict { - return PermissionVerdict{ - Allowed: true, - Reason: reason, - Risk: RiskLow, - Confidence: 1.0, - Source: "default", - } -} +var Allow = contracts.Allow // Deny returns a reject verdict with the given reason and rule. -func Deny(reason, rule string) PermissionVerdict { - return PermissionVerdict{ - Allowed: false, - Reason: reason, - Rule: rule, - Risk: RiskBlocked, - Confidence: 1.0, - Source: "rules", - } -} +var Deny = contracts.Deny // RequireApproval returns a "needs human approval" verdict. Allowed // is false; the caller can use Confidence < 1.0 to indicate the // request is plausible but uncertain. -func RequireApproval(reason, rule string, risk Risk) PermissionVerdict { - return PermissionVerdict{ - Allowed: false, - Reason: reason, - Rule: rule, - Risk: risk, - Confidence: 0.5, - Source: "guardian", - } -} - -// IsZero reports whether v is the zero value. Useful for -// detecting "no verdict produced" cases. -func (v PermissionVerdict) IsZero() bool { - return !v.Allowed && v.Reason == "" && v.Rule == "" && - v.Risk == 0 && v.Confidence == 0 && v.Source == "" -} - -// String returns a one-line summary for logs. -func (v PermissionVerdict) String() string { - action := "DENY" - if v.Allowed { - action = "ALLOW" - } - if v.Rule != "" { - return fmt.Sprintf("[%s] %s (%s, risk=%s, conf=%.2f): %s", - v.Source, action, v.Rule, v.Risk, v.Confidence, v.Reason) - } - return fmt.Sprintf("[%s] %s (risk=%s, conf=%.2f): %s", - v.Source, action, v.Risk, v.Confidence, v.Reason) -} - -// lower is a small ASCII-only strings.ToLower shim to keep the -// package's import list tight. -func lower(s string) string { - b := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - c += 'a' - 'A' - } - b[i] = c - } - return string(b) -} +var RequireApproval = contracts.RequireApproval diff --git a/internal/session/conversion.go b/internal/session/conversion.go new file mode 100644 index 00000000..a06e6af8 --- /dev/null +++ b/internal/session/conversion.go @@ -0,0 +1,101 @@ +package session + +import "github.com/GrayCodeAI/hawk/internal/types" + +// FromRuntimeMessages converts Hawk runtime messages into persisted session messages. +func FromRuntimeMessages(in []types.EyrieMessage) []Message { + if len(in) == 0 { + return nil + } + out := make([]Message, len(in)) + for i, msg := range in { + out[i] = Message{ + Role: msg.Role, + Content: msg.Content, + ToolUse: FromRuntimeToolCalls(msg.ToolUse), + ToolResults: FromRuntimeToolResults(msg.ToolResults), + } + } + return out +} + +// FromRuntimeToolCalls converts Hawk runtime tool calls into persisted contracts. +func FromRuntimeToolCalls(in []types.ToolCall) []ToolCall { + if len(in) == 0 { + return nil + } + out := make([]ToolCall, len(in)) + for i, tc := range in { + out[i] = ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } + } + return out +} + +// FromRuntimeToolResults converts Hawk runtime tool results into persisted contracts. +func FromRuntimeToolResults(in []types.ToolResult) []ToolResult { + if len(in) == 0 { + return nil + } + out := make([]ToolResult, len(in)) + for i, tr := range in { + out[i] = ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } + } + return out +} + +// ToRuntimeMessages converts persisted session messages back into Hawk runtime messages. +func ToRuntimeMessages(in []Message) []types.EyrieMessage { + if len(in) == 0 { + return nil + } + out := make([]types.EyrieMessage, len(in)) + for i, msg := range in { + out[i] = types.EyrieMessage{ + Role: msg.Role, + Content: msg.Content, + ToolUse: ToRuntimeToolCalls(msg.ToolUse), + ToolResults: ToRuntimeToolResults(msg.ToolResults), + } + } + return out +} + +// ToRuntimeToolCalls converts persisted contracts back into Hawk runtime tool calls. +func ToRuntimeToolCalls(in []ToolCall) []types.ToolCall { + if len(in) == 0 { + return nil + } + out := make([]types.ToolCall, len(in)) + for i, tc := range in { + out[i] = types.ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } + } + return out +} + +// ToRuntimeToolResults converts persisted contracts back into Hawk runtime tool results. +func ToRuntimeToolResults(in []ToolResult) []types.ToolResult { + if len(in) == 0 { + return nil + } + out := make([]types.ToolResult, len(in)) + for i, tr := range in { + out[i] = types.ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } + } + return out +} diff --git a/internal/session/conversion_test.go b/internal/session/conversion_test.go new file mode 100644 index 00000000..d44f450c --- /dev/null +++ b/internal/session/conversion_test.go @@ -0,0 +1,40 @@ +package session + +import ( + "reflect" + "testing" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +func TestRuntimeMessageRoundTrip(t *testing.T) { + in := []types.EyrieMessage{ + { + Role: "assistant", + Content: "working", + ToolUse: []types.ToolCall{{ + ID: "tool_1", + Name: "Read", + Arguments: map[string]interface{}{ + "path": "main.go", + }, + }}, + }, + { + Role: "user", + Content: "file contents", + ToolResults: []types.ToolResult{{ + ToolUseID: "tool_1", + Content: "package main", + IsError: false, + }}, + }, + } + + persisted := FromRuntimeMessages(in) + out := ToRuntimeMessages(persisted) + + if !reflect.DeepEqual(out, in) { + t.Fatalf("round trip mismatch:\n got: %#v\nwant: %#v", out, in) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 6202d20b..917e0efc 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -10,23 +10,23 @@ import ( "sync" "time" + contracts "github.com/GrayCodeAI/hawk-core-contracts/tools" "github.com/GrayCodeAI/hawk/internal/storage" - "github.com/GrayCodeAI/hawk/internal/types" ) // Message is a persisted conversation message. type Message struct { - Role string `json:"role"` - Content string `json:"content,omitempty"` - ToolUse []types.ToolCall `json:"tool_use,omitempty"` - ToolResults []types.ToolResult `json:"tool_results,omitempty"` + Role string `json:"role"` + Content string `json:"content,omitempty"` + ToolUse []contracts.ToolCall `json:"tool_use,omitempty"` + ToolResults []contracts.ToolResult `json:"tool_results,omitempty"` } // ToolCall is an alias to the shared ToolCall type for persistence. -type ToolCall = types.ToolCall +type ToolCall = contracts.ToolCall // ToolResult is an alias to the shared ToolResult type for persistence. -type ToolResult = types.ToolResult +type ToolResult = contracts.ToolResult // Session is a persisted conversation. type Session struct { diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index a3a0ea9f..bf605d00 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -59,6 +59,15 @@ var getEnvExemptions = map[string]bool{ "tool/web_search_searxng.go": true, // SEARXNG_URL } +// Direct eyrie/client imports are only allowed at the Hawk transport adapter edge +// and in a small number of tests that explicitly exercise provider mocks. +var eyrieClientImportExemptions = map[string]bool{ + "internal/types/client.go": true, + "internal/types/client_test.go": true, + "internal/bridge/sight/bridge.go": true, + "internal/engine/subagent_synthesis_test": true, +} + // TestNoRawPanicInInternal verifies that no non-test .go file in internal/ // calls panic() outside of init() functions. func TestNoRawPanicInInternal(t *testing.T) { @@ -184,6 +193,60 @@ func TestNoDirectOsGetenvInInternal(t *testing.T) { t.Logf("Total os.Getenv violations in internal/: %d (logged as tech debt)", violationCount) } +// TestNoDirectEyrieClientImportsOutsideAdapters verifies Hawk does not bypass +// its own transport seam by importing eyrie/client directly in production code. +func TestNoDirectEyrieClientImportsOutsideAdapters(t *testing.T) { + root := repoRoot(t) + paths := []string{ + filepath.Join(root, "internal"), + filepath.Join(root, "cmd"), + } + + for _, dir := range paths { + files := parseGoFiles(t, dir) + for _, pf := range files { + rel := relPath(root, pf.Path) + if isExemptPackage(rel, eyrieClientImportExemptions) { + continue + } + for _, imp := range pf.File.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if path != "github.com/GrayCodeAI/eyrie/client" { + continue + } + pos := pf.FSet.Position(imp.Pos()) + t.Fatalf("forbidden direct eyrie/client import at %s:%d; go through internal/types transport adapters instead", rel, pos.Line) + } + } + } +} + +// TestNoDirectSharedTypesImportsOutsideCompatibilityPackage verifies Hawk does +// not reintroduce the deprecated shared/types compatibility package into +// production code. The package remains only for legacy downstream users. +func TestNoDirectSharedTypesImportsOutsideCompatibilityPackage(t *testing.T) { + root := repoRoot(t) + paths := []string{ + filepath.Join(root, "internal"), + filepath.Join(root, "cmd"), + } + + for _, dir := range paths { + files := parseGoFiles(t, dir) + for _, pf := range files { + rel := relPath(root, pf.Path) + for _, imp := range pf.File.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if path != "github.com/GrayCodeAI/hawk/shared/types" { + continue + } + pos := pf.FSet.Position(imp.Pos()) + t.Fatalf("forbidden direct hawk/shared/types import at %s:%d; use hawk-core-contracts instead", rel, pos.Line) + } + } + } +} + // TestAllExportedTypesHaveDocComments verifies that all exported type // declarations in non-test .go files have doc comments. func TestAllExportedTypesHaveDocComments(t *testing.T) { diff --git a/internal/testaudit/docs_audit_test.go b/internal/testaudit/docs_audit_test.go new file mode 100644 index 00000000..ebed07ca --- /dev/null +++ b/internal/testaudit/docs_audit_test.go @@ -0,0 +1,100 @@ +package testaudit + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestArchitectureDocsDoNotContainStaleContractsLanguage(t *testing.T) { + root := repoRoot(t) + + files := []string{ + "README.md", + "AGENTS.md", + "docs/architecture/README.md", + "docs/architecture/hawk-product-architecture.md", + "docs/architecture/hawk-core-contracts-spec.md", + "docs/architecture/hawk-contract-migration-inventory.md", + "docs/plans/hawk-contracts-migration-backlog.md", + } + + forbiddenPhrases := []string{ + "hawk-core-contracts` (to add)", + "planned shared contracts layer", + "runtime still uses `eyrie/client` provider interfaces and config types", + } + + for _, rel := range files { + path := filepath.Join(root, rel) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + content := string(data) + for _, phrase := range forbiddenPhrases { + if strings.Contains(content, phrase) { + t.Fatalf("stale architecture phrase %q found in %s", phrase, rel) + } + } + } +} + +func TestArchitectureDocsMentionCurrentReviewVerifyContracts(t *testing.T) { + root := repoRoot(t) + + checks := map[string][]string{ + "README.md": { + "hawk-core-contracts/review", + "hawk-core-contracts/verify", + }, + "docs/architecture/hawk-product-architecture.md": { + "hawk-core-contracts/review", + "hawk-core-contracts/verify", + }, + "docs/architecture/hawk-core-contracts-spec.md": { + "hawk-core-contracts/review", + "hawk-core-contracts/verify", + }, + } + + for rel, required := range checks { + path := filepath.Join(root, rel) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + content := string(data) + for _, phrase := range required { + if !strings.Contains(content, phrase) { + t.Fatalf("required phrase %q missing in %s", phrase, rel) + } + } + } +} + +func TestArchitectureDocsDescribeSharedTypesAsDeprecated(t *testing.T) { + root := repoRoot(t) + + files := []string{ + "README.md", + "AGENTS.md", + "docs/architecture.md", + } + + for _, rel := range files { + path := filepath.Join(root, rel) + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + content := strings.ToLower(string(data)) + if !strings.Contains(content, "shared/types") { + t.Fatalf("expected %s to mention shared/types", rel) + } + if !strings.Contains(content, "deprecated") { + t.Fatalf("expected %s to describe shared/types as deprecated", rel) + } + } +} diff --git a/internal/testaudit/helpers.go b/internal/testaudit/helpers.go index 4eb388db..8ab80bce 100644 --- a/internal/testaudit/helpers.go +++ b/internal/testaudit/helpers.go @@ -27,6 +27,12 @@ type parsedFile struct { // parses all non-test .go files, and returns them keyed by package import path. func parseInternalPackages(t *testing.T, root string) []parsedFile { t.Helper() + return parseGoFiles(t, root) +} + +// parseGoFiles walks a directory tree, parses all non-test .go files, and returns them. +func parseGoFiles(t *testing.T, root string) []parsedFile { + t.Helper() var files []parsedFile diff --git a/internal/tool/git_commit.go b/internal/tool/git_commit.go index 8c3a64c5..75c1722c 100644 --- a/internal/tool/git_commit.go +++ b/internal/tool/git_commit.go @@ -160,15 +160,19 @@ func isConventionalSubject(msg string) bool { } // GenerateCommitMessage produces a Conventional Commits message for the given -// diff and goal. It delegates to the existing CommitMessageGenerator, using -// CommitMessageChatFn as the LLM seam and falling back to the deterministic -// rule-based generator when no model is wired up. +// diff and goal. It delegates to the existing CommitMessageGenerator, using a +// ToolContext-scoped commit chat function when available, then the package-level +// CommitMessageChatFn seam, and finally the deterministic rule-based generator. // // The returned message is normalized: if the output does not already begin with // a Conventional Commits type prefix, a "chore: " prefix is added. func GenerateCommitMessage(ctx context.Context, diff, goal string) (string, error) { + chatFn := CommitMessageChatFn + if tc := GetToolContext(ctx); tc != nil && tc.CommitMessageChatFn != nil { + chatFn = tc.CommitMessageChatFn + } gen := &CommitMessageGenerator{ - ChatFn: CommitMessageChatFn, + ChatFn: chatFn, FallbackToConventional: true, Style: "conventional", IncludeBody: true, diff --git a/internal/tool/git_commit_test.go b/internal/tool/git_commit_test.go index e9e50d48..51df9f39 100644 --- a/internal/tool/git_commit_test.go +++ b/internal/tool/git_commit_test.go @@ -244,6 +244,42 @@ func TestGenerateCommitMessageWithMockModel(t *testing.T) { } } +func TestGenerateCommitMessagePrefersContextChatFn(t *testing.T) { + orig := CommitMessageChatFn + defer func() { CommitMessageChatFn = orig }() + + CommitMessageChatFn = func(_ context.Context, _ string) (string, error) { + return "fix: global should not be used", nil + } + ctx := WithToolContext(context.Background(), &ToolContext{ + CommitMessageChatFn: func(_ context.Context, _ string) (string, error) { + return "feat: use context commit model", nil + }, + }) + + msg, err := GenerateCommitMessage(ctx, "diff --git a/x b/x\n+hello", "add a thing") + if err != nil { + t.Fatalf("GenerateCommitMessage: %v", err) + } + if !strings.HasPrefix(msg, "feat: use context commit model") { + t.Fatalf("expected context chat function result, got %q", msg) + } +} + +func TestGenerateCommitMessageFallsBackWithoutChatFn(t *testing.T) { + orig := CommitMessageChatFn + defer func() { CommitMessageChatFn = orig }() + CommitMessageChatFn = nil + + msg, err := GenerateCommitMessage(context.Background(), "diff --git a/README.md b/README.md\n+docs", "update docs") + if err != nil { + t.Fatalf("GenerateCommitMessage: %v", err) + } + if msg == "" || !isConventionalSubject(msg) { + t.Fatalf("expected conventional fallback message, got %q", msg) + } +} + func TestIsConventionalSubject(t *testing.T) { tests := []struct { msg string diff --git a/internal/tool/tool.go b/internal/tool/tool.go index 2b48eace..bf3e273d 100644 --- a/internal/tool/tool.go +++ b/internal/tool/tool.go @@ -8,7 +8,6 @@ import ( "strings" "sync" - "github.com/GrayCodeAI/eyrie/client" "github.com/GrayCodeAI/hawk/internal/intelligence/memory" "github.com/GrayCodeAI/hawk/internal/lint" "github.com/GrayCodeAI/hawk/internal/sandbox" @@ -60,19 +59,20 @@ type CodeSearchResult struct { // ToolContext carries session-level functions for tools that need them. type ToolContext struct { - AgentSpawnFn func(ctx context.Context, prompt string) (string, error) - AskUserFn func(question string) (string, error) - CodeSearchFn func(ctx context.Context, query string, limit int) ([]CodeSearchResult, error) - RefreshCodeIndexFn func(ctx context.Context) error - AvailableTools []Tool - AllowedDirectories []string - SandboxMode sandbox.Mode - AutoCommit bool - Protected PathProtector - YaadBridge *memory.YaadBridge - Attribution *types.Attribution - SettingsGet func(key string) (string, bool) - SettingsSet func(key, value string) error + AgentSpawnFn func(ctx context.Context, prompt string) (string, error) + AskUserFn func(question string) (string, error) + CodeSearchFn func(ctx context.Context, query string, limit int) ([]CodeSearchResult, error) + RefreshCodeIndexFn func(ctx context.Context) error + CommitMessageChatFn func(ctx context.Context, prompt string) (string, error) + AvailableTools []Tool + AllowedDirectories []string + SandboxMode sandbox.Mode + AutoCommit bool + Protected PathProtector + YaadBridge *memory.YaadBridge + Attribution *types.Attribution + SettingsGet func(key string) (string, bool) + SettingsSet func(key, value string) error // BackgroundManager tracks background sub-agents. If nil, background // mode is not available. BackgroundManager *BackgroundAgentManager @@ -205,13 +205,13 @@ func (r *Registry) Filter(allow []string) *Registry { return NewRegistry(filtered...) } -// EyrieTools converts all tools to eyrie tool definitions for the API. -func (r *Registry) EyrieTools() []client.EyrieTool { +// EyrieTools converts all tools to Hawk runtime tool definitions for the API boundary. +func (r *Registry) EyrieTools() []types.EyrieTool { r.mu.RLock() defer r.mu.RUnlock() - out := make([]client.EyrieTool, 0, len(r.primary)) + out := make([]types.EyrieTool, 0, len(r.primary)) for _, t := range r.primary { - out = append(out, client.EyrieTool{ + out = append(out, types.EyrieTool{ Name: t.Name(), Description: t.Description(), Parameters: t.Parameters(), diff --git a/internal/types/client.go b/internal/types/client.go index cbc00b12..67066234 100644 --- a/internal/types/client.go +++ b/internal/types/client.go @@ -7,32 +7,647 @@ import ( ) type ( - EyrieMessage = client.EyrieMessage - ChatOptions = client.ChatOptions - EyrieResponse = client.EyrieResponse - EyrieUsage = client.EyrieUsage - StreamResult = client.StreamResult - EyrieStreamEvent = client.EyrieStreamEvent - ContinuationConfig = client.ContinuationConfig - EyrieConfig = client.EyrieConfig - EyrieTool = client.EyrieTool - EyrieClient = client.EyrieClient - Provider = client.Provider - ResponseFormat = client.ResponseFormat + ContentPart = client.ContentPart + ImageURLPart = client.ImageURLPart + InputAudioPart = client.InputAudioPart ) +// ClientConfig is Hawk-owned client construction config at the transport edge. +type ClientConfig struct { + Provider string `json:"provider,omitempty"` + APIKey string `json:"-"` + BaseURL string `json:"base_url,omitempty"` + Model string `json:"model,omitempty"` + MaxRetries int `json:"max_retries,omitempty"` +} + +// ChatProvider is Hawk's transport-provider interface. +type ChatProvider interface { + Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) + StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) + Ping(ctx context.Context) error + Name() string +} + +// ResponseFormat specifies the desired output format for a Hawk runtime request. +type ResponseFormat struct { + Type string `json:"type"` + Schema string `json:"schema,omitempty"` +} + +// ToolChoiceOption controls how the model uses tools. +type ToolChoiceOption struct { + Type string `json:"type"` + Name string `json:"name,omitempty"` + DisableParallelToolUse bool `json:"disable_parallel_tool_use,omitempty"` +} + +// EyrieTool is Hawk's runtime tool definition DTO. +type EyrieTool struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +// ChatOptions holds Hawk-owned request options for a runtime chat call. +type ChatOptions struct { + Provider string `json:"provider,omitempty"` + Model string `json:"model,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + Stream bool `json:"stream,omitempty"` + Tools []EyrieTool `json:"tools,omitempty"` + System string `json:"system,omitempty"` + EnableCaching bool `json:"enable_caching,omitempty"` + ResponseFormat *ResponseFormat `json:"response_format,omitempty"` + ReasoningEffort string `json:"reasoning_effort,omitempty"` + ThinkingBudgetTokens int `json:"thinking_budget_tokens,omitempty"` + ThinkingMode string `json:"thinking_mode,omitempty"` + ThinkingDisplay string `json:"thinking_display,omitempty"` + GLMThinkingEnabled *bool `json:"glm_thinking_enabled,omitempty"` + VirtualKeyID string `json:"virtual_key_id,omitempty"` + KimiContextCacheID string `json:"kimi_context_cache_id,omitempty"` + KimiCacheResetTTL bool `json:"kimi_cache_reset_ttl,omitempty"` + TopP *float64 `json:"top_p,omitempty"` + TopK *int `json:"top_k,omitempty"` + StopSequences []string `json:"stop_sequences,omitempty"` + ToolChoice *ToolChoiceOption `json:"tool_choice,omitempty"` + MetadataUserID string `json:"metadata_user_id,omitempty"` + ServiceTier string `json:"service_tier,omitempty"` + OutputEffort string `json:"output_effort,omitempty"` + OutputSchema string `json:"output_schema,omitempty"` + PresencePenalty *float64 `json:"presence_penalty,omitempty"` + FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"` + N *int `json:"n,omitempty"` + LogProbs *bool `json:"logprobs,omitempty"` + TopLogProbs *int `json:"top_logprobs,omitempty"` + Seed *int `json:"seed,omitempty"` + Store *bool `json:"store,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + Modalities []string `json:"modalities,omitempty"` + AudioConfig string `json:"audio_config,omitempty"` + Prediction string `json:"prediction,omitempty"` + WebSearchOptions string `json:"web_search_options,omitempty"` +} + +// ContinuationConfig controls output continuation behavior for Hawk runtime calls. +type ContinuationConfig struct { + MaxContinuations int + MaxTotalTokens int +} + +// ToolCall is Hawk's runtime tool invocation DTO. +type ToolCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` +} + +// ToolResult is Hawk's runtime tool result DTO. +type ToolResult struct { + ToolUseID string `json:"tool_use_id"` + Content string `json:"content"` + IsError bool `json:"is_error,omitempty"` +} + +// EyrieUsage tracks token usage for Hawk runtime responses and streams. +type EyrieUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens,omitempty"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + ThinkingTokens int `json:"thinking_tokens,omitempty"` +} + +// EyrieResponse is Hawk's runtime chat response DTO. +type EyrieResponse struct { + Content string `json:"content"` + Thinking string `json:"thinking,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + RequestID string `json:"request_id,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` +} + +// EyrieStreamEvent is Hawk's runtime stream event DTO. +type EyrieStreamEvent struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + ToolCall *ToolCall `json:"tool_call,omitempty"` + Thinking string `json:"thinking,omitempty"` + Error string `json:"error,omitempty"` + RequestID string `json:"request_id,omitempty"` + Usage *EyrieUsage `json:"usage,omitempty"` + StopReason string `json:"stop_reason,omitempty"` + TTFTms int `json:"ttft_ms,omitempty"` + TTFT int `json:"ttft,omitempty"` +} + +// StreamResult wraps a Hawk-owned streaming response with cleanup. +type StreamResult struct { + Events <-chan EyrieStreamEvent + RequestID string + cancel context.CancelFunc +} + +// Close stops the stream and releases resources. +func (sr *StreamResult) Close() { + if sr != nil && sr.cancel != nil { + sr.cancel() + } +} + +// EyrieMessage is Hawk's runtime conversation DTO. +// It intentionally mirrors the provider runtime shape while remaining Hawk-owned. +type EyrieMessage struct { + Role string `json:"role"` + Content string `json:"content,omitempty"` + Thinking string `json:"thinking,omitempty"` + ContentParts []ContentPart `json:"content_parts,omitempty"` + Images []string `json:"images,omitempty"` + ToolUse []ToolCall `json:"tool_use,omitempty"` + ToolResults []ToolResult `json:"tool_results,omitempty"` +} + +// EyrieClient adapts eyrie's client to Hawk-owned message DTOs. +type EyrieClient struct { + inner *client.EyrieClient + providerName string +} + +type providerAdapter struct { + inner client.Provider +} + func DefaultContinuationConfig() ContinuationConfig { - return client.DefaultContinuationConfig() + cfg := client.DefaultContinuationConfig() + return ContinuationConfig{ + MaxContinuations: cfg.MaxContinuations, + MaxTotalTokens: cfg.MaxTotalTokens, + } +} + +func DetectProvider() string { + return client.DetectProvider() +} + +func RegisterDynamicProvider(name, baseURL, apiKeyEnv string) error { + return client.RegisterDynamicProvider(name, baseURL, apiKeyEnv) } -func NewClient(cfg *EyrieConfig) *EyrieClient { - return client.Client(cfg) +func NewClient(cfg *ClientConfig) *EyrieClient { + providerName := "" + if cfg != nil { + providerName = cfg.Provider + } + return &EyrieClient{ + inner: client.Client(ToClientConfig(cfg)), + providerName: providerName, + } } func ParseInlineToolCalls(content string) (string, []ToolCall) { - return client.ParseInlineToolCalls(content) + text, calls := client.ParseInlineToolCalls(content) + return text, FromClientToolCalls(calls) +} + +func WrapClientProvider(p client.Provider) ChatProvider { + if p == nil { + return nil + } + return &providerAdapter{inner: p} +} + +func StreamChatWithContinuation(ctx context.Context, p ChatProvider, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { + if p == nil { + return nil, nil + } + if adapted, ok := p.(*providerAdapter); ok { + stream, err := client.StreamChatWithContinuation(ctx, adapted.inner, ToClientMessages(messages), ToClientChatOptions(opts), ToClientContinuationConfig(cfg)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil + } + stream, err := p.StreamChat(ctx, messages, opts) + if err != nil { + return nil, err + } + return stream, nil +} + +func (c *EyrieClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { + resp, err := c.inner.Chat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientResponse(resp), nil +} + +func (c *EyrieClient) StreamChatContinue(ctx context.Context, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { + stream, err := c.inner.StreamChatContinue(ctx, ToClientMessages(messages), ToClientChatOptions(opts), ToClientContinuationConfig(cfg)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil +} + +func (c *EyrieClient) SetAPIKey(provider, apiKey string) { + c.inner.SetAPIKey(provider, apiKey) +} + +func (c *EyrieClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { + stream, err := c.inner.StreamChat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil +} + +func (c *EyrieClient) Ping(ctx context.Context) error { + return c.inner.Ping(ctx, "") +} + +func (c *EyrieClient) Name() string { + return c.providerName +} + +func (c *EyrieClient) GetProviders() []string { + return c.inner.GetProviders() +} + +func (p *providerAdapter) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) { + resp, err := p.inner.Chat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientResponse(resp), nil +} + +func (p *providerAdapter) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) { + stream, err := p.inner.StreamChat(ctx, ToClientMessages(messages), ToClientChatOptions(opts)) + if err != nil { + return nil, err + } + return FromClientStreamResult(stream), nil +} + +func (p *providerAdapter) Ping(ctx context.Context) error { + return p.inner.Ping(ctx) +} + +func (p *providerAdapter) Name() string { + return p.inner.Name() +} + +// ToClientConfig converts Hawk-owned transport config into the provider-runtime shape. +func ToClientConfig(cfg *ClientConfig) *client.EyrieConfig { + if cfg == nil { + return nil + } + return &client.EyrieConfig{ + Provider: cfg.Provider, + APIKey: cfg.APIKey, + BaseURL: cfg.BaseURL, + Model: cfg.Model, + MaxRetries: cfg.MaxRetries, + } +} + +// ToClientResponseFormat converts a Hawk runtime response format into the provider-runtime shape. +func ToClientResponseFormat(format *ResponseFormat) *client.ResponseFormat { + if format == nil { + return nil + } + return &client.ResponseFormat{ + Type: format.Type, + Schema: format.Schema, + } +} + +// ToClientToolChoiceOption converts a Hawk runtime tool choice into the provider-runtime shape. +func ToClientToolChoiceOption(choice *ToolChoiceOption) *client.ToolChoiceOption { + if choice == nil { + return nil + } + return &client.ToolChoiceOption{ + Type: choice.Type, + Name: choice.Name, + DisableParallelToolUse: choice.DisableParallelToolUse, + } +} + +// ToClientEyrieTool converts a Hawk runtime tool definition into the provider-runtime shape. +func ToClientEyrieTool(tool EyrieTool) client.EyrieTool { + return client.EyrieTool{ + Name: tool.Name, + Description: tool.Description, + Parameters: tool.Parameters, + } +} + +// ToClientEyrieTools converts Hawk runtime tool definitions into provider-runtime tool definitions. +func ToClientEyrieTools(tools []EyrieTool) []client.EyrieTool { + if len(tools) == 0 { + return nil + } + out := make([]client.EyrieTool, len(tools)) + for i, tool := range tools { + out[i] = ToClientEyrieTool(tool) + } + return out +} + +// ToClientChatOptions converts Hawk runtime chat options into the provider-runtime shape. +func ToClientChatOptions(opts ChatOptions) client.ChatOptions { + return client.ChatOptions{ + Provider: opts.Provider, + Model: opts.Model, + Temperature: opts.Temperature, + MaxTokens: opts.MaxTokens, + Stream: opts.Stream, + Tools: ToClientEyrieTools(opts.Tools), + System: opts.System, + EnableCaching: opts.EnableCaching, + ResponseFormat: ToClientResponseFormat(opts.ResponseFormat), + ReasoningEffort: opts.ReasoningEffort, + ThinkingBudgetTokens: opts.ThinkingBudgetTokens, + ThinkingMode: opts.ThinkingMode, + ThinkingDisplay: opts.ThinkingDisplay, + GLMThinkingEnabled: opts.GLMThinkingEnabled, + VirtualKeyID: opts.VirtualKeyID, + KimiContextCacheID: opts.KimiContextCacheID, + KimiCacheResetTTL: opts.KimiCacheResetTTL, + TopP: opts.TopP, + TopK: opts.TopK, + StopSequences: opts.StopSequences, + ToolChoice: ToClientToolChoiceOption(opts.ToolChoice), + MetadataUserID: opts.MetadataUserID, + ServiceTier: opts.ServiceTier, + OutputEffort: opts.OutputEffort, + OutputSchema: opts.OutputSchema, + PresencePenalty: opts.PresencePenalty, + FrequencyPenalty: opts.FrequencyPenalty, + N: opts.N, + LogProbs: opts.LogProbs, + TopLogProbs: opts.TopLogProbs, + Seed: opts.Seed, + Store: opts.Store, + Metadata: opts.Metadata, + Modalities: opts.Modalities, + AudioConfig: opts.AudioConfig, + Prediction: opts.Prediction, + WebSearchOptions: opts.WebSearchOptions, + } +} + +// ToClientContinuationConfig converts Hawk runtime continuation settings into the provider-runtime shape. +func ToClientContinuationConfig(cfg ContinuationConfig) client.ContinuationConfig { + return client.ContinuationConfig{ + MaxContinuations: cfg.MaxContinuations, + MaxTotalTokens: cfg.MaxTotalTokens, + } +} + +// ToClientToolCall converts a Hawk runtime tool call into the provider-runtime shape. +func ToClientToolCall(tc ToolCall) client.ToolCall { + return client.ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } +} + +// ToClientToolCalls converts Hawk runtime tool calls into provider-runtime tool calls. +func ToClientToolCalls(calls []ToolCall) []client.ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]client.ToolCall, len(calls)) + for i, tc := range calls { + out[i] = ToClientToolCall(tc) + } + return out +} + +// FromClientToolCall converts a provider-runtime tool call into Hawk's runtime shape. +func FromClientToolCall(tc client.ToolCall) ToolCall { + return ToolCall{ + ID: tc.ID, + Name: tc.Name, + Arguments: tc.Arguments, + } +} + +// FromClientToolCalls converts provider-runtime tool calls into Hawk runtime tool calls. +func FromClientToolCalls(calls []client.ToolCall) []ToolCall { + if len(calls) == 0 { + return nil + } + out := make([]ToolCall, len(calls)) + for i, tc := range calls { + out[i] = FromClientToolCall(tc) + } + return out +} + +// ToClientToolResult converts a Hawk runtime tool result into the provider-runtime shape. +func ToClientToolResult(tr ToolResult) client.ToolResult { + return client.ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } +} + +// ToClientToolResults converts Hawk runtime tool results into provider-runtime tool results. +func ToClientToolResults(results []ToolResult) []client.ToolResult { + if len(results) == 0 { + return nil + } + out := make([]client.ToolResult, len(results)) + for i, tr := range results { + out[i] = ToClientToolResult(tr) + } + return out +} + +// FromClientToolResult converts a provider-runtime tool result into Hawk's runtime shape. +func FromClientToolResult(tr client.ToolResult) ToolResult { + return ToolResult{ + ToolUseID: tr.ToolUseID, + Content: tr.Content, + IsError: tr.IsError, + } +} + +// FromClientToolResults converts provider-runtime tool results into Hawk runtime tool results. +func FromClientToolResults(results []client.ToolResult) []ToolResult { + if len(results) == 0 { + return nil + } + out := make([]ToolResult, len(results)) + for i, tr := range results { + out[i] = FromClientToolResult(tr) + } + return out +} + +// ToClientUsage converts a Hawk runtime usage payload into the provider-runtime shape. +func ToClientUsage(usage *EyrieUsage) *client.EyrieUsage { + if usage == nil { + return nil + } + return &client.EyrieUsage{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + CacheCreationTokens: usage.CacheCreationTokens, + CacheReadTokens: usage.CacheReadTokens, + ThinkingTokens: usage.ThinkingTokens, + } +} + +// FromClientUsage converts a provider-runtime usage payload into Hawk's runtime shape. +func FromClientUsage(usage *client.EyrieUsage) *EyrieUsage { + if usage == nil { + return nil + } + return &EyrieUsage{ + PromptTokens: usage.PromptTokens, + CompletionTokens: usage.CompletionTokens, + TotalTokens: usage.TotalTokens, + CacheCreationTokens: usage.CacheCreationTokens, + CacheReadTokens: usage.CacheReadTokens, + ThinkingTokens: usage.ThinkingTokens, + } +} + +// FromClientResponse converts a provider-runtime response into Hawk's runtime shape. +func FromClientResponse(resp *client.EyrieResponse) *EyrieResponse { + if resp == nil { + return nil + } + return &EyrieResponse{ + Content: resp.Content, + Thinking: resp.Thinking, + Usage: FromClientUsage(resp.Usage), + ToolCalls: FromClientToolCalls(resp.ToolCalls), + FinishReason: resp.FinishReason, + RequestID: resp.RequestID, + OrganizationID: resp.OrganizationID, + } +} + +// ToClientStreamEvent converts a Hawk runtime stream event into the provider-runtime shape. +func ToClientStreamEvent(ev EyrieStreamEvent) client.EyrieStreamEvent { + var toolCall *client.ToolCall + if ev.ToolCall != nil { + tc := ToClientToolCall(*ev.ToolCall) + toolCall = &tc + } + return client.EyrieStreamEvent{ + Type: ev.Type, + Content: ev.Content, + ToolCall: toolCall, + Thinking: ev.Thinking, + Error: ev.Error, + RequestID: ev.RequestID, + Usage: ToClientUsage(ev.Usage), + StopReason: ev.StopReason, + TTFTms: ev.TTFTms, + TTFT: ev.TTFT, + } +} + +// FromClientStreamEvent converts a provider-runtime stream event into Hawk's runtime shape. +func FromClientStreamEvent(ev client.EyrieStreamEvent) EyrieStreamEvent { + var toolCall *ToolCall + if ev.ToolCall != nil { + tc := FromClientToolCall(*ev.ToolCall) + toolCall = &tc + } + return EyrieStreamEvent{ + Type: ev.Type, + Content: ev.Content, + ToolCall: toolCall, + Thinking: ev.Thinking, + Error: ev.Error, + RequestID: ev.RequestID, + Usage: FromClientUsage(ev.Usage), + StopReason: ev.StopReason, + TTFTms: ev.TTFTms, + TTFT: ev.TTFT, + } +} + +// FromClientStreamResult converts a provider-runtime stream result into Hawk's runtime shape. +func FromClientStreamResult(stream *client.StreamResult) *StreamResult { + if stream == nil { + return nil + } + out := make(chan EyrieStreamEvent, 64) + go func() { + defer close(out) + for ev := range stream.Events { + out <- FromClientStreamEvent(ev) + } + }() + return &StreamResult{ + Events: out, + RequestID: stream.RequestID, + cancel: stream.Close, + } +} + +// ToClientMessage converts a Hawk runtime message into the provider-runtime shape. +func ToClientMessage(msg EyrieMessage) client.EyrieMessage { + return client.EyrieMessage{ + Role: msg.Role, + Content: msg.Content, + Thinking: msg.Thinking, + ContentParts: msg.ContentParts, + Images: msg.Images, + ToolUse: ToClientToolCalls(msg.ToolUse), + ToolResults: ToClientToolResults(msg.ToolResults), + } +} + +// ToClientMessages converts Hawk runtime messages into provider-runtime messages. +func ToClientMessages(messages []EyrieMessage) []client.EyrieMessage { + if len(messages) == 0 { + return nil + } + out := make([]client.EyrieMessage, len(messages)) + for i, msg := range messages { + out[i] = ToClientMessage(msg) + } + return out +} + +// FromClientMessage converts a provider-runtime message into Hawk's runtime shape. +func FromClientMessage(msg client.EyrieMessage) EyrieMessage { + return EyrieMessage{ + Role: msg.Role, + Content: msg.Content, + Thinking: msg.Thinking, + ContentParts: msg.ContentParts, + Images: msg.Images, + ToolUse: FromClientToolCalls(msg.ToolUse), + ToolResults: FromClientToolResults(msg.ToolResults), + } } -func StreamChatWithContinuation(ctx context.Context, p Provider, messages []EyrieMessage, opts ChatOptions, cfg ContinuationConfig) (*StreamResult, error) { - return client.StreamChatWithContinuation(ctx, p, messages, opts, cfg) +// FromClientMessages converts provider-runtime messages into Hawk runtime messages. +func FromClientMessages(messages []client.EyrieMessage) []EyrieMessage { + if len(messages) == 0 { + return nil + } + out := make([]EyrieMessage, len(messages)) + for i, msg := range messages { + out[i] = FromClientMessage(msg) + } + return out } diff --git a/internal/types/client_test.go b/internal/types/client_test.go new file mode 100644 index 00000000..ec6b3014 --- /dev/null +++ b/internal/types/client_test.go @@ -0,0 +1,127 @@ +package types + +import ( + "reflect" + "testing" + + "github.com/GrayCodeAI/eyrie/client" +) + +func TestEyrieMessageClientRoundTrip(t *testing.T) { + in := []EyrieMessage{ + { + Role: "assistant", + Content: "done", + Thinking: "reasoning", + Images: []string{"data:image/png;base64,abc"}, + ToolUse: []ToolCall{{ + ID: "tool_1", + Name: "Read", + Arguments: map[string]interface{}{ + "path": "main.go", + }, + }}, + }, + { + Role: "user", + ToolResults: []ToolResult{{ + ToolUseID: "tool_1", + Content: "package main", + }}, + }, + } + + got := FromClientMessages(ToClientMessages(in)) + if !reflect.DeepEqual(got, in) { + t.Fatalf("round trip mismatch:\n got: %#v\nwant: %#v", got, in) + } +} + +func TestToolCallClientRoundTrip(t *testing.T) { + in := []ToolCall{{ + ID: "tool_1", + Name: "Read", + Arguments: map[string]interface{}{ + "path": "main.go", + }, + }} + + got := FromClientToolCalls(ToClientToolCalls(in)) + if !reflect.DeepEqual(got, in) { + t.Fatalf("tool call round trip mismatch:\n got: %#v\nwant: %#v", got, in) + } +} + +func TestToolResultClientRoundTrip(t *testing.T) { + in := []ToolResult{{ + ToolUseID: "tool_1", + Content: "ok", + IsError: true, + }} + + got := FromClientToolResults(ToClientToolResults(in)) + if !reflect.DeepEqual(got, in) { + t.Fatalf("tool result round trip mismatch:\n got: %#v\nwant: %#v", got, in) + } +} + +func TestFromClientMessagePreservesContentParts(t *testing.T) { + in := client.EyrieMessage{ + Role: "user", + ContentParts: []client.ContentPart{{ + Type: "text", + Text: "hello", + }}, + } + + got := FromClientMessage(in) + if len(got.ContentParts) != 1 || got.ContentParts[0].Text != "hello" { + t.Fatalf("ContentParts = %#v, want text part", got.ContentParts) + } +} + +func TestToClientConfig(t *testing.T) { + cfg := &ClientConfig{ + Provider: "anthropic", + APIKey: "secret", + BaseURL: "https://proxy.example", + Model: "claude-sonnet", + MaxRetries: 3, + } + + got := ToClientConfig(cfg) + if got == nil { + t.Fatal("expected non-nil client config") + } + if got.Provider != cfg.Provider || got.APIKey != cfg.APIKey || got.BaseURL != cfg.BaseURL || got.Model != cfg.Model || got.MaxRetries != cfg.MaxRetries { + t.Fatalf("ToClientConfig = %#v, want %#v", got, cfg) + } +} + +func TestWrapClientProvider(t *testing.T) { + mock := client.NewMockProvider(client.MockModeFixed) + mock.Response = "wrapped" + + provider := WrapClientProvider(mock) + if provider == nil { + t.Fatal("expected non-nil provider adapter") + } + if provider.Name() != "mock" { + t.Fatalf("Name() = %q, want mock", provider.Name()) + } + + resp, err := provider.Chat(t.Context(), []EyrieMessage{{Role: "user", Content: "hi"}}, ChatOptions{}) + if err != nil { + t.Fatalf("Chat error: %v", err) + } + if resp.Content != "wrapped" { + t.Fatalf("Content = %q, want wrapped", resp.Content) + } +} + +func TestNewClientPreservesProviderName(t *testing.T) { + c := NewClient(&ClientConfig{Provider: "openai"}) + if c.Name() != "openai" { + t.Fatalf("Name() = %q, want openai", c.Name()) + } +} diff --git a/internal/types/severity.go b/internal/types/severity.go index 1b1b1961..07c94097 100644 --- a/internal/types/severity.go +++ b/internal/types/severity.go @@ -1,48 +1,41 @@ -// Package types provides shared types used across GrayCodeAI hawk-related modules. -// Severity, TokenSeverity, and AuditSeverity are forwarded from the shared package. -// ToolCall and ToolResult come from eyrie/client. +// Package types provides Hawk-owned runtime types and shared compatibility aliases. +// Severity, TokenSeverity, and AuditSeverity are forwarded from hawk-core-contracts/types. +// Provider-facing compatibility now lives in explicit adapters inside internal/types/client.go. package types import ( - "github.com/GrayCodeAI/eyrie/client" - "github.com/GrayCodeAI/hawk/shared/types" + contracts "github.com/GrayCodeAI/hawk-core-contracts/types" ) // Severity represents the impact level of a finding. -type Severity = types.Severity +type Severity = contracts.Severity const ( - SeverityInfo = types.SeverityInfo - SeverityLow = types.SeverityLow - SeverityMedium = types.SeverityMedium - SeverityHigh = types.SeverityHigh - SeverityCritical = types.SeverityCritical + SeverityInfo = contracts.SeverityInfo + SeverityLow = contracts.SeverityLow + SeverityMedium = contracts.SeverityMedium + SeverityHigh = contracts.SeverityHigh + SeverityCritical = contracts.SeverityCritical ) // ParseSeverity converts a string to a Severity. -var ParseSeverity = types.ParseSeverity +var ParseSeverity = contracts.ParseSeverity // TokenSeverity defines rule severity for compression error patterns. -type TokenSeverity = types.TokenSeverity +type TokenSeverity = contracts.TokenSeverity const ( - TokenSeverityCritical = types.TokenSeverityCritical - TokenSeverityHigh = types.TokenSeverityHigh - TokenSeverityMedium = types.TokenSeverityMedium - TokenSeverityLow = types.TokenSeverityLow + TokenSeverityCritical = contracts.TokenSeverityCritical + TokenSeverityHigh = contracts.TokenSeverityHigh + TokenSeverityMedium = contracts.TokenSeverityMedium + TokenSeverityLow = contracts.TokenSeverityLow ) // AuditSeverity indicates how dangerous a security audit finding is. -type AuditSeverity = types.AuditSeverity +type AuditSeverity = contracts.AuditSeverity const ( - AuditSeverityCritical = types.AuditSeverityCritical - AuditSeverityWarning = types.AuditSeverityWarning - AuditSeverityInfo = types.AuditSeverityInfo + AuditSeverityCritical = contracts.AuditSeverityCritical + AuditSeverityWarning = contracts.AuditSeverityWarning + AuditSeverityInfo = contracts.AuditSeverityInfo ) - -// ToolCall represents a tool invocation requested by the model. -type ToolCall = client.ToolCall - -// ToolResult represents the result of a tool execution. -type ToolResult = client.ToolResult diff --git a/scripts/check-ecosystem-boundaries.sh b/scripts/check-ecosystem-boundaries.sh new file mode 100644 index 00000000..0ffae976 --- /dev/null +++ b/scripts/check-ecosystem-boundaries.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +pattern='github\.com/GrayCodeAI/hawk/(internal/|shared/types)' +violations="" + +external_hits="$( + git grep -n -E "${pattern}" -- 'external/**/*.go' || true +)" +if [[ -n "${external_hits}" ]]; then + violations+="${external_hits}"$'\n' +fi + +for repo in ../sight ../inspect ../tok ../trace ../yaad ../eyrie; do + if [[ -d "${repo}" ]]; then + repo_hits="$( + grep -RInE --include='*.go' "${pattern}" "${repo}" || true + )" + if [[ -n "${repo_hits}" ]]; then + violations+="${repo_hits}"$'\n' + fi + fi +done + +if [[ -n "${violations}" ]]; then + echo "forbidden Hawk imports found in external ecosystem repos:" + echo "${violations}" + echo + echo "support repos must use hawk-core-contracts or their own contracts, not hawk/internal or hawk/shared/types" + exit 1 +fi + +echo "ecosystem boundary guard passed" diff --git a/scripts/check-eyrie-client-imports.sh b/scripts/check-eyrie-client-imports.sh new file mode 100755 index 00000000..7722e6ae --- /dev/null +++ b/scripts/check-eyrie-client-imports.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +violations="$( + git grep -n 'github\.com/GrayCodeAI/eyrie/client' -- '*.go' \ + ':(exclude)external/**' \ + ':(exclude)internal/types/client.go' \ + ':(exclude)**/*_test.go' || true +)" + +if [[ -n "${violations}" ]]; then + echo "forbidden direct imports of github.com/GrayCodeAI/eyrie/client found:" + echo "${violations}" + echo + echo "hawk production code must go through internal/types transport adapters instead of importing eyrie/client directly" + exit 1 +fi + +echo "eyrie/client boundary guard passed" diff --git a/scripts/check-shared-types-imports.sh b/scripts/check-shared-types-imports.sh new file mode 100644 index 00000000..f160a748 --- /dev/null +++ b/scripts/check-shared-types-imports.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +violations="$( + git grep -n 'github\.com/GrayCodeAI/hawk/shared/types' -- '*.go' \ + ':(exclude)external/**' \ + ':(exclude)shared/types/**' \ + ':(exclude)**/*_test.go' || true +)" + +if [[ -n "${violations}" ]]; then + echo "forbidden imports of github.com/GrayCodeAI/hawk/shared/types found:" + echo "${violations}" + echo + echo "use github.com/GrayCodeAI/hawk-core-contracts/types instead" + exit 1 +fi + +echo "shared/types import guard passed" diff --git a/shared/types/finding.go b/shared/types/finding.go index 3589f1ca..210f1ccd 100644 --- a/shared/types/finding.go +++ b/shared/types/finding.go @@ -1,156 +1,25 @@ +// Package types is a deprecated compatibility layer for shared Hawk ecosystem types. +// New cross-repo contracts belong in github.com/GrayCodeAI/hawk-core-contracts/types. package types -import ( - "fmt" - "time" -) +import contracts "github.com/GrayCodeAI/hawk-core-contracts/types" +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.Finding. // Finding represents a unified code-analysis concern sourced from sight, inspect, or manual review. -type Finding struct { - ID string `json:"id"` - Source string `json:"source"` // sight, inspect, manual - Concern string `json:"concern"` // e.g. "sql-injection", "broken-auth" - Severity Severity `json:"severity"` - File string `json:"file,omitempty"` - URL string `json:"url,omitempty"` - Line int `json:"line,omitempty"` - EndLine int `json:"end_line,omitempty"` - Message string `json:"message"` - CWE string `json:"cwe,omitempty"` - Confidence float64 `json:"confidence"` - Fix string `json:"fix,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - Tags []string `json:"tags,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` -} +type Finding = contracts.Finding +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingSlice. // FindingSlice is a sortable slice of Findings. -// Sort order: severity descending, then confidence descending. -type FindingSlice []Finding - -func (s FindingSlice) Len() int { return len(s) } - -func (s FindingSlice) Less(i, j int) bool { - if s[i].Severity != s[j].Severity { - return s[i].Severity > s[j].Severity // higher severity first - } - return s[i].Confidence > s[j].Confidence // higher confidence first -} - -func (s FindingSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// FilterBySource returns findings whose Source matches the given value. -func (s FindingSlice) FilterBySource(source string) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Source == source { - out = append(out, f) - } - } - return out -} - -// FilterBySeverity returns findings whose Severity is at least min. -func (s FindingSlice) FilterBySeverity(min Severity) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Severity.AtLeast(min) { - out = append(out, f) - } - } - return out -} - -// FilterByConfidence returns findings whose Confidence is >= min. -func (s FindingSlice) FilterByConfidence(min float64) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Confidence >= min { - out = append(out, f) - } - } - return out -} - -// ByFile groups findings by their File field. -func (s FindingSlice) ByFile() map[string]FindingSlice { - m := make(map[string]FindingSlice, len(s)) - for _, f := range s { - m[f.File] = append(m[f.File], f) - } - return m -} +type FindingSlice = contracts.FindingSlice +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingSummary. // FindingSummary provides aggregate counts over a set of findings. -type FindingSummary struct { - Total int `json:"total"` - BySource map[string]int `json:"by_source"` - BySeverity map[string]int `json:"by_severity"` - AvgConfidence float64 `json:"avg_confidence"` -} - -// Summary returns a FindingSummary for the slice. -func (s FindingSlice) Summary() FindingSummary { - bySrc := make(map[string]int) - bySev := make(map[string]int) - var confSum float64 - - for _, f := range s { - bySrc[f.Source]++ - bySev[f.Severity.String()]++ - confSum += f.Confidence - } - - avg := 0.0 - if len(s) > 0 { - avg = confSum / float64(len(s)) - } - - return FindingSummary{ - Total: len(s), - BySource: bySrc, - BySeverity: bySev, - AvgConfidence: avg, - } -} +type FindingSummary = contracts.FindingSummary // FindingFromSight constructs a Finding from a sight (AST/static-analysis) result. -func FindingFromSight( - concern, file string, - line int, - message, cwe string, - sev Severity, - confidence float64, -) Finding { - return Finding{ - ID: fmt.Sprintf("sight:%s:%s:%d", concern, file, line), - Source: "sight", - Concern: concern, - Severity: sev, - File: file, - Line: line, - Message: message, - CWE: cwe, - Confidence: confidence, - CreatedAt: time.Now(), - } -} +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingFromSight. +var FindingFromSight = contracts.FindingFromSight // FindingFromInspect constructs a Finding from an inspect (linting/analysis) result. -func FindingFromInspect( - concern, url, message string, - sev Severity, - tags []string, -) Finding { - return Finding{ - ID: fmt.Sprintf("inspect:%s:%s", concern, url), - Source: "inspect", - Concern: concern, - Severity: sev, - URL: url, - Message: message, - Tags: tags, - CreatedAt: time.Now(), - } -} +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingFromInspect. +var FindingFromInspect = contracts.FindingFromInspect diff --git a/shared/types/severity.go b/shared/types/severity.go index 94183539..96f7609d 100644 --- a/shared/types/severity.go +++ b/shared/types/severity.go @@ -1,66 +1,42 @@ -// Package types defines stable shared types for GrayCodeAI libraries (sight, inspect, tok, …). -// Types are defined here directly so external modules (tok, sight, inspect) can import this -// package without pulling in hawk/internal or eyrie/client, which would create import cycles. +// Package types is a deprecated compatibility layer for shared Hawk ecosystem types. +// New cross-repo contracts belong in github.com/GrayCodeAI/hawk-core-contracts/types. package types -import "strings" +import contracts "github.com/GrayCodeAI/hawk-core-contracts/types" +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.Severity. // Severity represents the impact level of a finding. -type Severity int +type Severity = contracts.Severity const ( - SeverityInfo Severity = iota - SeverityLow - SeverityMedium - SeverityHigh - SeverityCritical + SeverityInfo = contracts.SeverityInfo + SeverityLow = contracts.SeverityLow + SeverityMedium = contracts.SeverityMedium + SeverityHigh = contracts.SeverityHigh + SeverityCritical = contracts.SeverityCritical ) -var severityNames = [...]string{"info", "low", "medium", "high", "critical"} - -func (s Severity) String() string { - if int(s) < len(severityNames) { - return severityNames[s] - } - return "unknown" -} - // ParseSeverity converts a string to a Severity. -func ParseSeverity(s string) Severity { - switch strings.ToLower(strings.TrimSpace(s)) { - case "critical": - return SeverityCritical - case "high": - return SeverityHigh - case "medium": - return SeverityMedium - case "low": - return SeverityLow - default: - return SeverityInfo - } -} - -// AtLeast returns true if s >= threshold. -func (s Severity) AtLeast(threshold Severity) bool { - return s >= threshold -} +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.ParseSeverity. +var ParseSeverity = contracts.ParseSeverity +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.TokenSeverity. // TokenSeverity defines rule severity for compression error patterns. -type TokenSeverity string +type TokenSeverity = contracts.TokenSeverity const ( - TokenSeverityCritical TokenSeverity = "critical" - TokenSeverityHigh TokenSeverity = "high" - TokenSeverityMedium TokenSeverity = "medium" - TokenSeverityLow TokenSeverity = "low" + TokenSeverityCritical = contracts.TokenSeverityCritical + TokenSeverityHigh = contracts.TokenSeverityHigh + TokenSeverityMedium = contracts.TokenSeverityMedium + TokenSeverityLow = contracts.TokenSeverityLow ) +// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.AuditSeverity. // AuditSeverity indicates how dangerous a security audit finding is. -type AuditSeverity string +type AuditSeverity = contracts.AuditSeverity const ( - AuditSeverityCritical AuditSeverity = "CRITICAL" - AuditSeverityWarning AuditSeverity = "WARNING" - AuditSeverityInfo AuditSeverity = "INFO" + AuditSeverityCritical = contracts.AuditSeverityCritical + AuditSeverityWarning = contracts.AuditSeverityWarning + AuditSeverityInfo = contracts.AuditSeverityInfo ) From 1ec32924d68aa7fdb4bb1ee75e494a0cd16d69ae Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 06:24:50 +0530 Subject: [PATCH 02/23] docs(architecture): align contracts spec and dependency rules with reality The contracts architecture docs described an aspirational state that did not match the implemented module. Bring them in sync with the code: - dependency-rules: split the required graph (always-true edges) from contract edges. Document that an engine depends on hawk-core-contracts only when it produces/consumes a cross-repo contract. Reflect actual state: sight, inspect, and tok consume contracts; eyrie, yaad, and trace share no cross-repo types and stay contract-free. Mark enforcement (boundary guards in CI) as done and record open items (unpublished v0.0.0 module, rg dependency in guards). - contracts-spec: drop the non-existent `engines/` and `sessions/` packages from the "present" layout (kept as explicitly planned), and replace the aspirational type list with the contracts that actually exist per package. - migration backlog: record removal of the duplicate tok/types definitions. --- docs/architecture/hawk-core-contracts-spec.md | 87 ++++++++++--------- docs/architecture/hawk-dependency-rules.md | 63 +++++++++++--- .../plans/hawk-contracts-migration-backlog.md | 3 + 3 files changed, 97 insertions(+), 56 deletions(-) diff --git a/docs/architecture/hawk-core-contracts-spec.md b/docs/architecture/hawk-core-contracts-spec.md index 63d55500..89d195cb 100644 --- a/docs/architecture/hawk-core-contracts-spec.md +++ b/docs/architecture/hawk-core-contracts-spec.md @@ -27,54 +27,57 @@ Not allowed: - engine implementations - Hawk application internals -## Initial package layout +## Package layout + +Implemented today: ```text hawk-core-contracts/ -├── types/ -├── review/ -├── verify/ -├── events/ -├── policy/ -├── tools/ -├── engines/ -├── sessions/ +├── types/ # findings + severity vocabulary +├── review/ # code-review result contracts +├── verify/ # verification result contracts +├── events/ # trace/tool event schemas +├── policy/ # permission/guardian decision contracts +├── tools/ # provider-neutral tool call/result └── README.md ``` -## Initial contracts to move - -### Findings -- `Severity` -- `Finding` -- `ReviewFinding` -- `VerificationFinding` - -### Events -- `TraceEvent` -- `SessionEvent` -- `ToolEvent` -- `VerificationEvent` - -### Policy -- `PolicyDecision` -- `ApprovalRequirement` -- `ExecutionBoundary` - -### Tools -- `ToolCall` -- `ToolResult` -- `ToolError` - -### Engines -- `EngineRequest` -- `EngineResponse` -- engine-specific input/output envelopes - -### Sessions -- `SessionID` -- `SessionState` -- `SessionSummary` +Planned, not yet implemented (do not document as present until the package exists): + +```text +├── engines/ # engine request/response envelopes +└── sessions/ # session id/state/summary contracts +``` + +## Implemented contracts + +These are the types that actually live in `hawk-core-contracts` today. Keep this +list in sync with the code (it is the inventory the dependency rules assume). + +### `types/` +- `Severity`, `AuditSeverity`, `TokenSeverity` +- `Finding`, `FindingSlice`, `FindingSummary` +- helpers: `ParseSeverity`, `FindingFromSight`, `FindingFromInspect` + +### `review/` +- `Result`, `Finding`, `InlineComment`, `Stats`, `ConfidenceBreakdown` + +### `verify/` +- `Report`, `Finding`, `Stats` + +### `events/` +- `TraceEvent`, `ToolEvent`, `UsageInfo` + +### `policy/` +- `GuardianDecision`, `PermissionRequest`, `PermissionVerdict`, `Risk` +- helpers: `Allow`, `Deny`, `RequireApproval`, `ParseRisk` + +### `tools/` +- `ToolCall`, `ToolResult` + +### Planned (not yet in the module) +- engines: `EngineRequest`, `EngineResponse`, engine-specific envelopes +- sessions: `SessionID`, `SessionState`, `SessionSummary` ## Migration order diff --git a/docs/architecture/hawk-dependency-rules.md b/docs/architecture/hawk-dependency-rules.md index 33e87d54..24dc6ceb 100644 --- a/docs/architecture/hawk-dependency-rules.md +++ b/docs/architecture/hawk-dependency-rules.md @@ -2,6 +2,8 @@ ## Required graph +These edges always hold: + ```text hawk -> eyrie hawk -> yaad @@ -11,18 +13,35 @@ hawk -> sight hawk -> inspect hawk -> hawk-core-contracts -eyrie -> hawk-core-contracts -yaad -> hawk-core-contracts -tok -> hawk-core-contracts -trace -> hawk-core-contracts -sight -> hawk-core-contracts -inspect -> hawk-core-contracts - hawk-sdk-go -> hawk public API/contracts hawk-sdk-python -> hawk public API/contracts hawk-community-skills -> hawk plugin/skill API ``` +## Contract edges + +An engine depends on `hawk-core-contracts` **only when it produces or consumes a +cross-repo contract** (a shared finding, severity, event, etc.). Engines that +expose no cross-repo type stay contract-free — adding the dependency "to be +consistent" is a violation of "keep the graph minimal", not an improvement. + +Current state (keep in sync with the code; the boundary guards enforce the +*forbidden* edges below, not these contract edges): + +```text +sight -> hawk-core-contracts # severity/finding vocabulary +inspect -> hawk-core-contracts # severity/finding vocabulary +tok -> hawk-core-contracts # types/ re-exports contracts (compat shim) + +eyrie -> (none) # provider/transport types are eyrie-local +yaad -> (none) # memory event types are yaad-local +trace -> (none) # trace/redaction event types are trace-local +``` + +If `eyrie`, `yaad`, or `trace` later needs to emit or accept a shared +finding/event, the type moves into `hawk-core-contracts` first and the engine +adds the contract edge then — not before. + ## Forbidden graph ```text @@ -54,12 +73,28 @@ Provider-specific code should not leak into memory, review, verify, or trace eng Based on current local structure: -- `sight -> hawk/shared/types` removed -- `inspect -> hawk/shared/types` removed -- review any `eyrie` or `yaad` dependency on `tok` and reduce it to contracts or Hawk orchestration where possible +- `sight -> hawk/shared/types` removed (now `sight -> hawk-core-contracts`) +- `inspect -> hawk/shared/types` removed (now `inspect -> hawk-core-contracts`) +- `tok/types` duplicate definitions removed; `tok/types` now re-exports + `hawk-core-contracts/types` as a deprecated compat shim +- review any `eyrie` or `yaad` dependency on `tok` and reduce it to contracts or + Hawk orchestration where possible + +## Enforcement + +These were previously "ideas"; they are now implemented: + +- each support repo documents its import boundary in its README + ("Ecosystem Boundaries" section) +- CI runs `scripts/check-ecosystem-boundaries.sh` in every support repo, and + Hawk additionally runs `check-shared-types-imports.sh` and + `check-eyrie-client-imports.sh` +- `hawk-core-contracts` is kept minimal (leaf module, no external dependencies) -## Enforcement ideas +Still open: -- document allowed import boundaries in each repo README -- add CI checks for forbidden import paths -- keep `hawk-core-contracts` versioned and minimal +- `hawk-core-contracts` is consumed via local `replace` directives at `v0.0.0`; + it is not yet a tagged/published module +- the boundary guard scripts depend on `rg`; when `rg` is absent they pass + vacuously, so CI images must provide ripgrep (or the scripts should fall back + to `git grep`) diff --git a/docs/plans/hawk-contracts-migration-backlog.md b/docs/plans/hawk-contracts-migration-backlog.md index 48fff452..8801d64b 100644 --- a/docs/plans/hawk-contracts-migration-backlog.md +++ b/docs/plans/hawk-contracts-migration-backlog.md @@ -19,6 +19,9 @@ These items are already completed in the current workspace. - migrated `sight` and `inspect` to import contracts - switched Hawk `internal/types/severity.go` to re-export from contracts - converted `hawk/shared/types` into a compatibility shim +- removed the duplicate severity/finding definitions in `tok/types` (tok was the + original shared-types host); `tok/types` now re-exports + `hawk-core-contracts/types` as a deprecated compatibility shim ### Tool contract migration - added `hawk-core-contracts/tools` From d05176f64b920fe652eeb78c51562fd866d83094 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 09:44:49 +0530 Subject: [PATCH 03/23] build(contracts): bind hawk-core-contracts like every other engine Match the established ecosystem binding (eyrie/sight/etc.): require the module by published version and keep the local redirect in go.work, not go.mod. - go.mod: require hawk-core-contracts v0.1.0 (was v0.0.0), drop the `replace => ./external/hawk-core-contracts` - go.work: move hawk-core-contracts into the replace block alongside the other engines (was a stray `use` directive) Local builds resolve via go.work -> ./external/hawk-core-contracts; CI will resolve the published v0.1.0 once the module is tagged. Matches eyrie exactly. --- go.mod | 4 +--- go.work | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index bd0daa6c..e6e3fde9 100644 --- a/go.mod +++ b/go.mod @@ -2,11 +2,9 @@ module github.com/GrayCodeAI/hawk go 1.26.4 -replace github.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts - require ( github.com/GrayCodeAI/eyrie v0.1.0 - github.com/GrayCodeAI/hawk-core-contracts v0.0.0 + github.com/GrayCodeAI/hawk-core-contracts v0.1.0 github.com/GrayCodeAI/inspect v0.1.0 github.com/GrayCodeAI/sight v0.1.0 github.com/GrayCodeAI/tok v0.1.0 diff --git a/go.work b/go.work index 391521a9..f72d955d 100644 --- a/go.work +++ b/go.work @@ -2,9 +2,8 @@ go 1.26.4 use . -use ./external/hawk-core-contracts - replace ( + github.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts github.com/GrayCodeAI/eyrie => ./external/eyrie github.com/GrayCodeAI/inspect => ./external/inspect github.com/GrayCodeAI/sight => ./external/sight From a255f7bacbb29d0002a93a004ae175e9b72d3cc8 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 10:54:55 +0530 Subject: [PATCH 04/23] feat: enforce peer-isolated hawk architecture --- .github/actions/checkout-eyrie/action.yml | 6 +- .github/workflows/ci.yml | 4 +- .gitmodules | 3 + Makefile | 23 +-- README.md | 17 +- docs/architecture/README.md | 8 + docs/architecture/hawk-dependency-rules.md | 32 +++- .../architecture/hawk-product-architecture.md | 52 +++++- docs/architecture/hawk-repo-roles.md | 16 ++ external/eyrie | 2 +- external/hawk-core-contracts | 1 + external/hawk-core-contracts/README.md | 68 -------- external/hawk-core-contracts/events/events.go | 34 ---- .../hawk-core-contracts/events/events_test.go | 73 --------- external/hawk-core-contracts/go.mod | 3 - external/hawk-core-contracts/policy/policy.go | 136 --------------- .../hawk-core-contracts/policy/policy_test.go | 77 --------- external/hawk-core-contracts/review/review.go | 89 ---------- .../hawk-core-contracts/review/review_test.go | 38 ----- external/hawk-core-contracts/tools/tool.go | 15 -- .../hawk-core-contracts/tools/tool_test.go | 63 ------- external/hawk-core-contracts/types/finding.go | 155 ------------------ .../hawk-core-contracts/types/finding_test.go | 20 --- .../hawk-core-contracts/types/severity.go | 63 ------- .../types/severity_test.go | 26 --- external/hawk-core-contracts/verify/verify.go | 64 -------- .../hawk-core-contracts/verify/verify_test.go | 38 ----- external/yaad | 2 +- scripts/check-support-repo-coupling.sh | 58 +++++++ 29 files changed, 195 insertions(+), 991 deletions(-) create mode 160000 external/hawk-core-contracts delete mode 100644 external/hawk-core-contracts/README.md delete mode 100644 external/hawk-core-contracts/events/events.go delete mode 100644 external/hawk-core-contracts/events/events_test.go delete mode 100644 external/hawk-core-contracts/go.mod delete mode 100644 external/hawk-core-contracts/policy/policy.go delete mode 100644 external/hawk-core-contracts/policy/policy_test.go delete mode 100644 external/hawk-core-contracts/review/review.go delete mode 100644 external/hawk-core-contracts/review/review_test.go delete mode 100644 external/hawk-core-contracts/tools/tool.go delete mode 100644 external/hawk-core-contracts/tools/tool_test.go delete mode 100644 external/hawk-core-contracts/types/finding.go delete mode 100644 external/hawk-core-contracts/types/finding_test.go delete mode 100644 external/hawk-core-contracts/types/severity.go delete mode 100644 external/hawk-core-contracts/types/severity_test.go delete mode 100644 external/hawk-core-contracts/verify/verify.go delete mode 100644 external/hawk-core-contracts/verify/verify_test.go create mode 100644 scripts/check-support-repo-coupling.sh diff --git a/.github/actions/checkout-eyrie/action.yml b/.github/actions/checkout-eyrie/action.yml index 594f354c..dc7a2dd6 100644 --- a/.github/actions/checkout-eyrie/action.yml +++ b/.github/actions/checkout-eyrie/action.yml @@ -1,5 +1,5 @@ -name: Checkout eyrie -description: Clone ecosystem repos into hawk/external for hawk go.work +name: Checkout ecosystem +description: Clone Hawk ecosystem repos into hawk/external for hawk go.work inputs: ref: @@ -15,7 +15,7 @@ runs: run: | set -euo pipefail mkdir -p "${GITHUB_WORKSPACE}/external" - for repo in eyrie inspect sight tok trace yaad; do + for repo in hawk-core-contracts eyrie inspect sight tok trace yaad; do dest="${GITHUB_WORKSPACE}/external/${repo}" if [ -d "$dest/.git" ]; then echo "$repo already present at $dest" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60dd2019..21e8fa28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,8 @@ jobs: run: bash ./scripts/check-ecosystem-boundaries.sh - name: eyrie client boundary guard run: bash ./scripts/check-eyrie-client-imports.sh + - name: support repo coupling guard + run: bash ./scripts/check-support-repo-coupling.sh # ------------------------------------------------------------------------- # 2. Module hygiene — tidy, verify (hawk + external ecosystem repos via go.work). @@ -95,7 +97,7 @@ jobs: run: go mod verify - name: workspace points at external checkouts run: | - for module in eyrie inspect sight tok trace yaad; do + for module in hawk-core-contracts eyrie inspect sight tok trace yaad; do if ! grep -q "./external/${module}" go.work; then echo "::error::go.work must include ./external/${module}." cat go.work diff --git a/.gitmodules b/.gitmodules index 2767af33..d443c9b3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,3 +16,6 @@ [submodule "external/trace"] path = external/trace url = https://github.com/GrayCodeAI/trace.git +[submodule "external/hawk-core-contracts"] + path = external/hawk-core-contracts + url = https://github.com/GrayCodeAI/hawk-core-contracts.git diff --git a/Makefile b/Makefile index 476c64e1..328122dd 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ GORELEASER := $(GOBIN_DIR)/goreleaser # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- -.PHONY: all bench build ci clean contracts-guard ecosystem-guard eyrie-client-guard cover cover-new fmt help install lint lint-fix \ +.PHONY: all bench build ci clean contracts-guard ecosystem-guard eyrie-client-guard peer-guard cover cover-new fmt help install lint lint-fix \ release security setup smoke path test test-10x test-live test-new test-race tidy version vet # --------------------------------------------------------------------------- @@ -108,6 +108,9 @@ ecosystem-guard: ## Fail if external ecosystem repos import hawk/internal or dep eyrie-client-guard: ## Fail on new direct eyrie/client imports outside Hawk transport adapters. bash ./scripts/check-eyrie-client-imports.sh +peer-guard: ## Fail if support engines import each other instead of depending only on Hawk contracts. + bash ./scripts/check-support-repo-coupling.sh + lint: ## Run golangci-lint. @command -v $(GOLANGCI) >/dev/null 2>&1 || (echo "install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest" && exit 1) $(GOLANGCI) run ./... --timeout=5m @@ -127,7 +130,7 @@ tidy: ## Sync workspace modules and verify checksums. # --------------------------------------------------------------------------- # Composite gate used by CI and pre-push. # --------------------------------------------------------------------------- -ci: tidy fmt vet contracts-guard ecosystem-guard eyrie-client-guard lint test-race security ## Run everything CI runs. +ci: tidy fmt vet contracts-guard ecosystem-guard eyrie-client-guard peer-guard lint test-race security ## Run everything CI runs. @echo "All CI checks passed." smoke: ## Quick build + doctor + ecosystem verification. @@ -151,12 +154,14 @@ clean: ## Remove build artefacts. # --------------------------------------------------------------------------- # Setup — bootstrap local development environment. # --------------------------------------------------------------------------- +FOUNDATION_REPOS := hawk-core-contracts ECO_REPOS := eyrie inspect sight tok trace yaad +WORKSPACE_REPOS := $(FOUNDATION_REPOS) $(ECO_REPOS) setup: ## Set up local development environment (go.work + external repos). @echo "=== Setting up hawk development environment ===" @mkdir -p external - @for repo in $(ECO_REPOS); do \ + @for repo in $(WORKSPACE_REPOS); do \ if [ ! -d "external/$$repo" ]; then \ echo "Cloning $$repo..."; \ git clone --depth=1 "https://github.com/GrayCodeAI/$$repo.git" "external/$$repo" 2>/dev/null || \ @@ -168,14 +173,12 @@ setup: ## Set up local development environment (go.work + external repos). @echo "Generating go.work..." @echo "go 1.26.4" > go.work @echo "" >> go.work - @echo "use (" >> go.work - @echo " ." >> go.work - @if [ -d "external/hawk-core-contracts" ]; then \ - echo " ./external/hawk-core-contracts" >> go.work; \ - fi - @for repo in $(ECO_REPOS); do \ + @echo "use ." >> go.work + @echo "" >> go.work + @echo "replace (" >> go.work + @for repo in $(WORKSPACE_REPOS); do \ if [ -d "external/$$repo" ]; then \ - echo " ./external/$$repo" >> go.work; \ + echo " github.com/GrayCodeAI/$$repo => ./external/$$repo" >> go.work; \ fi; \ done @echo ")" >> go.work diff --git a/README.md b/README.md index 4d6a7ba3..b596472e 100644 --- a/README.md +++ b/README.md @@ -314,11 +314,17 @@ hawk/ ### Ecosystem -hawk integrates these GrayCodeAI repos in three ways: +hawk integrates these GrayCodeAI repos in three layers: -- **`go.mod` modules:** **eyrie**, **sight**, **inspect**, **tok**, **yaad** — pinned module requirements. -- **External checkout + `go.work`:** **eyrie**, **sight**, **inspect**, **tok**, **yaad**, **trace** — clone ecosystem repos under `external/`. `go.work` lists the local external checkouts. CI clones the same layout via **`.github/actions/checkout-eyrie`**. -- **Optional CLI (no Go import):** **trace** — installed separately; `hawk` shells into `trace` for session capture when present. +- **Primary product:** **hawk** is the only end-user product surface in this ecosystem. +- **Support engines mounted by Hawk:** **eyrie**, **yaad**, **tok**, **trace**, **sight**, **inspect**. Hawk imports or shells into these engines behind its own command surface. +- **Shared foundation:** **hawk-core-contracts** holds the neutral cross-repo types that keep the engines independent from Hawk internals. + +Local development uses: + +- **`go.mod` modules:** pinned requirements for the support engines and `hawk-core-contracts` +- **External checkout + `go.work`:** clone support repos under `external/`; `go.work` maps the module paths to those local checkouts +- **Submodules in this repo:** the same external layout is pinned under `external/` for reproducible CI and multi-repo work Cross-repo contracts now live in **`github.com/GrayCodeAI/hawk-core-contracts`** so support repos do not depend on Hawk internals. The legacy `hawk/shared/types` package is now a deprecated compatibility layer and should not be used for new code. @@ -341,7 +347,8 @@ You may keep a **personal** parent **`go.work`** that lists alternate clones on | **inspect** | [GrayCodeAI/inspect](https://github.com/GrayCodeAI/inspect) | Site audit library | | **tok** | [GrayCodeAI/tok](https://github.com/GrayCodeAI/tok) | Tokenizer & compression | | **yaad** | [GrayCodeAI/yaad](https://github.com/GrayCodeAI/yaad) | Graph-based memory | -| **trace** | [GrayCodeAI/trace](https://github.com/GrayCodeAI/trace) | Session capture CLI | +| **trace** | [GrayCodeAI/trace](https://github.com/GrayCodeAI/trace) | Session capture and replay engine mounted as `hawk trace ...` | +| **hawk-core-contracts** | [GrayCodeAI/hawk-core-contracts](https://github.com/GrayCodeAI/hawk-core-contracts) | Shared contracts and neutral cross-repo vocabulary | ## Development diff --git a/docs/architecture/README.md b/docs/architecture/README.md index b02e006d..b3ad3c01 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -16,3 +16,11 @@ Documents: Core rule: `hawk` is the product. The other Hawk repos are capabilities that power it. + +Final target shape: + +- `hawk` is the orchestrator and only primary product surface +- six peer support engines sit below Hawk: + `eyrie`, `yaad`, `tok`, `trace`, `sight`, `inspect` +- `hawk-core-contracts` sits below those engines as the shared contract layer +- SDKs and community skills sit above Hawk as consumers of Hawk public surfaces diff --git a/docs/architecture/hawk-dependency-rules.md b/docs/architecture/hawk-dependency-rules.md index 24dc6ceb..4c6fef71 100644 --- a/docs/architecture/hawk-dependency-rules.md +++ b/docs/architecture/hawk-dependency-rules.md @@ -18,6 +18,27 @@ hawk-sdk-python -> hawk public API/contracts hawk-community-skills -> hawk plugin/skill API ``` +Product shape: + +```text +top + hawk-sdk-go / hawk-sdk-python / hawk-community-skills + | + v + hawk + | + +------------------+------------------+ + | | | | | + v v v v v + eyrie yaad tok trace sight inspect + \ | | | | / + +--------+---------+--------+--------+--------+ + | + v + hawk-core-contracts +bottom +``` + ## Contract edges An engine depends on `hawk-core-contracts` **only when it produces or consumes a @@ -77,8 +98,7 @@ Based on current local structure: - `inspect -> hawk/shared/types` removed (now `inspect -> hawk-core-contracts`) - `tok/types` duplicate definitions removed; `tok/types` now re-exports `hawk-core-contracts/types` as a deprecated compat shim -- review any `eyrie` or `yaad` dependency on `tok` and reduce it to contracts or - Hawk orchestration where possible +- keep support engines peer-isolated as new features are added ## Enforcement @@ -87,14 +107,14 @@ These were previously "ideas"; they are now implemented: - each support repo documents its import boundary in its README ("Ecosystem Boundaries" section) - CI runs `scripts/check-ecosystem-boundaries.sh` in every support repo, and - Hawk additionally runs `check-shared-types-imports.sh` and - `check-eyrie-client-imports.sh` + Hawk additionally runs `check-shared-types-imports.sh`, + `check-eyrie-client-imports.sh`, and `check-support-repo-coupling.sh` - `hawk-core-contracts` is kept minimal (leaf module, no external dependencies) Still open: -- `hawk-core-contracts` is consumed via local `replace` directives at `v0.0.0`; - it is not yet a tagged/published module +- `hawk/shared/types` remains as a deprecated compatibility layer until downstream + migration evidence is complete - the boundary guard scripts depend on `rg`; when `rg` is absent they pass vacuously, so CI images must provide ripgrep (or the scripts should fall back to `git grep`) diff --git a/docs/architecture/hawk-product-architecture.md b/docs/architecture/hawk-product-architecture.md index 70554f25..27ed87d7 100644 --- a/docs/architecture/hawk-product-architecture.md +++ b/docs/architecture/hawk-product-architecture.md @@ -50,6 +50,52 @@ Users / SDKs / Skills hawk-core-contracts ``` +## Current vs proposed + +Current implementation in this repo: + +```text +hawk + -> eyrie + -> yaad + -> tok + -> trace + -> sight + -> inspect + -> hawk-core-contracts + +support engines + -> hawk-core-contracts only when they need shared contracts + x-> each other +``` + +Proposed steady-state architecture: + +```text +SDKs / Skills / future integrations + | + v + Hawk + | + +------------+------------+------------+------------+------------+------------+ + | | | | | | | + v v v v v v v + Eyrie Yaad Tok Trace Sight Inspect public APIs + \ | | | | / + +-----------+------------+------------+------------+-----------+ + | + v + hawk-core-contracts +``` + +This means: + +- Hawk is the product and orchestration boundary +- all six engines stay at the same architectural level +- engines remain independent from each other +- shared cross-repo vocabulary lives below them in `hawk-core-contracts` +- SDKs and community skills consume Hawk, not the engines directly + ## Hawk responsibilities Hawk owns: @@ -157,8 +203,10 @@ Status: - align SDKs and skills to Hawk public interfaces only Status: -- still future work -- Hawk SDK and skill repos should consume Hawk public contracts and plugin surfaces only +- policy is now explicit and guarded in Hawk docs +- `hawk-sdk-go` is covered by the support-repo coupling guard so it cannot grow + direct engine imports +- broader non-Go consumer enforcement remains future work ### Phase 6 - deprecate and eventually remove `hawk/shared/types` diff --git a/docs/architecture/hawk-repo-roles.md b/docs/architecture/hawk-repo-roles.md index 7805d836..78429301 100644 --- a/docs/architecture/hawk-repo-roles.md +++ b/docs/architecture/hawk-repo-roles.md @@ -15,6 +15,9 @@ Owns: - policy and permissions - engine coordination +Hawk is the only primary product surface. Users interact with Hawk, not with six +separate end-user products. + ## Support engines ### `eyrie` @@ -35,6 +38,18 @@ Hawk review engine. ### `inspect` Hawk verification engine. +All six support engines are peers: + +- `eyrie` +- `yaad` +- `tok` +- `trace` +- `sight` +- `inspect` + +They should stay isolated from each other and depend on Hawk only through +orchestration plus `hawk-core-contracts` where a shared vocabulary is required. + ## Ecosystem repos ### `hawk-sdk-go` @@ -58,4 +73,5 @@ This repo should stay small, stable, and implementation-free. - Users should feel they are using `hawk`, not six unrelated tools. - Engines are internal capabilities from a product perspective. - Engines can stay in separate repos for isolation, testing, and replacement. +- Engines must not import each other. - SDKs and skills extend Hawk, but should not bypass Hawk to reach engines directly. diff --git a/external/eyrie b/external/eyrie index 9c736018..0db0b705 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit 9c736018d8a8a4c01e7832e08bf77836a3d21cc8 +Subproject commit 0db0b705c61e14e5c234642ed18f661ed0c4469d diff --git a/external/hawk-core-contracts b/external/hawk-core-contracts new file mode 160000 index 00000000..ece1d01d --- /dev/null +++ b/external/hawk-core-contracts @@ -0,0 +1 @@ +Subproject commit ece1d01d9d4c0761eb7063808607e7b093c77d2e diff --git a/external/hawk-core-contracts/README.md b/external/hawk-core-contracts/README.md deleted file mode 100644 index 85692895..00000000 --- a/external/hawk-core-contracts/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# hawk-core-contracts - -Shared contracts for the Hawk ecosystem. - -This repo exists to hold stable cross-repo definitions used by: - -- `hawk` -- `eyrie` -- `yaad` -- `tok` -- `trace` -- `sight` -- `inspect` -- Hawk SDKs and extension surfaces where needed - -## Scope - -Allowed here: - -- shared enums -- shared structs -- event models -- finding/result models -- engine request/response contracts -- policy and tool contracts - -Not allowed here: - -- CLI code -- provider implementations -- runtime logic -- storage implementations -- product orchestration - -## Initial migration target - -The first migration target is the current Hawk package: - -- `github.com/GrayCodeAI/hawk/shared/types` - -That package currently exports severity and finding models for cross-repo use. Those definitions should move here so support repos stop depending on the Hawk product repo for shared contracts. - -## Package map - -- `types/` - severity, findings, and shared result vocabulary -- `tools/` - provider-neutral tool call and tool result contracts -- `events/` - normalized tool and trace event contracts -- `policy/` - risk, permission verdict, guardian decision, approval request contracts -- `review/` - neutral review findings, comments, stats, and result contracts -- `verify/` - neutral verification findings, stats, and report contracts - -## Current status - -Completed: - -1. shared finding and severity definitions moved here -2. `sight` and `inspect` migrated to import this repo -3. Hawk docs and READMEs updated -4. tool, event, and policy contracts added -5. review and verification result contracts added - -## Governance rules - -- keep this repo implementation-free -- prefer additive changes -- avoid product-specific runtime assumptions -- do not move Hawk orchestration code here -- if a type is only used inside one repo, it should stay in that repo diff --git a/external/hawk-core-contracts/events/events.go b/external/hawk-core-contracts/events/events.go deleted file mode 100644 index 2d94893d..00000000 --- a/external/hawk-core-contracts/events/events.go +++ /dev/null @@ -1,34 +0,0 @@ -package events - -import "time" - -// ToolEvent represents a normalized tool event emitted by Hawk workflows. -type ToolEvent struct { - ToolName string `json:"tool_name"` - ToolInput map[string]interface{} `json:"tool_input,omitempty"` - CWD string `json:"cwd,omitempty"` - Timestamp time.Time `json:"timestamp"` - SessionID string `json:"session_id,omitempty"` - Transcript string `json:"transcript,omitempty"` -} - -// TraceEvent represents a normalized trace record for model/runtime activity. -type TraceEvent struct { - ID string `json:"id"` - Name string `json:"name"` - Input string `json:"input,omitempty"` - Output string `json:"output,omitempty"` - Model string `json:"model,omitempty"` - StartTime time.Time `json:"start_time"` - EndTime time.Time `json:"end_time,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - Usage *UsageInfo `json:"usage,omitempty"` -} - -// UsageInfo captures token and cost information for a trace event. -type UsageInfo struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` - CostUSD float64 `json:"cost_usd,omitempty"` -} diff --git a/external/hawk-core-contracts/events/events_test.go b/external/hawk-core-contracts/events/events_test.go deleted file mode 100644 index 87b78cb1..00000000 --- a/external/hawk-core-contracts/events/events_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package events_test - -import ( - "encoding/json" - "testing" - "time" - - "github.com/GrayCodeAI/hawk-core-contracts/events" -) - -func TestToolEventJSONRoundTrip(t *testing.T) { - ts := time.Date(2026, time.June, 21, 10, 30, 0, 0, time.UTC) - in := events.ToolEvent{ - ToolName: "exec_command", - ToolInput: map[string]interface{}{"cmd": "pwd"}, - CWD: "/workspace", - Timestamp: ts, - SessionID: "sess_123", - } - - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - - var out events.ToolEvent - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - - if out.ToolName != in.ToolName { - t.Fatalf("ToolName = %q, want %q", out.ToolName, in.ToolName) - } - if out.CWD != in.CWD { - t.Fatalf("CWD = %q, want %q", out.CWD, in.CWD) - } - if !out.Timestamp.Equal(ts) { - t.Fatalf("Timestamp = %v, want %v", out.Timestamp, ts) - } -} - -func TestTraceEventJSONIncludesUsage(t *testing.T) { - event := events.TraceEvent{ - ID: "trace_123", - Name: "model_completion", - StartTime: time.Date(2026, time.June, 21, 10, 0, 0, 0, time.UTC), - EndTime: time.Date(2026, time.June, 21, 10, 0, 1, 0, time.UTC), - Usage: &events.UsageInfo{ - PromptTokens: 10, - CompletionTokens: 15, - TotalTokens: 25, - CostUSD: 0.01, - }, - } - - data, err := json.Marshal(event) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - - var got map[string]interface{} - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - - usage, ok := got["usage"].(map[string]interface{}) - if !ok { - t.Fatalf("usage = %#v, want object", got["usage"]) - } - if usage["total_tokens"] != float64(25) { - t.Fatalf("usage[total_tokens] = %#v, want 25", usage["total_tokens"]) - } -} diff --git a/external/hawk-core-contracts/go.mod b/external/hawk-core-contracts/go.mod deleted file mode 100644 index eef01214..00000000 --- a/external/hawk-core-contracts/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/GrayCodeAI/hawk-core-contracts - -go 1.26 diff --git a/external/hawk-core-contracts/policy/policy.go b/external/hawk-core-contracts/policy/policy.go deleted file mode 100644 index e93349a6..00000000 --- a/external/hawk-core-contracts/policy/policy.go +++ /dev/null @@ -1,136 +0,0 @@ -package policy - -import "fmt" - -// Risk is the severity of a permission or policy verdict. -type Risk int - -const ( - RiskLow Risk = iota - RiskMedium - RiskHigh - RiskBlocked -) - -// String returns a human-readable risk name. -func (r Risk) String() string { - switch r { - case RiskLow: - return "low" - case RiskMedium: - return "medium" - case RiskHigh: - return "high" - case RiskBlocked: - return "blocked" - default: - return fmt.Sprintf("Risk(%d)", int(r)) - } -} - -// ParseRisk parses a risk name (case-insensitive) into a Risk value. -func ParseRisk(s string) (Risk, error) { - switch lower(s) { - case "low": - return RiskLow, nil - case "medium", "med", "moderate": - return RiskMedium, nil - case "high", "hi": - return RiskHigh, nil - case "blocked", "block", "deny", "denied", "forbidden": - return RiskBlocked, nil - default: - return RiskMedium, fmt.Errorf("policy: unknown risk %q", s) - } -} - -// PermissionVerdict is the unified outcome type for permission subsystems. -type PermissionVerdict struct { - Allowed bool `json:"allowed"` - Reason string `json:"reason,omitempty"` - Rule string `json:"rule,omitempty"` - Risk Risk `json:"risk"` - Confidence float64 `json:"confidence,omitempty"` - Source string `json:"source,omitempty"` -} - -// Allow returns a permissive verdict. -func Allow(reason string) PermissionVerdict { - return PermissionVerdict{ - Allowed: true, - Reason: reason, - Risk: RiskLow, - Confidence: 1.0, - Source: "default", - } -} - -// Deny returns a reject verdict with the given reason and rule. -func Deny(reason, rule string) PermissionVerdict { - return PermissionVerdict{ - Allowed: false, - Reason: reason, - Rule: rule, - Risk: RiskBlocked, - Confidence: 1.0, - Source: "rules", - } -} - -// RequireApproval returns a "needs human approval" verdict. -func RequireApproval(reason, rule string, risk Risk) PermissionVerdict { - return PermissionVerdict{ - Allowed: false, - Reason: reason, - Rule: rule, - Risk: risk, - Confidence: 0.5, - Source: "guardian", - } -} - -// IsZero reports whether v is the zero value. -func (v PermissionVerdict) IsZero() bool { - return !v.Allowed && v.Reason == "" && v.Rule == "" && - v.Risk == 0 && v.Confidence == 0 && v.Source == "" -} - -// String returns a one-line summary for logs. -func (v PermissionVerdict) String() string { - action := "DENY" - if v.Allowed { - action = "ALLOW" - } - if v.Rule != "" { - return fmt.Sprintf("[%s] %s (%s, risk=%s, conf=%.2f): %s", - v.Source, action, v.Rule, v.Risk, v.Confidence, v.Reason) - } - return fmt.Sprintf("[%s] %s (risk=%s, conf=%.2f): %s", - v.Source, action, v.Risk, v.Confidence, v.Reason) -} - -// GuardianDecision is a provider-neutral automatic permission review response. -type GuardianDecision struct { - Allowed bool `json:"allowed"` - Reason string `json:"reason"` - Confidence float64 `json:"confidence"` -} - -// PermissionRequest represents a user-facing approval request. -type PermissionRequest struct { - ToolName string `json:"tool_name"` - ToolID string `json:"tool_id,omitempty"` - Summary string `json:"summary,omitempty"` -} - -func lower(s string) string { - b := make([]byte, len(s)) - for i := 0; i < len(s); i++ { - c := s[i] - if c >= 'A' && c <= 'Z' { - c += 'a' - 'A' - } - b[i] = c - } - return string(b) -} diff --git a/external/hawk-core-contracts/policy/policy_test.go b/external/hawk-core-contracts/policy/policy_test.go deleted file mode 100644 index 1c6daf15..00000000 --- a/external/hawk-core-contracts/policy/policy_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package policy_test - -import ( - "strings" - "testing" - - "github.com/GrayCodeAI/hawk-core-contracts/policy" -) - -func TestParseRisk(t *testing.T) { - tests := []struct { - in string - want policy.Risk - }{ - {in: "low", want: policy.RiskLow}, - {in: "MED", want: policy.RiskMedium}, - {in: "moderate", want: policy.RiskMedium}, - {in: "hi", want: policy.RiskHigh}, - {in: "forbidden", want: policy.RiskBlocked}, - } - - for _, tt := range tests { - got, err := policy.ParseRisk(tt.in) - if err != nil { - t.Fatalf("ParseRisk(%q) unexpected error = %v", tt.in, err) - } - if got != tt.want { - t.Fatalf("ParseRisk(%q) = %v, want %v", tt.in, got, tt.want) - } - } -} - -func TestParseRiskUnknown(t *testing.T) { - got, err := policy.ParseRisk("mystery") - if err == nil { - t.Fatal("ParseRisk() error = nil, want non-nil") - } - if got != policy.RiskMedium { - t.Fatalf("ParseRisk() risk = %v, want %v", got, policy.RiskMedium) - } -} - -func TestPermissionVerdictHelpers(t *testing.T) { - allow := policy.Allow("safe") - if !allow.Allowed || allow.Risk != policy.RiskLow || allow.Source != "default" { - t.Fatalf("Allow() = %#v", allow) - } - - deny := policy.Deny("blocked", "rule.exec") - if deny.Allowed || deny.Risk != policy.RiskBlocked || deny.Rule != "rule.exec" { - t.Fatalf("Deny() = %#v", deny) - } - - approval := policy.RequireApproval("needs review", "rule.write", policy.RiskHigh) - if approval.Allowed || approval.Risk != policy.RiskHigh || approval.Source != "guardian" { - t.Fatalf("RequireApproval() = %#v", approval) - } - if approval.IsZero() { - t.Fatalf("RequireApproval() should not be zero: %#v", approval) - } - if !(policy.PermissionVerdict{}).IsZero() { - t.Fatal("zero PermissionVerdict should report IsZero() = true") - } -} - -func TestPermissionVerdictString(t *testing.T) { - got := policy.Deny("dangerous command", "rule.shell").String() - if !strings.Contains(got, "DENY") { - t.Fatalf("String() = %q, want DENY marker", got) - } - if !strings.Contains(got, "rule.shell") { - t.Fatalf("String() = %q, want rule name", got) - } - if !strings.Contains(got, "risk=blocked") { - t.Fatalf("String() = %q, want blocked risk", got) - } -} diff --git a/external/hawk-core-contracts/review/review.go b/external/hawk-core-contracts/review/review.go deleted file mode 100644 index ecbccc60..00000000 --- a/external/hawk-core-contracts/review/review.go +++ /dev/null @@ -1,89 +0,0 @@ -package review - -import ( - "time" - - contracts "github.com/GrayCodeAI/hawk-core-contracts/types" -) - -// Finding is the neutral review finding contract shared across Hawk and review engines. -type Finding struct { - Concern string `json:"concern"` - Severity contracts.Severity `json:"severity"` - File string `json:"file"` - Line int `json:"line"` - EndLine int `json:"end_line,omitempty"` - Message string `json:"message"` - Fix string `json:"fix,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - CWE string `json:"cwe,omitempty"` - Confidence float64 `json:"confidence"` - SASTSource bool `json:"sast_source,omitempty"` -} - -// InlineComment is a review finding mapped to a concrete diff position. -type InlineComment struct { - Path string `json:"path"` - StartLine int `json:"start_line"` - EndLine int `json:"end_line,omitempty"` - Body string `json:"body"` - Suggestion string `json:"suggestion,omitempty"` -} - -// Stats captures review execution metrics. -type Stats struct { - FilesReviewed int `json:"files_reviewed"` - HunksAnalyzed int `json:"hunks_analyzed"` - FindingsTotal int `json:"findings_total"` - BySeverity map[contracts.Severity]int `json:"by_severity"` - ByConcern map[string]int `json:"by_concern"` - TokensUsed int `json:"tokens_used"` - DurationPerConcern map[string]time.Duration `json:"duration_per_concern"` - AverageConfidence float64 `json:"average_confidence"` - HighConfidenceCount int `json:"high_confidence_count"` - LowConfidenceCount int `json:"low_confidence_count"` -} - -// ConfidenceBreakdown groups review findings by confidence band. -type ConfidenceBreakdown struct { - High []Finding `json:"high"` - Medium []Finding `json:"medium"` - Low []Finding `json:"low"` -} - -// Result is the neutral review result contract. -type Result struct { - Findings []Finding `json:"findings"` - Comments []InlineComment `json:"comments"` - Stats Stats `json:"stats"` - Report string `json:"report"` - FailOn contracts.Severity `json:"fail_on"` - ConfidenceBreakdown *ConfidenceBreakdown `json:"confidence_breakdown,omitempty"` -} - -// Failed reports whether any finding meets or exceeds the configured fail threshold. -func (r *Result) Failed() bool { - if r == nil { - return false - } - for _, f := range r.Findings { - if f.Severity.AtLeast(r.FailOn) { - return true - } - } - return false -} - -// MaxSeverity returns the highest severity present in the result. -func (r *Result) MaxSeverity() contracts.Severity { - if r == nil { - return contracts.SeverityInfo - } - max := contracts.SeverityInfo - for _, f := range r.Findings { - if f.Severity > max { - max = f.Severity - } - } - return max -} diff --git a/external/hawk-core-contracts/review/review_test.go b/external/hawk-core-contracts/review/review_test.go deleted file mode 100644 index 4718e124..00000000 --- a/external/hawk-core-contracts/review/review_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package review - -import ( - "testing" - - contracts "github.com/GrayCodeAI/hawk-core-contracts/types" -) - -func TestResultFailedAndMaxSeverity(t *testing.T) { - t.Parallel() - - result := &Result{ - FailOn: contracts.SeverityHigh, - Findings: []Finding{ - {Severity: contracts.SeverityMedium}, - {Severity: contracts.SeverityCritical}, - }, - } - - if !result.Failed() { - t.Fatal("expected result to fail at high threshold") - } - if got := result.MaxSeverity(); got != contracts.SeverityCritical { - t.Fatalf("MaxSeverity = %v, want %v", got, contracts.SeverityCritical) - } -} - -func TestNilResultMethods(t *testing.T) { - t.Parallel() - - var result *Result - if result.Failed() { - t.Fatal("nil result should not fail") - } - if got := result.MaxSeverity(); got != contracts.SeverityInfo { - t.Fatalf("MaxSeverity(nil) = %v, want %v", got, contracts.SeverityInfo) - } -} diff --git a/external/hawk-core-contracts/tools/tool.go b/external/hawk-core-contracts/tools/tool.go deleted file mode 100644 index 87545a14..00000000 --- a/external/hawk-core-contracts/tools/tool.go +++ /dev/null @@ -1,15 +0,0 @@ -package tools - -// ToolCall represents a provider-neutral tool invocation contract. -type ToolCall struct { - ID string `json:"id,omitempty"` - Name string `json:"name"` - Arguments map[string]interface{} `json:"arguments"` -} - -// ToolResult represents a provider-neutral tool execution result contract. -type ToolResult struct { - ToolUseID string `json:"tool_use_id"` - Content string `json:"content"` - IsError bool `json:"is_error,omitempty"` -} diff --git a/external/hawk-core-contracts/tools/tool_test.go b/external/hawk-core-contracts/tools/tool_test.go deleted file mode 100644 index 448ea95e..00000000 --- a/external/hawk-core-contracts/tools/tool_test.go +++ /dev/null @@ -1,63 +0,0 @@ -package tools_test - -import ( - "encoding/json" - "testing" - - "github.com/GrayCodeAI/hawk-core-contracts/tools" -) - -func TestToolCallJSONRoundTrip(t *testing.T) { - in := tools.ToolCall{ - ID: "call_123", - Name: "exec", - Arguments: map[string]interface{}{ - "cmd": "go test ./...", - "tty": true, - }, - } - - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - - var out tools.ToolCall - if err := json.Unmarshal(data, &out); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - - if out.ID != in.ID { - t.Fatalf("ID = %q, want %q", out.ID, in.ID) - } - if out.Name != in.Name { - t.Fatalf("Name = %q, want %q", out.Name, in.Name) - } - if got, ok := out.Arguments["cmd"].(string); !ok || got != "go test ./..." { - t.Fatalf("Arguments[cmd] = %#v, want %q", out.Arguments["cmd"], "go test ./...") - } - if got, ok := out.Arguments["tty"].(bool); !ok || !got { - t.Fatalf("Arguments[tty] = %#v, want true", out.Arguments["tty"]) - } -} - -func TestToolResultJSONOmitsFalseIsError(t *testing.T) { - result := tools.ToolResult{ - ToolUseID: "toolu_123", - Content: "ok", - } - - data, err := json.Marshal(result) - if err != nil { - t.Fatalf("Marshal() error = %v", err) - } - - var got map[string]interface{} - if err := json.Unmarshal(data, &got); err != nil { - t.Fatalf("Unmarshal() error = %v", err) - } - - if _, exists := got["is_error"]; exists { - t.Fatalf("unexpected is_error field in %#v", got) - } -} diff --git a/external/hawk-core-contracts/types/finding.go b/external/hawk-core-contracts/types/finding.go deleted file mode 100644 index d7aa928f..00000000 --- a/external/hawk-core-contracts/types/finding.go +++ /dev/null @@ -1,155 +0,0 @@ -package types - -import ( - "fmt" - "time" -) - -// Finding represents a unified analysis concern sourced from Hawk support engines. -type Finding struct { - ID string `json:"id"` - Source string `json:"source"` - Concern string `json:"concern"` - Severity Severity `json:"severity"` - File string `json:"file,omitempty"` - URL string `json:"url,omitempty"` - Line int `json:"line,omitempty"` - EndLine int `json:"end_line,omitempty"` - Message string `json:"message"` - CWE string `json:"cwe,omitempty"` - Confidence float64 `json:"confidence"` - Fix string `json:"fix,omitempty"` - Reasoning string `json:"reasoning,omitempty"` - Tags []string `json:"tags,omitempty"` - Metadata map[string]string `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -// FindingSlice is sortable by severity descending and confidence descending. -type FindingSlice []Finding - -func (s FindingSlice) Len() int { return len(s) } - -func (s FindingSlice) Less(i, j int) bool { - if s[i].Severity != s[j].Severity { - return s[i].Severity > s[j].Severity - } - return s[i].Confidence > s[j].Confidence -} - -func (s FindingSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } - -// FilterBySource returns findings whose Source matches the given value. -func (s FindingSlice) FilterBySource(source string) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Source == source { - out = append(out, f) - } - } - return out -} - -// FilterBySeverity returns findings whose Severity is at least min. -func (s FindingSlice) FilterBySeverity(min Severity) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Severity.AtLeast(min) { - out = append(out, f) - } - } - return out -} - -// FilterByConfidence returns findings whose Confidence is >= min. -func (s FindingSlice) FilterByConfidence(min float64) FindingSlice { - out := make(FindingSlice, 0, len(s)) - for _, f := range s { - if f.Confidence >= min { - out = append(out, f) - } - } - return out -} - -// ByFile groups findings by their File field. -func (s FindingSlice) ByFile() map[string]FindingSlice { - m := make(map[string]FindingSlice, len(s)) - for _, f := range s { - m[f.File] = append(m[f.File], f) - } - return m -} - -// FindingSummary provides aggregate counts over a set of findings. -type FindingSummary struct { - Total int `json:"total"` - BySource map[string]int `json:"by_source"` - BySeverity map[string]int `json:"by_severity"` - AvgConfidence float64 `json:"avg_confidence"` -} - -// Summary returns a FindingSummary for the slice. -func (s FindingSlice) Summary() FindingSummary { - bySrc := make(map[string]int) - bySev := make(map[string]int) - var confSum float64 - - for _, f := range s { - bySrc[f.Source]++ - bySev[f.Severity.String()]++ - confSum += f.Confidence - } - - avg := 0.0 - if len(s) > 0 { - avg = confSum / float64(len(s)) - } - - return FindingSummary{ - Total: len(s), - BySource: bySrc, - BySeverity: bySev, - AvgConfidence: avg, - } -} - -// FindingFromSight constructs a Finding from a sight review result. -func FindingFromSight( - concern, file string, - line int, - message, cwe string, - sev Severity, - confidence float64, -) Finding { - return Finding{ - ID: fmt.Sprintf("sight:%s:%s:%d", concern, file, line), - Source: "sight", - Concern: concern, - Severity: sev, - File: file, - Line: line, - Message: message, - CWE: cwe, - Confidence: confidence, - CreatedAt: time.Now(), - } -} - -// FindingFromInspect constructs a Finding from an inspect analysis result. -func FindingFromInspect( - concern, url, message string, - sev Severity, - tags []string, -) Finding { - return Finding{ - ID: fmt.Sprintf("inspect:%s:%s", concern, url), - Source: "inspect", - Concern: concern, - Severity: sev, - URL: url, - Message: message, - Tags: tags, - CreatedAt: time.Now(), - } -} diff --git a/external/hawk-core-contracts/types/finding_test.go b/external/hawk-core-contracts/types/finding_test.go deleted file mode 100644 index 6bf95722..00000000 --- a/external/hawk-core-contracts/types/finding_test.go +++ /dev/null @@ -1,20 +0,0 @@ -package types_test - -import ( - "testing" - - "github.com/GrayCodeAI/hawk-core-contracts/types" -) - -func TestFindingSliceFilterBySeverity(t *testing.T) { - findings := types.FindingSlice{ - {ID: "a", Severity: types.SeverityLow, Confidence: 0.2}, - {ID: "b", Severity: types.SeverityHigh, Confidence: 0.8}, - {ID: "c", Severity: types.SeverityCritical, Confidence: 0.9}, - } - - got := findings.FilterBySeverity(types.SeverityHigh) - if len(got) != 2 { - t.Fatalf("FilterBySeverity() len = %d, want 2", len(got)) - } -} diff --git a/external/hawk-core-contracts/types/severity.go b/external/hawk-core-contracts/types/severity.go deleted file mode 100644 index 921bc827..00000000 --- a/external/hawk-core-contracts/types/severity.go +++ /dev/null @@ -1,63 +0,0 @@ -package types - -import "strings" - -// Severity represents the impact level of a finding. -type Severity int - -const ( - SeverityInfo Severity = iota - SeverityLow - SeverityMedium - SeverityHigh - SeverityCritical -) - -var severityNames = [...]string{"info", "low", "medium", "high", "critical"} - -func (s Severity) String() string { - if int(s) < len(severityNames) { - return severityNames[s] - } - return "unknown" -} - -// ParseSeverity converts a string to a Severity. -func ParseSeverity(s string) Severity { - switch strings.ToLower(strings.TrimSpace(s)) { - case "critical": - return SeverityCritical - case "high": - return SeverityHigh - case "medium": - return SeverityMedium - case "low": - return SeverityLow - default: - return SeverityInfo - } -} - -// AtLeast returns true if s >= threshold. -func (s Severity) AtLeast(threshold Severity) bool { - return s >= threshold -} - -// TokenSeverity defines rule severity for compression error patterns. -type TokenSeverity string - -const ( - TokenSeverityCritical TokenSeverity = "critical" - TokenSeverityHigh TokenSeverity = "high" - TokenSeverityMedium TokenSeverity = "medium" - TokenSeverityLow TokenSeverity = "low" -) - -// AuditSeverity indicates how dangerous a security audit finding is. -type AuditSeverity string - -const ( - AuditSeverityCritical AuditSeverity = "CRITICAL" - AuditSeverityWarning AuditSeverity = "WARNING" - AuditSeverityInfo AuditSeverity = "INFO" -) diff --git a/external/hawk-core-contracts/types/severity_test.go b/external/hawk-core-contracts/types/severity_test.go deleted file mode 100644 index e532712a..00000000 --- a/external/hawk-core-contracts/types/severity_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package types_test - -import ( - "testing" - - "github.com/GrayCodeAI/hawk-core-contracts/types" -) - -func TestParseSeverity(t *testing.T) { - tests := []struct { - in string - want types.Severity - }{ - {in: "critical", want: types.SeverityCritical}, - {in: "HIGH", want: types.SeverityHigh}, - {in: " medium ", want: types.SeverityMedium}, - {in: "low", want: types.SeverityLow}, - {in: "unknown", want: types.SeverityInfo}, - } - - for _, tt := range tests { - if got := types.ParseSeverity(tt.in); got != tt.want { - t.Fatalf("ParseSeverity(%q) = %v, want %v", tt.in, got, tt.want) - } - } -} diff --git a/external/hawk-core-contracts/verify/verify.go b/external/hawk-core-contracts/verify/verify.go deleted file mode 100644 index 0254d007..00000000 --- a/external/hawk-core-contracts/verify/verify.go +++ /dev/null @@ -1,64 +0,0 @@ -package verify - -import ( - "time" - - contracts "github.com/GrayCodeAI/hawk-core-contracts/types" -) - -// Finding is the neutral verification finding contract shared across Hawk and verification engines. -type Finding struct { - Check string `json:"check"` - Severity contracts.Severity `json:"severity"` - URL string `json:"url"` - Element string `json:"element,omitempty"` - Message string `json:"message"` - Fix string `json:"fix,omitempty"` - Evidence string `json:"evidence,omitempty"` -} - -// Stats captures verification execution metrics. -type Stats struct { - PagesScanned int `json:"pages_scanned"` - FindingsTotal int `json:"findings_total"` - BySeverity map[contracts.Severity]int `json:"by_severity"` - ByCheck map[string]int `json:"by_check"` - DurationPerCheck map[string]time.Duration `json:"duration_per_check"` -} - -// Report is the neutral verification report contract. -type Report struct { - Target string `json:"target"` - Findings []Finding `json:"findings"` - Stats Stats `json:"stats"` - CrawledURLs int `json:"crawled_urls"` - Duration time.Duration `json:"duration"` - FailOn contracts.Severity `json:"fail_on"` -} - -// Failed reports whether any finding meets or exceeds the configured fail threshold. -func (r *Report) Failed() bool { - if r == nil { - return false - } - for _, f := range r.Findings { - if f.Severity.AtLeast(r.FailOn) { - return true - } - } - return false -} - -// MaxSeverity returns the highest severity present in the report. -func (r *Report) MaxSeverity() contracts.Severity { - if r == nil { - return contracts.SeverityInfo - } - max := contracts.SeverityInfo - for _, f := range r.Findings { - if f.Severity > max { - max = f.Severity - } - } - return max -} diff --git a/external/hawk-core-contracts/verify/verify_test.go b/external/hawk-core-contracts/verify/verify_test.go deleted file mode 100644 index a8416f2f..00000000 --- a/external/hawk-core-contracts/verify/verify_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package verify - -import ( - "testing" - - contracts "github.com/GrayCodeAI/hawk-core-contracts/types" -) - -func TestReportFailedAndMaxSeverity(t *testing.T) { - t.Parallel() - - report := &Report{ - FailOn: contracts.SeverityMedium, - Findings: []Finding{ - {Severity: contracts.SeverityLow}, - {Severity: contracts.SeverityHigh}, - }, - } - - if !report.Failed() { - t.Fatal("expected report to fail at medium threshold") - } - if got := report.MaxSeverity(); got != contracts.SeverityHigh { - t.Fatalf("MaxSeverity = %v, want %v", got, contracts.SeverityHigh) - } -} - -func TestNilReportMethods(t *testing.T) { - t.Parallel() - - var report *Report - if report.Failed() { - t.Fatal("nil report should not fail") - } - if got := report.MaxSeverity(); got != contracts.SeverityInfo { - t.Fatalf("MaxSeverity(nil) = %v, want %v", got, contracts.SeverityInfo) - } -} diff --git a/external/yaad b/external/yaad index ceb690ae..d628127d 160000 --- a/external/yaad +++ b/external/yaad @@ -1 +1 @@ -Subproject commit ceb690ae823bcd0f8c2b6d0725edceb75cd0ae2e +Subproject commit d628127d314c203c5125339684e53ec1dcb6d88e diff --git a/scripts/check-support-repo-coupling.sh b/scripts/check-support-repo-coupling.sh new file mode 100644 index 00000000..b37c16d0 --- /dev/null +++ b/scripts/check-support-repo-coupling.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +support_repos=(eyrie inspect sight tok trace yaad) +violations="" + +scan_dir() { + local owner="$1" + local dir="$2" + local peers=() + local pattern="" + local hits="" + + if [[ ! -d "${dir}" ]]; then + return + fi + + for repo in "${support_repos[@]}"; do + if [[ "${repo}" != "${owner}" ]]; then + peers+=("${repo}") + fi + done + + pattern="$(IFS='|'; echo "${peers[*]}")" + hits="$( + grep -RInE --include='*.go' "github\\.com/GrayCodeAI/(${pattern})(/|\")" "${dir}" || true + )" + if [[ -n "${hits}" ]]; then + violations+="${hits}"$'\n' + fi +} + +for repo in "${support_repos[@]}"; do + scan_dir "${repo}" "external/${repo}" + scan_dir "${repo}" "../${repo}" +done + +if [[ -d "../hawk-sdk-go" ]]; then + sdk_hits="$( + grep -RInE --include='*.go' 'github\.com/GrayCodeAI/(eyrie|inspect|sight|tok|trace|yaad)(/|")' ../hawk-sdk-go || true + )" + if [[ -n "${sdk_hits}" ]]; then + violations+="${sdk_hits}"$'\n' + fi +fi + +if [[ -n "${violations}" ]]; then + echo "forbidden cross-repo peer imports found:" + echo "${violations}" + echo + echo "support engines must not import each other; Hawk is the orchestrator and shared contracts belong in hawk-core-contracts" + exit 1 +fi + +echo "support repo coupling guard passed" From 9c432e5f5f5b27b92b5cd72825941787ba92befb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 11:03:52 +0530 Subject: [PATCH 05/23] docs: add consolidated hawk repo architecture map --- README.md | 2 + docs/architecture/README.md | 1 + docs/architecture/hawk-current-vs-proposed.md | 400 ++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 docs/architecture/hawk-current-vs-proposed.md diff --git a/README.md b/README.md index b596472e..681aced3 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,8 @@ You may keep a **personal** parent **`go.work`** that lists alternate clones on | **trace** | [GrayCodeAI/trace](https://github.com/GrayCodeAI/trace) | Session capture and replay engine mounted as `hawk trace ...` | | **hawk-core-contracts** | [GrayCodeAI/hawk-core-contracts](https://github.com/GrayCodeAI/hawk-core-contracts) | Shared contracts and neutral cross-repo vocabulary | +For the consolidated repo map and the current-vs-proposed architecture diagrams, see [docs/architecture/hawk-current-vs-proposed.md](docs/architecture/hawk-current-vs-proposed.md). + ## Development ### Prerequisites diff --git a/docs/architecture/README.md b/docs/architecture/README.md index b3ad3c01..abf3cd5a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -4,6 +4,7 @@ This directory holds the implementation planning docs for Hawk as a model-agnost Documents: +- `hawk-current-vs-proposed.md` - current workspace shape vs target Hawk-centered repo architecture - `hawk-product-architecture.md` - target architecture and runtime flow - `hawk-repo-roles.md` - role of each Hawk repo in the product ecosystem - `hawk-dependency-rules.md` - import and ownership boundaries diff --git a/docs/architecture/hawk-current-vs-proposed.md b/docs/architecture/hawk-current-vs-proposed.md new file mode 100644 index 00000000..94b35454 --- /dev/null +++ b/docs/architecture/hawk-current-vs-proposed.md @@ -0,0 +1,400 @@ +# Hawk Current vs Proposed Architecture + +## Purpose + +This document is the single source of truth for: + +- what exists in the current local workspace +- which repos are part of the Hawk product architecture +- which repos are support-only +- what the final steady-state repo graph should be + +The goal is simple: + +- `hawk` is the product +- support engines are independent from each other +- support engines depend on Hawk orchestration, not on sibling engines +- shared vocabulary lives in `hawk-core-contracts` +- SDKs and skills extend Hawk, not the engines directly + +## Scope + +This workspace contains both: + +- the Hawk product ecosystem +- other GrayCodeAI company/platform repos that are not part of Hawk runtime architecture + +Those should not be mixed together when making product or dependency decisions. + +## Current workspace shape + +The local workspace currently contains these top-level repos: + +### Hawk product repos + +- `hawk` +- `eyrie` +- `yaad` +- `tok` +- `trace` +- `sight` +- `inspect` +- `hawk-core-contracts` +- `hawk-sdk-go` +- `hawk-sdk-python` +- `hawk-community-skills` + +### Non-Hawk repo currently present in the same workspace + +- `graycode-core` + +## Current actual layout + +Today, the workspace is a multi-repo development area, and `hawk` also vendors or +pins support repos under `hawk/external` for reproducible integration work. + +```text +hawk-eco/ +├── hawk # primary product repo +│ └── external/ +│ ├── eyrie +│ ├── yaad +│ ├── tok +│ ├── trace +│ ├── sight +│ ├── inspect +│ └── hawk-core-contracts +├── eyrie +├── yaad +├── tok +├── trace +├── sight +├── inspect +├── hawk-core-contracts +├── hawk-sdk-go +├── hawk-sdk-python +├── hawk-community-skills +└── graycode-core # separate company/platform repo, not Hawk runtime +``` + +## Current runtime relationship + +The intended runtime shape is already mostly reflected in code and guards: + +```text +users / scripts / sdk / skills + | + v + hawk + | + +---------+---------+---------+---------+---------+---------+ + | | | | | | | + v v v v v v v + eyrie yaad tok trace sight inspect product APIs + \ | | | | / + +--------+---------+---------+---------+--------+ + | + v + hawk-core-contracts +``` + +Important clarification: + +- engines are at the same level +- `sight` and `inspect` are not below `eyrie` or below a separate execution tier +- Hawk decides when each engine is called +- engines do not coordinate each other directly + +## Current problems this document resolves + +Without a strict product map, a multi-repo workspace like this can drift into four +failure modes: + +1. People treat support engines as separate products instead of Hawk capabilities. +2. Engines start importing each other for convenience. +3. Shared types leak from `hawk/internal` or ad hoc packages. +4. Company/platform repos such as `graycode-core` get confused with Hawk runtime dependencies. + +The current architecture cleanup addressed the second and third risks with guardrails. +This document addresses the first and fourth by making the repo model explicit. + +## Proposed steady-state architecture + +The steady-state architecture for Hawk should be: + +```text +top: integrations + + hawk-sdk-go hawk-sdk-python hawk-community-skills + \ | / + \ | / + +---------------+----------------------+ + | + v + hawk + +middle: support engines + + +---------+---------+---------+---------+---------+---------+ + | | | | | | | + v v v v v v v + eyrie yaad tok trace sight inspect hawk APIs + +bottom: shared contracts + + hawk-core-contracts +``` + +## Repo classification + +### 1. Primary product repo + +#### `hawk` + +Owns: + +- CLI and command surface +- session and workflow orchestration +- provider selection +- tool execution policy +- approval model +- coordination of memory, context, tracing, review, and verification +- public product APIs used by SDKs and skills + +This is the only primary end-user product in the Hawk ecosystem. + +### 2. Support engine repos + +These exist to power Hawk and should remain replaceable, testable, and isolated. + +#### `eyrie` + +Purpose: + +- provider execution runtime +- request/response normalization +- streaming, retry, timeout, fallback mechanics + +#### `yaad` + +Purpose: + +- memory +- retrieval +- long-lived context persistence + +#### `tok` + +Purpose: + +- token budgeting +- packing +- truncation +- context shaping + +#### `trace` + +Purpose: + +- trace capture +- replay +- provenance +- audit visibility + +#### `sight` + +Purpose: + +- review findings +- code quality/risk analysis +- normalized review output + +#### `inspect` + +Purpose: + +- verification findings +- checks/assertions normalization +- pass/fail verification output + +### 3. Shared foundation repo + +#### `hawk-core-contracts` + +Purpose: + +- shared neutral contracts +- common findings and severity vocabulary +- events, tools, review, verify, and policy DTOs + +This repo should remain: + +- small +- stable +- implementation-light +- dependency-light + +### 4. Consumer/extension repos + +#### `hawk-sdk-go` + +Purpose: + +- Go integrations for Hawk public surfaces + +#### `hawk-sdk-python` + +Purpose: + +- Python integrations for Hawk public surfaces + +#### `hawk-community-skills` + +Purpose: + +- community skills +- recipes +- extension packs + +These repos should consume Hawk surfaces, not bypass Hawk and talk to engines as a primary path. + +### 5. Out-of-scope repo for Hawk runtime + +#### `graycode-core` + +Current meaning in this workspace: + +- GrayCodeAI website/platform/company repo + +For Hawk architecture decisions: + +- it is not required in Hawk local runtime +- it is not part of Hawk’s core dependency graph +- it can later host company web, cloud, account, billing, or product control-plane concerns + +That means `graycode-core` may matter to GrayCodeAI as a company, but it should not be treated as a Hawk engine. + +## Current vs proposed + +### Current workspace reality + +```text +many repos in one workspace + | + +-- hawk is the primary product repo + +-- support repos also exist as full sibling repos + +-- hawk/external pins copies for integrated development and CI + +-- graycode-core lives nearby but is not a Hawk runtime dependency +``` + +### Proposed steady-state interpretation + +```text +one product + -> hawk + +six support engines + -> eyrie + -> yaad + -> tok + -> trace + -> sight + -> inspect + +one shared foundation + -> hawk-core-contracts + +three extension repos + -> hawk-sdk-go + -> hawk-sdk-python + -> hawk-community-skills + +separate company/platform repos + -> graycode-core and future non-Hawk products +``` + +## Required dependency rules + +### Allowed + +```text +hawk -> eyrie +hawk -> yaad +hawk -> tok +hawk -> trace +hawk -> sight +hawk -> inspect +hawk -> hawk-core-contracts + +engine -> hawk-core-contracts # only when a true cross-repo contract is needed +sdk -> hawk public API/contracts +skills -> hawk plugin/skill API +``` + +### Forbidden + +```text +engine -> engine +sdk -> engine +skills -> engine +engine -> hawk/internal/* +engine -> graycode-core +hawk runtime -> graycode-core +``` + +## Why this is the right shape + +This design gives the best balance of OSS clarity and industry-grade scale: + +- product clarity: users understand they are adopting Hawk, not a bag of unrelated tools +- repo isolation: each engine can evolve, test, and release independently +- low coupling: peer engines do not create a dependency mesh +- better replacement path: a single engine can be rewritten or swapped without collapsing the system +- future cloud readiness: a hosted control plane can be added later without redesigning engine boundaries +- easier multi-product future: Hawk, Lark, and Gitant can later share company/platform layers without polluting Hawk runtime design + +## Future-ready company view + +The future GrayCodeAI portfolio can look like this without changing Hawk’s internal architecture: + +```text +GrayCodeAI +├── Hawk # coding agent product +├── Lark # future product +├── Gitant # future product +└── GrayCode platform/cloud + ├── accounts + ├── billing + ├── hosted control plane + ├── docs/web + └── org/admin services +``` + +That is the right separation: + +- product runtime architecture stays product-local +- company platform concerns stay above products, not inside support engines + +## Final recommendation + +For Hawk, keep exactly this product set: + +- `hawk` +- `eyrie` +- `yaad` +- `tok` +- `trace` +- `sight` +- `inspect` +- `hawk-core-contracts` +- `hawk-sdk-go` +- `hawk-sdk-python` +- `hawk-community-skills` + +Treat `graycode-core` as separate from Hawk runtime architecture. + +Do not merge support engines into one another. +Do not let support engines depend on sibling engines. +Do not make SDKs or skills bypass Hawk. + +That is the cleanest structure for OSS usability, production hardening, and future scale. From 5d16024519959f7b1161d7952d3aca35eb349448 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 11:29:51 +0530 Subject: [PATCH 06/23] docs: add hawk ecosystem summary --- docs/architecture/README.md | 1 + docs/architecture/hawk-ecosystem-summary.md | 218 ++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 docs/architecture/hawk-ecosystem-summary.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index abf3cd5a..abd908c4 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -5,6 +5,7 @@ This directory holds the implementation planning docs for Hawk as a model-agnost Documents: - `hawk-current-vs-proposed.md` - current workspace shape vs target Hawk-centered repo architecture +- `hawk-ecosystem-summary.md` - one-page repo role, dependency, and future cloud summary - `hawk-product-architecture.md` - target architecture and runtime flow - `hawk-repo-roles.md` - role of each Hawk repo in the product ecosystem - `hawk-dependency-rules.md` - import and ownership boundaries diff --git a/docs/architecture/hawk-ecosystem-summary.md b/docs/architecture/hawk-ecosystem-summary.md new file mode 100644 index 00000000..8da03f77 --- /dev/null +++ b/docs/architecture/hawk-ecosystem-summary.md @@ -0,0 +1,218 @@ +# Hawk Ecosystem Summary + +## One-line view + +`hawk` is the product. Everything else in the Hawk ecosystem either powers Hawk, +extends Hawk, or provides shared contracts for Hawk. + +## Final repo map + +```text +top: consumers and extensions + + hawk-sdk-go hawk-sdk-python hawk-community-skills + \ | / + \ | / + +---------------+----------------------+ + | + v + hawk + +middle: support engines + + +---------+---------+---------+---------+---------+---------+ + | | | | | | | + v v v v v v v + eyrie yaad tok trace sight inspect public APIs + +bottom: shared contracts + + hawk-core-contracts +``` + +## Dependency direction + +### Product layer + +- `hawk -> eyrie` +- `hawk -> yaad` +- `hawk -> tok` +- `hawk -> trace` +- `hawk -> sight` +- `hawk -> inspect` +- `hawk -> hawk-core-contracts` + +### Shared-contract layer + +- `engine -> hawk-core-contracts` only when a real cross-repo DTO is required + +### Extension layer + +- `hawk-sdk-go -> hawk` +- `hawk-sdk-python -> hawk` +- `hawk-community-skills -> hawk` + +### Forbidden edges + +- `engine -> engine` +- `sdk -> engine` +- `skills -> engine` +- `engine -> hawk/internal/*` +- `hawk runtime -> graycode-core` + +## Repo-by-repo roles + +| Repo | Layer | Role | Depends on | Must not depend on | +|---|---|---|---|---| +| `hawk` | Product | CLI, orchestration, policy, execution control, public APIs | support engines, `hawk-core-contracts` | sibling company/platform repos in runtime paths | +| `eyrie` | Support engine | provider runtime, model execution, streaming, retries | `hawk-core-contracts` only if needed | `yaad`, `tok`, `trace`, `sight`, `inspect` | +| `yaad` | Support engine | memory, retrieval, persistence of long-lived context | `hawk-core-contracts` only if needed | `eyrie`, `tok`, `trace`, `sight`, `inspect` | +| `tok` | Support engine | token budgeting, packing, truncation, context shaping | `hawk-core-contracts` only if needed | `eyrie`, `yaad`, `trace`, `sight`, `inspect` | +| `trace` | Support engine | trace capture, replay, provenance, audit trail | `hawk-core-contracts` only if needed | `eyrie`, `yaad`, `tok`, `sight`, `inspect` | +| `sight` | Support engine | review findings, code-quality/risk analysis | `hawk-core-contracts` | `eyrie`, `yaad`, `tok`, `trace`, `inspect` | +| `inspect` | Support engine | verification findings, checks, pass/fail validation | `hawk-core-contracts` | `eyrie`, `yaad`, `tok`, `trace`, `sight` | +| `hawk-core-contracts` | Foundation | shared DTOs and vocabulary | leaf module | product logic or engine implementation logic | +| `hawk-sdk-go` | Consumer | Go integrations for Hawk public surfaces | Hawk public API/contracts | support engines directly | +| `hawk-sdk-python` | Consumer | Python integrations for Hawk public surfaces | Hawk public API/contracts | support engines directly | +| `hawk-community-skills` | Consumer | skills, recipes, extension packs | Hawk plugin/skill surfaces | support engines directly | + +## Runtime responsibility split + +### `hawk` + +Owns: + +- the CLI and command UX +- session lifecycle +- approval and permission policy +- tool routing +- provider selection +- engine coordination +- user-visible product APIs + +### `eyrie` + +Owns: + +- provider adapters +- request/response normalization +- streaming transport +- retry and timeout logic + +### `yaad` + +Owns: + +- memory retrieval +- memory persistence +- summarization support for long-lived context + +### `tok` + +Owns: + +- token estimation +- context packing +- truncation strategy +- prompt-ready context shaping + +### `trace` + +Owns: + +- event capture +- replay artifacts +- provenance/session evidence +- audit-grade traceability + +### `sight` + +Owns: + +- review heuristics +- issue/finding generation +- normalized review output at the Hawk boundary + +### `inspect` + +Owns: + +- verification heuristics +- test/assertion result normalization +- final pass/fail output at the Hawk boundary + +## Why all support engines are at the same level + +They are all peers because Hawk is the orchestrator. + +That means: + +- `eyrie` is not "above" `sight` or `inspect` +- `sight` is not "below" execution +- `inspect` is not a child of review +- `yaad` and `tok` are not shared utility packages for other engines + +Hawk calls whichever engine is needed for a given turn. + +## Future cloud position + +Future GrayCodeAI cloud or platform work should sit above products, not inside +the support-engine mesh. + +Correct future shape: + +```text +GrayCodeAI company/platform +├── web/docs +├── accounts +├── billing +├── hosted control plane +├── org/admin services +└── product gateways + ├── Hawk + ├── Lark + └── Gitant +``` + +For Hawk specifically: + +- local CLI runtime should still work without GrayCode cloud +- cloud can add hosted sessions, org policy, billing, identity, telemetry, or remote execution later +- none of that should force `eyrie`, `yaad`, `tok`, `trace`, `sight`, or `inspect` to depend on platform repos + +## What belongs in `graycode-core` + +Good candidates: + +- company website +- account system +- billing +- control plane +- docs portal +- admin and org management + +Not a good candidate: + +- Hawk runtime-critical engine logic +- direct support-engine dependencies + +## Practical rule set + +When adding new work: + +1. If it is user-facing product behavior, put it in `hawk`. +2. If it is specialized execution/memory/context/review/verify/trace capability, put it in the matching support engine. +3. If multiple repos need the same DTO or vocabulary, move that contract into `hawk-core-contracts`. +4. If it is an integration surface for external developers, put it in an SDK or skill repo. +5. If it is company platform or cloud control-plane work, keep it outside Hawk runtime repos. + +## Final recommendation + +Keep this exact model: + +- one primary product repo: `hawk` +- six peer support engines: `eyrie`, `yaad`, `tok`, `trace`, `sight`, `inspect` +- one shared contract repo: `hawk-core-contracts` +- three extension repos: `hawk-sdk-go`, `hawk-sdk-python`, `hawk-community-skills` + +This is the cleanest shape for OSS clarity, production hardening, and future scale. From 79f9f7479ecea424406b774836a707afc34a5d47 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 11:39:09 +0530 Subject: [PATCH 07/23] fix: harden hawk tool and daemon guardrails --- cmd/mentions.go | 11 +++++ cmd/mentions_test.go | 39 +++++++++++++++ internal/daemon/gateway.go | 5 +- internal/daemon/routes_review.go | 4 ++ internal/engine/persistence_service.go | 20 ++++++-- internal/engine/session_h3_h4_test.go | 29 +++++++++++ internal/hooks/decision.go | 31 ++---------- internal/hooks/decision_test.go | 19 +++++++ internal/permissions/guardian.go | 5 ++ internal/resilience/circuit.go | 19 +++++-- internal/resilience/circuit_test.go | 21 ++++++++ internal/tool/agent.go | 28 +++++++++++ internal/tool/agent_limits_test.go | 68 ++++++++++++++++++++++++++ internal/tool/cron.go | 8 +++ internal/tool/cron_limits_test.go | 22 +++++++++ internal/tool/transaction.go | 8 +++ internal/tool/transaction_test.go | 27 ++++++++++ 17 files changed, 327 insertions(+), 37 deletions(-) create mode 100644 cmd/mentions_test.go create mode 100644 internal/tool/agent_limits_test.go create mode 100644 internal/tool/cron_limits_test.go diff --git a/cmd/mentions.go b/cmd/mentions.go index 0529d587..f129dc83 100644 --- a/cmd/mentions.go +++ b/cmd/mentions.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/GrayCodeAI/hawk/internal/mention" + "github.com/GrayCodeAI/hawk/internal/tool" ) // handleMentions processes @-prefixed file mentions in user input. @@ -29,6 +30,16 @@ func (m *chatModel) handleMentions(text string) string { // Read each mentioned file and append to session context. var contextParts []string for _, path := range result.MentionedFiles { + // Enforce the same sensitive-path block the Read/Write tools use. + // Otherwise `@~/.ssh/id_rsa` or `@/etc/passwd` would sidestep the + // security boundary and inject secrets into the LLM context. + if reason := tool.IsSensitivePath(path); reason != "" { + m.messages = append(m.messages, displayMsg{ + role: "error", + content: fmt.Sprintf("Refused to read @%s: %s", path, reason), + }) + continue + } content, err := os.ReadFile(path) if err != nil { m.messages = append(m.messages, displayMsg{ diff --git a/cmd/mentions_test.go b/cmd/mentions_test.go new file mode 100644 index 00000000..63802f00 --- /dev/null +++ b/cmd/mentions_test.go @@ -0,0 +1,39 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/GrayCodeAI/hawk/internal/engine" +) + +func TestHandleMentions_BlocksSensitivePaths(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + sensitive := filepath.Join(dir, ".env") + if err := os.WriteFile(sensitive, []byte("SECRET=top-secret"), 0o600); err != nil { + t.Fatalf("write sensitive file: %v", err) + } + + m := &chatModel{session: &engine.Session{}} + got := m.handleMentions(`explain @"` + sensitive + `" please`) + + if strings.Contains(got, sensitive) { + t.Fatalf("cleaned input still contains mention: %q", got) + } + if len(m.messages) != 1 { + t.Fatalf("expected 1 UI message, got %d", len(m.messages)) + } + if m.messages[0].role != "error" { + t.Fatalf("expected error message, got role %q", m.messages[0].role) + } + if !strings.Contains(m.messages[0].content, "Refused to read") { + t.Fatalf("expected refusal message, got %q", m.messages[0].content) + } + if !strings.Contains(m.messages[0].content, ".env") { + t.Fatalf("expected blocked path detail, got %q", m.messages[0].content) + } +} diff --git a/internal/daemon/gateway.go b/internal/daemon/gateway.go index 1773d7ca..8dcd004a 100644 --- a/internal/daemon/gateway.go +++ b/internal/daemon/gateway.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "crypto/subtle" "encoding/json" "io" "log/slog" @@ -147,7 +148,9 @@ func (a *authorizer) tryPair(senderID, text string) (isPair, ok bool) { if len(fields) > 1 { supplied = fields[1] } - if supplied != a.pairingCode { + // Constant-time compare so the pairing code is not exposed to a timing + // oracle. + if subtle.ConstantTimeCompare([]byte(supplied), []byte(a.pairingCode)) != 1 { return true, false } a.allow[senderID] = struct{}{} diff --git a/internal/daemon/routes_review.go b/internal/daemon/routes_review.go index bede4640..51fce66a 100644 --- a/internal/daemon/routes_review.go +++ b/internal/daemon/routes_review.go @@ -51,6 +51,10 @@ func (s *Server) handleReview(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "concerns must not start with '--'"}) return } + if strings.HasPrefix(req.Model, "--") { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "model must not start with '--'"}) + return + } // Trigger review asynchronously via hawk review run. go func() { diff --git a/internal/engine/persistence_service.go b/internal/engine/persistence_service.go index 0ee16201..ef6f2d40 100644 --- a/internal/engine/persistence_service.go +++ b/internal/engine/persistence_service.go @@ -56,7 +56,11 @@ func NewPersistenceService(log *logger.Logger) *PersistenceService { func (s *PersistenceService) Messages() []types.EyrieMessage { s.mu.RLock() defer s.mu.RUnlock() - raw := s.RawMessages() + // Read s.messages directly; the lock is already held. Calling + // RawMessages() here would recursively RLock and can deadlock if a + // writer arrives between the two read locks (Go's RWMutex forbids + // recursive read-locking). + raw := s.messages out := make([]types.EyrieMessage, len(raw)) copy(out, raw) return out @@ -194,23 +198,29 @@ func (s *PersistenceService) SetSystem(sys string) { func (s *PersistenceService) MessageCount() int { s.mu.RLock() defer s.mu.RUnlock() - return len(s.RawMessages()) + // Direct field read; lock already held (avoid recursive RLock). + return len(s.messages) } // RemoveLastExchange removes the last (assistant, user) pair. func (s *PersistenceService) RemoveLastExchange() { s.mu.Lock() defer s.mu.Unlock() - if len(s.RawMessages()) < 2 { + // Operate on s.messages directly: the write lock is held, so calling + // the RawMessages()/SetRawMessages() accessors (which lock again) would + // deadlock. + if len(s.messages) < 2 { return } - s.SetRawMessages(s.RawMessages()[:len(s.RawMessages())-2]) + s.messages = s.messages[:len(s.messages)-2] } // LoadMessages replaces the transcript with a fresh slice. func (s *PersistenceService) LoadMessages(msgs []types.EyrieMessage) { s.mu.Lock() - s.SetRawMessages(msgs) + // Assign directly; the lock is held, so SetRawMessages() (which locks + // again) would deadlock on a recursive write lock. + s.messages = msgs s.mu.Unlock() } diff --git a/internal/engine/session_h3_h4_test.go b/internal/engine/session_h3_h4_test.go index f757563d..d9b4cca6 100644 --- a/internal/engine/session_h3_h4_test.go +++ b/internal/engine/session_h3_h4_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/GrayCodeAI/eyrie/storage" + "github.com/GrayCodeAI/hawk/internal/types" ) // TestSession_SetConvoDAG_DualWrite is a regression guard for the @@ -119,3 +120,31 @@ func TestPersistenceService_AddUserWithImage_DataURL(t *testing.T) { t.Errorf("content = %q, want 'look at this'", msgs[0].Content) } } + +func TestPersistenceService_RemoveLastExchangeAndCount(t *testing.T) { + t.Parallel() + + ps := NewPersistenceService(nil) + ps.LoadMessages([]types.EyrieMessage{ + {Role: "user", Content: "u1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "u2"}, + {Role: "assistant", Content: "a2"}, + }) + + if got := ps.MessageCount(); got != 4 { + t.Fatalf("MessageCount() = %d, want 4", got) + } + + ps.RemoveLastExchange() + msgs := ps.Messages() + if len(msgs) != 2 { + t.Fatalf("len(Messages()) = %d, want 2", len(msgs)) + } + if msgs[0].Content != "u1" || msgs[1].Content != "a1" { + t.Fatalf("unexpected remaining messages: %#v", msgs) + } + if got := ps.MessageCount(); got != 2 { + t.Fatalf("MessageCount() after remove = %d, want 2", got) + } +} diff --git a/internal/hooks/decision.go b/internal/hooks/decision.go index 1aa13342..d79dbd4d 100644 --- a/internal/hooks/decision.go +++ b/internal/hooks/decision.go @@ -130,33 +130,10 @@ func ExecuteDecisionHooks(event string, data map[string]interface{}) *HookDecisi continue } - // Execute with fail-open: catch panics and log errors - func() { - defer func() { - if r := recover(); r != nil { - slog.Warn("decision hook panicked (fail-open)", - "hook", h.Config.Name, - "event", event, - "tool", toolName, - "panic", r) - } - }() - if decision := h.Fn(event, data); decision != nil { - // Return the decision (caller will handle it) - // We can't return from inside the closure, so we use a wrapper - } - }() - - // Execute again to get the result (the closure above was for panic recovery) - decision := func() *HookDecision { - defer func() *HookDecision { - if r := recover(); r != nil { - return nil - } - return nil - }() - return h.Fn(event, data) - }() + // Execute exactly once with fail-open panic recovery. The previous + // implementation invoked h.Fn twice (once in a discard closure, once + // to capture the result), double-firing side-effecting hooks. + decision := safeExecuteHook(h, event, data, toolName) if decision != nil { return decision } diff --git a/internal/hooks/decision_test.go b/internal/hooks/decision_test.go index 4f83a9b6..048fe230 100644 --- a/internal/hooks/decision_test.go +++ b/internal/hooks/decision_test.go @@ -92,3 +92,22 @@ func TestDecisionHookNilWhenEmpty(t *testing.T) { t.Fatalf("expected nil when no hooks registered, got %+v", decision) } } + +func TestExecuteDecisionHooks_ExecutesHookOnlyOnce(t *testing.T) { + ResetDecisionHooks() + defer ResetDecisionHooks() + + calls := 0 + RegisterDecisionHook(func(event string, data map[string]interface{}) *HookDecision { + calls++ + return &HookDecision{Action: "allow", Reason: "once"} + }) + + decision := ExecuteDecisionHooks("any_event", nil) + if decision == nil { + t.Fatal("expected decision") + } + if calls != 1 { + t.Fatalf("hook called %d times, want 1", calls) + } +} diff --git a/internal/permissions/guardian.go b/internal/permissions/guardian.go index 8f2d4d5a..8dbda6ad 100644 --- a/internal/permissions/guardian.go +++ b/internal/permissions/guardian.go @@ -299,6 +299,11 @@ func parseGuardianResponse(response string) (*GuardianDecision, error) { // Validate confidence range. Models occasionally emit // out-of-range values; clamp rather than reject so the rest of // the decision (allowed/reason) still flows through. + // NaN fails both < 0 and > 1, so handle it first (self-comparison is + // the standard NaN test) and treat it as lowest confidence — fail safe. + if decision.Confidence != decision.Confidence { //nolint:staticcheck // NaN check + decision.Confidence = 0 + } if decision.Confidence < 0 { decision.Confidence = 0 } diff --git a/internal/resilience/circuit.go b/internal/resilience/circuit.go index 1300cd57..0e5ce249 100644 --- a/internal/resilience/circuit.go +++ b/internal/resilience/circuit.go @@ -38,6 +38,9 @@ type Breaker struct { failures int lastFailureTime time.Time successCount int + // halfOpenCalls counts probe calls dispatched in the half-open state so + // a burst of traffic cannot all slip through to a recovering service. + halfOpenCalls int maxFailures int timeout time.Duration @@ -88,15 +91,22 @@ func (b *Breaker) Allow() bool { if time.Since(b.lastFailureTime) > b.timeout { b.state = HalfOpen b.successCount = 0 + b.halfOpenCalls = 1 // this first probe counts against the budget return true } return false } - if b.state == HalfOpen && b.successCount >= b.halfOpenMaxCalls { - b.state = Closed - b.failures = 0 - b.successCount = 0 + if b.state == HalfOpen { + // Bound the number of in-flight probe calls so a traffic burst does + // not all slip through to a service that is still recovering. Once the + // probe budget is spent, fail fast until RecordSuccess closes the + // circuit or RecordFailure reopens it. + if b.halfOpenCalls >= b.halfOpenMaxCalls { + return false + } + b.halfOpenCalls++ + return true } return true @@ -113,6 +123,7 @@ func (b *Breaker) RecordSuccess() { b.state = Closed b.failures = 0 b.successCount = 0 + b.halfOpenCalls = 0 } return } diff --git a/internal/resilience/circuit_test.go b/internal/resilience/circuit_test.go index 75645540..a1d9bbfe 100644 --- a/internal/resilience/circuit_test.go +++ b/internal/resilience/circuit_test.go @@ -122,6 +122,27 @@ func TestBreakerHalfOpenFailure(t *testing.T) { } } +func TestBreakerHalfOpenProbeBudget(t *testing.T) { + b := New(Config{MaxFailures: 1, Timeout: 50 * time.Millisecond, HalfOpenMaxCalls: 2}) + + _ = b.Call(func() error { return errors.New("fail") }) + if b.State() != Open { + t.Fatalf("expected open state, got %s", b.State()) + } + + time.Sleep(100 * time.Millisecond) + + if !b.Allow() { + t.Fatal("expected first half-open probe to be allowed") + } + if !b.Allow() { + t.Fatal("expected second half-open probe to be allowed") + } + if b.Allow() { + t.Fatal("expected probe budget to block additional half-open calls") + } +} + func TestCallWithResult(t *testing.T) { b := New(Config{MaxFailures: 3}) diff --git a/internal/tool/agent.go b/internal/tool/agent.go index 5e78b641..d21a3469 100644 --- a/internal/tool/agent.go +++ b/internal/tool/agent.go @@ -8,6 +8,20 @@ import ( "time" ) +const ( + // maxAgentPromptBytes caps a sub-agent prompt so a single LLM-emitted + // tool call cannot balloon memory with an enormous prompt. + maxAgentPromptBytes = 256 * 1024 // 256KB + // maxParallelAgentTasks caps how many sub-agents a single MultiAgent call + // will fan out to, bounding goroutine and memory growth from an + // LLM-supplied tasks array. + maxParallelAgentTasks = 32 + // maxConcurrentAgentTasks bounds how many sub-agents run at once in the + // synchronous MultiAgent path so we don't fire dozens of LLM calls + // simultaneously. + maxConcurrentAgentTasks = 8 +) + type AgentTool struct{} func (AgentTool) Name() string { return "Agent" } @@ -49,6 +63,9 @@ func (AgentTool) Execute(ctx context.Context, input json.RawMessage) (string, er if err := json.Unmarshal(input, &p); err != nil { return "", err } + if len(p.Prompt) > maxAgentPromptBytes { + return "", fmt.Errorf("agent prompt too large: %d bytes (max %d)", len(p.Prompt), maxAgentPromptBytes) + } tc := GetToolContext(ctx) if tc == nil || tc.AgentSpawnFn == nil { return "", fmt.Errorf("agent spawning not configured") @@ -161,6 +178,14 @@ func (MultiAgentTool) Execute(ctx context.Context, input json.RawMessage) (strin if err := json.Unmarshal(input, &p); err != nil { return "", err } + if len(p.Tasks) > maxParallelAgentTasks { + return "", fmt.Errorf("too many tasks: %d (max %d)", len(p.Tasks), maxParallelAgentTasks) + } + for _, task := range p.Tasks { + if len(task) > maxAgentPromptBytes { + return "", fmt.Errorf("task prompt too large: %d bytes (max %d)", len(task), maxAgentPromptBytes) + } + } tc := GetToolContext(ctx) if tc == nil || tc.AgentSpawnFn == nil { return "", fmt.Errorf("agent spawning not configured") @@ -187,10 +212,13 @@ func (MultiAgentTool) Execute(ctx context.Context, input json.RawMessage) (strin } results := make([]result, len(p.Tasks)) var wg sync.WaitGroup + sem := make(chan struct{}, maxConcurrentAgentTasks) for i, task := range p.Tasks { wg.Add(1) go func(idx int, prompt string) { defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() out, err := tc.AgentSpawnFn(ctx, prompt) results[idx] = result{idx: idx, output: out, err: err} }(i, task) diff --git a/internal/tool/agent_limits_test.go b/internal/tool/agent_limits_test.go new file mode 100644 index 00000000..e810024d --- /dev/null +++ b/internal/tool/agent_limits_test.go @@ -0,0 +1,68 @@ +package tool + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestAgentTool_PromptTooLarge(t *testing.T) { + t.Parallel() + + ctx := WithToolContext(context.Background(), &ToolContext{ + AgentSpawnFn: func(_ context.Context, prompt string) (string, error) { + return prompt, nil + }, + }) + oversized := strings.Repeat("a", maxAgentPromptBytes+1) + + _, err := AgentTool{}.Execute(ctx, mustJSONRaw(t, map[string]any{"prompt": oversized})) + if err == nil || !strings.Contains(err.Error(), "agent prompt too large") { + t.Fatalf("expected prompt-too-large error, got %v", err) + } +} + +func TestMultiAgentTool_TooManyTasks(t *testing.T) { + t.Parallel() + + ctx := WithToolContext(context.Background(), &ToolContext{ + AgentSpawnFn: func(_ context.Context, prompt string) (string, error) { + return prompt, nil + }, + }) + tasks := make([]string, maxParallelAgentTasks+1) + for i := range tasks { + tasks[i] = "task" + } + + _, err := MultiAgentTool{}.Execute(ctx, mustJSONRaw(t, map[string]any{"tasks": tasks})) + if err == nil || !strings.Contains(err.Error(), "too many tasks") { + t.Fatalf("expected too-many-tasks error, got %v", err) + } +} + +func TestMultiAgentTool_TaskPromptTooLarge(t *testing.T) { + t.Parallel() + + ctx := WithToolContext(context.Background(), &ToolContext{ + AgentSpawnFn: func(_ context.Context, prompt string) (string, error) { + return prompt, nil + }, + }) + oversized := strings.Repeat("a", maxAgentPromptBytes+1) + + _, err := MultiAgentTool{}.Execute(ctx, mustJSONRaw(t, map[string]any{"tasks": []string{oversized}})) + if err == nil || !strings.Contains(err.Error(), "task prompt too large") { + t.Fatalf("expected task-prompt-too-large error, got %v", err) + } +} + +func mustJSONRaw(t *testing.T, v any) json.RawMessage { + t.Helper() + data, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + return data +} diff --git a/internal/tool/cron.go b/internal/tool/cron.go index a08ec342..ce7d20c5 100644 --- a/internal/tool/cron.go +++ b/internal/tool/cron.go @@ -31,6 +31,11 @@ type CronScheduler struct { next int } +// maxCronJobs caps the number of concurrently scheduled jobs so an LLM +// cannot create unbounded jobs in a tight loop (each Create stores an entry +// in the in-memory map). +const maxCronJobs = 256 + var globalCronScheduler = &CronScheduler{jobs: make(map[string]*CronJob)} func GetCronScheduler() *CronScheduler { return globalCronScheduler } @@ -43,6 +48,9 @@ func (s *CronScheduler) Create(schedule, prompt string, recurring, durable bool) s.mu.Lock() defer s.mu.Unlock() + if len(s.jobs) >= maxCronJobs { + return nil, fmt.Errorf("cron job limit reached (%d); delete existing jobs first", maxCronJobs) + } s.next++ id := fmt.Sprintf("cron_%d", s.next) diff --git a/internal/tool/cron_limits_test.go b/internal/tool/cron_limits_test.go new file mode 100644 index 00000000..44099f5b --- /dev/null +++ b/internal/tool/cron_limits_test.go @@ -0,0 +1,22 @@ +package tool + +import ( + "strings" + "testing" +) + +func TestCronScheduler_CreateLimit(t *testing.T) { + t.Parallel() + + s := &CronScheduler{jobs: make(map[string]*CronJob)} + for i := 0; i < maxCronJobs; i++ { + if _, err := s.Create("*/5 * * * *", "task", true, false); err != nil { + t.Fatalf("create job %d: %v", i, err) + } + } + + _, err := s.Create("*/5 * * * *", "overflow", true, false) + if err == nil || !strings.Contains(err.Error(), "cron job limit reached") { + t.Fatalf("expected cron limit error, got %v", err) + } +} diff --git a/internal/tool/transaction.go b/internal/tool/transaction.go index 31d06524..4860417a 100644 --- a/internal/tool/transaction.go +++ b/internal/tool/transaction.go @@ -480,6 +480,14 @@ func (TransactionTool) Execute(ctx context.Context, input json.RawMessage) (stri if content == "" && op.NewContent != "" { content = op.NewContent } + // Scan new file content for credentials, mirroring the FileWrite / + // FileEdit guard. Without this, AtomicMultiEdit is a bypass for the + // anti-exfil check: an LLM could persist a stolen API key to disk. + if op.Type == "create" || op.Type == "modify" { + if cred := DetectCredentials(content); cred != "" { + return "", fmt.Errorf("content for %s contains a credential (%s) — refusing to write", op.Path, cred) + } + } mode := os.FileMode(0o644) if op.Mode != 0 { mode = os.FileMode(op.Mode) diff --git a/internal/tool/transaction_test.go b/internal/tool/transaction_test.go index 47192630..b80bce66 100644 --- a/internal/tool/transaction_test.go +++ b/internal/tool/transaction_test.go @@ -735,6 +735,33 @@ func TestTransactionTool_ExecuteEmptyOperations(t *testing.T) { } } +func TestTransactionTool_RejectsCredentialContent(t *testing.T) { + dir := t.TempDir() + createPath := filepath.Join(dir, "secrets.txt") + + input := transactionInput{ + Operations: []struct { + Type string `json:"type"` + Path string `json:"path"` + OldPath string `json:"old_path,omitempty"` + Content string `json:"content,omitempty"` + NewContent string `json:"new_content,omitempty"` + Mode int `json:"mode,omitempty"` + }{ + {Type: "create", Path: createPath, Content: "token=sk-abcdefghijklmnopqrstuvwxyz"}, + }, + } + + data, _ := json.Marshal(input) + _, err := (TransactionTool{}).Execute(context.Background(), data) + if err == nil || !strings.Contains(err.Error(), "contains a credential") { + t.Fatalf("expected credential rejection, got %v", err) + } + if _, statErr := os.Stat(createPath); !os.IsNotExist(statErr) { + t.Fatalf("expected file not to be created, stat err = %v", statErr) + } +} + func TestTransaction_RenameRequiresOldPath(t *testing.T) { dir := t.TempDir() tx := NewTransaction() From 272ec5b4a4b3b6ba08e65972013b172307ad6572 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 14:19:01 +0530 Subject: [PATCH 08/23] fix: harden plugin clone and cover persistence deadlock --- .../persistence_service_deadlock_test.go | 40 +++++++++++++++++++ internal/plugin/dynamic.go | 4 +- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 internal/engine/persistence_service_deadlock_test.go diff --git a/internal/engine/persistence_service_deadlock_test.go b/internal/engine/persistence_service_deadlock_test.go new file mode 100644 index 00000000..0fc3d62a --- /dev/null +++ b/internal/engine/persistence_service_deadlock_test.go @@ -0,0 +1,40 @@ +package engine + +import ( + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/types" +) + +// TestPersistenceServiceNoRecursiveLock guards against the recursive-lock +// deadlocks that previously existed in Messages, MessageCount, +// RemoveLastExchange, and LoadMessages. Each held s.mu and then called an +// accessor (RawMessages/SetRawMessages) that tried to take the same lock +// again — RemoveLastExchange and LoadMessages would hang unconditionally. +func TestPersistenceServiceNoRecursiveLock(t *testing.T) { + done := make(chan struct{}) + go func() { + defer close(done) + s := NewPersistenceService(nil) + s.LoadMessages([]types.EyrieMessage{ + {Role: "user", Content: "a"}, + {Role: "assistant", Content: "b"}, + }) + if got := s.MessageCount(); got != 2 { + t.Errorf("MessageCount = %d, want 2", got) + } + if got := len(s.Messages()); got != 2 { + t.Errorf("Messages len = %d, want 2", got) + } + s.RemoveLastExchange() + if got := s.MessageCount(); got != 0 { + t.Errorf("after RemoveLastExchange MessageCount = %d, want 0", got) + } + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("PersistenceService deadlocked (recursive lock acquisition)") + } +} diff --git a/internal/plugin/dynamic.go b/internal/plugin/dynamic.go index 4a0d6c60..bfcd92f2 100644 --- a/internal/plugin/dynamic.go +++ b/internal/plugin/dynamic.go @@ -401,7 +401,9 @@ func (dm *DynamicPluginManager) InstallFromGitHub(repo string) error { url = "https://github.com/" + repo + ".git" } - cmd := exec.CommandContext(context.Background(), "git", "clone", "--depth", "1", "--single-branch", url, pluginDir) + // "--" terminates option parsing so a url is never interpreted as a git + // flag (defense-in-depth; isFullURL already forces an http(s):// prefix). + cmd := exec.CommandContext(context.Background(), "git", "clone", "--depth", "1", "--single-branch", "--", url, pluginDir) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git clone failed: %w\n%s", err, string(out)) From 1f90ad44c31c1f4eb8f455369ebc7aa4f3c33627 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 14:31:13 +0530 Subject: [PATCH 09/23] docs: define shared types retirement gate --- Makefile | 3 + docs/architecture/README.md | 1 + .../hawk-contract-migration-inventory.md | 6 + .../hawk-shared-types-removal-plan.md | 112 ++++++++++++++++++ .../plans/hawk-contracts-migration-backlog.md | 6 + ...check-shared-types-retirement-readiness.sh | 52 ++++++++ shared/types/doc.go | 19 +++ 7 files changed, 199 insertions(+) create mode 100644 docs/architecture/hawk-shared-types-removal-plan.md create mode 100644 scripts/check-shared-types-retirement-readiness.sh create mode 100644 shared/types/doc.go diff --git a/Makefile b/Makefile index 328122dd..49df44b7 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,9 @@ vet: ## Run go vet. contracts-guard: ## Fail on new imports of hawk/shared/types outside compatibility paths. bash ./scripts/check-shared-types-imports.sh +shared-types-retirement-guard: ## Verify the local ecosystem no longer imports hawk/shared/types. + bash ./scripts/check-shared-types-retirement-readiness.sh + ecosystem-guard: ## Fail if external ecosystem repos import hawk/internal or deprecated hawk/shared/types. bash ./scripts/check-ecosystem-boundaries.sh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index abd908c4..0923715b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,6 +9,7 @@ Documents: - `hawk-product-architecture.md` - target architecture and runtime flow - `hawk-repo-roles.md` - role of each Hawk repo in the product ecosystem - `hawk-dependency-rules.md` - import and ownership boundaries +- `hawk-shared-types-removal-plan.md` - explicit retirement gate for the deprecated shared/types shim - `hawk-core-contracts-spec.md` - shared contracts layer and current status - `hawk-provider-abstraction.md` - provider/runtime abstraction design - `hawk-review-verify-lifecycle.md` - review and verification lifecycle diff --git a/docs/architecture/hawk-contract-migration-inventory.md b/docs/architecture/hawk-contract-migration-inventory.md index 12cc9a6d..3f566173 100644 --- a/docs/architecture/hawk-contract-migration-inventory.md +++ b/docs/architecture/hawk-contract-migration-inventory.md @@ -190,3 +190,9 @@ Update docs that currently describe `hawk/shared/types` as the cross-repo API. ### Step 6 Deprecate `hawk/shared/types` after all external consumers migrate. In progress. + +Current status: + +- local ecosystem migration is complete +- deletion is still gated on downstream compatibility outside this workspace +- see `hawk-shared-types-removal-plan.md` diff --git a/docs/architecture/hawk-shared-types-removal-plan.md b/docs/architecture/hawk-shared-types-removal-plan.md new file mode 100644 index 00000000..a2438f79 --- /dev/null +++ b/docs/architecture/hawk-shared-types-removal-plan.md @@ -0,0 +1,112 @@ +# Hawk Shared Types Removal Plan + +## Purpose + +This document defines the only safe path to remove: + +- `github.com/GrayCodeAI/hawk/shared/types` + +The key constraint is simple: + +- local migration is complete +- external compatibility is not automatically knowable from this workspace + +That means removal is a release decision, not just a code cleanup. + +## Current state + +Local reality today: + +- Hawk production code does not import `hawk/shared/types` +- support repos in this workspace do not import `hawk/shared/types` +- consumer repos in this workspace do not import `hawk/shared/types` +- the package now exists only as a compatibility shim + +What is still unknown: + +- whether downstream users outside this workspace still import it + +## Decision rule + +Do not delete `hawk/shared/types` until both are true: + +1. local retirement readiness passes +2. external compatibility risk is explicitly accepted + +## Local retirement readiness + +The local readiness check is now executable: + +```bash +cd hawk +bash ./scripts/check-shared-types-retirement-readiness.sh +``` + +Passing this check means: + +- no active local ecosystem repo still imports `github.com/GrayCodeAI/hawk/shared/types` + +It does **not** mean deletion is automatically safe for external users. + +## External compatibility gate + +Before deletion, complete this checklist: + +1. Search GitHub code search, internal consumers, and release notes history for `github.com/GrayCodeAI/hawk/shared/types`. +2. Confirm whether any public or internal downstream repos still import it. +3. If yes, migrate them first to `github.com/GrayCodeAI/hawk-core-contracts/types`. +4. Publish a release note that `hawk/shared/types` is deprecated and scheduled for removal. +5. Leave at least one visible release window for downstream migration. +6. Delete the package only in the next intentional breaking-change release. + +## Recommended removal sequence + +### Phase A: present state + +- keep the package +- keep the deprecation comments +- keep blocking new imports +- keep the retirement readiness script green + +### Phase B: release warning + +- publish changelog note +- mark the removal target version/date +- notify downstream maintainers if known + +### Phase C: breaking removal + +Delete: + +- `shared/types/doc.go` +- `shared/types/severity.go` +- `shared/types/finding.go` +- `shared/types/severity_test.go` +- `shared/types/finding_test.go` + +Then update: + +- docs that still describe it as a compatibility shim +- migration backlog status +- any CI/tests that assume the shim still exists + +## What “done” means + +This migration is fully done only when: + +- `hawk-core-contracts/types` is the sole shared-type source of truth +- `hawk/shared/types` no longer exists +- no local or known external consumer depends on the shim + +## Honest status + +Today: + +- local migration: complete +- local enforcement: complete +- removal safety for external consumers: not yet proven + +So the correct current status is: + +- ready for retirement +- not yet ready for blind deletion diff --git a/docs/plans/hawk-contracts-migration-backlog.md b/docs/plans/hawk-contracts-migration-backlog.md index 8801d64b..9ea59b4f 100644 --- a/docs/plans/hawk-contracts-migration-backlog.md +++ b/docs/plans/hawk-contracts-migration-backlog.md @@ -144,6 +144,12 @@ Only after: Until then it stays as a compatibility shim. +Current status: + +- local workspace is ready for retirement +- external downstream compatibility is not yet proven from this repo alone +- removal should happen only as an explicit breaking-change release step + ## Non-goals Do not do these without a separate decision: diff --git a/scripts/check-shared-types-retirement-readiness.sh b/scripts/check-shared-types-retirement-readiness.sh new file mode 100644 index 00000000..17f0af7a --- /dev/null +++ b/scripts/check-shared-types-retirement-readiness.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +violations="" + +check_repo() { + local dir="$1" + if [[ ! -d "$dir" ]]; then + return + fi + local hits + hits="$( + grep -RInE --include='*.go' '"github\.com/GrayCodeAI/hawk/shared/types"' "$dir" | grep -v 'internal/testaudit/' || true + )" + if [[ -n "$hits" ]]; then + violations+="${hits}"$'\n' + fi +} + +# Compatibility package self-tests are allowed until the package is removed. +for repo in \ + cmd \ + internal \ + external/eyrie \ + external/yaad \ + external/tok \ + external/trace \ + external/sight \ + external/inspect \ + ../eyrie \ + ../yaad \ + ../tok \ + ../trace \ + ../sight \ + ../inspect \ + ../hawk-sdk-go +do + check_repo "$repo" +done + +if [[ -n "${violations}" ]]; then + echo "hawk/shared/types is not ready for retirement; active imports remain:" + echo "${violations}" + echo + echo "migrate remaining imports to github.com/GrayCodeAI/hawk-core-contracts/types before deleting hawk/shared/types" + exit 1 +fi + +echo "shared/types retirement readiness passed for the local ecosystem" diff --git a/shared/types/doc.go b/shared/types/doc.go new file mode 100644 index 00000000..74ddfc06 --- /dev/null +++ b/shared/types/doc.go @@ -0,0 +1,19 @@ +// Package types is a deprecated compatibility layer for pre-contracts Hawk +// ecosystem consumers. +// +// Migration target: +// +// github.com/GrayCodeAI/hawk-core-contracts/types +// +// Status: +// +// - The local Hawk ecosystem has already migrated off this package. +// - The package remains only to avoid a blind breaking change for downstream +// consumers outside this workspace. +// - New code must not import this package. +// +// Removal policy: +// +// Delete this package only after the retirement checklist in +// docs/architecture/hawk-shared-types-removal-plan.md is satisfied. +package types From d50fe045d3b1bdcd9d511e17c227d64c7316fc68 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 14:40:22 +0530 Subject: [PATCH 10/23] refactor: remove legacy shared types shim --- AGENTS.md | 9 +- CHANGELOG.md | 4 +- Makefile | 7 +- README.md | 2 +- docs/architecture.md | 4 +- docs/architecture/README.md | 1 - .../hawk-contract-migration-inventory.md | 25 +- docs/architecture/hawk-dependency-rules.md | 9 +- .../architecture/hawk-product-architecture.md | 9 +- .../hawk-shared-types-removal-plan.md | 112 --------- .../plans/hawk-contracts-migration-backlog.md | 21 +- internal/testaudit/audit_test.go | 9 +- internal/testaudit/docs_audit_test.go | 6 +- scripts/check-ecosystem-boundaries.sh | 2 +- scripts/check-shared-types-imports.sh | 6 +- ...check-shared-types-retirement-readiness.sh | 52 ---- shared/types/doc.go | 19 -- shared/types/finding.go | 25 -- shared/types/finding_test.go | 225 ------------------ shared/types/severity.go | 42 ---- shared/types/severity_test.go | 27 --- 21 files changed, 47 insertions(+), 569 deletions(-) delete mode 100644 docs/architecture/hawk-shared-types-removal-plan.md delete mode 100644 scripts/check-shared-types-retirement-readiness.sh delete mode 100644 shared/types/doc.go delete mode 100644 shared/types/finding.go delete mode 100644 shared/types/finding_test.go delete mode 100644 shared/types/severity.go delete mode 100644 shared/types/severity_test.go diff --git a/AGENTS.md b/AGENTS.md index 1fae5a5a..dd5a55dd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,7 +62,6 @@ hawk/ │ ├── daemon/ # Background HTTP/SSE server │ ├── resilience/ # Circuit breaker, rate limiting, health checks │ └── feature/ # Eval, fingerprint, scaffolding -├── shared/types/ # Deprecated compatibility layer; migrate to hawk-core-contracts ├── docs/ # Architecture docs, research notes └── testdata/ # Test fixtures ``` @@ -79,8 +78,8 @@ hawk/ never talks to LLM APIs directly, and production code should go through `internal/types` transport adapters instead of importing `eyrie/client`. - **Shared contracts:** cross-repo types now live in `hawk-core-contracts` - (`types`, `review`, `verify`, `tools`, `events`, `policy`). Hawk production - code should not add new imports of `hawk/shared/types`. + (`types`, `review`, `verify`, `tools`, `events`, `policy`). The old + `hawk/shared/types` path has been removed. ## Development Guidelines @@ -150,7 +149,7 @@ test: add coverage for guardian - `CONTRIBUTING.md` — PR process, commit conventions - `docs/` — Architecture details, security model, ecosystem message flow - `external/` — Ecosystem repo checkouts for `go.work` development -- `shared/types/` — Deprecated compatibility types; new cross-repo contracts belong in `hawk-core-contracts` +- `hawk-core-contracts/` — Shared cross-repo contracts; use this instead of any legacy Hawk-owned shared-type path ## Testing Philosophy @@ -216,7 +215,7 @@ test: add coverage for guardian - **Caution**: `internal/engine/session.go` Session struct — widely referenced across 30+ sub-packages - **Caution**: `internal/config/settings.go` Settings struct — serialized to JSON, dual-format (snake_case + camelCase) - **Caution**: `internal/tool/` Tool interface — implemented by 40+ tools -- **Migration-sensitive**: `shared/types/` remains only as a deprecated compatibility layer for legacy downstreams; do not add new imports. +- **Migration-sensitive**: `hawk/shared/types` has been removed; use `hawk-core-contracts/types` instead. ## Key File Locations diff --git a/CHANGELOG.md b/CHANGELOG.md index e1f4ccc0..2f85a286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `flake.nix`, `.github/workflows/release.yml`, and the `update`/daemon test suites, aligning hawk with the rest of the GrayCodeAI ecosystem (`eyrie`, `tok`, `yaad`, `sight`, `inspect`). - **Architecture boundary hardening**: Hawk now owns runtime request/response DTOs, transport config/provider seams, and review/verification product-boundary contracts, with `eyrie/client` usage restricted to internal adapters and guarded in CI. -- **`shared/types` deprecation tightened**: compatibility symbols are now explicitly marked deprecated in code, and local boundary checks enforce migration away from that package. +- **`shared/types` removed**: Hawk no longer ships the old shared type path, and local boundary checks now block any attempt to reintroduce it. ### Added - **Watch mode (`--watch`)**: file-watcher loop that acts on `AI!` (do-now) and `AI?` (answer) code comments. Off by default. @@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`GET /v1/ready`**: dependency-aware readiness endpoint on the daemon. - REPL magic commands (%reset, %undo, %tokens, %history, %copy, %save, %compact, %model, %clear) - Prompt cache keep-alive pings -- Unified Finding type in shared/types for cross-tool interoperability +- Unified finding/severity contracts now live in `hawk-core-contracts/types` ### Added — Round 2 ecosystem improvements (2026-06-01) - **Cavecrew personas** (`internal/multiagent/agents`): three new diff --git a/Makefile b/Makefile index 49df44b7..bf15fc2e 100644 --- a/Makefile +++ b/Makefile @@ -99,13 +99,10 @@ fmt: ## Format source files (gofumpt + goimports). vet: ## Run go vet. go vet ./... -contracts-guard: ## Fail on new imports of hawk/shared/types outside compatibility paths. +contracts-guard: ## Fail on any legacy imports of removed hawk/shared/types. bash ./scripts/check-shared-types-imports.sh -shared-types-retirement-guard: ## Verify the local ecosystem no longer imports hawk/shared/types. - bash ./scripts/check-shared-types-retirement-readiness.sh - -ecosystem-guard: ## Fail if external ecosystem repos import hawk/internal or deprecated hawk/shared/types. +ecosystem-guard: ## Fail if external ecosystem repos import hawk/internal or removed hawk/shared/types. bash ./scripts/check-ecosystem-boundaries.sh eyrie-client-guard: ## Fail on new direct eyrie/client imports outside Hawk transport adapters. diff --git a/README.md b/README.md index 681aced3..7ead9d9f 100644 --- a/README.md +++ b/README.md @@ -326,7 +326,7 @@ Local development uses: - **External checkout + `go.work`:** clone support repos under `external/`; `go.work` maps the module paths to those local checkouts - **Submodules in this repo:** the same external layout is pinned under `external/` for reproducible CI and multi-repo work -Cross-repo contracts now live in **`github.com/GrayCodeAI/hawk-core-contracts`** so support repos do not depend on Hawk internals. The legacy `hawk/shared/types` package is now a deprecated compatibility layer and should not be used for new code. +Cross-repo contracts now live in **`github.com/GrayCodeAI/hawk-core-contracts`** so support repos do not depend on Hawk internals. The old `hawk/shared/types` path has been removed; use `hawk-core-contracts/types` for shared severity and finding contracts. Current contract packages: diff --git a/docs/architecture.md b/docs/architecture.md index 42cb9935..a35ae7ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,11 +51,13 @@ hawk/ │ ├── mcp/ plug MCP client and server │ ├── bridge/ link Bridges to ecosystem services │ └── resilience/ refresh-cw Circuit breaker, retry, rate limit -├── shared/types/ share-2 Deprecated compatibility layer; keep only for legacy downstreams ├── docs/ book-open Architecture docs └── external/ link Local go.work checkouts ``` +Legacy note: `hawk/shared/types` has been removed. Shared cross-repo severity +and finding contracts now live in `hawk-core-contracts/types`. + --- ## globe Daemon HTTP API (:4590) diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0923715b..abd908c4 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -9,7 +9,6 @@ Documents: - `hawk-product-architecture.md` - target architecture and runtime flow - `hawk-repo-roles.md` - role of each Hawk repo in the product ecosystem - `hawk-dependency-rules.md` - import and ownership boundaries -- `hawk-shared-types-removal-plan.md` - explicit retirement gate for the deprecated shared/types shim - `hawk-core-contracts-spec.md` - shared contracts layer and current status - `hawk-provider-abstraction.md` - provider/runtime abstraction design - `hawk-review-verify-lifecycle.md` - review and verification lifecycle diff --git a/docs/architecture/hawk-contract-migration-inventory.md b/docs/architecture/hawk-contract-migration-inventory.md index 3f566173..c4a5897c 100644 --- a/docs/architecture/hawk-contract-migration-inventory.md +++ b/docs/architecture/hawk-contract-migration-inventory.md @@ -8,22 +8,10 @@ This document captures the current shared-type coupling that should be moved int ### `hawk/shared/types` -Files: - -- `shared/types/severity.go` -- `shared/types/finding.go` +Status: removed -Current exported concepts: - -- `Severity` -- `ParseSeverity` -- `TokenSeverity` -- `AuditSeverity` -- `Finding` -- `FindingSlice` -- `FindingSummary` -- `FindingFromSight` -- `FindingFromInspect` +The legacy Hawk-owned shared type shim has been deleted. Shared severity and +finding contracts now live only in `hawk-core-contracts/types`. ## Current external consumers @@ -188,11 +176,12 @@ Update `hawk/internal/types/severity.go` to re-export from `hawk-core-contracts/ ### Step 5 Update docs that currently describe `hawk/shared/types` as the cross-repo API. +Status: completed. + ### Step 6 -Deprecate `hawk/shared/types` after all external consumers migrate. In progress. +Remove `hawk/shared/types` after local migration completes. Completed. Current status: - local ecosystem migration is complete -- deletion is still gated on downstream compatibility outside this workspace -- see `hawk-shared-types-removal-plan.md` +- Hawk no longer ships the `hawk/shared/types` package diff --git a/docs/architecture/hawk-dependency-rules.md b/docs/architecture/hawk-dependency-rules.md index 4c6fef71..63cceafe 100644 --- a/docs/architecture/hawk-dependency-rules.md +++ b/docs/architecture/hawk-dependency-rules.md @@ -68,7 +68,7 @@ adds the contract edge then — not before. ```text engine -> engine engine -> hawk/internal/* -engine -> hawk/shared/* as a long-term public dependency +engine -> hawk/shared/* as a public dependency sdk -> engine skills -> engine ``` @@ -94,8 +94,8 @@ Provider-specific code should not leak into memory, review, verify, or trace eng Based on current local structure: -- `sight -> hawk/shared/types` removed (now `sight -> hawk-core-contracts`) -- `inspect -> hawk/shared/types` removed (now `inspect -> hawk-core-contracts`) +- `sight -> hawk/shared/types` removed +- `inspect -> hawk/shared/types` removed - `tok/types` duplicate definitions removed; `tok/types` now re-exports `hawk-core-contracts/types` as a deprecated compat shim - keep support engines peer-isolated as new features are added @@ -113,8 +113,7 @@ These were previously "ideas"; they are now implemented: Still open: -- `hawk/shared/types` remains as a deprecated compatibility layer until downstream - migration evidence is complete +- `hawk/shared/types` has been removed from Hawk - the boundary guard scripts depend on `rg`; when `rg` is absent they pass vacuously, so CI images must provide ripgrep (or the scripts should fall back to `git grep`) diff --git a/docs/architecture/hawk-product-architecture.md b/docs/architecture/hawk-product-architecture.md index 27ed87d7..9c59ccf9 100644 --- a/docs/architecture/hawk-product-architecture.md +++ b/docs/architecture/hawk-product-architecture.md @@ -188,7 +188,7 @@ Status: Status: - completed for current workspace boundaries -- local/CI guards now block support-repo imports of `hawk/internal/*` and deprecated `hawk/shared/types` +- local/CI guards now block support-repo imports of `hawk/internal/*` and removed legacy `hawk/shared/types` ### Phase 4 - harden orchestration boundaries in Hawk @@ -209,8 +209,11 @@ Status: - broader non-Go consumer enforcement remains future work ### Phase 6 -- deprecate and eventually remove `hawk/shared/types` -- keep compatibility warnings and guardrails in place until downstream migration evidence is complete +- remove legacy `hawk/shared/types` +- keep import guards in place so the old path cannot return + +Status: +- completed in the local ecosystem ## Done criteria diff --git a/docs/architecture/hawk-shared-types-removal-plan.md b/docs/architecture/hawk-shared-types-removal-plan.md deleted file mode 100644 index a2438f79..00000000 --- a/docs/architecture/hawk-shared-types-removal-plan.md +++ /dev/null @@ -1,112 +0,0 @@ -# Hawk Shared Types Removal Plan - -## Purpose - -This document defines the only safe path to remove: - -- `github.com/GrayCodeAI/hawk/shared/types` - -The key constraint is simple: - -- local migration is complete -- external compatibility is not automatically knowable from this workspace - -That means removal is a release decision, not just a code cleanup. - -## Current state - -Local reality today: - -- Hawk production code does not import `hawk/shared/types` -- support repos in this workspace do not import `hawk/shared/types` -- consumer repos in this workspace do not import `hawk/shared/types` -- the package now exists only as a compatibility shim - -What is still unknown: - -- whether downstream users outside this workspace still import it - -## Decision rule - -Do not delete `hawk/shared/types` until both are true: - -1. local retirement readiness passes -2. external compatibility risk is explicitly accepted - -## Local retirement readiness - -The local readiness check is now executable: - -```bash -cd hawk -bash ./scripts/check-shared-types-retirement-readiness.sh -``` - -Passing this check means: - -- no active local ecosystem repo still imports `github.com/GrayCodeAI/hawk/shared/types` - -It does **not** mean deletion is automatically safe for external users. - -## External compatibility gate - -Before deletion, complete this checklist: - -1. Search GitHub code search, internal consumers, and release notes history for `github.com/GrayCodeAI/hawk/shared/types`. -2. Confirm whether any public or internal downstream repos still import it. -3. If yes, migrate them first to `github.com/GrayCodeAI/hawk-core-contracts/types`. -4. Publish a release note that `hawk/shared/types` is deprecated and scheduled for removal. -5. Leave at least one visible release window for downstream migration. -6. Delete the package only in the next intentional breaking-change release. - -## Recommended removal sequence - -### Phase A: present state - -- keep the package -- keep the deprecation comments -- keep blocking new imports -- keep the retirement readiness script green - -### Phase B: release warning - -- publish changelog note -- mark the removal target version/date -- notify downstream maintainers if known - -### Phase C: breaking removal - -Delete: - -- `shared/types/doc.go` -- `shared/types/severity.go` -- `shared/types/finding.go` -- `shared/types/severity_test.go` -- `shared/types/finding_test.go` - -Then update: - -- docs that still describe it as a compatibility shim -- migration backlog status -- any CI/tests that assume the shim still exists - -## What “done” means - -This migration is fully done only when: - -- `hawk-core-contracts/types` is the sole shared-type source of truth -- `hawk/shared/types` no longer exists -- no local or known external consumer depends on the shim - -## Honest status - -Today: - -- local migration: complete -- local enforcement: complete -- removal safety for external consumers: not yet proven - -So the correct current status is: - -- ready for retirement -- not yet ready for blind deletion diff --git a/docs/plans/hawk-contracts-migration-backlog.md b/docs/plans/hawk-contracts-migration-backlog.md index 9ea59b4f..7627482c 100644 --- a/docs/plans/hawk-contracts-migration-backlog.md +++ b/docs/plans/hawk-contracts-migration-backlog.md @@ -18,7 +18,7 @@ These items are already completed in the current workspace. - moved severity and finding definitions into contracts - migrated `sight` and `inspect` to import contracts - switched Hawk `internal/types/severity.go` to re-export from contracts -- converted `hawk/shared/types` into a compatibility shim +- removed `hawk/shared/types` after local ecosystem migration - removed the duplicate severity/finding definitions in `tok/types` (tok was the original shared-types host); `tok/types` now re-exports `hawk-core-contracts/types` as a deprecated compatibility shim @@ -51,7 +51,7 @@ These items are already completed in the current workspace. - added `check-eyrie-client-imports.sh` - wired both guards into `Makefile` and CI - wired the `eyrie/client` boundary guard into `Makefile` and CI -- tightened `shared/types` deprecation comments on the compatibility package itself +- added a legacy import guard so the removed `shared/types` path cannot return - extended the ecosystem boundary guard to scan sibling engine repos when present locally - updated docs across Hawk, sight, inspect, and external workspace copies - added standalone boundary guards in `sight` and `inspect` @@ -136,19 +136,12 @@ Possible later additions: Do not move every internal event shape by default. ### 6. Remove `hawk/shared/types` -Only after: -- no external repos import it -- no compatibility reason remains -- release/migration notes are ready -- deprecation warnings have been visible long enough for downstream users - -Until then it stays as a compatibility shim. +Completed for the local ecosystem. Current status: -- local workspace is ready for retirement -- external downstream compatibility is not yet proven from this repo alone -- removal should happen only as an explicit breaking-change release step +- Hawk no longer ships `hawk/shared/types` +- local import guards prevent the old path from returning ## Non-goals @@ -175,7 +168,7 @@ Do not do these without a separate decision: - completed: added neutral review/verification result contracts and wired Hawk bridge/persistence edges ### PR 5 -- remove `hawk/shared/types` only when safe +- completed: removed `hawk/shared/types` from the local ecosystem and kept a legacy import guard ## Success criteria @@ -184,6 +177,6 @@ The migration is in a good long-term state when: - `hawk-core-contracts` is the only source of truth for shared contracts - support repos do not import `hawk/internal/*` - support repos do not import `hawk/shared/types` -- compatibility shims are temporary and clearly documented +- removed compatibility shims do not return - CI prevents regressions - new shared contracts are added deliberately, not by habit diff --git a/internal/testaudit/audit_test.go b/internal/testaudit/audit_test.go index bf605d00..d2481874 100644 --- a/internal/testaudit/audit_test.go +++ b/internal/testaudit/audit_test.go @@ -221,10 +221,9 @@ func TestNoDirectEyrieClientImportsOutsideAdapters(t *testing.T) { } } -// TestNoDirectSharedTypesImportsOutsideCompatibilityPackage verifies Hawk does -// not reintroduce the deprecated shared/types compatibility package into -// production code. The package remains only for legacy downstream users. -func TestNoDirectSharedTypesImportsOutsideCompatibilityPackage(t *testing.T) { +// TestNoDirectSharedTypesImports verifies Hawk does not reintroduce the removed +// legacy shared/types import path into production code. +func TestNoDirectSharedTypesImports(t *testing.T) { root := repoRoot(t) paths := []string{ filepath.Join(root, "internal"), @@ -241,7 +240,7 @@ func TestNoDirectSharedTypesImportsOutsideCompatibilityPackage(t *testing.T) { continue } pos := pf.FSet.Position(imp.Pos()) - t.Fatalf("forbidden direct hawk/shared/types import at %s:%d; use hawk-core-contracts instead", rel, pos.Line) + t.Fatalf("forbidden direct hawk/shared/types import at %s:%d; the path has been removed, use hawk-core-contracts instead", rel, pos.Line) } } } diff --git a/internal/testaudit/docs_audit_test.go b/internal/testaudit/docs_audit_test.go index ebed07ca..28d81c92 100644 --- a/internal/testaudit/docs_audit_test.go +++ b/internal/testaudit/docs_audit_test.go @@ -74,7 +74,7 @@ func TestArchitectureDocsMentionCurrentReviewVerifyContracts(t *testing.T) { } } -func TestArchitectureDocsDescribeSharedTypesAsDeprecated(t *testing.T) { +func TestArchitectureDocsDescribeSharedTypesAsRemoved(t *testing.T) { root := repoRoot(t) files := []string{ @@ -93,8 +93,8 @@ func TestArchitectureDocsDescribeSharedTypesAsDeprecated(t *testing.T) { if !strings.Contains(content, "shared/types") { t.Fatalf("expected %s to mention shared/types", rel) } - if !strings.Contains(content, "deprecated") { - t.Fatalf("expected %s to describe shared/types as deprecated", rel) + if !strings.Contains(content, "removed") { + t.Fatalf("expected %s to describe shared/types as removed", rel) } } } diff --git a/scripts/check-ecosystem-boundaries.sh b/scripts/check-ecosystem-boundaries.sh index 0ffae976..e97e3ae2 100644 --- a/scripts/check-ecosystem-boundaries.sh +++ b/scripts/check-ecosystem-boundaries.sh @@ -29,7 +29,7 @@ if [[ -n "${violations}" ]]; then echo "forbidden Hawk imports found in external ecosystem repos:" echo "${violations}" echo - echo "support repos must use hawk-core-contracts or their own contracts, not hawk/internal or hawk/shared/types" + echo "support repos must use hawk-core-contracts or their own contracts, not hawk/internal or removed hawk/shared/types" exit 1 fi diff --git a/scripts/check-shared-types-imports.sh b/scripts/check-shared-types-imports.sh index f160a748..2be904ff 100644 --- a/scripts/check-shared-types-imports.sh +++ b/scripts/check-shared-types-imports.sh @@ -12,11 +12,11 @@ violations="$( )" if [[ -n "${violations}" ]]; then - echo "forbidden imports of github.com/GrayCodeAI/hawk/shared/types found:" + echo "forbidden imports of removed github.com/GrayCodeAI/hawk/shared/types found:" echo "${violations}" echo - echo "use github.com/GrayCodeAI/hawk-core-contracts/types instead" + echo "hawk/shared/types has been removed; use github.com/GrayCodeAI/hawk-core-contracts/types instead" exit 1 fi -echo "shared/types import guard passed" +echo "legacy shared/types import guard passed" diff --git a/scripts/check-shared-types-retirement-readiness.sh b/scripts/check-shared-types-retirement-readiness.sh deleted file mode 100644 index 17f0af7a..00000000 --- a/scripts/check-shared-types-retirement-readiness.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -violations="" - -check_repo() { - local dir="$1" - if [[ ! -d "$dir" ]]; then - return - fi - local hits - hits="$( - grep -RInE --include='*.go' '"github\.com/GrayCodeAI/hawk/shared/types"' "$dir" | grep -v 'internal/testaudit/' || true - )" - if [[ -n "$hits" ]]; then - violations+="${hits}"$'\n' - fi -} - -# Compatibility package self-tests are allowed until the package is removed. -for repo in \ - cmd \ - internal \ - external/eyrie \ - external/yaad \ - external/tok \ - external/trace \ - external/sight \ - external/inspect \ - ../eyrie \ - ../yaad \ - ../tok \ - ../trace \ - ../sight \ - ../inspect \ - ../hawk-sdk-go -do - check_repo "$repo" -done - -if [[ -n "${violations}" ]]; then - echo "hawk/shared/types is not ready for retirement; active imports remain:" - echo "${violations}" - echo - echo "migrate remaining imports to github.com/GrayCodeAI/hawk-core-contracts/types before deleting hawk/shared/types" - exit 1 -fi - -echo "shared/types retirement readiness passed for the local ecosystem" diff --git a/shared/types/doc.go b/shared/types/doc.go deleted file mode 100644 index 74ddfc06..00000000 --- a/shared/types/doc.go +++ /dev/null @@ -1,19 +0,0 @@ -// Package types is a deprecated compatibility layer for pre-contracts Hawk -// ecosystem consumers. -// -// Migration target: -// -// github.com/GrayCodeAI/hawk-core-contracts/types -// -// Status: -// -// - The local Hawk ecosystem has already migrated off this package. -// - The package remains only to avoid a blind breaking change for downstream -// consumers outside this workspace. -// - New code must not import this package. -// -// Removal policy: -// -// Delete this package only after the retirement checklist in -// docs/architecture/hawk-shared-types-removal-plan.md is satisfied. -package types diff --git a/shared/types/finding.go b/shared/types/finding.go deleted file mode 100644 index 210f1ccd..00000000 --- a/shared/types/finding.go +++ /dev/null @@ -1,25 +0,0 @@ -// Package types is a deprecated compatibility layer for shared Hawk ecosystem types. -// New cross-repo contracts belong in github.com/GrayCodeAI/hawk-core-contracts/types. -package types - -import contracts "github.com/GrayCodeAI/hawk-core-contracts/types" - -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.Finding. -// Finding represents a unified code-analysis concern sourced from sight, inspect, or manual review. -type Finding = contracts.Finding - -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingSlice. -// FindingSlice is a sortable slice of Findings. -type FindingSlice = contracts.FindingSlice - -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingSummary. -// FindingSummary provides aggregate counts over a set of findings. -type FindingSummary = contracts.FindingSummary - -// FindingFromSight constructs a Finding from a sight (AST/static-analysis) result. -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingFromSight. -var FindingFromSight = contracts.FindingFromSight - -// FindingFromInspect constructs a Finding from an inspect (linting/analysis) result. -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.FindingFromInspect. -var FindingFromInspect = contracts.FindingFromInspect diff --git a/shared/types/finding_test.go b/shared/types/finding_test.go deleted file mode 100644 index 8156a710..00000000 --- a/shared/types/finding_test.go +++ /dev/null @@ -1,225 +0,0 @@ -package types_test - -import ( - "sort" - "testing" - - "github.com/GrayCodeAI/hawk/shared/types" -) - -func TestFindingSlice_SortBySeverityThenConfidence(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "a", Severity: types.SeverityLow, Confidence: 0.9}, - {ID: "b", Severity: types.SeverityCritical, Confidence: 0.7}, - {ID: "c", Severity: types.SeverityCritical, Confidence: 0.95}, - {ID: "d", Severity: types.SeverityMedium, Confidence: 0.8}, - } - sort.Sort(findings) - - if findings[0].ID != "c" { - t.Fatalf("expected first finding to be c (critical, 0.95), got %s", findings[0].ID) - } - if findings[1].ID != "b" { - t.Fatalf("expected second finding to be b (critical, 0.7), got %s", findings[1].ID) - } - if findings[2].ID != "d" { - t.Fatalf("expected third finding to be d (medium), got %s", findings[2].ID) - } - if findings[3].ID != "a" { - t.Fatalf("expected fourth finding to be a (low), got %s", findings[3].ID) - } -} - -func TestFilterBySource(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Source: "sight"}, - {ID: "2", Source: "inspect"}, - {ID: "3", Source: "sight"}, - {ID: "4", Source: "manual"}, - } - - got := findings.FilterBySource("sight") - if len(got) != 2 { - t.Fatalf("expected 2 sight findings, got %d", len(got)) - } - for _, f := range got { - if f.Source != "sight" { - t.Fatalf("expected source sight, got %s", f.Source) - } - } -} - -func TestFilterBySeverity(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Severity: types.SeverityInfo}, - {ID: "2", Severity: types.SeverityHigh}, - {ID: "3", Severity: types.SeverityCritical}, - {ID: "4", Severity: types.SeverityLow}, - {ID: "5", Severity: types.SeverityMedium}, - } - - got := findings.FilterBySeverity(types.SeverityHigh) - if len(got) != 2 { - t.Fatalf("expected 2 findings >= high, got %d", len(got)) - } -} - -func TestFilterByConfidence(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Confidence: 0.9}, - {ID: "2", Confidence: 0.5}, - {ID: "3", Confidence: 0.7}, - {ID: "4", Confidence: 0.3}, - } - - got := findings.FilterByConfidence(0.7) - if len(got) != 2 { - t.Fatalf("expected 2 findings with confidence >= 0.7, got %d", len(got)) - } - for _, f := range got { - if f.Confidence < 0.7 { - t.Fatalf("expected confidence >= 0.7, got %f", f.Confidence) - } - } -} - -func TestByFile(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", File: "a.go"}, - {ID: "2", File: "b.go"}, - {ID: "3", File: "a.go"}, - {ID: "4", File: ""}, - } - - grouped := findings.ByFile() - if len(grouped["a.go"]) != 2 { - t.Fatalf("expected 2 findings in a.go, got %d", len(grouped["a.go"])) - } - if len(grouped["b.go"]) != 1 { - t.Fatalf("expected 1 finding in b.go, got %d", len(grouped["b.go"])) - } - if len(grouped[""]) != 1 { - t.Fatalf("expected 1 finding with empty file, got %d", len(grouped[""])) - } -} - -func TestSummary(t *testing.T) { - t.Parallel() - findings := types.FindingSlice{ - {ID: "1", Source: "sight", Severity: types.SeverityHigh, Confidence: 0.8}, - {ID: "2", Source: "sight", Severity: types.SeverityLow, Confidence: 0.6}, - {ID: "3", Source: "inspect", Severity: types.SeverityHigh, Confidence: 1.0}, - } - - summary := findings.Summary() - if summary.Total != 3 { - t.Fatalf("expected total 3, got %d", summary.Total) - } - if summary.BySource["sight"] != 2 { - t.Fatalf("expected 2 sight findings, got %d", summary.BySource["sight"]) - } - if summary.BySource["inspect"] != 1 { - t.Fatalf("expected 1 inspect finding, got %d", summary.BySource["inspect"]) - } - if summary.BySeverity["high"] != 2 { - t.Fatalf("expected 2 high severity, got %d", summary.BySeverity["high"]) - } - if summary.BySeverity["low"] != 1 { - t.Fatalf("expected 1 low severity, got %d", summary.BySeverity["low"]) - } - // avg confidence = (0.8 + 0.6 + 1.0) / 3 = 0.8 - if summary.AvgConfidence < 0.79 || summary.AvgConfidence > 0.81 { - t.Fatalf("expected avg confidence ~0.8, got %f", summary.AvgConfidence) - } -} - -func TestFindingFromSight(t *testing.T) { - t.Parallel() - f := types.FindingFromSight("sql-injection", "db.go", 42, "unparameterized query", "CWE-89", types.SeverityCritical, 0.95) - - if f.ID != "sight:sql-injection:db.go:42" { - t.Fatalf("unexpected ID: %s", f.ID) - } - if f.Source != "sight" { - t.Fatalf("expected source sight, got %s", f.Source) - } - if f.Concern != "sql-injection" { - t.Fatalf("expected concern sql-injection, got %s", f.Concern) - } - if f.Severity != types.SeverityCritical { - t.Fatalf("expected severity critical, got %v", f.Severity) - } - if f.File != "db.go" { - t.Fatalf("expected file db.go, got %s", f.File) - } - if f.Line != 42 { - t.Fatalf("expected line 42, got %d", f.Line) - } - if f.CWE != "CWE-89" { - t.Fatalf("expected CWE-89, got %s", f.CWE) - } - if f.Confidence != 0.95 { - t.Fatalf("expected confidence 0.95, got %f", f.Confidence) - } - if f.CreatedAt.IsZero() { - t.Fatal("expected CreatedAt to be set") - } -} - -func TestFindingFromInspect(t *testing.T) { - t.Parallel() - tags := []string{"security", "injection"} - f := types.FindingFromInspect("broken-auth", "https://example.com/api", "missing auth header", types.SeverityHigh, tags) - - if f.ID != "inspect:broken-auth:https://example.com/api" { - t.Fatalf("unexpected ID: %s", f.ID) - } - if f.Source != "inspect" { - t.Fatalf("expected source inspect, got %s", f.Source) - } - if f.URL != "https://example.com/api" { - t.Fatalf("expected url, got %s", f.URL) - } - if f.Severity != types.SeverityHigh { - t.Fatalf("expected severity high, got %v", f.Severity) - } - if len(f.Tags) != 2 || f.Tags[0] != "security" { - t.Fatalf("unexpected tags: %v", f.Tags) - } - if f.CreatedAt.IsZero() { - t.Fatal("expected CreatedAt to be set") - } -} - -func TestEmptySliceHandling(t *testing.T) { - t.Parallel() - var empty types.FindingSlice - - if empty.Len() != 0 { - t.Fatalf("expected len 0, got %d", empty.Len()) - } - if len(empty.FilterBySource("x")) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.FilterBySeverity(types.SeverityCritical)) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.FilterByConfidence(0.5)) != 0 { - t.Fatal("expected empty filter result") - } - if len(empty.ByFile()) != 0 { - t.Fatal("expected empty ByFile map") - } - summary := empty.Summary() - if summary.Total != 0 { - t.Fatalf("expected total 0, got %d", summary.Total) - } - if summary.AvgConfidence != 0.0 { - t.Fatalf("expected avg confidence 0, got %f", summary.AvgConfidence) - } -} diff --git a/shared/types/severity.go b/shared/types/severity.go deleted file mode 100644 index 96f7609d..00000000 --- a/shared/types/severity.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package types is a deprecated compatibility layer for shared Hawk ecosystem types. -// New cross-repo contracts belong in github.com/GrayCodeAI/hawk-core-contracts/types. -package types - -import contracts "github.com/GrayCodeAI/hawk-core-contracts/types" - -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.Severity. -// Severity represents the impact level of a finding. -type Severity = contracts.Severity - -const ( - SeverityInfo = contracts.SeverityInfo - SeverityLow = contracts.SeverityLow - SeverityMedium = contracts.SeverityMedium - SeverityHigh = contracts.SeverityHigh - SeverityCritical = contracts.SeverityCritical -) - -// ParseSeverity converts a string to a Severity. -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.ParseSeverity. -var ParseSeverity = contracts.ParseSeverity - -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.TokenSeverity. -// TokenSeverity defines rule severity for compression error patterns. -type TokenSeverity = contracts.TokenSeverity - -const ( - TokenSeverityCritical = contracts.TokenSeverityCritical - TokenSeverityHigh = contracts.TokenSeverityHigh - TokenSeverityMedium = contracts.TokenSeverityMedium - TokenSeverityLow = contracts.TokenSeverityLow -) - -// Deprecated: use github.com/GrayCodeAI/hawk-core-contracts/types.AuditSeverity. -// AuditSeverity indicates how dangerous a security audit finding is. -type AuditSeverity = contracts.AuditSeverity - -const ( - AuditSeverityCritical = contracts.AuditSeverityCritical - AuditSeverityWarning = contracts.AuditSeverityWarning - AuditSeverityInfo = contracts.AuditSeverityInfo -) diff --git a/shared/types/severity_test.go b/shared/types/severity_test.go deleted file mode 100644 index 43a0ff59..00000000 --- a/shared/types/severity_test.go +++ /dev/null @@ -1,27 +0,0 @@ -package types_test - -import ( - "testing" - - "github.com/GrayCodeAI/hawk/shared/types" -) - -func TestParseSeverity_forwarded(t *testing.T) { - t.Parallel() - cases := []struct { - in string - want types.Severity - }{ - {"critical", types.SeverityCritical}, - {"high", types.SeverityHigh}, - {"medium", types.SeverityMedium}, - {"low", types.SeverityLow}, - {"info", types.SeverityInfo}, - {"unknown", types.SeverityInfo}, - } - for _, tc := range cases { - if got := types.ParseSeverity(tc.in); got != tc.want { - t.Fatalf("ParseSeverity(%q) = %v, want %v", tc.in, got, tc.want) - } - } -} From 8adff6e1575e0fdb392db056eaea143ab630aa1c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 15:01:37 +0530 Subject: [PATCH 11/23] docs: mark hawk-core-contracts as the completed shared types layer Update architecture.md to reflect the finished contracts migration and pin the external hawk-core-contracts checkout to the refreshed README. --- docs/architecture.md | 3 +-- external/hawk-core-contracts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a35ae7ee..0e98d9a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,5 +120,4 @@ Tool Call → link Bridges to ecosystem services │ └── resilience/ refresh-cw Circuit breaker, retry, rate limit ├── docs/ book-open Architecture docs -└── external/ link Local go.work checkouts +└── external/ link Pinned ecosystem submodules for go.work integration ``` Legacy note: `hawk/shared/types` has been removed. Shared cross-repo severity @@ -119,5 +119,5 @@ Tool Call → Status: active +> Status: locally complete > Scope: Hawk ecosystem architecture cleanup after introducing `hawk-core-contracts` > Goal: keep `hawk` as the product while moving stable cross-repo contracts out of Hawk internals +External follow-up still outside the scope of this local workspace audit: + +- confirm upstream branches contain the final architecture commits +- confirm published module tags/releases match the merged contract changes + ## Done These items are already completed in the current workspace. @@ -68,20 +73,28 @@ These items are already completed in the current workspace. - switched Hawk review persistence to neutral review contracts - switched Hawk review/inspect bridge paths to return neutral review/verify contracts -## Next +## Remaining external follow-up + +These are the highest-value follow-up tasks that this local workspace cannot +prove automatically. + +### 1. Confirm upstream/default-branch convergence + +Local state: +- implemented and verified in this workspace + +Still external: +- confirm the same commits are merged on the intended upstream default branches + +### 2. Confirm release/publication convergence -These are the highest-value follow-up tasks. +Local state: +- Hawk local integration snapshot points at architecture-aligned support-repo revisions -### 1. Extend standalone import guards to the remaining support repos -Done: -- `sight` -- `inspect` -- `tok` -- `eyrie` -- `yaad` -- `trace` +Still external: +- confirm released module versions used by Hawk match the merged contract changes -### 2. Decide whether Hawk session/API message DTOs should fully stop using `eyrie/client` types +### 3. Decide whether Hawk session/API message DTOs should fully stop using `eyrie/client` types Current state: - session persistence uses neutral tool contracts - Hawk now owns the runtime message DTO in `internal/types.EyrieMessage` From ecf2d0dc8b9724c170ca6f0a7936e47832cee4cd Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 17:51:47 +0530 Subject: [PATCH 18/23] docs: add upstream release convergence plan --- .../architecture-upstream-release-plan.md | 210 ++++++++++++++++++ .../plans/hawk-contracts-migration-backlog.md | 1 + 2 files changed, 211 insertions(+) create mode 100644 docs/plans/architecture-upstream-release-plan.md diff --git a/docs/plans/architecture-upstream-release-plan.md b/docs/plans/architecture-upstream-release-plan.md new file mode 100644 index 00000000..3c7008f0 --- /dev/null +++ b/docs/plans/architecture-upstream-release-plan.md @@ -0,0 +1,210 @@ +# Plan: Hawk Architecture Upstream and Release Convergence + +> Status: ready for execution +> Scope: push, merge, repin, verify, and release the Hawk ecosystem architecture work +> Goal: move the locally verified Hawk-centered architecture into upstream default branches and aligned published versions + +## Purpose + +The architecture cleanup is complete in the local `hawk-eco` workspace. + +This plan covers the remaining operational work: + +- push local branches upstream +- open and merge PRs in dependency order +- repin Hawk submodules to merged upstream SHAs +- rerun final integration verification +- publish tags/modules only after upstream convergence + +## Principles + +1. Merge shared contracts first. +2. Merge support-engine boundaries before Hawk. +3. Merge consumer guards after the engine direction is settled. +4. Merge Hawk last, because Hawk pins the support repos. +5. Do not redesign architecture during release convergence. + +## Repo order + +### Phase 1: shared contract base + +1. `hawk-core-contracts` + +### Phase 2: support engines + +2. `sight` +3. `inspect` +4. `eyrie` +5. `yaad` +6. `trace` +7. `tok` + +### Phase 3: consumers + +8. `hawk-sdk-go` +9. `hawk-sdk-python` +10. `hawk-community-skills` + +### Phase 4: product repo + +11. `hawk` + +## Repo board + +| Repo | Branch | Commit | PR title | Merge gate | +|---|---|---|---|---| +| `hawk-core-contracts` | `main` | `f9989e5` | `docs: describe hawk-core-contracts as the live cross-repo API` | none | +| `sight` | `feat/contracts-migration` | `b990666` | `feat(contracts): migrate to hawk-core-contracts and enforce boundary` | `hawk-core-contracts` merged | +| `inspect` | `feat/contracts-migration` | `d6ca739` | `feat(contracts): migrate to hawk-core-contracts and enforce boundary` | `hawk-core-contracts` merged | +| `eyrie` | `feat/ecosystem-boundary-guard` | `c1a6a4d` | `docs: remove legacy shared types references` | `hawk-core-contracts` merged | +| `yaad` | `feat/ecosystem-boundary-guard` | `010178d` | `chore: strip Co-authored-by trailers in lefthook hooks` | `hawk-core-contracts` merged | +| `trace` | `feat/ecosystem-boundary-guard` | `735e3f4` | `chore: strip Co-authored-by trailers in lefthook hooks` | `hawk-core-contracts` merged | +| `tok` | `feat/contracts-types-realignment` | `83cfc551` | `refactor: remove tok types compatibility shim` | `hawk-core-contracts` merged | +| `hawk-sdk-go` | `ci/consumer-boundary-guard` | `97b523e` | `ci: guard sdk-go consumer boundaries` | support-engine direction settled | +| `hawk-sdk-python` | `ci/consumer-boundary-guard` | `c43ad43` | `ci: guard sdk-python consumer boundaries` | support-engine direction settled | +| `hawk-community-skills` | `ci/consumer-boundary-guard` | `350f4f2c6` | `ci: guard skills consumer boundaries` | support-engine direction settled | +| `hawk` | `docs/contracts-architecture-truth` | `a2a4583` | `chore: align hawk external architecture snapshot` | all upstream support repos merged | +| `hawk` | `docs/contracts-architecture-truth` | `46697b9` | `docs: retire tok types shim references` | `tok` merged | +| `hawk` | `docs/contracts-architecture-truth` | `c204597` | `docs: normalize architecture status` | final architecture state agreed | + +## Execution checklist + +### Phase 1: push branches + +For each repo: + +1. confirm working tree is clean +2. push the local branch +3. open PR with the planned title/summary +4. wait for CI + +Suggested command pattern: + +```bash +git -C push -u origin +gh -R GrayCodeAI/ pr create --fill +``` + +### Phase 2: merge in dependency order + +Merge order: + +1. `hawk-core-contracts` +2. `sight` +3. `inspect` +4. `eyrie` +5. `yaad` +6. `trace` +7. `tok` +8. `hawk-sdk-go` +9. `hawk-sdk-python` +10. `hawk-community-skills` +11. `hawk` + +Rules: + +- do not merge `hawk` before the support repos +- do not publish module tags before merge convergence +- if upstream rebases or squashes PRs, treat the merged upstream SHA as the new source of truth + +### Phase 3: repin Hawk + +After the support-repo PRs merge: + +1. fetch upstream default branches for all pinned repos +2. update `hawk/external/*` to the merged upstream SHAs +3. run `go work sync` +4. rerun Hawk verification +5. commit the repin if the upstream SHAs differ from current local pins + +Checks: + +- `external/eyrie` +- `external/hawk-core-contracts` +- `external/inspect` +- `external/sight` +- `external/tok` +- `external/trace` +- `external/yaad` + +### Phase 4: final verification + +From `hawk`: + +```bash +/bin/sh ./scripts/check-shared-types-imports.sh +/bin/sh ./scripts/check-ecosystem-boundaries.sh +go work sync +go test ./internal/testaudit -count=1 +``` + +From support repos: + +```bash +/bin/sh ./scripts/check-ecosystem-boundaries.sh +go test ./... -count=1 +``` + +From consumer repos: + +```bash +/bin/sh ./scripts/check-consumer-boundaries.sh +``` + +## Release/tag guidance + +Only after merge convergence: + +1. decide which repos need tags immediately +2. publish `hawk-core-contracts` first if modules consume tagged versions +3. publish any support repos whose released versions are referenced by Hawk or external consumers +4. verify Hawk docs/examples do not claim unpublished versions + +Minimum release check: + +- merged commit exists on upstream default branch +- CI green on merged branch +- version/tag points at the merged contract-compatible state + +## Risks and responses + +### Upstream merge SHA differs from local SHA + +Response: + +- update Hawk submodule pins to the merged upstream SHA +- rerun `go work sync` +- rerun Hawk verification + +### PR is squashed and commit messages change + +Response: + +- treat the merged branch state as canonical +- do not assume local commit SHAs remain valid for Hawk submodule pins + +### A support repo fails CI after merge + +Response: + +- stop before merging Hawk +- fix the support repo first +- only repin Hawk after the repaired upstream state is green + +### A published module version lags merged code + +Response: + +- do not claim release convergence yet +- tag/publish before calling the ecosystem release-ready + +## Exit criteria + +This plan is complete when: + +- all listed PRs are merged upstream +- Hawk submodules point at merged upstream SHAs +- Hawk verification passes against those SHAs +- any required published module versions match the merged architecture state +- no repo needs the old architecture path or compatibility shims + diff --git a/docs/plans/hawk-contracts-migration-backlog.md b/docs/plans/hawk-contracts-migration-backlog.md index 472041ca..ead280dc 100644 --- a/docs/plans/hawk-contracts-migration-backlog.md +++ b/docs/plans/hawk-contracts-migration-backlog.md @@ -8,6 +8,7 @@ External follow-up still outside the scope of this local workspace audit: - confirm upstream branches contain the final architecture commits - confirm published module tags/releases match the merged contract changes +- execute `architecture-upstream-release-plan.md` ## Done From d2b33c52fa512ce11b48410e03083822e9feb785 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 21 Jun 2026 18:41:58 +0530 Subject: [PATCH 19/23] fix(ci): align setup-deps go.work format and add hawk-core-contracts --- .github/actions/setup-deps/action.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-deps/action.yml b/.github/actions/setup-deps/action.yml index 40b98197..533e50b5 100644 --- a/.github/actions/setup-deps/action.yml +++ b/.github/actions/setup-deps/action.yml @@ -26,6 +26,7 @@ runs: echo "Failed to clone $repo after 3 attempts" && return 1 } mkdir -p external + clone_with_retry hawk-core-contracts external/hawk-core-contracts main clone_with_retry eyrie external/eyrie main clone_with_retry tok external/tok main clone_with_retry yaad external/yaad main @@ -41,4 +42,4 @@ runs: - name: Create workspace shell: bash run: | - printf 'go 1.26.4\n\nuse (\n\t.\n\t./external/eyrie\n\t./external/hawk-core-contracts\n\t./external/inspect\n\t./external/sight\n\t./external/tok\n\t./external/trace\n\t./external/yaad\n)\n' > go.work + printf 'go 1.26.4\n\nuse .\n\nreplace (\n\tgithub.com/GrayCodeAI/hawk-core-contracts => ./external/hawk-core-contracts\n\tgithub.com/GrayCodeAI/eyrie => ./external/eyrie\n\tgithub.com/GrayCodeAI/inspect => ./external/inspect\n\tgithub.com/GrayCodeAI/sight => ./external/sight\n\tgithub.com/GrayCodeAI/tok => ./external/tok\n\tgithub.com/GrayCodeAI/trace => ./external/trace\n\tgithub.com/GrayCodeAI/yaad => ./external/yaad\n)\n' > go.work From 59060a9fddedb12fc5edda6dee8599dad153622f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 22 Jun 2026 08:44:22 +0530 Subject: [PATCH 20/23] fix(submodule): point hawk-core-contracts at published main SHA --- external/hawk-core-contracts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/external/hawk-core-contracts b/external/hawk-core-contracts index f9989e56..d8835088 160000 --- a/external/hawk-core-contracts +++ b/external/hawk-core-contracts @@ -1 +1 @@ -Subproject commit f9989e563ef3931754ff4d197d49ee154fcc7391 +Subproject commit d883508876710fa8b5f10affef486de0d33d1688 From 2650e6784a4c3d924116369ae0444ad5fd8b9739 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 22 Jun 2026 08:44:23 +0530 Subject: [PATCH 21/23] docs: remove trailing blank lines (markdownlint MD012) --- docs/plans/architecture-upstream-release-plan.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/plans/architecture-upstream-release-plan.md b/docs/plans/architecture-upstream-release-plan.md index 3c7008f0..7fb77c91 100644 --- a/docs/plans/architecture-upstream-release-plan.md +++ b/docs/plans/architecture-upstream-release-plan.md @@ -207,4 +207,3 @@ This plan is complete when: - Hawk verification passes against those SHAs - any required published module versions match the merged architecture state - no repo needs the old architecture path or compatibility shims - From 45108cdf4b83cb4f536968e988978576b910ab59 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 22 Jun 2026 19:07:38 +0530 Subject: [PATCH 22/23] fix(docker): build against pinned submodules incl hawk-core-contracts, not engine mains --- .github/workflows/docker.yml | 4 ++++ Dockerfile | 17 ++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 81e3a688..577366bc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -26,6 +26,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + # Pull the pinned ecosystem submodules under external/ so the Docker + # build compiles against the integrated revisions, not each repo's main. + submodules: recursive - name: Set up QEMU uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 diff --git a/Dockerfile b/Dockerfile index 81825c00..e0e16737 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,20 +22,19 @@ ARG BUILD_DATE=unknown COPY . . -# Clone every sibling hawk imports into ./external, then generate a go.work that -# resolves them locally. NOTE: the committed go.work/go.work.sum are excluded by -# .dockerignore, so we must (re)create the workspace here. Do NOT run -# 'go mod download' first — the frozen-proxy v0.1.0 fails checksum verification. +# external/ are committed submodules pinned to the integrated revisions +# (populated by `submodules: recursive` in .github/workflows/docker.yml, or +# `git submodule update --init --recursive` for a local `docker build`). Build +# against those pinned checkouts via a generated go.work — the committed +# go.work/go.work.sum are excluded by .dockerignore, and the public proxy froze +# v0.1.0 at older commits. Do NOT run 'go mod download' first. # # main.Version / main.Commit / main.BuildDate are baked in from the ARGs above; # this is the only correct source — `git describe` would always return empty # because `.dockerignore` excludes `.git/` from the build context. -RUN rm -rf external go.work go.work.sum && mkdir -p external && \ - for repo in eyrie inspect sight tok trace yaad; do \ - git clone --depth=1 "https://github.com/GrayCodeAI/${repo}.git" "external/${repo}"; \ - done && \ +RUN rm -f go.work go.work.sum && \ { echo "go 1.26.4"; echo; echo "use ."; echo; echo "replace ("; \ - for repo in eyrie inspect sight tok trace yaad; do \ + for repo in hawk-core-contracts eyrie inspect sight tok trace yaad; do \ echo " github.com/GrayCodeAI/${repo} => ./external/${repo}"; \ done; echo ")"; } > go.work && \ CGO_ENABLED=0 GOOS=linux go build -trimpath \ From da687442d1c5db4fdb9c37ae6bcd9edc0af6d08b Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Mon, 22 Jun 2026 19:16:06 +0530 Subject: [PATCH 23/23] fix(submodules): refresh engine pins to current PR HEADs (unbreak orphaned trace SHA) --- external/eyrie | 2 +- external/inspect | 2 +- external/sight | 2 +- external/tok | 2 +- external/trace | 2 +- external/yaad | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/external/eyrie b/external/eyrie index c1a6a4db..7c0b0812 160000 --- a/external/eyrie +++ b/external/eyrie @@ -1 +1 @@ -Subproject commit c1a6a4dbd86d56af072c46ebe28d2131b180312d +Subproject commit 7c0b081241ecd74c0717892089d0d2d9b6c68a5a diff --git a/external/inspect b/external/inspect index d6ca739a..1cb685e9 160000 --- a/external/inspect +++ b/external/inspect @@ -1 +1 @@ -Subproject commit d6ca739aa90a2e15dfada7ce729b4b128ea96449 +Subproject commit 1cb685e922a1d68c1bfc8c7966f17459e11f2425 diff --git a/external/sight b/external/sight index b9906664..b9099740 160000 --- a/external/sight +++ b/external/sight @@ -1 +1 @@ -Subproject commit b9906664f496f86b4fbe9b37d95c42dd9d15a30f +Subproject commit b909974052c51b8bdd047547f421601a88f061b4 diff --git a/external/tok b/external/tok index 83cfc551..d623aa30 160000 --- a/external/tok +++ b/external/tok @@ -1 +1 @@ -Subproject commit 83cfc55199ac759a2d66ec5b952cacb05b6f8102 +Subproject commit d623aa30234066312dc4c16afb432cef85c703ea diff --git a/external/trace b/external/trace index 735e3f4e..659a804a 160000 --- a/external/trace +++ b/external/trace @@ -1 +1 @@ -Subproject commit 735e3f4e0a55d6f50656b9b6714235cb3b09ab8e +Subproject commit 659a804a19a2556888e5aea6bc89750a31ae3e59 diff --git a/external/yaad b/external/yaad index 010178d5..4d730a86 160000 --- a/external/yaad +++ b/external/yaad @@ -1 +1 @@ -Subproject commit 010178d5ee7c58ca23902d12bd8c28d727b728c6 +Subproject commit 4d730a86904eb4a42550ef0e427e667575115a54