Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
3ae4c1e
fix(cli): resolve TUI layout and session sync bugs, update eyrie subm…
Patel230 Jul 1, 2026
117d71c
fix(cli): resolve spinner freezing and missing UI updates when waitin…
Patel230 Jul 1, 2026
9e25c3e
fix(ui): separate cost warning from assistant response
Patel230 Jul 1, 2026
b6063fb
fix(ui): style blast radius and allow 0-cost models
Patel230 Jul 1, 2026
55c1d30
fix(cli): resolve spinner freezing and missing UI updates when waitin…
Patel230 Jul 1, 2026
a6f887d
feat(cli): add repl_mode setting to configuration
Patel230 Jul 1, 2026
cedef39
feat(ui): add real time clock to footer statusbar
Patel230 Jul 2, 2026
102cda4
fix(ui): increase minFooterRightCols to prevent clipping clock
Patel230 Jul 2, 2026
07fae03
feat: enhance status bar UI with new icons and session duration timer
Patel230 Jul 2, 2026
963bee5
fix(ui): add terminal-agnostic mouse scroll detection for Terminal.app
Patel230 Jul 2, 2026
b30d212
Revert "fix(ui): add terminal-agnostic mouse scroll detection for Ter…
Patel230 Jul 2, 2026
5f7031b
fix(ui): make welcome screen scrollable in viewport
Patel230 Jul 2, 2026
edcc01e
fix(cmd): welcome screen frozen - start at top not bottom in welcome-…
Patel230 Jul 2, 2026
d57040b
fix(engine): resolve stream cancellation and refine UI scrollbar
Patel230 Jul 2, 2026
04f73cc
fix: harden hawk tui startup and scrollback
Patel230 Jul 2, 2026
90e0b5b
perf: eliminate O(n²) streaming render and redundant startup I/O
Patel230 Jul 2, 2026
508a7b2
fix(security): close Bash credential-read bypass and related gaps
Patel230 Jul 2, 2026
58a18de
fix(ui): polish welcome screen, theming, status bar, version output
Patel230 Jul 2, 2026
3ebc631
fix(security): make sandbox network denial reachable
Patel230 Jul 2, 2026
4ed54f1
fix(security): clamp autonomy for untrusted GitHub Actions events
Patel230 Jul 2, 2026
0f6966e
perf: make streaming render truly incremental + add benchmark
Patel230 Jul 2, 2026
b27c64c
feat(ui): make neutral text/border colors light-terminal legible
Patel230 Jul 2, 2026
b878d0a
fix(cli): harden shell flows and polish TUI performance
Patel230 Jul 2, 2026
584b560
perf: defer first-paint blockers and harden sandbox
Patel230 Jul 3, 2026
ad9621c
fix: retry container boot 3x with exponential backoff before host fal…
Patel230 Jul 3, 2026
4343a35
fix: unlock mutex and persist in ReplaceSystemContextSection append b…
Patel230 Jul 3, 2026
6088a1b
feat: replace plan mode with spec mode, add autonomy picker, polish TUI
Patel230 Jul 3, 2026
05ba9c7
chore: sync external/eyrie submodule to latest
Patel230 Jul 3, 2026
e508cbd
chore: sync all submodules to latest
Patel230 Jul 3, 2026
a902cd5
test: cover spec-gate/autonomy interaction and permission-center helpers
Patel230 Jul 4, 2026
512bc3f
feat: add spec mode, autonomy picker, and updated skills/tools
Patel230 Jul 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@ coverage.out

