Skip to content
Draft
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
95 changes: 95 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Looper

Looper is a local automation system for running role-based coding-agent workflows across GitHub projects. This context records the product language used when extending Looper's workflow behavior.

## Language

**Workflow Policy Pack**:
A replaceable set of role-bound workflow policies that Looper injects into agent prompts.
_Avoid_: skill pack, prompt bundle, Matt skills

**Role**:
A built-in Looper responsibility lane that decides what kind of work an agent run performs.
_Avoid_: skill, agent type

**Workflow Policy**:
A focused behavior rule that guides one role's agent prompt without changing Looper's lifecycle or output contracts.
_Avoid_: raw prompt, system prompt, skill

**Prompt-Time Policy**:
A workflow policy that can change agent instructions but cannot change queueing, locks, GitHub side effects, lifecycle, recovery, or completion-marker behavior.
_Avoid_: runtime plugin, scheduler extension

**Policy Pack ID**:
A stable machine-readable identifier used to bind a workflow policy pack in configuration.
_Avoid_: display name, title

**Policy Pack Name**:
A human-friendly label for a workflow policy pack shown in CLI output, docs, and diagnostics.
_Avoid_: id, slug

**Role Policy Binding**:
A configuration choice that assigns one workflow policy pack to one Looper role.
_Avoid_: per-policy selection, policy list

**Role-Direct Policy Text**:
Workflow policy text authored directly for a Looper role inside a workflow policy pack.
_Avoid_: abstract policy graph, reusable policy atom

**Policy Precedence**:
The prompt assembly order that places workflow policy pack instructions before user/project custom instructions and before Looper's final lifecycle contracts.
_Avoid_: priority, override order

**Explicit Policy Binding**:
The v1 requirement that a role uses a workflow policy pack only when its role config names that pack.
_Avoid_: implicit global policy, default pack

**Built-In Policy Pack**:
A workflow policy pack shipped with Looper and loaded through the same schema as file-based packs.
_Avoid_: hardcoded prompt constants

**File Policy Pack**:
A workflow policy pack loaded from a user-provided local file.
_Avoid_: Go plugin, remote plugin

**Policy Validation**:
The safety check that ensures workflow policy packs can guide role behavior without overriding Looper's protected lifecycle, safety, disclosure, or completion-marker contracts.
_Avoid_: lint only, schema only

**Policy Visibility**:
The user-facing display of enabled workflow policy packs and role bindings before a loop runs.
_Avoid_: hidden prompt behavior

## Relationships

- A **Workflow Policy Pack** contains one or more **Workflow Policies**.
- A **Workflow Policy Pack** can be bound to one or more **Roles**.
- A **Role** can use a **Workflow Policy Pack** while still preserving Looper's lifecycle, safety, disclosure, and completion-marker contracts.
- A **Prompt-Time Policy** is the v1 boundary for **Workflow Policies**.
- A **Policy Pack ID** identifies a **Workflow Policy Pack** in config.
- A **Policy Pack Name** describes a **Workflow Policy Pack** for humans.
- A **Role Policy Binding** selects a whole **Workflow Policy Pack** for a **Role** in v1.
- **Role-Direct Policy Text** is the v1 content format for a **Workflow Policy Pack**.
- **Policy Precedence** lets user/project custom instructions refine a **Workflow Policy Pack** while Looper's lifecycle, safety, disclosure, and completion-marker contracts remain final.
- **Explicit Policy Binding** prevents **Workflow Policy Packs** from changing a **Role** unless the role names the pack.
- **Built-In Policy Packs** and **File Policy Packs** share the same pack schema and validation path.
- **Policy Validation** applies to both **Built-In Policy Packs** and **File Policy Packs**.
- **Policy Visibility** lets users inspect active **Role Policy Bindings** through config, status, or prompt preview commands.

## Example dialogue

> **Dev:** "Can we make the Matt skills replaceable?"
> **Domain expert:** "Yes, but in Looper they are **Workflow Policy Packs**. Users bind a pack to a **Role**, and Looper injects the matching **Workflow Policies** into that role's prompt."

## Flagged ambiguities

