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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ This repo contains the CLI for Entire.
- `entire/cli/commands`: actual command implementations
- `entire/cli/agent`: agent implementations (Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI Droid, Copilot CLI, Pi) - see [Agent Integration Checklist](docs/architecture/agent-integration-checklist.md) and [Agent Implementation Guide](docs/architecture/agent-guide.md)
- `entire/cli/strategy`: strategy implementation (manual-commit) - see section below
- `entire/cli/plugins`: embedded Lua plugin runtime (pure-Go gopher-lua sandbox, lifecycle/git hook bus, capability model, plugin-contributed commands) - see [Lua Plugins](docs/architecture/plugins-lua.md)
- `entire/cli/checkpoint`: checkpoint storage abstractions (ephemeral and persistent)
- `entire/cli/session`: session state management
- `entire/cli/integration_test`: integration tests (simulated hooks)
Expand Down Expand Up @@ -630,6 +631,32 @@ The phase state machine, metadata directory layout, sharded checkpoint format, m
- Test with `mise run test` - strategy tests are in `*_test.go` files
- Keep this file and `docs/architecture/sessions-and-checkpoints.md` current when changing strategy behavior (`AGENTS.md` is a symlink to this file)

### Lua Plugins (`cmd/entire/cli/plugins/`)

Third parties can author no-build-step plugins in Lua (pure-Go gopher-lua, so
`CGO_ENABLED=0` still holds) that subscribe to lifecycle/git hooks and contribute
`entire <name>` commands, layered on the kubectl-style binary plugins.

Key invariants when touching this code:

- Plugins are **inert until allow-listed** (`plugins.<name>.enabled = true`).
**Repo-local `.entire/plugins/` plugins may only be enabled from the personal,
uncommitted `.entire/settings.local.json` — never from the committed team
`.entire/settings.json`** (`settings.LocalPluginGrants` + `grantForSource` in
the `plugins` package), so cloning a hostile repo that ships both a plugin and
a team-settings enable cannot run its code. User-global installed plugins use
the merged allow-list (either file). Privileged APIs (`http`/`exec`/`fs`/`net`)
and the mutating hooks (`prepare_commit_msg`, `pre_push`) are
**capability-gated** and fail loud when ungranted.
- The `plugins` package must NOT import `cli` or `strategy` (they import it);
seams build `map[string]any` payloads that the package converts to Lua tables.
- Command resolution order is built-in > Lua command > `entire-<name>` binary.
- Hooks fire best-effort with per-hook timeouts and panic isolation; an observer
must never break a lifecycle event or git hook.

See [Lua Plugins](docs/architecture/plugins-lua.md) for the full reference and
`examples/plugins/` for working examples.

### `entire review` Command

`entire review` runs a configured review profile. Keep documentation brief and user-facing.
Expand Down
22 changes: 22 additions & 0 deletions cmd/entire/cli/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,23 @@ func DispatchLifecycleEvent(ctx context.Context, ag agent.Agent, event *agent.Ev
}
}

err := dispatchLifecycleEventHandlers(ctx, ag, event)

// Dispatch the corresponding observer hook to Lua plugins after the
// framework has handled the event, so plugins observe post-handling state.
// Only on success — a failed handler leaves state the plugin shouldn't act
// on. Best-effort and a no-op when no plugin is enabled.
if err == nil {
firePluginLifecycleHook(ctx, ag, event)
}
return err
}

