fix: remove redundant code in processing logic
Some checks failed
CI / build-and-test (push) Failing after -31m35s

This commit is contained in:
Hein
2026-04-30 16:04:04 +02:00
parent 537e65ea6d
commit 65715f7ad3
31 changed files with 2691 additions and 61 deletions

View File

@@ -205,6 +205,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
Projects: tools.NewProjectsTool(db, activeProjects),
Version: tools.NewVersionTool(cfg.MCP.ServerName, info),
Learnings: tools.NewLearningsTool(db, activeProjects, cfg.Search),
Plans: tools.NewPlansTool(db, activeProjects, cfg.Search),
Context: tools.NewContextTool(db, embeddings, cfg.Search, activeProjects),
Recall: tools.NewRecallTool(db, embeddings, cfg.Search, activeProjects),
Summarize: tools.NewSummarizeTool(db, embeddings, metadata, cfg.Search, activeProjects),

View File

@@ -1 +0,0 @@
placeholder file to keep ui/dist present for go:embed in test environments

View File

@@ -15,8 +15,9 @@ type ModelPublicAgentGuardrails struct {
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Severity resolvespec_common.SqlString `bun:"severity,type:text,default:'medium',notnull," json:"severity"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelGuardrailIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
RelGuardrailIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:id=guardrail_id" json:"relguardrailidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
}

View File

@@ -14,9 +14,10 @@ type ModelPublicAgentSkills struct {
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelRelatedSkillIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:id=related_skill_id" json:"relrelatedskillidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
RelSkillIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
RelSkillIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:id=skill_id" json:"relskillidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
}

View File

@@ -13,7 +13,7 @@ type ModelPublicChatHistories struct {
AgentID resolvespec_common.SqlString `bun:"agent_id,type:text,nullzero," json:"agent_id"`
Channel resolvespec_common.SqlString `bun:"channel,type:text,nullzero," json:"channel"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Messages resolvespec_common.SqlJSONB `bun:"messages,type:jsonb,default:'[',notnull," json:"messages"`
Messages resolvespec_common.SqlJSONB `bun:"messages,type:jsonb,default:'',notnull," json:"messages"`
Metadata resolvespec_common.SqlJSONB `bun:"metadata,type:jsonb,default:'{}',notnull," json:"metadata"`
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
SessionID resolvespec_common.SqlString `bun:"session_id,type:text,notnull," json:"session_id"`

View File

@@ -11,7 +11,7 @@ type ModelPublicEmbeddings struct {
bun.BaseModel `bun:"table:public.embeddings,alias:embeddings"`
ID resolvespec_common.SqlInt64 `bun:"id,type:bigserial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),nullzero," json:"created_at"`
Dim resolvespec_common.SqlInt32 `bun:"dim,type:int,notnull," json:"dim"`
Dim int32 `bun:"dim,type:int,notnull," json:"dim"`
Embedding resolvespec_common.SqlString `bun:"embedding,type:vector,notnull," json:"embedding"`
GUID resolvespec_common.SqlUUID `bun:"guid,type:uuid,default:gen_random_uuid(),notnull," json:"guid"`
Model resolvespec_common.SqlString `bun:"model,type:text,notnull,unique:uidx_embeddings_thought_id_model," json:"model"`

View File

@@ -15,7 +15,7 @@ type ModelPublicImportantDates struct {
FamilyMemberID resolvespec_common.SqlUUID `bun:"family_member_id,type:uuid,nullzero," json:"family_member_id"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
RecurringYearly bool `bun:"recurring_yearly,type:boolean,default:false,notnull," json:"recurring_yearly"`
ReminderDaysBefore resolvespec_common.SqlInt32 `bun:"reminder_days_before,type:int,default:7,notnull," json:"reminder_days_before"`
ReminderDaysBefore int32 `bun:"reminder_days_before,type:int,default:7,notnull," json:"reminder_days_before"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
RelFamilyMemberID *ModelPublicFamilyMembers `bun:"rel:has-one,join:family_member_id=id" json:"relfamilymemberid,omitempty"` // Has one ModelPublicFamilyMembers
}

View File

@@ -28,7 +28,7 @@ type ModelPublicLearnings struct {
Status resolvespec_common.SqlString `bun:"status,type:text,default:'pending',notnull," json:"status"`
Summary resolvespec_common.SqlString `bun:"summary,type:text,notnull," json:"summary"`
SupersedesLearningID resolvespec_common.SqlUUID `bun:"supersedes_learning_id,type:uuid,nullzero," json:"supersedes_learning_id"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelDuplicateOfLearningID *ModelPublicLearnings `bun:"rel:has-one,join:duplicate_of_learning_id=id" json:"relduplicateoflearningid,omitempty"` // Has one ModelPublicLearnings
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanDependencies struct {
bun.BaseModel `bun:"table:public.plan_dependencies,alias:plan_dependencies"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
DependsOnPlanID resolvespec_common.SqlUUID `bun:"depends_on_plan_id,type:uuid,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"depends_on_plan_id"`
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_dependencies_plan_id_depends_on_plan_id," json:"plan_id"`
RelDependsOnPlanID *ModelPublicPlans `bun:"rel:has-one,join:depends_on_plan_id=id" json:"reldependsonplanid,omitempty"` // Has one ModelPublicPlans
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
}
// TableName returns the table name for ModelPublicPlanDependencies
func (m ModelPublicPlanDependencies) TableName() string {
return "public.plan_dependencies"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanDependencies
func (m ModelPublicPlanDependencies) TableNameOnly() string {
return "plan_dependencies"
}
// SchemaName returns the schema name for ModelPublicPlanDependencies
func (m ModelPublicPlanDependencies) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanDependencies) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanDependencies) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanDependencies) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanDependencies) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanDependencies) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanDependencies) GetPrefix() string {
return "PDL"
}

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanGuardrails struct {
bun.BaseModel `bun:"table:public.plan_guardrails,alias:plan_guardrails"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
GuardrailID resolvespec_common.SqlUUID `bun:"guardrail_id,type:uuid,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"guardrail_id"`
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_guardrails_plan_id_guardrail_id," json:"plan_id"`
RelGuardrailID *ModelPublicAgentGuardrails `bun:"rel:has-one,join:guardrail_id=id" json:"relguardrailid,omitempty"` // Has one ModelPublicAgentGuardrails
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
}
// TableName returns the table name for ModelPublicPlanGuardrails
func (m ModelPublicPlanGuardrails) TableName() string {
return "public.plan_guardrails"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanGuardrails
func (m ModelPublicPlanGuardrails) TableNameOnly() string {
return "plan_guardrails"
}
// SchemaName returns the schema name for ModelPublicPlanGuardrails
func (m ModelPublicPlanGuardrails) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanGuardrails) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanGuardrails) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanGuardrails) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanGuardrails) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanGuardrails) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanGuardrails) GetPrefix() string {
return "PGL"
}

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanRelatedPlans struct {
bun.BaseModel `bun:"table:public.plan_related_plans,alias:plan_related_plans"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanAID resolvespec_common.SqlUUID `bun:"plan_a_id,type:uuid,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_a_id"`
PlanBID resolvespec_common.SqlUUID `bun:"plan_b_id,type:uuid,notnull,unique:uidx_plan_related_plans_plan_a_id_plan_b_id," json:"plan_b_id"`
RelPlanAID *ModelPublicPlans `bun:"rel:has-one,join:plan_a_id=id" json:"relplanaid,omitempty"` // Has one ModelPublicPlans
RelPlanBID *ModelPublicPlans `bun:"rel:has-one,join:plan_b_id=id" json:"relplanbid,omitempty"` // Has one ModelPublicPlans
}
// TableName returns the table name for ModelPublicPlanRelatedPlans
func (m ModelPublicPlanRelatedPlans) TableName() string {
return "public.plan_related_plans"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanRelatedPlans
func (m ModelPublicPlanRelatedPlans) TableNameOnly() string {
return "plan_related_plans"
}
// SchemaName returns the schema name for ModelPublicPlanRelatedPlans
func (m ModelPublicPlanRelatedPlans) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanRelatedPlans) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanRelatedPlans) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanRelatedPlans) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanRelatedPlans) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanRelatedPlans) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanRelatedPlans) GetPrefix() string {
return "PRP"
}

View File

@@ -0,0 +1,63 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlanSkills struct {
bun.BaseModel `bun:"table:public.plan_skills,alias:plan_skills"`
ID resolvespec_common.SqlInt32 `bun:"id,type:serial,pk,autoincrement," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
PlanID resolvespec_common.SqlUUID `bun:"plan_id,type:uuid,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"plan_id"`
SkillID resolvespec_common.SqlUUID `bun:"skill_id,type:uuid,notnull,unique:uidx_plan_skills_plan_id_skill_id," json:"skill_id"`
RelPlanID *ModelPublicPlans `bun:"rel:has-one,join:plan_id=id" json:"relplanid,omitempty"` // Has one ModelPublicPlans
RelSkillID *ModelPublicAgentSkills `bun:"rel:has-one,join:skill_id=id" json:"relskillid,omitempty"` // Has one ModelPublicAgentSkills
}
// TableName returns the table name for ModelPublicPlanSkills
func (m ModelPublicPlanSkills) TableName() string {
return "public.plan_skills"
}
// TableNameOnly returns the table name without schema for ModelPublicPlanSkills
func (m ModelPublicPlanSkills) TableNameOnly() string {
return "plan_skills"
}
// SchemaName returns the schema name for ModelPublicPlanSkills
func (m ModelPublicPlanSkills) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlanSkills) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlanSkills) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlanSkills) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlanSkills) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlanSkills) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlanSkills) GetPrefix() string {
return "PSL"
}