# Lefthook-generated git hook wrappers
.githooks/
__pycache__/
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Changed
- **Fixed `/mode auto` misclassifying plain English as shell commands**: `shellmode.ClassifyInput` trusted `exec.LookPath(firstWord)` alone, so any sentence starting with a word that's also a real Unix binary (`make sure this works`, `find the bug in this file`, `kill the old branch`, `sort out the imports`...) was silently executed as a shell command instead of being sent to the model — confirmed 18 of 20 sampled sentences misclassified before the fix. Now a curated allowlist of unambiguous dev-tool names (`git`, `npm`, `docker`, `ls`, `cat`, ...) is trusted immediately, while every other PATH match requires real shell-syntax evidence (a flag, a path, a file extension, or an operator like `|`/`>`/`&&`) before being trusted as a shell command — matching the same ambiguity Warp's own terminal autodetect documents and resolves with a user-configurable denylist.
- **Permission system unified into two independent axes**: the old `PermissionMode` (`default`/`acceptEdits`/`bypassPermissions`/`dontAsk`/`plan`) is removed. `/autonomy` now controls the 5-tier trust ladder (`Always Ask`/`Scout`/`Builder`/`Operator`/`Autonomous`, bare `/autonomy` opens a picker), and `/spec` controls an independent, orthogonal spec-driven workflow gate (`Specify → Plan → Tasks → ApproveImplementation`, bare `/spec` opens a picker) that blocks Write/Edit/Bash regardless of trust tier — including at Autonomous. Fixes a real bug where the old Plan Mode's write-block could be silently bypassed at high autonomy tiers, since tier and mode were checked independently with no ordering guarantee.
- **Fixed `PermissionService.SetAutonomy`/`Autonomy()`**: previously wrote to/read from a shadow field the permission engine's `CheckTool` never consulted, meaning autonomy tier changes may not have reliably taken effect. Now both read/write the same `PermissionEngine.Autonomy` field the check logic uses.
- **`--permission-mode` CLI flag removed**; `--dangerously-skip-permissions` unchanged (now maps to the Autonomous tier). New `--dry-run` flag added as an unconditional kill switch (deny every tool call, regardless of tier or spec stage) — replaces `dontAsk`'s hard-lockout role.
- **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` removed**: Hawk no longer ships the old shared type path, and local boundary checks now block any attempt to reintroduce it.

### Added
- **Spec-driven workflow (`/spec`)**: independent, orthogonal permission gate that walks the model through `Specify → Plan → Tasks`, writing real `spec.md`/`plan.md`/`tasks.md` files to `.hawk/specs/<slug>/`, and requires explicit `ApproveImplementation` approval (always prompts, at any trust tier) before Write/Edit/Bash unlock. The approval prompt shows the actual written content, not a blind yes/no.
- **`/autonomy` and `/spec` picker overlays**: bare `/autonomy` or `/spec` opens an arrow-key-navigable, filterable picker (Esc/Enter) instead of requiring subcommand syntax; typed subcommands (`/autonomy tier scout`, `/spec status`, etc.) still work.
- **Watch mode (`--watch`)**: file-watcher loop that acts on `AI!` (do-now) and `AI?` (answer) code comments. Off by default.
- **GitHub Action** (`.github/actions/hawk`): interactive mode on `@hawk` mentions, automation mode on labeled issues/PRs, and skill dispatch for `/`-prefixed prompts.
- **Messaging gateways**: opt-in Telegram, Discord, and Slack gateways on the daemon for chatting with hawk from messaging apps.
Expand Down
4 changes: 0 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,4 @@ size-check: build ## Report binary size and warn if over threshold (110MB, match
@SIZE=$$(stat -f%z bin/$(NAME) 2>/dev/null || stat -c%s bin/$(NAME) 2>/dev/null); \
MB=$$(echo "scale=1; $$SIZE / 1048576" | bc); \
echo "Binary size: $${MB} MB"; \
# Threshold matches CI (.github/workflows/ci.yml). CI emits a warning
# (::warning::) not an error so the build doesn't fail; we mirror that here
# so `make size-check` and CI agree on what's acceptable. Bump the threshold
# in both places if you intentionally grow the binary past 110MB.
if [ $$SIZE -gt 115343360 ]; then echo "::warning::Binary size $${MB} MB exceeds 110 MB threshold (CI gate)"; fi
56 changes: 36 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,23 +102,30 @@ hawk skills audit # Security scan installed skills

### Permission Center

hawk now exposes one visible permission command center in chat:
hawk exposes two independent chat command centers — trust tier and the
spec-driven workflow gate — rather than one merged permission mode:

```text
/permissions
/permissions tier <scout|builder|operator|autonomous>
/permissions sandbox <strict|workspace|off>
/permissions mode <default|edits|bypass|dontask|plan>
/permissions allow <rule>
/permissions deny <rule>
/permissions rules
/permissions reset
/permissions save [project|global]
/autonomy
/autonomy tier <scout|builder|operator|autonomous>
/autonomy sandbox <strict|workspace|off>
/autonomy dry-run <on|off>
/autonomy allow <rule>
/autonomy deny <rule>
/autonomy rules
/autonomy reset
/autonomy save [project|global]