// dispatchLifecycleEventHandlers routes an event to its handler. It is split
// from DispatchLifecycleEvent so plugin observer hooks can fire after a
// successful handler without changing the handlers' direct error-propagation
// shape (which several handlers rely on to stay lint-clean).
func dispatchLifecycleEventHandlers(ctx context.Context, ag agent.Agent, event *agent.Event) error {
switch event.Type {
case agent.SessionStart:
return handleLifecycleSessionStart(ctx, ag, event)
Expand Down Expand Up @@ -944,6 +961,11 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev
return fmt.Errorf("failed to save step: %w", err)
}

// Observer hook: a session step checkpoint was written. Fired here (rather
// than inside SaveStep) so the strategy package stays decoupled from the
// plugin layer and the payload can draw on the fully-populated step context.
firePluginCheckpointSaved(ctx, stepCtx)

// Update session state with backfilled prompt after SaveStep.
// Done after SaveStep because SaveStep may reinitialize session state,
// which would overwrite an earlier LastPrompt update.
Expand Down
102 changes: 102 additions & 0 deletions cmd/entire/cli/lua_plugin_hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cli

import (
"context"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/plugins"
"github.com/entireio/cli/cmd/entire/cli/strategy"
)

// lifecycleHookName maps an internal agent lifecycle event to the stable,
// plugin-facing hook name. The mapping is deliberately explicit (not a direct
// enum-to-string) so the internal EventType can evolve without breaking the
// documented plugin surface. Events with no plugin hook (SubagentStart,
// ToolUse) return ok=false.
func lifecycleHookName(t agent.EventType) (string, bool) {
switch t {
case agent.SessionStart:
return plugins.HookSessionStart, true
case agent.TurnStart:
return plugins.HookTurnStart, true
case agent.TurnEnd:
return plugins.HookTurnEnd, true
case agent.Compaction:
return plugins.HookCompaction, true
case agent.SessionEnd:
return plugins.HookSessionEnd, true
case agent.SubagentEnd:
return plugins.HookSubagentEnd, true
case agent.ModelUpdate:
return plugins.HookModelUpdate, true
case agent.SubagentStart, agent.ToolUse:
return "", false
default:
return "", false
}
}

// firePluginLifecycleHook dispatches the observer hook for a successfully
// handled lifecycle event. Best-effort: FireHook is a no-op when no plugin is
// enabled and never propagates plugin failures.
func firePluginLifecycleHook(ctx context.Context, ag agent.Agent, event *agent.Event) {
hook, ok := lifecycleHookName(event.Type)
if !ok {
return
}
plugins.FireHook(ctx, hook, lifecycleHookPayload(ag, event))
}

// lifecycleHookPayload builds the Lua-table payload for a lifecycle hook. It
// exposes operational metadata (ids, agent, model, file paths) but not prompt
// text or file contents — richer, sensitive data is reserved for
// capability-gated APIs.
func lifecycleHookPayload(ag agent.Agent, event *agent.Event) map[string]any {
payload := map[string]any{
"event": event.Type.String(),
"agent": string(ag.Name()),
}
if event.SessionID != "" {
payload["session_id"] = event.SessionID
}
if event.SessionRef != "" {
payload["session_ref"] = event.SessionRef
}
if event.Model != "" {
payload["model"] = event.Model
}
if len(event.ModifiedFiles) > 0 {
payload["modified_files"] = event.ModifiedFiles
}
if len(event.NewFiles) > 0 {
payload["new_files"] = event.NewFiles
}
if len(event.DeletedFiles) > 0 {
payload["deleted_files"] = event.DeletedFiles
}
if event.SubagentType != "" {
payload["subagent_type"] = event.SubagentType
}
return payload
}

// firePluginCheckpointSaved dispatches the checkpoint_saved observer hook after
// a session step checkpoint is written.
func firePluginCheckpointSaved(ctx context.Context, stepCtx strategy.StepContext) {
payload := map[string]any{
"agent": string(stepCtx.AgentType),
}
if stepCtx.SessionID != "" {
payload["session_id"] = stepCtx.SessionID
}
if len(stepCtx.ModifiedFiles) > 0 {
payload["modified_files"] = stepCtx.ModifiedFiles
}
if len(stepCtx.NewFiles) > 0 {
payload["new_files"] = stepCtx.NewFiles
}
if len(stepCtx.DeletedFiles) > 0 {
payload["deleted_files"] = stepCtx.DeletedFiles
}
plugins.FireHook(ctx, plugins.HookCheckpointSaved, payload)
}
66 changes: 66 additions & 0 deletions cmd/entire/cli/lua_plugin_hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package cli

import (
"testing"

"github.com/entireio/cli/cmd/entire/cli/agent"
"github.com/entireio/cli/cmd/entire/cli/plugins"
)

func TestLifecycleHookName_Mapping(t *testing.T) {
t.Parallel()
cases := []struct {
in agent.EventType
want string
ok bool
}{
{agent.SessionStart, plugins.HookSessionStart, true},
{agent.TurnStart, plugins.HookTurnStart, true},
{agent.TurnEnd, plugins.HookTurnEnd, true},
{agent.Compaction, plugins.HookCompaction, true},
{agent.SessionEnd, plugins.HookSessionEnd, true},
{agent.SubagentEnd, plugins.HookSubagentEnd, true},
{agent.ModelUpdate, plugins.HookModelUpdate, true},
{agent.SubagentStart, "", false},
{agent.ToolUse, "", false},
}
for _, c := range cases {
got, ok := lifecycleHookName(c.in)
if got != c.want || ok != c.ok {
t.Errorf("lifecycleHookName(%v) = (%q, %v), want (%q, %v)", c.in, got, ok, c.want, c.ok)
}
}
}

func TestLifecycleHookPayload_OmitsEmptyAndSensitive(t *testing.T) {
t.Parallel()
ag, err := agent.Get(agent.AgentNameClaudeCode)
if err != nil {
t.Skipf("claude-code agent not registered: %v", err)
}
event := &agent.Event{
Type: agent.TurnEnd,
SessionID: "sess-1",
Model: "claude-sonnet",
Prompt: "secret prompt text",
ModifiedFiles: []string{"a.go", "b.go"},
}
payload := lifecycleHookPayload(ag, event)

if payload["session_id"] != "sess-1" {
t.Errorf("session_id = %v", payload["session_id"])
}
if payload["model"] != "claude-sonnet" {
t.Errorf("model = %v", payload["model"])
}
if _, ok := payload["prompt"]; ok {
t.Error("payload must not expose prompt text")
}
if _, ok := payload["session_ref"]; ok {
t.Error("empty session_ref should be omitted")
}
files, ok := payload["modified_files"].([]string)
if !ok || len(files) != 2 {
t.Errorf("modified_files = %v", payload["modified_files"])
}
}
Loading
Loading