View File

@@ -0,0 +1,80 @@
// Code generated by relspecgo. DO NOT EDIT.
package generatedmodels
import (
"fmt"
resolvespec_common "github.com/bitechdev/ResolveSpec/pkg/spectypes"
"github.com/uptrace/bun"
)
type ModelPublicPlans struct {
bun.BaseModel `bun:"table:public.plans,alias:plans"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CompletedAt resolvespec_common.SqlTimeStamp `bun:"completed_at,type:timestamptz,nullzero," json:"completed_at"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Description resolvespec_common.SqlString `bun:"description,type:text,default:'',notnull," json:"description"`
DueDate resolvespec_common.SqlTimeStamp `bun:"due_date,type:timestamptz,nullzero," json:"due_date"`
LastReviewedAt resolvespec_common.SqlTimeStamp `bun:"last_reviewed_at,type:timestamptz,nullzero," json:"last_reviewed_at"`
Owner resolvespec_common.SqlString `bun:"owner,type:text,nullzero," json:"owner"`
Priority resolvespec_common.SqlString `bun:"priority,type:text,default:'medium',notnull," json:"priority"` // low, medium, high, critical
ProjectID resolvespec_common.SqlUUID `bun:"project_id,type:uuid,nullzero," json:"project_id"`
ReviewedBy resolvespec_common.SqlString `bun:"reviewed_by,type:text,nullzero," json:"reviewed_by"`
Status resolvespec_common.SqlString `bun:"status,type:text,default:'draft',notnull," json:"status"` // draft, active, blocked, completed, cancelled, superseded
SupersedesPlanID resolvespec_common.SqlUUID `bun:"supersedes_plan_id,type:uuid,nullzero," json:"supersedes_plan_id"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,default:'{}',notnull," json:"tags"`
Title resolvespec_common.SqlString `bun:"title,type:text,notnull," json:"title"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelProjectID *ModelPublicProjects `bun:"rel:has-one,join:project_id=guid" json:"relprojectid,omitempty"` // Has one ModelPublicProjects
RelSupersedesPlanID *ModelPublicPlans `bun:"rel:has-one,join:supersedes_plan_id=id" json:"relsupersedesplanid,omitempty"` // Has one ModelPublicPlans
RelDependsOnPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=depends_on_plan_id" json:"reldependsonplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
RelPlanIDPublicPlanDependencies []*ModelPublicPlanDependencies `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplandependencies,omitempty"` // Has many ModelPublicPlanDependencies
RelPlanAIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_a_id" json:"relplanaidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
RelPlanBIDPublicPlanRelatedPlans []*ModelPublicPlanRelatedPlans `bun:"rel:has-many,join:id=plan_b_id" json:"relplanbidpublicplanrelatedplans,omitempty"` // Has many ModelPublicPlanRelatedPlans
RelPlanIDPublicPlanSkills []*ModelPublicPlanSkills `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanskills,omitempty"` // Has many ModelPublicPlanSkills
RelPlanIDPublicPlanGuardrails []*ModelPublicPlanGuardrails `bun:"rel:has-many,join:id=plan_id" json:"relplanidpublicplanguardrails,omitempty"` // Has many ModelPublicPlanGuardrails
}
// TableName returns the table name for ModelPublicPlans
func (m ModelPublicPlans) TableName() string {
return "public.plans"
}
// TableNameOnly returns the table name without schema for ModelPublicPlans
func (m ModelPublicPlans) TableNameOnly() string {
return "plans"
}
// SchemaName returns the schema name for ModelPublicPlans
func (m ModelPublicPlans) SchemaName() string {
return "public"
}
// GetID returns the primary key value
func (m ModelPublicPlans) GetID() int64 {
return m.ID.Int64()
}
// GetIDStr returns the primary key as a string
func (m ModelPublicPlans) GetIDStr() string {
return fmt.Sprintf("%v", m.ID)
}
// SetID sets the primary key value
func (m ModelPublicPlans) SetID(newid int64) {
m.UpdateID(newid)
}
// UpdateID updates the primary key value
func (m *ModelPublicPlans) UpdateID(newid int64) {
m.ID.FromString(fmt.Sprintf("%d", newid))
}
// GetIDName returns the name of the primary key column
func (m ModelPublicPlans) GetIDName() string {
return "id"
}
// GetPrefix returns the table prefix
func (m ModelPublicPlans) GetPrefix() string {
return "PLA"
}

View File

@@ -20,7 +20,7 @@ type ModelPublicProfessionalContacts struct {
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
Phone resolvespec_common.SqlString `bun:"phone,type:text,nullzero," json:"phone"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,default:'{}',notnull," json:"tags"`
Title resolvespec_common.SqlString `bun:"title,type:text,nullzero," json:"title"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelContactIDPublicContactInteractions []*ModelPublicContactInteractions `bun:"rel:has-many,join:id=contact_id" json:"relcontactidpubliccontactinteractions,omitempty"` // Has many ModelPublicContactInteractions

View File

@@ -20,6 +20,7 @@ type ModelPublicProjects struct {
RelProjectIDPublicStoredFiles []*ModelPublicStoredFiles `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicstoredfiles,omitempty"` // Has many ModelPublicStoredFiles
RelProjectIDPublicChatHistories []*ModelPublicChatHistories `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicchathistories,omitempty"` // Has many ModelPublicChatHistories
RelProjectIDPublicLearnings []*ModelPublicLearnings `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpubliclearnings,omitempty"` // Has many ModelPublicLearnings
RelProjectIDPublicPlans []*ModelPublicPlans `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicplans,omitempty"` // Has many ModelPublicPlans
RelProjectIDPublicProjectSkills []*ModelPublicProjectSkills `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectskills,omitempty"` // Has many ModelPublicProjectSkills
RelProjectIDPublicProjectGuardrails []*ModelPublicProjectGuardrails `bun:"rel:has-many,join:guid=project_id" json:"relprojectidpublicprojectguardrails,omitempty"` // Has many ModelPublicProjectGuardrails
}

View File