/spec
/spec [what to build]
/spec status
/spec reset
```

The model is:

- `Tier` controls autonomy:
- `Tier` controls autonomy (bare `/autonomy` opens a picker for this):
- `Always Ask` — prompts for permission on every tool call
- `Scout`
- `Builder`
- `Operator`
Expand All @@ -127,10 +134,18 @@ The model is:
- `strict`
- `workspace`
- `off`
- `Dry-run` is a kill switch: denies every tool call unconditionally,
regardless of tier or spec stage.
- `Rules` control explicit allow/deny exceptions.
- `Spec` is a separate, independent workflow gate (bare `/spec` opens a
picker): starting it walks the model through `Specify → Plan → Tasks`,
writing real files to `.hawk/specs/<slug>/`, and blocks Write/Edit/Bash
until you approve moving to implementation — at **any** trust tier,
including Autonomous.

For normal chat usage, `/permissions` is the main control surface. Older
permission chat commands have been removed in favor of this single flow.
`/autonomy` and `/spec` are the main control surfaces for normal chat usage.
Older merged permission-mode chat commands have been removed in favor of
these two independent flows.

### MCP & LSP Support

Expand Down Expand Up @@ -199,13 +214,14 @@ hawk --provider openai --model gpt-4o # Override provider

```bash
# Inside the TUI
/permissions
/permissions tier builder
/permissions sandbox workspace
/permissions mode plan
/permissions allow Bash(git:*)
/permissions deny Bash(rm -rf *)
/permissions save project
/autonomy
/autonomy tier builder
/autonomy sandbox workspace
/autonomy allow Bash(git:*)
/autonomy deny Bash(rm -rf *)
/autonomy save project

