agent/pkg/lifecycle/plans.go
Snider e90a84eaa0 feat: merge go-agent + go-agentic + php-devops into unified agent repo
Combines three repositories into a single workspace:
- go-agent → pkg/orchestrator (Clotho), pkg/jobrunner, pkg/loop, cmd/
- go-agentic → pkg/lifecycle (allowance, sessions, plans, dispatch)
- php-devops → repos.yaml, setup.sh, scripts/, .core/

Module path: forge.lthn.ai/core/agent

All packages build, all tests pass.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-06 15:23:00 +00:00

525 lines
15 KiB
Go

package lifecycle
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"forge.lthn.ai/core/go-log"
)
// PlanStatus represents the state of a plan.
type PlanStatus string
const (
PlanDraft PlanStatus = "draft"
PlanActive PlanStatus = "active"
PlanPaused PlanStatus = "paused"
PlanCompleted PlanStatus = "completed"
PlanArchived PlanStatus = "archived"
)
// PhaseStatus represents the state of a phase within a plan.
type PhaseStatus string
const (
PhasePending PhaseStatus = "pending"
PhaseInProgress PhaseStatus = "in_progress"
PhaseCompleted PhaseStatus = "completed"
PhaseBlocked PhaseStatus = "blocked"
PhaseSkipped PhaseStatus = "skipped"
)
// Plan represents an agent plan from the PHP API.
type Plan struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status PlanStatus `json:"status"`
CurrentPhase int `json:"current_phase,omitempty"`
Progress Progress `json:"progress,omitempty"`
Phases []Phase `json:"phases,omitempty"`
Metadata any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// Phase represents a phase within a plan.
type Phase struct {
ID int `json:"id,omitempty"`
Order int `json:"order"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status PhaseStatus `json:"status"`
Tasks []PhaseTask `json:"tasks,omitempty"`
TaskProgress TaskProgress `json:"task_progress,omitempty"`
RemainingTasks []string `json:"remaining_tasks,omitempty"`
Dependencies []int `json:"dependencies,omitempty"`
DependencyBlockers []string `json:"dependency_blockers,omitempty"`
CanStart bool `json:"can_start,omitempty"`
Checkpoints []any `json:"checkpoints,omitempty"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
// PhaseTask represents a task within a phase. Tasks are stored as a JSON array
// in the phase and may be simple strings or objects with status/notes.
type PhaseTask struct {
Name string `json:"name"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
}
// UnmarshalJSON handles the fact that tasks can be either strings or objects.
func (t *PhaseTask) UnmarshalJSON(data []byte) error {
// Try string first
var s string
if err := json.Unmarshal(data, &s); err == nil {
t.Name = s
t.Status = "pending"
return nil
}
// Try object
type taskAlias PhaseTask
var obj taskAlias
if err := json.Unmarshal(data, &obj); err != nil {
return err
}
*t = PhaseTask(obj)
return nil
}
// Progress represents plan progress metrics.
type Progress struct {
Total int `json:"total"`
Completed int `json:"completed"`
InProgress int `json:"in_progress"`
Pending int `json:"pending"`
Percentage int `json:"percentage"`
}
// TaskProgress represents task-level progress within a phase.
type TaskProgress struct {
Total int `json:"total"`
Completed int `json:"completed"`
Pending int `json:"pending"`
Percentage int `json:"percentage"`
}
// ListPlanOptions specifies filters for listing plans.
type ListPlanOptions struct {
Status PlanStatus `json:"status,omitempty"`
IncludeArchived bool `json:"include_archived,omitempty"`
}
// CreatePlanRequest is the payload for creating a new plan.
type CreatePlanRequest struct {
Title string `json:"title"`
Slug string `json:"slug,omitempty"`
Description string `json:"description,omitempty"`
Context map[string]any `json:"context,omitempty"`
Phases []CreatePhaseInput `json:"phases,omitempty"`
}
// CreatePhaseInput is a phase definition for plan creation.
type CreatePhaseInput struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Tasks []string `json:"tasks,omitempty"`
}
// planListResponse wraps the list endpoint response.
type planListResponse struct {
Plans []Plan `json:"plans"`
Total int `json:"total"`
}
// planCreateResponse wraps the create endpoint response.
type planCreateResponse struct {
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
Phases int `json:"phases"`
}
// planUpdateResponse wraps the update endpoint response.
type planUpdateResponse struct {
Slug string `json:"slug"`
Status string `json:"status"`
}
// planArchiveResponse wraps the archive endpoint response.
type planArchiveResponse struct {
Slug string `json:"slug"`
Status string `json:"status"`
ArchivedAt string `json:"archived_at,omitempty"`
}
// ListPlans retrieves plans matching the given options.
func (c *Client) ListPlans(ctx context.Context, opts ListPlanOptions) ([]Plan, error) {
const op = "agentic.Client.ListPlans"
params := url.Values{}
if opts.Status != "" {
params.Set("status", string(opts.Status))
}
if opts.IncludeArchived {
params.Set("include_archived", "1")
}
endpoint := c.BaseURL + "/v1/plans"
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result planListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return result.Plans, nil
}
// GetPlan retrieves a plan by slug (returns full detail with phases).
func (c *Client) GetPlan(ctx context.Context, slug string) (*Plan, error) {
const op = "agentic.Client.GetPlan"
if slug == "" {
return nil, log.E(op, "plan slug is required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var plan Plan
if err := json.NewDecoder(resp.Body).Decode(&plan); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &plan, nil
}
// CreatePlan creates a new plan with optional phases.
func (c *Client) CreatePlan(ctx context.Context, req CreatePlanRequest) (*planCreateResponse, error) {
const op = "agentic.Client.CreatePlan"
if req.Title == "" {
return nil, log.E(op, "title is required", nil)
}
data, err := json.Marshal(req)
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := c.BaseURL + "/v1/plans"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result planCreateResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}
// UpdatePlanStatus changes a plan's status.
func (c *Client) UpdatePlanStatus(ctx context.Context, slug string, status PlanStatus) error {
const op = "agentic.Client.UpdatePlanStatus"
if slug == "" {
return log.E(op, "plan slug is required", nil)
}
data, err := json.Marshal(map[string]string{"status": string(status)})
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// ArchivePlan archives a plan with an optional reason.
func (c *Client) ArchivePlan(ctx context.Context, slug string, reason string) error {
const op = "agentic.Client.ArchivePlan"
if slug == "" {
return log.E(op, "plan slug is required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
var body *bytes.Reader
if reason != "" {
data, _ := json.Marshal(map[string]string{"reason": reason})
body = bytes.NewReader(data)
}
var reqBody *bytes.Reader
if body != nil {
reqBody = body
}
var httpReq *http.Request
var err error
if reqBody != nil {
httpReq, err = http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, reqBody)
if err != nil {
return log.E(op, "failed to create request", err)
}
httpReq.Header.Set("Content-Type", "application/json")
} else {
httpReq, err = http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return log.E(op, "failed to create request", err)
}
}
c.setHeaders(httpReq)
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// GetPhase retrieves a specific phase within a plan.
func (c *Client) GetPhase(ctx context.Context, planSlug string, phase string) (*Phase, error) {
const op = "agentic.Client.GetPhase"
if planSlug == "" || phase == "" {
return nil, log.E(op, "plan slug and phase identifier are required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result Phase
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}
// UpdatePhaseStatus changes a phase's status.
func (c *Client) UpdatePhaseStatus(ctx context.Context, planSlug, phase string, status PhaseStatus, notes string) error {
const op = "agentic.Client.UpdatePhaseStatus"
if planSlug == "" || phase == "" {
return log.E(op, "plan slug and phase identifier are required", nil)
}
payload := map[string]string{"status": string(status)}
if notes != "" {
payload["notes"] = notes
}
data, err := json.Marshal(payload)
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// AddCheckpoint adds a checkpoint note to a phase.
func (c *Client) AddCheckpoint(ctx context.Context, planSlug, phase, note string, checkpointCtx map[string]any) error {
const op = "agentic.Client.AddCheckpoint"
if planSlug == "" || phase == "" || note == "" {
return log.E(op, "plan slug, phase, and note are required", nil)
}
payload := map[string]any{"note": note}
if len(checkpointCtx) > 0 {
payload["context"] = checkpointCtx
}
data, err := json.Marshal(payload)
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/checkpoint",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// UpdateTaskStatus updates a task within a phase.
func (c *Client) UpdateTaskStatus(ctx context.Context, planSlug, phase string, taskIdx int, status string, notes string) error {
const op = "agentic.Client.UpdateTaskStatus"
if planSlug == "" || phase == "" {
return log.E(op, "plan slug and phase are required", nil)
}
payload := map[string]any{}
if status != "" {
payload["status"] = status
}
if notes != "" {
payload["notes"] = notes
}
data, err := json.Marshal(payload)
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/tasks/%d",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase), taskIdx)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// ToggleTask toggles a task between pending and completed.
func (c *Client) ToggleTask(ctx context.Context, planSlug, phase string, taskIdx int) error {
const op = "agentic.Client.ToggleTask"
if planSlug == "" || phase == "" {
return log.E(op, "plan slug and phase are required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/tasks/%d/toggle",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase), taskIdx)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}