- "Matt skills" was used to mean Codex-local skills and Looper runtime behavior. Resolved: Looper should expose replaceable **Workflow Policy Packs**, not depend on Codex skill installation.
- "Pluggable component" could imply runtime plugins. Resolved: v1 **Workflow Policy Packs** are **Prompt-Time Policies** only.
- "name" could mean either a stable config key or a display label. Resolved: use **Policy Pack ID** for stable references and **Policy Pack Name** for human-friendly display.
- "select policies" could mean choosing individual policies from a pack. Resolved: v1 uses whole-pack **Role Policy Binding** only.
- "policy composition" could mean an abstract graph of reusable policy atoms. Resolved: v1 packs use **Role-Direct Policy Text**.
- "override" could imply policy packs outrank project-specific instructions. Resolved: **Policy Precedence** puts pack text before user/project custom instructions, and Looper contracts last.
- "enabled" could imply all roles automatically use a policy pack. Resolved: v1 requires **Explicit Policy Binding** per role.
- "built-in" could imply hardcoded Go prompt strings. Resolved: built-ins should be bundled files loaded like **File Policy Packs**.
- "validation" could mean only JSON shape validation. Resolved: **Policy Validation** also blocks attempts to alter Looper's protected contracts.
- "active policy" should not be hidden inside agent prompts. Resolved: **Policy Visibility** should show enabled packs and role bindings before runs execute.
90 changes: 65 additions & 25 deletions internal/api/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -499,13 +499,14 @@ func (h *Handler) buildHealthResponse(ctx context.Context) (healthResponse, erro
}

type statusResponse struct {
Service statusService `json:"service"`
Storage statusStorage `json:"storage"`
Scheduler statusScheduler `json:"scheduler"`
Loops statusLoops `json:"loops"`
Safety statusSafety `json:"safety"`
Notifications statusNotifications `json:"notifications"`
Tools statusTools `json:"tools"`
Service statusService `json:"service"`
Storage statusStorage `json:"storage"`
Scheduler statusScheduler `json:"scheduler"`
Loops statusLoops `json:"loops"`
Safety statusSafety `json:"safety"`
WorkflowPolicies statusWorkflowPolicies `json:"workflowPolicies"`
Notifications statusNotifications `json:"notifications"`
Tools statusTools `json:"tools"`
}