/spec add dark mode support
```

### Non-Interactive Mode
Expand Down
1 change: 0 additions & 1 deletion cmd/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,6 @@ func (ac *Autocompleter) completeFlags(prefix string) []Suggestion {
{"--vibe", "Vibe coding mode"},
{"--power", "Power level 1-10"},
{"--timeout", "Time budget"},
{"--permission-mode", "Advanced permission mode"},
{"--session-id", "Session ID"},
}

Expand Down
209 changes: 209 additions & 0 deletions cmd/autonomy_picker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package cmd

import (
"strings"

"github.com/GrayCodeAI/hawk/internal/engine"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)

// autonomyPickerEntry is one selectable row in the picker.
type autonomyPickerEntry struct {
Level engine.AutonomyLevel
Name string
Description string
}

// allAutonomyTiers lists every tier in strictness order (most to least
// cautious), for the picker only. This is intentionally separate from
// containerAutonomyTiers (the Ctrl+L cycle), which deliberately excludes
// Supervised so repeated key-presses can't land you in max-friction mode by
// accident — the picker is a deliberate selection, so Supervised is fine here.
var allAutonomyTiers = []engine.AutonomyLevel{
engine.AutonomySupervised,
engine.AutonomyBasic,
engine.AutonomySemi,
engine.AutonomyFull,
engine.AutonomyYOLO,
}

// AutonomyPicker is a Ctrl+L-adjacent quick-select overlay for choosing a
// trust tier directly, modeled on CommandPalette's interaction pattern
// (arrow keys to navigate, Enter to select, Esc to dismiss, type to filter).
type AutonomyPicker struct {
open bool
input textinput.Model
entries []autonomyPickerEntry
filtered []autonomyPickerEntry
sel int
width int
}

// NewAutonomyPicker creates a new autonomy tier picker.
func NewAutonomyPicker(width int) *AutonomyPicker {
ti := textinput.New()
ti.Placeholder = "Type to filter…"
ti.Focus()
ti.CharLimit = 40
ti.Width = 40

entries := make([]autonomyPickerEntry, 0, len(allAutonomyTiers))
for _, level := range allAutonomyTiers {
entries = append(entries, autonomyPickerEntry{
Level: level,
Name: autonomyTierName(level),
Description: autonomyTierDescription(level),
})
}

return &AutonomyPicker{
input: ti,
width: width,
entries: entries,
filtered: entries,
}
}

// Open opens the picker, pre-selecting the currently active tier.
func (ap *AutonomyPicker) Open(current engine.AutonomyLevel) {
ap.open = true
ap.input.SetValue("")
ap.input.Focus()
ap.filtered = ap.entries
ap.sel = 0
for i, e := range ap.entries {
if e.Level == current {
ap.sel = i
break
}
}
}

// Close closes the picker.
func (ap *AutonomyPicker) Close() {
ap.open = false
ap.input.SetValue("")
ap.sel = 0
}

// IsOpen returns whether the picker is open.
func (ap *AutonomyPicker) IsOpen() bool {
return ap.open
}

// Selected returns the currently highlighted entry, or nil.
func (ap *AutonomyPicker) Selected() *autonomyPickerEntry {
if ap.sel >= 0 && ap.sel < len(ap.filtered) {
return &ap.filtered[ap.sel]
}
return nil
}

// Update handles key events. Returns (chosen, handled) — chosen is non-nil
// only on the keypress that commits a selection.
func (ap *AutonomyPicker) Update(msg tea.KeyMsg) (*autonomyPickerEntry, bool) {
if !ap.open {
return nil, false
}

switch msg.Type {
case tea.KeyEsc:
ap.Close()
return nil, true
case tea.KeyEnter:
sel := ap.Selected()
ap.Close()
return sel, true
case tea.KeyUp:
if len(ap.filtered) > 0 {
ap.sel--
if ap.sel < 0 {
ap.sel = len(ap.filtered) - 1
}
}
return nil, true
case tea.KeyDown:
if len(ap.filtered) > 0 {
ap.sel = (ap.sel + 1) % len(ap.filtered)
}
return nil, true
default:
var cmd tea.Cmd
ap.input, cmd = ap.input.Update(msg)
_ = cmd
ap.filter(ap.input.Value())
ap.sel = 0
return nil, true
}
}

func (ap *AutonomyPicker) filter(query string) {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
ap.filtered = ap.entries
return
}
filtered := make([]autonomyPickerEntry, 0, len(ap.entries))
for _, e := range ap.entries {
if strings.Contains(strings.ToLower(e.Name), query) || strings.Contains(strings.ToLower(e.Description), query) {
filtered = append(filtered, e)
}
}
ap.filtered = filtered
}

// Render renders the picker as a string, styled to match CommandPalette.
func (ap *AutonomyPicker) Render(viewWidth int) string {
if !ap.open {
return ""
}

if viewWidth < 60 {
viewWidth = 60
}
boxWidth := viewWidth - 4
if boxWidth > 78 {
boxWidth = 78
}

var b strings.Builder
b.WriteString(paletteTitleStyle.Render(" Autonomy"))
b.WriteString(paletteDimStyle.Render(" (↑↓ navigate · Enter select · Esc dismiss)"))
b.WriteString("\n\n")

ap.input.Width = boxWidth - 4
b.WriteString(paletteInputStyle.Width(boxWidth - 2).Render(ap.input.View()))
b.WriteString("\n\n")

if len(ap.filtered) == 0 {
b.WriteString(paletteDimStyle.Render(" No matching tiers"))
} else {
nameWidth := 0
for _, e := range ap.filtered {
if len(e.Name) > nameWidth {
nameWidth = len(e.Name)
}
}
for i, e := range ap.filtered {
name := lipgloss.NewStyle().Bold(true).Foreground(autonomyTierColor(e.Level)).Render(padRight(e.Name, nameWidth))
line := " " + name + " " + e.Description
if i == ap.sel {
b.WriteString(paletteSelStyle.Width(boxWidth).Render(" " + padRight(e.Name, nameWidth) + " " + e.Description))
} else {
b.WriteString(paletteItemStyle.Width(boxWidth).Render(line))
}
b.WriteString("\n")
}
}

return paletteBoxStyle.Width(boxWidth).Render(strings.TrimRight(b.String(), "\n"))
}

func padRight(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}
Loading
Loading