planengine

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: AGPL-3.0 Imports: 14 Imported by: 0

Documentation

Overview

Package planengine implements the storage, tools, and embedded skills that power the /plan and /execute slash commands.

A plan is a hierarchical record of an implementation strategy with four levels: an intent statement, an ordered list of tickets, an ordered list of phases within each ticket, and an ordered list of tasks within each phase. A ticket maps to an independently reviewable, deployable stacked git branch; a phase maps to one atomic git commit on that branch; a task is a single work item that goes into the commit. The model collaborates with the user to build the structure during /plan, then walks it during /execute — cutting each ticket's stacked branch, committing each phase, and pushing the branch when the user confirms the ticket is done. Forge-specific publishing (e.g. opening a pull request) is handled externally via the ticket.completed lifecycle hook.

State lives in the shared SQLite database at <repo>/.jungi/state/state.db (opened by the statestore package); a rendered markdown view is written alongside at .jungi/state/plans/<slug>/plan.md whenever the model invokes render_plan_markdown. The database is the source of truth — the markdown file is for humans and is overwritten on every render.

Index

Constants

This section is empty.

Variables

View Source
var ErrNotFound = errors.New("plan: not found")

ErrNotFound is returned when a lookup by slug or ID finds no row. Callers can match against this with errors.Is.

Functions

func BranchName

func BranchName(planSlug, ticketSlug string) string