@@ -13,14 +13,14 @@ type ModelPublicRecipes struct {
CookTimeMinutes resolvespec_common.SqlInt32 `bun:"cook_time_minutes,type:int,nullzero," json:"cook_time_minutes"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Cuisine resolvespec_common.SqlString `bun:"cuisine,type:text,nullzero," json:"cuisine"`
Ingredients resolvespec_common.SqlJSONB `bun:"ingredients,type:jsonb,default:'[',notnull," json:"ingredients"`
Instructions resolvespec_common.SqlJSONB `bun:"instructions,type:jsonb,default:'[',notnull," json:"instructions"`
Ingredients resolvespec_common.SqlJSONB `bun:"ingredients,type:jsonb,default:'',notnull," json:"ingredients"`
Instructions resolvespec_common.SqlJSONB `bun:"instructions,type:jsonb,default:'',notnull," json:"instructions"`
Name resolvespec_common.SqlString `bun:"name,type:text,notnull," json:"name"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
PrepTimeMinutes resolvespec_common.SqlInt32 `bun:"prep_time_minutes,type:int,nullzero," json:"prep_time_minutes"`
Rating resolvespec_common.SqlInt32 `bun:"rating,type:int,nullzero," json:"rating"`
Servings resolvespec_common.SqlInt32 `bun:"servings,type:int,nullzero," json:"servings"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,nullzero," json:"tags"`
Tags resolvespec_common.SqlString `bun:"tags,type:text,default:'{}',notnull," json:"tags"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
RelRecipeIDPublicMealPlans []*ModelPublicMealPlans `bun:"rel:has-many,join:id=recipe_id" json:"relrecipeidpublicmealplans,omitempty"` // Has many ModelPublicMealPlans
}

View File

@@ -11,7 +11,7 @@ type ModelPublicShoppingLists struct {
bun.BaseModel `bun:"table:public.shopping_lists,alias:shopping_lists"`
ID resolvespec_common.SqlUUID `bun:"id,type:uuid,pk,default:gen_random_uuid()," json:"id"`
CreatedAt resolvespec_common.SqlTimeStamp `bun:"created_at,type:timestamptz,default:now(),notnull," json:"created_at"`
Items resolvespec_common.SqlJSONB `bun:"items,type:jsonb,default:'[',notnull," json:"items"`
Items resolvespec_common.SqlJSONB `bun:"items,type:jsonb,default:'',notnull," json:"items"`
Notes resolvespec_common.SqlString `bun:"notes,type:text,nullzero," json:"notes"`
UpdatedAt resolvespec_common.SqlTimeStamp `bun:"updated_at,type:timestamptz,default:now(),notnull," json:"updated_at"`
WeekStart resolvespec_common.SqlDate `bun:"week_start,type:date,notnull," json:"week_start"`

View File

@@ -41,6 +41,7 @@ type ToolSet struct {
ChatHistory *tools.ChatHistoryTool
Describe *tools.DescribeTool
Learnings *tools.LearningsTool
Plans *tools.PlansTool
}
// Handlers groups the HTTP handlers produced for an MCP server instance.
@@ -85,6 +86,7 @@ func NewHandlers(cfg config.MCPConfig, logger *slog.Logger, toolSet ToolSet, onS
registerThoughtTools,
registerProjectTools,
registerLearningTools,
registerPlanTools,
registerFileTools,
registerMaintenanceTools,
registerSkillTools,
@@ -273,6 +275,100 @@ func registerLearningTools(server *mcp.Server, logger *slog.Logger, toolSet Tool
return nil
}
func registerPlanTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{
Name: "create_plan",
Description: "Create a structured plan linked to a project.",
}, toolSet.Plans.Create); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "get_plan",
Description: "Retrieve a plan with its dependencies, related plans, skills, and guardrails.",
}, toolSet.Plans.Get); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "update_plan",
Description: "Update plan fields; only provided fields are changed.",
}, toolSet.Plans.Update); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "delete_plan",
Description: "Hard-delete a plan by id.",
}, toolSet.Plans.Delete); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "list_plans",
Description: "List plans with optional project, status, priority, owner, tag, and text filters.",
}, toolSet.Plans.List); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_plan_dependency",
Description: "Mark plan_id as depending on depends_on_plan_id (must complete first).",
}, toolSet.Plans.AddDependency); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_plan_dependency",
Description: "Remove a dependency between two plans.",
}, toolSet.Plans.RemoveDependency); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_related_plan",
Description: "Link two plans as thematically related (bidirectional).",
}, toolSet.Plans.AddRelated); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_related_plan",
Description: "Unlink two related plans.",
}, toolSet.Plans.RemoveRelated); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_plan_skill",
Description: "Link an agent skill to a plan.",
}, toolSet.Plans.AddSkill); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_plan_skill",
Description: "Unlink an agent skill from a plan.",
}, toolSet.Plans.RemoveSkill); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "list_plan_skills",
Description: "List skills linked to a plan.",
}, toolSet.Plans.ListSkills); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "add_plan_guardrail",
Description: "Link an agent guardrail to a plan.",
}, toolSet.Plans.AddGuardrail); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "remove_plan_guardrail",
Description: "Unlink an agent guardrail from a plan.",
}, toolSet.Plans.RemoveGuardrail); err != nil {
return err
}
if err := addTool(server, logger, &mcp.Tool{
Name: "list_plan_guardrails",
Description: "List guardrails linked to a plan.",
}, toolSet.Plans.ListGuardrails); err != nil {
return err
}
return nil
}
func registerFileTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
server.AddResourceTemplate(&mcp.ResourceTemplate{
Name: "stored_file",
@@ -460,7 +556,7 @@ func registerChatHistoryTools(server *mcp.Server, logger *slog.Logger, toolSet T
func registerDescribeTools(server *mcp.Server, logger *slog.Logger, toolSet ToolSet) error {
if err := addTool(server, logger, &mcp.Tool{
Name: "describe_tools",
Description: "Call first each session. All tools with categories and usage notes. Categories: system, thoughts, projects, files, admin, maintenance, skills, chat, meta.",
Description: "Call first each session. All tools with categories and usage notes. Categories: system, thoughts, projects, files, admin, maintenance, skills, plans, chat, meta.",
}, toolSet.Describe.Describe); err != nil {
return err
}
@@ -506,6 +602,23 @@ func BuildToolCatalog() []tools.ToolEntry {
{Name: "get_learning", Description: "Retrieve a structured learning by id.", Category: "projects"},
{Name: "list_learnings", Description: "List structured learnings with optional project, category, area, status, priority, tag, and text filters.", Category: "projects"},
// plans
{Name: "create_plan", Description: "Create a structured plan with status, priority, owner, due date, and optional project link.", Category: "plans"},
{Name: "get_plan", Description: "Retrieve a full plan including dependencies (depends_on/blocks), related plans, linked skills, and guardrails.", Category: "plans"},
{Name: "update_plan", Description: "Partially update a plan; only provided fields are changed. Use mark_reviewed to stamp last_reviewed_at.", Category: "plans"},
{Name: "delete_plan", Description: "Hard-delete a plan by id.", Category: "plans"},
{Name: "list_plans", Description: "List plans with optional filters: project, status, priority, owner, tag, and full-text query.", Category: "plans"},
{Name: "add_plan_dependency", Description: "Declare that plan_id cannot proceed until depends_on_plan_id is complete.", Category: "plans"},
{Name: "remove_plan_dependency", Description: "Remove a directional dependency between two plans.", Category: "plans"},
{Name: "add_related_plan", Description: "Link two plans as thematically related (bidirectional, order-independent).", Category: "plans"},
{Name: "remove_related_plan", Description: "Unlink two related plans.", Category: "plans"},
{Name: "add_plan_skill", Description: "Link an agent skill to a plan so it is loaded with the plan's context.", Category: "plans"},
{Name: "remove_plan_skill", Description: "Unlink an agent skill from a plan.", Category: "plans"},
{Name: "list_plan_skills", Description: "List all skills linked to a plan.", Category: "plans"},
{Name: "add_plan_guardrail", Description: "Link an agent guardrail to a plan so it applies during plan execution.", Category: "plans"},
{Name: "remove_plan_guardrail", Description: "Unlink an agent guardrail from a plan.", Category: "plans"},
{Name: "list_plan_guardrails", Description: "List all guardrails linked to a plan.", Category: "plans"},
// files
{Name: "upload_file", Description: "Stage a file and get an amcs://files/{id} resource URI. Use content_path (absolute server-side path, no size limit) for large or binary files, or content_base64 (≤10 MB) for small files. Pass thought_id/project to link immediately, or omit and pass the URI to save_file later.", Category: "files"},
{Name: "save_file", Description: "Store a file and optionally link it to a thought. Use content_base64 (≤10 MB) for small files, or content_uri (amcs://files/{id} from a prior upload_file) for previously staged files. For files larger than 10 MB, use upload_file with content_path first. If the goal is to retain the artifact, store the file directly instead of reading or summarising it first.", Category: "files"},
@@ -544,7 +657,7 @@ func BuildToolCatalog() []tools.ToolEntry {
{Name: "delete_chat_history", Description: "Permanently delete a saved chat history by id.", Category: "chat"},
// meta
{Name: "describe_tools", Description: "Call this first in every session. Returns all available MCP tools with names, descriptions, categories, and your accumulated usage notes. Filter by category to narrow results. Available categories: system, thoughts, projects, files, admin, household, maintenance, calendar, meals, crm, skills, chat, meta.", Category: "meta"},
{Name: "describe_tools", Description: "Call this first in every session. Returns all available MCP tools with names, descriptions, categories, and your accumulated usage notes. Filter by category to narrow results. Available categories: system, thoughts, projects, files, admin, household, maintenance, calendar, meals, crm, skills, plans, chat, meta.", Category: "meta"},
{Name: "annotate_tool", Description: "Persist usage notes, gotchas, or workflow patterns for a specific tool. Notes survive across sessions and are returned by describe_tools. Call this whenever you discover something non-obvious about a tool's behaviour. Pass an empty string to clear notes.", Category: "meta"},
}
}

View File

@@ -246,7 +246,7 @@ func importantDateFromModel(m generatedmodels.ModelPublicImportantDates, memberN
Title: m.Title.String(),
DateValue: m.DateValue.Time(),
RecurringYearly: m.RecurringYearly,
ReminderDaysBefore: int(m.ReminderDaysBefore.Int64()),
ReminderDaysBefore: int(m.ReminderDaysBefore),
Notes: m.Notes.String(),
CreatedAt: m.CreatedAt.Time(),
}
@@ -418,6 +418,56 @@ func shoppingListFromModel(m generatedmodels.ModelPublicShoppingLists) ext.Shopp
return list
}
func planFromModel(m generatedmodels.ModelPublicPlans, tags []string) ext.Plan {
var projectID *uuid.UUID
if m.ProjectID.Valid {
id := m.ProjectID.UUID()
projectID = &id
}
var dueDate *time.Time
if m.DueDate.Valid {
t := m.DueDate.Time()
dueDate = &t
}
var completedAt *time.Time
if m.CompletedAt.Valid {
t := m.CompletedAt.Time()
completedAt = &t
}
var lastReviewedAt *time.Time
if m.LastReviewedAt.Valid {
t := m.LastReviewedAt.Time()
lastReviewedAt = &t
}
var supersedesPlanID *uuid.UUID
if m.SupersedesPlanID.Valid {
id := m.SupersedesPlanID.UUID()
supersedesPlanID = &id
}
return ext.Plan{
ID: m.ID.UUID(),
Title: m.Title.String(),
Description: m.Description.String(),
Status: ext.PlanStatus(m.Status.String()),
Priority: ext.PlanPriority(m.Priority.String()),
ProjectID: projectID,
Owner: m.Owner.String(),
DueDate: dueDate,
CompletedAt: completedAt,
ReviewedBy: m.ReviewedBy.String(),
LastReviewedAt: lastReviewedAt,
SupersedesPlanID: supersedesPlanID,
Tags: tags,
CreatedAt: m.CreatedAt.Time(),
UpdatedAt: m.UpdatedAt.Time(),
}
}
func learningFromModel(m generatedmodels.ModelPublicLearnings, tags []string) ext.Learning {
var projectID *uuid.UUID
if m.ProjectID.Valid {

477
internal/store/plans.go Normal file
View File

@@ -0,0 +1,477 @@
package store
import (
"context"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"git.warky.dev/wdevs/amcs/internal/generatedmodels"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
const planColumns = `
id, title, description, status, priority, project_id, owner, due_date,
completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags::text[], created_at, updated_at`
func (db *DB) CreatePlan(ctx context.Context, plan ext.Plan) (ext.Plan, error) {
row := db.pool.QueryRow(ctx, `
insert into plans (title, description, status, priority, project_id, owner, due_date,
completed_at, reviewed_by, last_reviewed_at, supersedes_plan_id, tags)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
returning`+planColumns,
strings.TrimSpace(plan.Title),
strings.TrimSpace(plan.Description),
string(plan.Status),
string(plan.Priority),
plan.ProjectID,
nullableText(plan.Owner),
plan.DueDate,
plan.CompletedAt,
nullableText(plan.ReviewedBy),
plan.LastReviewedAt,
plan.SupersedesPlanID,
plan.Tags,
)
return scanPlan(row)
}
func (db *DB) GetPlan(ctx context.Context, id uuid.UUID) (ext.Plan, error) {
row := db.pool.QueryRow(ctx, `select`+planColumns+` from plans where id = $1`, id)
plan, err := scanPlan(row)
if err != nil {
if err == pgx.ErrNoRows {
return ext.Plan{}, fmt.Errorf("plan not found: %s", id)
}
return ext.Plan{}, fmt.Errorf("get plan: %w", err)
}
return plan, nil
}
func (db *DB) GetPlanDetail(ctx context.Context, id uuid.UUID) (ext.PlanDetail, error) {
plan, err := db.GetPlan(ctx, id)
if err != nil {
return ext.PlanDetail{}, err
}
dependsOn, err := db.listPlansByQuery(ctx, `
select`+planColumns+`
from plans p
join plan_dependencies pd on pd.depends_on_plan_id = p.id
where pd.plan_id = $1 order by p.title`, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan depends_on: %w", err)
}
blocks, err := db.listPlansByQuery(ctx, `
select`+planColumns+`
from plans p
join plan_dependencies pd on pd.plan_id = p.id
where pd.depends_on_plan_id = $1 order by p.title`, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan blocks: %w", err)
}
related, err := db.listPlansByQuery(ctx, `
select`+planColumns+`
from plans p
where p.id in (
select plan_b_id from plan_related_plans where plan_a_id = $1
union
select plan_a_id from plan_related_plans where plan_b_id = $1
) order by p.title`, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan related: %w", err)
}
skills, err := db.ListPlanSkills(ctx, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan skills: %w", err)
}
guardrails, err := db.ListPlanGuardrails(ctx, id)
if err != nil {
return ext.PlanDetail{}, fmt.Errorf("get plan guardrails: %w", err)
}
return ext.PlanDetail{
Plan: plan,
DependsOn: dependsOn,
Blocks: blocks,
RelatedPlans: related,
Skills: skills,
Guardrails: guardrails,
}, nil
}
func (db *DB) UpdatePlan(ctx context.Context, id uuid.UUID, u ext.PlanUpdate) (ext.Plan, error) {
sets := []string{"updated_at = now()"}
args := []any{}
if u.Title != nil {
args = append(args, strings.TrimSpace(*u.Title))
sets = append(sets, fmt.Sprintf("title = $%d", len(args)))
}
if u.Description != nil {
args = append(args, strings.TrimSpace(*u.Description))
sets = append(sets, fmt.Sprintf("description = $%d", len(args)))
}
if u.Status != nil {
args = append(args, strings.TrimSpace(*u.Status))
sets = append(sets, fmt.Sprintf("status = $%d", len(args)))
}
if u.Priority != nil {
args = append(args, strings.TrimSpace(*u.Priority))
sets = append(sets, fmt.Sprintf("priority = $%d", len(args)))
}
if u.Owner != nil {
args = append(args, nullableText(*u.Owner))
sets = append(sets, fmt.Sprintf("owner = $%d", len(args)))
}
if u.ClearDueDate {
sets = append(sets, "due_date = null")
} else if u.DueDate != nil {
args = append(args, *u.DueDate)
sets = append(sets, fmt.Sprintf("due_date = $%d", len(args)))
}
if u.ClearCompletedAt {
sets = append(sets, "completed_at = null")
} else if u.CompletedAt != nil {
args = append(args, *u.CompletedAt)
sets = append(sets, fmt.Sprintf("completed_at = $%d", len(args)))
}
if u.ReviewedBy != nil {
args = append(args, nullableText(*u.ReviewedBy))
sets = append(sets, fmt.Sprintf("reviewed_by = $%d", len(args)))
}
if u.MarkReviewed {
sets = append(sets, "last_reviewed_at = now()")
}
if u.ClearSupersedesPlanID {
sets = append(sets, "supersedes_plan_id = null")
} else if u.SupersedesPlanID != nil {
args = append(args, *u.SupersedesPlanID)
sets = append(sets, fmt.Sprintf("supersedes_plan_id = $%d", len(args)))
}
if u.Tags != nil {
args = append(args, *u.Tags)
sets = append(sets, fmt.Sprintf("tags = $%d", len(args)))
}
args = append(args, id)
query := fmt.Sprintf(
"update plans set %s where id = $%d returning%s",
strings.Join(sets, ", "), len(args), planColumns,
)
row := db.pool.QueryRow(ctx, query, args...)
plan, err := scanPlan(row)
if err != nil {
if err == pgx.ErrNoRows {
return ext.Plan{}, fmt.Errorf("plan not found: %s", id)
}
return ext.Plan{}, fmt.Errorf("update plan: %w", err)
}
return plan, nil
}
func (db *DB) DeletePlan(ctx context.Context, id uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `delete from plans where id = $1`, id)
if err != nil {
return fmt.Errorf("delete plan: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan not found")
}
return nil
}
func (db *DB) ListPlans(ctx context.Context, filter ext.PlanFilter) ([]ext.Plan, error) {
args := make([]any, 0, 8)
conditions := make([]string, 0, 8)
if filter.ProjectID != nil {
args = append(args, *filter.ProjectID)
conditions = append(conditions, fmt.Sprintf("project_id = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Status); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("status = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Priority); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("priority = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Owner); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("owner = $%d", len(args)))
}
if v := strings.TrimSpace(filter.Tag); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args)))
}
if v := strings.TrimSpace(filter.Query); v != "" {
args = append(args, v)
conditions = append(conditions, fmt.Sprintf(
"to_tsvector('simple', title || ' ' || coalesce(description, '')) @@ websearch_to_tsquery('simple', $%d)", len(args)))
}
query := "select" + planColumns + " from plans"
if len(conditions) > 0 {
query += " where " + strings.Join(conditions, " and ")
}
query += " order by updated_at desc"
if filter.Limit > 0 {
args = append(args, filter.Limit)
query += fmt.Sprintf(" limit $%d", len(args))
}
return db.listPlansByQuery(ctx, query, args...)
}
// Dependencies
func (db *DB) AddPlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
insert into plan_dependencies (plan_id, depends_on_plan_id)
values ($1, $2)
on conflict do nothing
`, planID, dependsOnPlanID)
if err != nil {
return fmt.Errorf("add plan dependency: %w", err)
}
return nil
}
func (db *DB) RemovePlanDependency(ctx context.Context, planID, dependsOnPlanID uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `
delete from plan_dependencies where plan_id = $1 and depends_on_plan_id = $2
`, planID, dependsOnPlanID)
if err != nil {
return fmt.Errorf("remove plan dependency: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan dependency not found")
}
return nil
}
// Related Plans
func (db *DB) AddRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error {
a, b := canonicalPlanPair(planAID, planBID)
_, err := db.pool.Exec(ctx, `
insert into plan_related_plans (plan_a_id, plan_b_id)
values ($1, $2)
on conflict do nothing
`, a, b)
if err != nil {
return fmt.Errorf("add related plan: %w", err)
}
return nil
}
func (db *DB) RemoveRelatedPlan(ctx context.Context, planAID, planBID uuid.UUID) error {
a, b := canonicalPlanPair(planAID, planBID)
tag, err := db.pool.Exec(ctx, `
delete from plan_related_plans where plan_a_id = $1 and plan_b_id = $2
`, a, b)
if err != nil {
return fmt.Errorf("remove related plan: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("related plan link not found")
}
return nil
}
// Plan Skills
func (db *DB) AddPlanSkill(ctx context.Context, planID, skillID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
insert into plan_skills (plan_id, skill_id) values ($1, $2) on conflict do nothing
`, planID, skillID)
if err != nil {
return fmt.Errorf("add plan skill: %w", err)
}
return nil
}
func (db *DB) RemovePlanSkill(ctx context.Context, planID, skillID uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `
delete from plan_skills where plan_id = $1 and skill_id = $2
`, planID, skillID)
if err != nil {
return fmt.Errorf("remove plan skill: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan skill link not found")
}
return nil
}
func (db *DB) ListPlanSkills(ctx context.Context, planID uuid.UUID) ([]ext.AgentSkill, error) {
rows, err := db.pool.Query(ctx, `
select s.id, s.name, s.description, s.content, s.tags::text[], s.created_at, s.updated_at
from agent_skills s
join plan_skills ps on ps.skill_id = s.id
where ps.plan_id = $1
order by s.name
`, planID)
if err != nil {
return nil, fmt.Errorf("list plan skills: %w", err)
}
defer rows.Close()
var skills []ext.AgentSkill
for rows.Next() {
var model generatedmodels.ModelPublicAgentSkills
var tags []string
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan plan skill: %w", err)
}
s := ext.AgentSkill{
ID: model.ID.UUID(),
Name: model.Name.String(),
Description: model.Description.String(),
Content: model.Content.String(),
Tags: tags,
CreatedAt: model.CreatedAt.Time(),
UpdatedAt: model.UpdatedAt.Time(),
}
if s.Tags == nil {
s.Tags = []string{}
}
skills = append(skills, s)
}
return skills, rows.Err()
}
// Plan Guardrails
func (db *DB) AddPlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
insert into plan_guardrails (plan_id, guardrail_id) values ($1, $2) on conflict do nothing
`, planID, guardrailID)
if err != nil {
return fmt.Errorf("add plan guardrail: %w", err)
}
return nil
}
func (db *DB) RemovePlanGuardrail(ctx context.Context, planID, guardrailID uuid.UUID) error {
tag, err := db.pool.Exec(ctx, `
delete from plan_guardrails where plan_id = $1 and guardrail_id = $2
`, planID, guardrailID)
if err != nil {
return fmt.Errorf("remove plan guardrail: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("plan guardrail link not found")
}
return nil
}
func (db *DB) ListPlanGuardrails(ctx context.Context, planID uuid.UUID) ([]ext.AgentGuardrail, error) {
rows, err := db.pool.Query(ctx, `
select g.id, g.name, g.description, g.content, g.severity, g.tags::text[], g.created_at, g.updated_at
from agent_guardrails g
join plan_guardrails pg on pg.guardrail_id = g.id
where pg.plan_id = $1
order by g.name
`, planID)
if err != nil {
return nil, fmt.Errorf("list plan guardrails: %w", err)
}
defer rows.Close()
var guardrails []ext.AgentGuardrail
for rows.Next() {
var model generatedmodels.ModelPublicAgentGuardrails
var tags []string
if err := rows.Scan(&model.ID, &model.Name, &model.Description, &model.Content, &model.Severity, &tags, &model.CreatedAt, &model.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan plan guardrail: %w", err)
}
g := ext.AgentGuardrail{
ID: model.ID.UUID(),
Name: model.Name.String(),
Description: model.Description.String(),
Content: model.Content.String(),
Severity: model.Severity.String(),
Tags: tags,
CreatedAt: model.CreatedAt.Time(),
UpdatedAt: model.UpdatedAt.Time(),
}
if g.Tags == nil {
g.Tags = []string{}
}
guardrails = append(guardrails, g)
}
return guardrails, rows.Err()
}
// helpers
type planScanner interface {
Scan(dest ...any) error
}
func scanPlan(row planScanner) (ext.Plan, error) {
var model generatedmodels.ModelPublicPlans
var tags []string
err := row.Scan(
&model.ID,
&model.Title,
&model.Description,
&model.Status,
&model.Priority,
&model.ProjectID,
&model.Owner,
&model.DueDate,
&model.CompletedAt,
&model.ReviewedBy,
&model.LastReviewedAt,
&model.SupersedesPlanID,
&tags,
&model.CreatedAt,
&model.UpdatedAt,
)
if err != nil {
return ext.Plan{}, err
}
if tags == nil {
tags = []string{}
}
return planFromModel(model, tags), nil
}
func (db *DB) listPlansByQuery(ctx context.Context, query string, args ...any) ([]ext.Plan, error) {
rows, err := db.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
plans := make([]ext.Plan, 0)
for rows.Next() {
plan, err := scanPlan(rows)
if err != nil {
return nil, fmt.Errorf("scan plan: %w", err)
}
plans = append(plans, plan)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate plans: %w", err)
}
return plans, nil
}
// canonicalPlanPair ensures the smaller UUID is always plan_a_id to prevent duplicates.
func canonicalPlanPair(a, b uuid.UUID) (uuid.UUID, uuid.UUID) {
if strings.Compare(a.String(), b.String()) <= 0 {
return a, b
}
return b, a
}

344
internal/tools/plans.go Normal file
View File

@@ -0,0 +1,344 @@
package tools
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/config"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
thoughttypes "git.warky.dev/wdevs/amcs/internal/types"
)
type PlansTool struct {
store *store.DB
sessions *session.ActiveProjects
cfg config.SearchConfig
}
func NewPlansTool(db *store.DB, sessions *session.ActiveProjects, cfg config.SearchConfig) *PlansTool {
return &PlansTool{store: db, sessions: sessions, cfg: cfg}
}
// --- I/O types ---
type CreatePlanInput struct {
Title string `json:"title" jsonschema:"plan title"`
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
Owner string `json:"owner,omitempty"`
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339 timestamp"`
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
Tags []string `json:"tags,omitempty"`
}
type CreatePlanOutput struct {
Plan thoughttypes.Plan `json:"plan"`
}
type GetPlanInput struct {
ID uuid.UUID `json:"id" jsonschema:"plan id"`
}
type GetPlanOutput struct {
Plan thoughttypes.PlanDetail `json:"plan"`
}
type UpdatePlanInput struct {
ID uuid.UUID `json:"id" jsonschema:"plan id"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Status string `json:"status,omitempty" jsonschema:"draft|active|blocked|completed|cancelled|superseded"`
Priority string `json:"priority,omitempty" jsonschema:"low|medium|high|critical"`
Owner *string `json:"owner,omitempty" jsonschema:"empty string clears the owner"`
DueDate string `json:"due_date,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
ClearDueDate bool `json:"clear_due_date,omitempty"`
CompletedAt string `json:"completed_at,omitempty" jsonschema:"RFC3339; omit to keep, 'clear' to remove"`
ClearCompletedAt bool `json:"clear_completed_at,omitempty"`
ReviewedBy *string `json:"reviewed_by,omitempty" jsonschema:"empty string clears the reviewer"`
MarkReviewed bool `json:"mark_reviewed,omitempty" jsonschema:"set last_reviewed_at to now"`
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
ClearSupersedesPlanID bool `json:"clear_supersedes_plan_id,omitempty"`
Tags *[]string `json:"tags,omitempty" jsonschema:"replaces all tags when provided; pass [] to clear"`
}
type UpdatePlanOutput struct {
Plan thoughttypes.Plan `json:"plan"`
}
type DeletePlanInput struct {
ID uuid.UUID `json:"id" jsonschema:"plan id"`
}
type DeletePlanOutput struct {
Deleted bool `json:"deleted"`
}
type ListPlansInput struct {
Limit int `json:"limit,omitempty"`
Project string `json:"project,omitempty" jsonschema:"project name or id; falls back to active session project"`
Status string `json:"status,omitempty"`
Priority string `json:"priority,omitempty"`
Owner string `json:"owner,omitempty"`
Tag string `json:"tag,omitempty"`
Query string `json:"query,omitempty"`
}
type ListPlansOutput struct {
Plans []thoughttypes.Plan `json:"plans"`
}
type PlanDependencyInput struct {
PlanID uuid.UUID `json:"plan_id" jsonschema:"the plan that depends on another"`
DependsOnPlanID uuid.UUID `json:"depends_on_plan_id" jsonschema:"the plan that must complete first"`
}
type PlanRelatedInput struct {
PlanAID uuid.UUID `json:"plan_a_id"`
PlanBID uuid.UUID `json:"plan_b_id"`
}
type PlanLinkOutput struct {
OK bool `json:"ok"`
}
type PlanSkillInput struct {
PlanID uuid.UUID `json:"plan_id"`
SkillID uuid.UUID `json:"skill_id"`
}
type ListPlanSkillsInput struct {
PlanID uuid.UUID `json:"plan_id"`
}
type ListPlanSkillsOutput struct {
Skills []thoughttypes.AgentSkill `json:"skills"`
}
type PlanGuardrailInput struct {
PlanID uuid.UUID `json:"plan_id"`
GuardrailID uuid.UUID `json:"guardrail_id"`
}
type ListPlanGuardrailsInput struct {
PlanID uuid.UUID `json:"plan_id"`
}
type ListPlanGuardrailsOutput struct {
Guardrails []thoughttypes.AgentGuardrail `json:"guardrails"`
}
// --- Handlers ---
func (t *PlansTool) Create(ctx context.Context, req *mcp.CallToolRequest, in CreatePlanInput) (*mcp.CallToolResult, CreatePlanOutput, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, CreatePlanOutput{}, errRequiredField("title")
}
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, CreatePlanOutput{}, err
}
plan := thoughttypes.Plan{
Title: title,
Description: strings.TrimSpace(in.Description),
Status: thoughttypes.PlanStatus(defaultString(strings.TrimSpace(in.Status), string(thoughttypes.PlanStatusDraft))),
Priority: thoughttypes.PlanPriority(defaultString(strings.TrimSpace(in.Priority), string(thoughttypes.PlanPriorityMedium))),
Owner: strings.TrimSpace(in.Owner),
SupersedesPlanID: in.SupersedesPlanID,
Tags: normalizeStringSlice(in.Tags),
}
if project != nil {
plan.ProjectID = &project.ID
}
if v := strings.TrimSpace(in.DueDate); v != "" {
t, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, CreatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
}
plan.DueDate = &t
}
created, err := t.store.CreatePlan(ctx, plan)
if err != nil {
return nil, CreatePlanOutput{}, err
}
return nil, CreatePlanOutput{Plan: created}, nil
}
func (t *PlansTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetPlanInput) (*mcp.CallToolResult, GetPlanOutput, error) {
detail, err := t.store.GetPlanDetail(ctx, in.ID)
if err != nil {
return nil, GetPlanOutput{}, err
}
return nil, GetPlanOutput{Plan: detail}, nil
}
func (t *PlansTool) Update(ctx context.Context, _ *mcp.CallToolRequest, in UpdatePlanInput) (*mcp.CallToolResult, UpdatePlanOutput, error) {
u := thoughttypes.PlanUpdate{
ReviewedBy: in.ReviewedBy,
MarkReviewed: in.MarkReviewed,
ClearDueDate: in.ClearDueDate,
ClearCompletedAt: in.ClearCompletedAt,
ClearSupersedesPlanID: in.ClearSupersedesPlanID,
SupersedesPlanID: in.SupersedesPlanID,
Tags: in.Tags,
Owner: in.Owner,
}
if v := strings.TrimSpace(in.Title); v != "" {
u.Title = &v
}
if v := strings.TrimSpace(in.Description); v != "" {
u.Description = &v
}
if v := strings.TrimSpace(in.Status); v != "" {
u.Status = &v
}
if v := strings.TrimSpace(in.Priority); v != "" {
u.Priority = &v
}
if v := strings.TrimSpace(in.DueDate); v != "" && !in.ClearDueDate {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, UpdatePlanOutput{}, errInvalidField("due_date", "invalid due_date", "use RFC3339 format")
}
u.DueDate = &parsed
}
if v := strings.TrimSpace(in.CompletedAt); v != "" && !in.ClearCompletedAt {
parsed, err := time.Parse(time.RFC3339, v)
if err != nil {
return nil, UpdatePlanOutput{}, errInvalidField("completed_at", "invalid completed_at", "use RFC3339 format")
}
u.CompletedAt = &parsed
}
plan, err := t.store.UpdatePlan(ctx, in.ID, u)
if err != nil {
return nil, UpdatePlanOutput{}, err
}
return nil, UpdatePlanOutput{Plan: plan}, nil
}
func (t *PlansTool) Delete(ctx context.Context, _ *mcp.CallToolRequest, in DeletePlanInput) (*mcp.CallToolResult, DeletePlanOutput, error) {
if err := t.store.DeletePlan(ctx, in.ID); err != nil {
return nil, DeletePlanOutput{}, err
}
return nil, DeletePlanOutput{Deleted: true}, nil
}
func (t *PlansTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListPlansInput) (*mcp.CallToolResult, ListPlansOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
if err != nil {
return nil, ListPlansOutput{}, err
}
filter := thoughttypes.PlanFilter{
Limit: normalizeLimit(in.Limit, t.cfg),
Status: strings.TrimSpace(in.Status),
Priority: strings.TrimSpace(in.Priority),
Owner: strings.TrimSpace(in.Owner),
Tag: strings.TrimSpace(in.Tag),
Query: strings.TrimSpace(in.Query),
}
if project != nil {
filter.ProjectID = &project.ID
}
plans, err := t.store.ListPlans(ctx, filter)
if err != nil {
return nil, ListPlansOutput{}, err
}
return nil, ListPlansOutput{Plans: plans}, nil
}
func (t *PlansTool) AddDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if in.PlanID == in.DependsOnPlanID {
return nil, PlanLinkOutput{}, errInvalidField("depends_on_plan_id", "a plan cannot depend on itself", "use a different plan id")
}
if err := t.store.AddPlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveDependency(ctx context.Context, _ *mcp.CallToolRequest, in PlanDependencyInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemovePlanDependency(ctx, in.PlanID, in.DependsOnPlanID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) AddRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if in.PlanAID == in.PlanBID {
return nil, PlanLinkOutput{}, errInvalidField("plan_b_id", "a plan cannot be related to itself", "use a different plan id")
}
if err := t.store.AddRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveRelated(ctx context.Context, _ *mcp.CallToolRequest, in PlanRelatedInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemoveRelatedPlan(ctx, in.PlanAID, in.PlanBID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.AddPlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveSkill(ctx context.Context, _ *mcp.CallToolRequest, in PlanSkillInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemovePlanSkill(ctx, in.PlanID, in.SkillID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) ListSkills(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanSkillsInput) (*mcp.CallToolResult, ListPlanSkillsOutput, error) {
skills, err := t.store.ListPlanSkills(ctx, in.PlanID)
if err != nil {
return nil, ListPlanSkillsOutput{}, err
}
if skills == nil {
skills = []thoughttypes.AgentSkill{}
}
return nil, ListPlanSkillsOutput{Skills: skills}, nil
}
func (t *PlansTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.AddPlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) RemoveGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in PlanGuardrailInput) (*mcp.CallToolResult, PlanLinkOutput, error) {
if err := t.store.RemovePlanGuardrail(ctx, in.PlanID, in.GuardrailID); err != nil {
return nil, PlanLinkOutput{}, err
}
return nil, PlanLinkOutput{OK: true}, nil
}
func (t *PlansTool) ListGuardrails(ctx context.Context, _ *mcp.CallToolRequest, in ListPlanGuardrailsInput) (*mcp.CallToolResult, ListPlanGuardrailsOutput, error) {
guardrails, err := t.store.ListPlanGuardrails(ctx, in.PlanID)
if err != nil {
return nil, ListPlanGuardrailsOutput{}, err
}
if guardrails == nil {
guardrails = []thoughttypes.AgentGuardrail{}
}
return nil, ListPlanGuardrailsOutput{Guardrails: guardrails}, nil
}

83
internal/types/plan.go Normal file
View File

@@ -0,0 +1,83 @@
package types
import (
"time"
"github.com/google/uuid"
)
type PlanStatus string
const (
PlanStatusDraft PlanStatus = "draft"
PlanStatusActive PlanStatus = "active"
PlanStatusBlocked PlanStatus = "blocked"
PlanStatusCompleted PlanStatus = "completed"
PlanStatusCancelled PlanStatus = "cancelled"
PlanStatusSuperseded PlanStatus = "superseded"
)
type PlanPriority string
const (
PlanPriorityLow PlanPriority = "low"
PlanPriorityMedium PlanPriority = "medium"
PlanPriorityHigh PlanPriority = "high"
PlanPriorityCritical PlanPriority = "critical"
)
type Plan struct {
ID uuid.UUID `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Status PlanStatus `json:"status"`
Priority PlanPriority `json:"priority"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Owner string `json:"owner,omitempty"`
DueDate *time.Time `json:"due_date,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
ReviewedBy string `json:"reviewed_by,omitempty"`
LastReviewedAt *time.Time `json:"last_reviewed_at,omitempty"`
SupersedesPlanID *uuid.UUID `json:"supersedes_plan_id,omitempty"`
Tags []string `json:"tags"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PlanDetail enriches Plan with all related records, returned by get_plan.
type PlanDetail struct {
Plan
DependsOn []Plan `json:"depends_on"`
Blocks []Plan `json:"blocks"`
RelatedPlans []Plan `json:"related_plans"`
Skills []AgentSkill `json:"skills"`
Guardrails []AgentGuardrail `json:"guardrails"`
}
type PlanFilter struct {
Limit int
ProjectID *uuid.UUID
Status string
Priority string
Owner string
Tag string
Query string
}
// PlanUpdate describes a partial update; nil pointer fields are not touched.
type PlanUpdate struct {
Title *string
Description *string
Status *string
Priority *string
Owner *string // "" to clear
DueDate *time.Time // nil = no change
ClearDueDate bool // true = set NULL (takes priority over DueDate)
CompletedAt *time.Time
ClearCompletedAt bool
ReviewedBy *string // "" to clear
MarkReviewed bool // sets last_reviewed_at = now()
SupersedesPlanID *uuid.UUID
ClearSupersedesPlanID bool
Tags *[]string // nil = no change; replace when non-nil
}

View File

@@ -89,6 +89,32 @@ Do not abandon the project scope or retry without a project. The project simply
- Do not base64-encode a file to pass it to `save_file` if an `amcs://files/{id}` URI is already available from a prior `upload_file` or HTTP upload.
- When saving, choose the narrowest correct scope: project if project-specific, global if not.
## Plans
Plans are structured, trackable work items linked to projects. Use plans for multi-step goals, workstreams, or anything that needs an owner, due date, status lifecycle, or explicit dependency tracking.
- **Status lifecycle**: `draft``active``blocked` | `completed` | `cancelled` | `superseded`
- **Priority**: `low`, `medium` (default), `high`, `critical`
- Create plans with `create_plan` (required: `title`; optional: `description`, `status`, `priority`, `project`, `owner`, `due_date`, `supersedes_plan_id`, `tags`).
- Retrieve a full plan with `get_plan` — returns the plan plus `depends_on`, `blocks`, `related_plans`, `skills`, and `guardrails` in a single call.
- Partially update a plan with `update_plan` (only provided fields change). Use `mark_reviewed: true` to stamp `last_reviewed_at` without manually passing a timestamp.
- List and filter with `list_plans` (project/status/priority/owner/tag/query).
- Delete permanently with `delete_plan`.
**Dependencies** (directional — "A cannot proceed until B is done"):
- `add_plan_dependency` / `remove_plan_dependency` using `plan_id` and `depends_on_plan_id`.
- `get_plan` returns `depends_on` (plans this plan waits on) and `blocks` (plans waiting on this one).
**Related plans** (bidirectional — thematically linked, no ordering):
- `add_related_plan` / `remove_related_plan` using `plan_a_id` and `plan_b_id` (order does not matter).
**Plan skills and guardrails** (agent behaviour scoped to a plan):
- `add_plan_skill` / `remove_plan_skill` / `list_plan_skills`
- `add_plan_guardrail` / `remove_plan_guardrail` / `list_plan_guardrails`
- Load plan skills and guardrails alongside project skills/guardrails when working within a specific plan's scope.
**Freshness**: use `last_reviewed_at` and `reviewed_by` to track whether a plan is current. Set `mark_reviewed: true` on `update_plan` after reviewing a plan so staleness is visible in `list_plans` results.
## Tool Annotations
As you learn non-obvious behaviours, gotchas, or workflow patterns for individual tools, persist them with `annotate_tool`:
@@ -109,4 +135,4 @@ Notes are returned by `describe_tools` in future sessions. Annotate whenever you
## Short Operational Form
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, and store curated durable lessons with `add_learning`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.
At the start of every session, call `describe_tools` to read the full tool list and any accumulated usage notes. Use AMCS memory in project scope when the current work matches a known project; if no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. If your MCP client does not preserve sessions across calls, pass `project` explicitly instead of relying on `set_active_project`. Store raw/durable notes with `capture_thought`, store curated durable lessons with `add_learning`, and track structured multi-step goals with `create_plan`. Use `get_plan` to load a plan's full context including dependencies, related plans, and linked skills/guardrails. Stamp `last_reviewed_at` on plans you review with `update_plan mark_reviewed: true`. For binary files or files larger than 10 MB, call `upload_file` with `content_path` to stage the file and get an `amcs://files/{id}` URI, then pass that URI to `save_file` as `content_uri` to link it to a thought. For small files, use `save_file` or `upload_file` with `content_base64` directly. Browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Stored files can also be read as raw binary via MCP resources at `amcs://files/{id}`. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. If a tool returns `project_not_found`, call `create_project` with that name and retry — never drop the project scope. Whenever you discover a non-obvious tool behaviour, gotcha, or workflow pattern, record it with `annotate_tool` so future sessions benefit.

File diff suppressed because it is too large Load Diff

92
schema/plans.dbml Normal file
View File

@@ -0,0 +1,92 @@
Table plans {
id uuid [pk, default: `gen_random_uuid()`]
title text [not null]
description text [not null, default: '']
status text [not null, default: 'draft'] // draft, active, blocked, completed, cancelled, superseded
priority text [not null, default: 'medium'] // low, medium, high, critical
project_id uuid [ref: > projects.guid]
owner text
due_date timestamptz
completed_at timestamptz
reviewed_by text
last_reviewed_at timestamptz
supersedes_plan_id uuid [ref: > plans.id]
tags "text[]" [not null, default: `'{}'`]
created_at timestamptz [not null, default: `now()`]
updated_at timestamptz [not null, default: `now()`]
indexes {
project_id
status
priority
owner
due_date
last_reviewed_at
tags [type: gin]
title [type: gin]
}
}
// Directional: plan_id cannot proceed until depends_on_plan_id is complete
Table plan_dependencies {
id serial [pk]
plan_id uuid [not null, ref: > plans.id]
depends_on_plan_id uuid [not null, ref: > plans.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_id, depends_on_plan_id) [unique]
plan_id
depends_on_plan_id
}
}
// Bidirectional: store with plan_a_id < plan_b_id to avoid duplicates
Table plan_related_plans {
id serial [pk]
plan_a_id uuid [not null, ref: > plans.id]
plan_b_id uuid [not null, ref: > plans.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_a_id, plan_b_id) [unique]
plan_a_id
plan_b_id
}
}
Table plan_skills {
id serial [pk]
plan_id uuid [not null, ref: > plans.id]
skill_id uuid [not null, ref: > agent_skills.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_id, skill_id) [unique]
plan_id
}
}
Table plan_guardrails {
id serial [pk]
plan_id uuid [not null, ref: > plans.id]
guardrail_id uuid [not null, ref: > agent_guardrails.id]
created_at timestamptz [not null, default: `now()`]
indexes {
(plan_id, guardrail_id) [unique]
plan_id
}
}
// Cross-file refs (for relspecgo merge)
Ref: plans.project_id > projects.guid [delete: set null]
Ref: plans.supersedes_plan_id > plans.id [delete: set null]
Ref: plan_dependencies.plan_id > plans.id [delete: cascade]
Ref: plan_dependencies.depends_on_plan_id > plans.id [delete: cascade]
Ref: plan_related_plans.plan_a_id > plans.id [delete: cascade]
Ref: plan_related_plans.plan_b_id > plans.id [delete: cascade]
Ref: plan_skills.plan_id > plans.id [delete: cascade]
Ref: plan_skills.skill_id > agent_skills.id [delete: cascade]
Ref: plan_guardrails.plan_id > plans.id [delete: cascade]
Ref: plan_guardrails.guardrail_id > agent_guardrails.id [delete: cascade]

View File

@@ -313,6 +313,21 @@ export const api = {
dry_run: input?.dry_run ?? false
})
},
plans: {
list: async (params?: { status?: string; priority?: string; project_id?: string; limit?: number }) => {
const filters: ResolveSpecFilter[] = [];
if (params?.status) filters.push({ column: 'status', operator: 'eq', value: params.status });
if (params?.priority) filters.push({ column: 'priority', operator: 'eq', value: params.priority });
if (params?.project_id) filters.push({ column: 'project_id', operator: 'eq', value: params.project_id });
const rows = await rsReadMany<Omit<import('./types').Plan, 'tags'> & { tags?: unknown }>('plans', {
filters,
limit: params?.limit ?? 500,
sort: [{ column: 'updated_at', direction: 'desc' }]
});
return rows.map((row) => ({ ...row, tags: normalizeTags(row.tags) }));
}
},
stats: async () => {
type StatsThoughtRow = {
metadata?: {

View File

@@ -0,0 +1,197 @@
<script lang="ts">
import { GridlerFull, type GridlerColumn } from "@warkypublic/svelix";
import { GlobalStateStore } from "../../shellState";
import { adminGridTheme } from "../../gridTheme";
import type { Plan } from "../../types";
let selectedPlan = $state<Plan | null>(null);
let gridTotal = $state<number | null>(null);
const plansDataSourceOptions = {
url: "/api/rs",
authToken: GlobalStateStore.getState().session.authToken,
schema: "public",
entity: "plans",
uniqueID: "id",
sort: [{ column: "updated_at", direction: "desc" }],
} as unknown as {
url: string;
authToken?: string;
schema: string;
entity: string;
uniqueID: string;
};
const columns: GridlerColumn[] = [
{ id: "title", title: "Title", dataKey: "title", width: 340 },
{ id: "status", title: "Status", dataKey: "status", width: 120 },
{ id: "priority", title: "Priority", dataKey: "priority", width: 110 },
{ id: "owner", title: "Owner", dataKey: "owner", width: 160 },
{ id: "due_date", title: "Due", dataKey: "due_date", width: 180, format: "datetime" },
{ id: "last_reviewed_at", title: "Reviewed", dataKey: "last_reviewed_at", width: 180, format: "datetime" },
{ id: "updated_at", title: "Updated", dataKey: "updated_at", width: 180, format: "datetime" },
];
function normalizeTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((t) => String(t).trim()).filter(Boolean);
if (typeof value !== "string" || !value.trim()) return [];
const trimmed = value.trim();
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
return trimmed.slice(1, -1).split(",").map((t) => t.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
}
return trimmed.split(",").map((t) => t.trim()).filter(Boolean);
}
function normalizePlan(rowData: Record<string, unknown>): Plan {
return {
id: String(rowData.id ?? ""),
title: typeof rowData.title === "string" ? rowData.title : "",
description: typeof rowData.description === "string" ? rowData.description : "",
status: (typeof rowData.status === "string" ? rowData.status : "draft") as Plan["status"],
priority: (typeof rowData.priority === "string" ? rowData.priority : "medium") as Plan["priority"],
project_id: typeof rowData.project_id === "string" ? rowData.project_id : undefined,
owner: typeof rowData.owner === "string" && rowData.owner ? rowData.owner : undefined,
due_date: typeof rowData.due_date === "string" ? rowData.due_date : undefined,
completed_at: typeof rowData.completed_at === "string" ? rowData.completed_at : undefined,
reviewed_by: typeof rowData.reviewed_by === "string" ? rowData.reviewed_by : undefined,
last_reviewed_at: typeof rowData.last_reviewed_at === "string" ? rowData.last_reviewed_at : undefined,
supersedes_plan_id: typeof rowData.supersedes_plan_id === "string" ? rowData.supersedes_plan_id : undefined,
tags: normalizeTags(rowData.tags),
created_at: String(rowData.created_at ?? ""),
updated_at: String(rowData.updated_at ?? ""),
};
}
function onRowClick(_row: number, rowData: Record<string, unknown> | undefined) {
selectedPlan = rowData ? normalizePlan(rowData) : null;
}
function onGridEvent(
type: string,
_item?: unknown,
_column?: unknown,
_coords?: unknown,
detail?: Record<string, unknown>,
) {
if (type !== "page_loaded" && type !== "load") return;
const total = detail?.total;
if (typeof total === "number") gridTotal = total;
}
function formatDate(value?: string): string {
if (!value) return "—";
return new Date(value).toLocaleString();
}
const statusClasses: Record<string, string> = {
draft: "bg-slate-700/60 text-slate-300",
active: "bg-cyan-900/60 text-cyan-200",
blocked: "bg-amber-900/60 text-amber-200",
completed: "bg-emerald-900/60 text-emerald-200",
cancelled: "bg-slate-800/60 text-slate-500",
superseded: "bg-purple-900/60 text-purple-300",
};
const priorityClasses: Record<string, string> = {
low: "bg-slate-700/60 text-slate-400",
medium: "bg-cyan-900/60 text-cyan-300",
high: "bg-amber-900/60 text-amber-300",
critical: "bg-rose-900/60 text-rose-300",
};
</script>
<div class="space-y-4 w-full">
<div class="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 class="text-2xl font-semibold text-white">Plans</h2>
<p class="mt-1 text-sm text-slate-400">
{#if gridTotal === null}
Server-backed grid
{:else}
{gridTotal} plan{gridTotal !== 1 ? "s" : ""}
{/if}
</p>
</div>
</div>
<div class="grid gap-4 xl:grid-cols-[1.6fr_1fr]">
<div class="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
<GridlerFull
{columns}
theme={adminGridTheme}
rowMarkers="number"
height={560}
width="100%"
pageSize={40}
dataSource="resolvespec"
dataSourceOptions={plansDataSourceOptions}
serverSideSearch={true}
searchColumns={["title", "description", "status", "priority", "owner"]}
{onGridEvent}
{onRowClick}
/>
</div>
<aside class="rounded-2xl border border-white/10 bg-slate-900/70 p-4">
<h3 class="text-sm font-semibold text-white">Plan Inspector</h3>
{#if !selectedPlan}
<p class="mt-3 text-sm text-slate-500">
Select a plan row to inspect details and relationships.
</p>
{:else}
<div class="mt-3 space-y-3 text-sm text-slate-300">
<p class="text-base font-semibold text-slate-100">{selectedPlan.title}</p>
<div class="flex flex-wrap gap-2">
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${statusClasses[selectedPlan.status] ?? "bg-slate-700/60 text-slate-300"}`}>
{selectedPlan.status}
</span>
<span class={`inline-flex items-center rounded-lg px-2.5 py-0.5 text-xs font-medium ${priorityClasses[selectedPlan.priority] ?? "bg-slate-700/60 text-slate-300"}`}>
{selectedPlan.priority}
</span>
</div>
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
<p><strong class="text-slate-100">Owner:</strong> {selectedPlan.owner || "—"}</p>
<p><strong class="text-slate-100">Due:</strong> {formatDate(selectedPlan.due_date)}</p>
<p><strong class="text-slate-100">Completed:</strong> {formatDate(selectedPlan.completed_at)}</p>
<p><strong class="text-slate-100">Last reviewed:</strong> {formatDate(selectedPlan.last_reviewed_at)}</p>
<p><strong class="text-slate-100">Reviewed by:</strong> {selectedPlan.reviewed_by || "—"}</p>
<p><strong class="text-slate-100">Created:</strong> {formatDate(selectedPlan.created_at)}</p>
<p><strong class="text-slate-100">Updated:</strong> {formatDate(selectedPlan.updated_at)}</p>
</div>
{#if selectedPlan.description}
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Description</p>
<p class="mt-2 whitespace-pre-wrap text-slate-300">{selectedPlan.description}</p>
</div>
{/if}
{#if selectedPlan.tags.length > 0}
<div class="rounded-xl border border-white/10 bg-white/5 p-3">
<p class="text-xs uppercase tracking-[0.18em] text-slate-500">Tags</p>
<div class="mt-2 flex flex-wrap gap-1.5">
{#each selectedPlan.tags as tag}
<span class="rounded-md bg-white/10 px-2 py-0.5 text-xs text-slate-300">{tag}</span>
{/each}
</div>
</div>
{/if}
{#if selectedPlan.project_id || selectedPlan.supersedes_plan_id}
<div class="rounded-xl border border-white/10 bg-white/5 p-3 space-y-1">
{#if selectedPlan.project_id}
<p><strong class="text-slate-100">Project:</strong> <span class="font-mono text-xs text-slate-400">{selectedPlan.project_id}</span></p>
{/if}
{#if selectedPlan.supersedes_plan_id}
<p><strong class="text-slate-100">Supersedes:</strong> <span class="font-mono text-xs text-slate-400">{selectedPlan.supersedes_plan_id}</span></p>
{/if}
</div>
{/if}
</div>
{/if}
</aside>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import FilesPage from '../files/FilesPage.svelte';
import GuardrailsPage from '../guardrails/GuardrailsPage.svelte';
import LearningsPage from '../learnings/LearningsPage.svelte';
import PlansPage from '../plans/PlansPage.svelte';
import MaintenancePage from '../maintenance/MaintenancePage.svelte';
import DashboardPage from '../dashboard/DashboardPage.svelte';
import ProjectsPage from '../projects/ProjectsPage.svelte';
@@ -41,6 +42,8 @@
<ThoughtsPage />
{:else if currentPage === 'learnings'}
<LearningsPage />
{:else if currentPage === 'plans'}
<PlansPage />
{:else if currentPage === 'skills'}
<SkillsPage />
{:else if currentPage === 'guardrails'}

View File

@@ -16,6 +16,7 @@
{ id: 'projects', label: 'Projects', description: 'Browse and manage projects.' },
{ id: 'thoughts', label: 'Thoughts', description: 'Search and inspect thoughts.' },
{ id: 'learnings', label: 'Learnings', description: 'Curated insights and outcomes.' },
{ id: 'plans', label: 'Plans', description: 'Structured plans and workstreams.' },
{ id: 'skills', label: 'Skills', description: 'Agent skill registry.' },
{ id: 'guardrails', label: 'Guardrails', description: 'Agent guardrail registry.' },
{ id: 'files', label: 'Files', description: 'Stored file inventory.' },

View File

@@ -56,7 +56,7 @@ export type NavItem = {
disabled?: boolean;
};
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'skills' | 'guardrails' | 'files' | 'maintenance';
export type ShellPage = 'dashboard' | 'projects' | 'thoughts' | 'learnings' | 'plans' | 'skills' | 'guardrails' | 'files' | 'maintenance';
export type Project = {
id: string;
@@ -181,6 +181,24 @@ export type Learning = {
updated_at: string;
};
export type Plan = {
id: string;
title: string;
description: string;
status: 'draft' | 'active' | 'blocked' | 'completed' | 'cancelled' | 'superseded';
priority: 'low' | 'medium' | 'high' | 'critical';
project_id?: string;
owner?: string;
due_date?: string;
completed_at?: string;
reviewed_by?: string;
last_reviewed_at?: string;
supersedes_plan_id?: string;
tags: string[];
created_at: string;
updated_at: string;
};
export type MaintenanceTask = {
id: string;
name: string;

File diff suppressed because one or more lines are too long