type statusService struct {
Expand Down Expand Up @@ -594,6 +595,17 @@ type statusSafety struct {
OpenPRStrategy config.OpenPRStrategy `json:"openPrStrategy"`
}

type statusWorkflowPolicies struct {
Enabled bool `json:"enabled"`
Bindings map[string]*statusWorkflowPolicyBinding `json:"bindings"`
}

type statusWorkflowPolicyBinding struct {
ID string `json:"id"`
Name string `json:"name"`
Display string `json:"display"`
}

type statusNotifications struct {
InAppEnabled bool `json:"inAppEnabled"`
OsascriptEnabled bool `json:"osascriptEnabled"`
Expand All @@ -606,19 +618,20 @@ type statusTools struct {
}

type configResponse struct {
Server configServerResponse `json:"server"`
Storage config.StorageConfig `json:"storage"`
Scheduler config.SchedulerConfig `json:"scheduler"`
Agent config.AgentConfig `json:"agent"`
Logging config.LoggingConfig `json:"logging"`
Notifications config.NotificationConfig `json:"notifications"`
Tools config.ToolPathsConfig `json:"tools"`
Daemon configDaemonResponse `json:"daemon"`
Package config.PackageConfig `json:"package"`
Defaults config.DefaultsConfig `json:"defaults"`
Reviewer config.ReviewerConfig `json:"reviewer"`
Roles config.RoleConfigs `json:"roles"`
Projects []config.ProjectRefConfig `json:"projects"`
Server configServerResponse `json:"server"`
Storage config.StorageConfig `json:"storage"`
Scheduler config.SchedulerConfig `json:"scheduler"`
Agent config.AgentConfig `json:"agent"`
Logging config.LoggingConfig `json:"logging"`
Notifications config.NotificationConfig `json:"notifications"`
Tools config.ToolPathsConfig `json:"tools"`
Daemon configDaemonResponse `json:"daemon"`
Package config.PackageConfig `json:"package"`
Defaults config.DefaultsConfig `json:"defaults"`
Reviewer config.ReviewerConfig `json:"reviewer"`
WorkflowPacks config.WorkflowPolicyPacksConfig `json:"workflowPolicyPacks"`
Roles config.RoleConfigs `json:"roles"`
Projects []config.ProjectRefConfig `json:"projects"`
}

type configServerResponse struct {
Expand Down Expand Up @@ -665,11 +678,12 @@ func (h *Handler) buildConfigResponse() configResponse {
WorkingDirectory: cfg.Daemon.WorkingDirectory,
Environment: cfg.Daemon.Environment,
},
Package: cfg.Package,
Defaults: cfg.Defaults,
Reviewer: cfg.Reviewer,
Roles: cfg.Roles,
Projects: append([]config.ProjectRefConfig{}, cfg.Projects...),
Package: cfg.Package,
Defaults: cfg.Defaults,
Reviewer: cfg.Reviewer,
WorkflowPacks: cfg.WorkflowPolicyPacks,
Roles: cfg.Roles,
Projects: append([]config.ProjectRefConfig{}, cfg.Projects...),
}
}

Expand Down Expand Up @@ -745,6 +759,7 @@ func (h *Handler) buildStatusResponse(ctx context.Context) (statusResponse, erro
FixAllPullRequests: h.context.Config.Defaults.FixAllPullRequests,
OpenPRStrategy: h.context.Config.Defaults.OpenPRStrategy,
},
WorkflowPolicies: buildWorkflowPolicyStatus(h.context.Config),
Notifications: statusNotifications{
InAppEnabled: h.context.Config.Notifications.InApp,
OsascriptEnabled: h.context.Config.Notifications.Osascript.Enabled,
Expand All @@ -757,6 +772,31 @@ func (h *Handler) buildStatusResponse(ctx context.Context) (statusResponse, erro
}, nil
}

func buildWorkflowPolicyStatus(cfg config.Config) statusWorkflowPolicies {
bindings := map[string]*statusWorkflowPolicyBinding{
"planner": workflowPolicyStatusBinding(cfg, cfg.Roles.Planner.PolicyPack),
"reviewer": workflowPolicyStatusBinding(cfg, cfg.Roles.Reviewer.PolicyPack),
"worker": workflowPolicyStatusBinding(cfg, cfg.Roles.Worker.PolicyPack),
"fixer": workflowPolicyStatusBinding(cfg, cfg.Roles.Fixer.PolicyPack),
}
return statusWorkflowPolicies{Enabled: cfg.WorkflowPolicyPacks.Enabled, Bindings: bindings}
}

func workflowPolicyStatusBinding(cfg config.Config, packID string) *statusWorkflowPolicyBinding {
packID = strings.TrimSpace(packID)
if packID == "" {
return nil
}
name := packID
for _, ref := range cfg.WorkflowPolicyPacks.Packs {
if ref.ID == packID && strings.TrimSpace(ref.Name) != "" {
name = ref.Name
break
}
}
return &statusWorkflowPolicyBinding{ID: packID, Name: name, Display: fmt.Sprintf("%s (%s)", packID, name)}
}

type storageState struct {
OK bool
LatestAvailableID string
Expand Down
8 changes: 8 additions & 0 deletions internal/api/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func TestIsTerminalReviewerLoopRecordTreatsFailedAsTerminal(t *testing.T) {

func TestHandlerStatusSuccessContainsExpectedSections(t *testing.T) {
rt, cfg := startTestRuntime(t)
cfg.Roles.Worker.PolicyPack = "matt-series"
seedStatusData(t, rt)
seedStatusLoopCounts(t, rt)

Expand All @@ -92,6 +93,7 @@ func TestHandlerStatusSuccessContainsExpectedSections(t *testing.T) {
binaryInfo := service["binary"].(map[string]any)
storageInfo := data["storage"].(map[string]any)
scheduler := data["scheduler"].(map[string]any)
workflowPolicies := data["workflowPolicies"].(map[string]any)
loops := data["loops"].(map[string]any)

assertEqual(t, service["healthy"], true)
Expand All @@ -111,6 +113,10 @@ func TestHandlerStatusSuccessContainsExpectedSections(t *testing.T) {
}
assertEqual(t, scheduler["totalRuns"], float64(1))
assertEqual(t, scheduler["activeRuns"], float64(1))
workflowBindings := workflowPolicies["bindings"].(map[string]any)
workerBinding := workflowBindings["worker"].(map[string]any)
assertEqual(t, workflowPolicies["enabled"], true)
assertEqual(t, workerBinding["display"], "matt-series (Matt Series Engineering Workflow)")

reviewer := loops["reviewer"].(map[string]any)
assertEqual(t, reviewer["queued"], float64(1))
Expand Down Expand Up @@ -141,6 +147,7 @@ func TestHandlerConfigSuccessContainsExpectedSections(t *testing.T) {
daemon := data["daemon"].(map[string]any)
reviewer := data["reviewer"].(map[string]any)
reviewerLoop := reviewer["loop"].(map[string]any)
workflowPacks := data["workflowPolicyPacks"].(map[string]any)

assertEqual(t, server["host"], cfg.Server.Host)
assertEqual(t, server["port"], float64(cfg.Server.Port))
Expand All @@ -157,6 +164,7 @@ func TestHandlerConfigSuccessContainsExpectedSections(t *testing.T) {
assertEqual(t, threadResolution["mode"], string(cfg.Reviewer.ThreadResolution.Mode))
assertEqual(t, reviewerLoop["enabledByDefault"], cfg.Reviewer.Loop.EnabledByDefault)
assertEqual(t, reviewerLoop["maxConsecutiveFailures"], float64(cfg.Reviewer.Loop.MaxConsecutiveFailures))
assertEqual(t, workflowPacks["enabled"], cfg.WorkflowPolicyPacks.Enabled)
if _, ok := daemon["shutdownTimeoutMs"]; ok {
t.Fatalf("daemon.shutdownTimeoutMs should be omitted from config response: %#v", daemon)
}
Expand Down
19 changes: 19 additions & 0 deletions internal/api/testdata/contracts/daemon-http.responses.compat.json
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,15 @@
"fixAllPullRequests": false,
"openPrStrategy": "all_done"
},
"workflowPolicies": {
"enabled": true,
"bindings": {
"planner": null,
"reviewer": null,
"worker": null,
"fixer": null
}
},
"notifications": {
"inAppEnabled": true,
"osascriptEnabled": true
Expand Down Expand Up @@ -273,6 +282,16 @@
"scope": "looper_authored_only"
}
},
"workflowPolicyPacks": {
"enabled": true,
"packs": [
{
"id": "matt-series",
"name": "Matt Series Engineering Workflow",
"source": "builtin"
}
]
},
"roles": {
"planner": {
"autoDiscovery": true,
Expand Down
8 changes: 7 additions & 1 deletion internal/cliapp/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,12 @@ func TestStatusWithoutJSONPrintsHumanReadableSections(t *testing.T) {
"worker": map[string]any{"running": 2, "paused": 0, "failed": 1},
"fixer": map[string]any{"running": 0, "paused": 0, "failed": 0},
},
"workflowPolicies": map[string]any{
"enabled": true,
"bindings": map[string]any{
"worker": map[string]any{"id": "matt-series", "name": "Matt Series Engineering Workflow", "display": "matt-series (Matt Series Engineering Workflow)"},
},
},
"tools": map[string]any{"git": true, "gh": false, "osascript": true},
"notifications": map[string]any{"inAppEnabled": true, "osascriptEnabled": false},
}))
Expand All @@ -812,7 +818,7 @@ func TestStatusWithoutJSONPrintsHumanReadableSections(t *testing.T) {
if stderr != "" {
t.Fatalf("Run([status]) stderr = %q, want empty string", stderr)
}
for _, want := range []string{"Service", "healthy : yes", "version : 1.2.3", "Storage", "Scheduler", "type", "reviewer", "Tools", "gh : no", "Notifications"} {
for _, want := range []string{"Service", "healthy : yes", "version : 1.2.3", "Storage", "Scheduler", "Workflow policies", "worker : matt-series (Matt Series Engineering Workflow)", "type", "reviewer", "Tools", "gh : no", "Notifications"} {
if !strings.Contains(stdout, want) {
t.Fatalf("Run([status]) stdout = %q, want to contain %q", stdout, want)
}
Expand Down
Loading