BranchName derives a ticket's stacked branch name from the plan and ticket slugs. The scheme is deliberately FLAT (a single trailing path segment after the prefix) so two tickets never produce a directory/file ref conflict in .git/refs/heads. Both slugs are already kebab-cased by the plan tools' slugify, so the result is a valid git ref by construction. Overlapping segments between the plan and ticket slugs (e.g. a ticket slug that repeats the plan's trailing words) are de-duplicated via mergeSlugs so names like "na-1769-property-tabbed-nav-tabbed-nav" collapse to "na-1769-property-tabbed-nav".

func DeriveBranch

func DeriveBranch(plan Plan, ticketCount int, ticket Ticket) string

DeriveBranch resolves the branch name for a ticket, honoring a user-supplied plan.Branch override when present. With no override, it falls back to the generated jungi/<plan>-<ticket> scheme (BranchName), which de-duplicates overlapping segments.

With an override: a single-ticket plan (ticketCount == 1) uses plan.Branch verbatim, with no jungi/ prefix, since there is only one ticket to disambiguate. A multi-ticket plan appends the ticket slug via mergeSlugs (still unprefixed) so each ticket's branch is unique while staying anchored to the user's requested name.

func MarkdownPath

func MarkdownPath(repoRoot, slug string) string

MarkdownPath returns the absolute path where the rendered markdown view of a plan should be written. Each render overwrites this file; the SQLite DB is the source of truth.

func PlansDir

func PlansDir(repoRoot string) string

PlansDir returns the directory under which per-plan subdirectories live. Each plan gets <PlansDir>/<slug>/, currently containing only plan.md but reserved for future per-plan artifacts (research findings, etc.).

func RenderMarkdown

func RenderMarkdown(p FullPlan) string

RenderMarkdown produces the human-readable view of a plan. The output shape is deliberate and stable so users can scan progress at a glance: a banner, an H1 with the slug, a paragraph for the intent, then one H2 per ticket (with its branch/stacks-on/status, and its relevant skills when any were recorded), one H3 per phase (with its commit when made), and a checkbox list of each phase's tasks.

The function is pure: it takes a FullPlan and returns a string. The caller (the render_plan_markdown tool) is responsible for creating the target directory and writing the bytes.

func ShortSHA

func ShortSHA(sha string) string

ShortSHA trims a commit hash to the conventional 7-character prefix for compact display in tool-event variants and rendered plan markdown. Inputs shorter than 7 are returned unchanged.

func WriteMarkdown

func WriteMarkdown(repoRoot string, p FullPlan) (string, error)

WriteMarkdown renders the plan and writes it to the canonical path under the repo root, creating the per-plan subdirectory if missing. Returns the absolute path of the file that was written so the tool dispatcher can surface it to the model.

Types

type FullPlan

type FullPlan struct {
	Plan    Plan
	Tickets []TicketWithPhases
}

FullPlan groups a plan with its tickets, each ticket's phases, and each phase's tasks, all in display order. It is the shape returned by Store.GetPlan and emitted by the get_plan tool so the model can load everything it needs in one round-trip.

type Phase

type Phase struct {
	ID                  int64
	TicketID            int64
	Ord                 int
	Name                string
	Status              PhaseStatus
	CommitSHA           string
	StatusLastChangedAt time.Time
	TaskListID          int64
}

Phase is one row of the phases table. Ord is the 1-based display order within the parent ticket; the store assigns it on insert as MAX(ord)+1 so the model can add phases without having to track sequencing. A phase is one atomic commit — CommitSHA is empty until commit_phase records the commit it produced. Status is active by default and dropped when update_phase removes it (only permitted before a commit is recorded). StatusLastChangedAt is bumped whenever Status changes. TaskListID is the tasklist-owned list linked to this phase via phase_task_lists; the model threads it into add_task_to_task_list so tasks land in the phase's list.

type PhaseStatus

type PhaseStatus string

PhaseStatus names the lifecycle state of a single phase. active is the default, ordinary state a phase carries from creation through commit. dropped is a structural removal state set by update_phase("dropped") for a phase that has not yet produced a commit (guarded so a committed phase can never be dropped).

const (
	PhaseStatusActive  PhaseStatus = "active"
	PhaseStatusDropped PhaseStatus = "dropped"
)

func (PhaseStatus) IsValid

func (s PhaseStatus) IsValid() bool

IsValid reports whether s is one of the recognised phase statuses.

type PhaseUpdate

type PhaseUpdate struct {
	Name   *string
	Status *PhaseStatus
}

PhaseUpdate carries an optional, partial edit to a phase: name is display-only and always allowed; Status is restricted by UpdatePhase to the active/dropped structural transition.

type PhaseWithTasks

type PhaseWithTasks struct {
	Phase Phase
	Tasks []tasklist.Task
}

PhaseWithTasks pairs a phase with the tasks in its linked task list, in display order. Tasks are tasklist.Task values hydrated from the tasklist tables via the phase_task_lists link.

type Plan

type Plan struct {
	ID     int64
	Slug   string
	Intent string
	Status PlanStatus
	// BaseBranch is the human-readable ref the first ticket stacks on
	// (e.g. "main"), surfaced to ticket lifecycle hooks as BASE_REF so
	// forge hook scripts know which branch to target when opening a review
	// request. BaseSHA is the exact commit ticket 1 branches from. Both are
	// empty until /execute starts the first ticket, at which point they are
	// resolved from the worktree's HEAD and the repo's default branch.
	BaseBranch string
	BaseSHA    string
	// Branch is the user-supplied, already-slugified branch base captured
	// at plan creation (empty when none was supplied). When set, it
	// overrides the generated jungi/<plan>-<ticket> naming scheme:
	// DeriveBranch uses it verbatim (no jungi/ prefix) for a single-ticket
	// plan, or with a unique per-ticket suffix for a multi-ticket plan.
	Branch    string
	CreatedAt time.Time
	UpdatedAt time.Time
}

Plan is one row of the plans table. ID is the rowid; Slug is the human-readable kebab-case identifier the user sees and that the model uses to address the plan. Slug uniqueness is enforced by the schema with the store appending -2, -3, ... on collision so the model never has to handle the conflict.

type PlanStatus

type PlanStatus string

PlanStatus names the lifecycle state of an entire plan. A plan is in_progress from creation until the user marks it accepted (planning complete, ready to execute) or abandoned. Today only in_progress is ever written — accepted/abandoned are reserved for future use when the planning workflow needs to gate execution on user acceptance.

const (
	PlanStatusInProgress PlanStatus = "in_progress"
	PlanStatusAccepted   PlanStatus = "accepted"
	PlanStatusAbandoned  PlanStatus = "abandoned"
	PlanStatusCompleted  PlanStatus = "completed"
)

func (PlanStatus) IsValid

func (s PlanStatus) IsValid() bool

IsValid reports whether s is one of the recognised plan statuses. Used to validate input on update_plan so a typo can be surfaced to the model instead of silently corrupting the DB.

type PlanUpdate

type PlanUpdate struct {
	Intent *string
	Slug   *string
	// Branch, when non-nil, sets the branch override; pointing at "" clears
	// it (stored as SQL NULL via nullableString).
	Branch *string
	Status *PlanStatus
}

PlanUpdate carries an optional, partial edit to a plan: each non-nil pointer field is applied, each nil field is left unchanged. Mirrors tasklist.TaskUpdate's pattern so update_plan can change any subset of intent, slug, branch, and status in one call.

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store wraps a SQLite connection and offers narrowly-scoped helpers for the operations the plan tools and skills need. Method receivers are safe for concurrent use because *sql.DB serialises access internally; the schema's UNIQUE constraints prevent races between two callers trying to add a phase or task with the same ord.

func New

func New(db *statestore.DB, tasks *tasklist.Store) (*Store, error)

New returns a Store backed by the shared statestore connection, bootstrapping its tables on first use. It does not open or own the database — statestore does — so the connection is shared with every other state-owning package and is closed once, by the session, via the statestore handle.

tasks is the tasklist store whose tables back this plan's phase task lists; it must already be bootstrapped (its task_lists/tasks tables created) before planengine bootstraps, because planengine's phase_task_lists table has a foreign key into task_lists. The caller (session.Manager) constructs the tasklist store first and passes it here. planengine does not write tasks through it — task writes flow through tasklist's generic tools, with the plan-touch wired via tasklist.Store.SetAfterMutate(store.TouchPlanForTask).

The caller passes a handle whose repo root has already been validated via worktree.RepoRoot.

func (*Store) AddPhase

func (s *Store) AddPhase(ticketID int64, name string) (Phase, error)

AddPhase appends a new phase to the ticket identified by ticketID, auto- assigning the next ord, creates the phase's task list, and records the phase_task_lists link — all in one transaction. Returns the persisted Phase. Errors if ticketID does not exist.

The task list is created via tasklist.CreateListTx (not the tasklist Store's CreateList) so it runs on this transaction's connection: grabbing a second pool connection while this write transaction is open would deadlock under SQLite's single-writer model.

func (*Store) AddTicket

func (s *Store) AddTicket(planID int64, name, slug string, skills ...string) (Ticket, error)

AddTicket appends a new ticket to the plan identified by planID, auto- assigning the next ord. The slug is made unique within the plan by appending -2, -3, ... on collision (mirroring CreatePlan). skills is the optional set of relevant skill names persisted with the ticket (stored as a JSON array); callers are expected to have already filtered out any names they do not want recorded. Returns the persisted Ticket. Errors if planID does not exist.

func (*Store) Bootstrap

func (s *Store) Bootstrap(ctx context.Context) error

Bootstrap creates the plan/ticket/phase/task tables on the shared connection via the embedded CREATE TABLE IF NOT EXISTS script. It is idempotent: a no-op on an already-bootstrapped database, full creation on a fresh one. modernc.org/sqlite executes the multi-statement script as a batch.

func (*Store) CompleteTicket

func (s *Store) CompleteTicket(ticketID int64) (bool, error)

CompleteTicket marks a started ticket completed. It errors if the ticket has not been started (no branch), so completion can never precede the branch/commit work it represents. pushed_at is preserved from MarkTicketReadyForReview and is not written here. It is idempotent: if the ticket is already completed it returns (false, nil) without bumping timestamps or touching the plan.

func (*Store) CreatePlan

func (s *Store) CreatePlan(slug, intent, branch string) (Plan, error)

CreatePlan inserts a new plan row, resolving slug collisions by appending -2, -3, ... to the requested slug until insertion succeeds. Returns the persisted Plan with its allocated ID and final slug.

intent is required; slug may be empty in which case the caller (the create_plan tool) is expected to have derived one from intent already. Slug normalisation (lowercase, kebab-case) is the caller's responsibility — the store enforces uniqueness, not formatting.

func (*Store) GetPhase

func (s *Store) GetPhase(phaseID int64) (Phase, error)

GetPhase loads a single phase by ID, including the commit it produced (empty until commit_phase records one).

func (*Store) GetPlan

func (s *Store) GetPlan(slug string) (FullPlan, error)

GetPlan returns the full plan tree (plan + tickets + phases + tasks, all in display order) for the given slug. Used by get_plan during /execute to load context, and by the renderer to assemble plan.md. Each result set is read to completion and closed before the next query so only one cursor is open at a time.

func (*Store) GetPlanByID

func (s *Store) GetPlanByID(planID int64) (string, error)

GetPlanByID returns the slug for the plan with the given ID, regardless of its status. Used by execRenderMarkdown to resolve a plan_id to a slug without being blocked by the completed-plan filter in ListPlans.

func (*Store) GetTicket

func (s *Store) GetTicket(ticketID int64) (Ticket, error)

GetTicket loads a single ticket by ID.

func (*Store) GetTicketByOrd

func (s *Store) GetTicketByOrd(planID int64, ord int) (Ticket, error)

GetTicketByOrd loads the ticket at a given 1-based position within a plan. Used to resolve "the previous ticket" when computing a stacked ticket's base branch.

func (*Store) ListPlans

func (s *Store) ListPlans() ([]Plan, error)

ListPlans returns all plans ordered by most-recently-updated first, excluding plans with status "completed" or "abandoned". Used by the list_plans tool when the user runs /execute without naming a plan and the model needs to surface options.

func (*Store) MarkTicketReadyForReview

func (s *Store) MarkTicketReadyForReview(ticketID int64) (bool, error)

MarkTicketReadyForReview marks a started ticket ready_for_review and records pushed_at to capture when the branch was pushed. It errors if the ticket has not been started (no branch). It is idempotent: if the ticket is already ready_for_review it returns (false, nil) without bumping timestamps or touching the plan.

func (*Store) MarkTicketReviewed

func (s *Store) MarkTicketReviewed(ticketID int64) (bool, error)

MarkTicketReviewed marks a started ticket reviewed. It errors if the ticket has not been started (no branch). It is idempotent: if the ticket is already reviewed it returns (false, nil) without bumping timestamps or touching the plan.

func (*Store) MovePhase

func (s *Store) MovePhase(phaseID int64, targetPos int) error

MovePhase repositions the phase identified by phaseID to targetPos (1-based) among its ticket's phases, using the collision-safe reorder algorithm. Guarded so a committed phase — real git history — can never be moved: a phase that already has a commit_sha is rejected. Errors with ErrNotFound if the phase does not exist.

func (*Store) MoveTicket

func (s *Store) MoveTicket(ticketID int64, targetPos int) error

MoveTicket repositions the ticket identified by ticketID to targetPos (1-based) among its plan's tickets, using the collision-safe reorder algorithm. Guarded so the stacked-branch ordering can never be disturbed: a started ticket (branch_name set) cannot itself be moved, and no ticket may be moved to or before the ord of the last started ticket in the plan (which would insert it "underneath" already-cut branches). Errors with ErrNotFound if the ticket does not exist.

func (*Store) RepoRoot

func (s *Store) RepoRoot() string

RepoRoot returns the repo root the store was opened against. The renderer and tool dispatcher use it to resolve markdown paths.

func (*Store) SetPhaseCommit

func (s *Store) SetPhaseCommit(phaseID int64, sha string) error

SetPhaseCommit records the commit a phase produced. Errors if the phase does not exist.

func (*Store) SetPlanBase

func (s *Store) SetPlanBase(planID int64, baseBranch, baseSHA string) error

SetPlanBase records the ref and commit the first ticket stacks on. Called once, when /execute starts ticket 1, so the first ticket's PR base and the stack's root commit are persisted for later reference.

func (*Store) StartTicket

func (s *Store) StartTicket(ticketID int64, branchName, baseRef string) error

StartTicket records the branch a ticket was cut on and marks it in_progress. It rejects a ticket that has already been started so a double start_ticket is surfaced rather than silently re-pointing the branch. baseRef is the ref the branch was created from (the plan's base SHA for ticket 1, the previous ticket's branch otherwise).

func (*Store) TicketHasPhaseCommit

func (s *Store) TicketHasPhaseCommit(ticketID int64, sha string) (bool, error)

TicketHasPhaseCommit reports whether any phase of the ticket already records the given commit SHA. Used to tell an unrecorded manual commit (which should be adopted by the current phase) apart from a truly empty phase (HEAD already attributed to an earlier phase).

func (*Store) TouchPlanForTask

func (s *Store) TouchPlanForTask(taskID int64) error

TouchPlanForTask bumps the updated_at of the plan that owns the given task, resolved through the task -> phase_task_lists -> phase -> ticket -> plan link walk, so list_plans ordering reflects task edits. It is the plan-touch side effect that planengine's own AddTask/UpdateTaskStatus used to perform inline; now that all task writes flow through tasklist's generic tools, the session wires this method as tasklist.Store.SetAfterMutate so every committed task insert or status change touches the owning plan.

It is a deliberate no-op (returns nil) for standalone tasks — those whose list is not linked to any phase — because they belong to no plan. Only a genuine lookup failure is surfaced as an error.

func (*Store) UpdatePhase

func (s *Store) UpdatePhase(phaseID int64, upd PhaseUpdate) error

UpdatePhase applies the provided fields of upd to the phase identified by phaseID. Name is display-only and always editable, even after a commit has been recorded. Status is restricted to the active/dropped structural transition: dropping a phase that already has a commit_sha is rejected, since a committed phase represents real git history that must not be silently removed from the plan. A nil-everywhere update is a no-op error. Errors with ErrNotFound if the phase does not exist.

func (*Store) UpdatePlan

func (s *Store) UpdatePlan(planID int64, upd PlanUpdate) error

UpdatePlan applies the provided fields of upd to the plan identified by planID. Only non-nil fields are written. Slug edits reject on a UNIQUE collision (no auto-suffix, unlike CreatePlan, since the caller is making a deliberate rename). A nil-everywhere update is a no-op error. Errors with ErrNotFound if the plan does not exist.

func (*Store) UpdatePlanStatus

func (s *Store) UpdatePlanStatus(planID int64, status PlanStatus) error

UpdatePlanStatus sets the status of the plan identified by planID. Errors if the plan does not exist or the status is not a recognised PlanStatus value.

func (*Store) UpdateTicket

func (s *Store) UpdateTicket(ticketID int64, upd TicketUpdate) error

UpdateTicket applies the provided fields of upd to the ticket identified by ticketID. name/slug/skills are always editable. Status is restricted to the structural pending/dropped transition: dropping a ticket whose branch_name is already set (started) is rejected, since executed git work must never be silently removed from the plan. Lifecycle statuses (in_progress, ready_for_review, reviewed, completed) must go through StartTicket / MarkTicketReadyForReview / MarkTicketReviewed / CompleteTicket instead, which also fire their hooks; UpdateTicket rejects them so a caller cannot bypass that path. A nil-everywhere update is a no-op error. Errors with ErrNotFound if the ticket does not exist.

type Ticket

type Ticket struct {
	ID                  int64
	PlanID              int64
	Ord                 int
	Name                string
	Slug                string
	Status              TicketStatus
	BranchName          string
	BaseRef             string
	StartedAt           time.Time
	PushedAt            time.Time
	StatusLastChangedAt time.Time
	// Skills lists the names of available skills the planning model judged
	// relevant to executing this ticket. /execute loads each one (via
	// load_skill) when it starts the ticket. Empty when no skill was deemed
	// relevant. Stored as a JSON array in the tickets.skills column.
	Skills []string
}

Ticket is one row of the tickets table. A ticket maps to a stacked git branch, independently reviewable and deployable. Ord is the 1-based stacking order within the plan (ticket 1 branches from the plan's base; ticket N branches from ticket N-1). BranchName, BaseRef, and StartedAt are empty/zero until start_ticket cuts the branch; Status tracks the full lifecycle: pending → in_progress → ready_for_review → reviewed → completed, with dropped as a structural removal state for tickets that were never started. PushedAt is set by submit_ticket_for_review when the branch is pushed. StatusLastChangedAt is bumped every time Status changes (including on StartTicket/MarkTicketReadyForReview/MarkTicketReviewed/CompleteTicket and drop/restore), independent of StartedAt/PushedAt which mark specific lifecycle events rather than "status changed at all".

type TicketStatus

type TicketStatus string

TicketStatus names the lifecycle state of a single ticket. The full lifecycle is:

pending → in_progress → ready_for_review → reviewed → completed

pending is the starting state when the ticket is created during planning. in_progress is set by start_ticket once the ticket's stacked branch has been cut. ready_for_review is set by submit_ticket_for_review after the branch has been pushed; this is when a PR is opened. reviewed is set by update_ticket("reviewed") after the review is complete. completed is an explicit, user-instructed final state set by update_ticket("completed"). dropped is a structural removal state set by update_ticket("dropped") for a ticket that has not been started yet (guarded so executed git work can never be dropped).

const (
	TicketStatusPending        TicketStatus = "pending"
	TicketStatusInProgress     TicketStatus = "in_progress"
	TicketStatusReadyForReview TicketStatus = "ready_for_review"
	TicketStatusReviewed       TicketStatus = "reviewed"
	TicketStatusCompleted      TicketStatus = "completed"
	TicketStatusDropped        TicketStatus = "dropped"
)

func (TicketStatus) IsValid

func (s TicketStatus) IsValid() bool

IsValid reports whether s is one of the recognised ticket statuses.

type TicketUpdate

type TicketUpdate struct {
	Name   *string
	Slug   *string
	Skills *[]string
	Status *TicketStatus
}

TicketUpdate carries an optional, partial edit to a ticket: each non-nil pointer field is applied, each nil field is left unchanged. Skills is a pointer to a slice so a caller can distinguish "leave skills alone" (nil) from "replace with this set, possibly empty" (non-nil, sanitized by the tool layer before it reaches the store). Status here is restricted by UpdateTicket to the structural pending/dropped transitions — lifecycle transitions (in_progress, ready_for_review, reviewed, completed) go through their dedicated methods, which also fire hooks.

type TicketWithPhases

type TicketWithPhases struct {
	Ticket Ticket
	Phases []PhaseWithTasks
}

TicketWithPhases pairs a ticket with its phases in stacking/display order.

Source Files

  • git.go
  • paths.go
  • plan.go
  • render.go
  • store.go

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL