feat(goals): complete_goal MCP tool + draft→active auto-transition

Three improvements that turn the doc-gardener demo from "runs but
stays in 'draft' forever" into a goal that properly transitions
through its lifecycle and renders a completion summary on /goals/<id>.

### 1. complete_goal MCP tool (#59, #62)

New tool surface: complete_goal(goal_id, status, summary, completion_message_id?)

The critic calls this from inside the sandbox after it sends its FINAL:
DM. Records the one-paragraph human-readable summary on the goal row
plus a pointer to the message that carried the FINAL text, so the Web
UI /goals/<id> page has both the verdict and a deep link to the full
findings JSON.

Status parameter accepts completed | stuck | cancelled. Idempotent
when called with the current status. Rejects callers owned by a
different human than the goal owner.

Plumbing:
- New migration 026_goals_completion_summary.sql adds two columns
  to goals: completion_summary TEXT, completion_message_id INTEGER
  (FK messages.id, ON DELETE SET NULL).
- internal/goals/types.go: new CompletionSummary + CompletionMessageID
  fields on Goal struct.
- internal/goals/store.go: Get/List Scan both new columns;
  SetCompletion(goalID, status, summary, messageID) helper that
  updates status+summary+message_id atomically and populates
  completed_at for terminal states.
- internal/goals/service.go: Complete(ctx, goalID, status, summary,
  messageID) wraps the store method with legalTransition gating.
  legalTransition expanded so draft can jump straight to completed
  (no mandatory "active" hop required).
- internal/mcp/goals_tools.go: completeGoalTool definition +
  handleCompleteGoal handler. Tool count 6 → 7.
- internal/api/goals_handler.go: surfaces completion_summary,
  completion_message_id, and completed_at on both list and detail
  endpoints so the Svelte /goals UI can render them.

### 2. Draft → active auto-transition in propose_task_tree (#60)

handleProposeTaskTree now flips the goal from draft to active at the
end. Previously the coordinator would call create_goal +
propose_task_tree and dispatch inspector, but the goal stayed in
draft forever because nothing transitioned it. Now the mere fact
of having a task tree means the goal is active.

Safe: the transition is best-effort and ignores the legal-transition
error when the goal is already beyond draft.

### 3. REVISE round cap (#61)

Two-layer enforcement:

- Server-side: examples/doc-gardener/start.sh drops max_trigger_depth
  from 8 to 4. Each REVISE round costs 2 hops (critic→inspector +
  inspector→critic), so depth=4 caps the loop at roughly 2 rounds
  before the reactor refuses further dispatches.

- Prompt-side: inspector now includes revision_round (starting at
  0, incremented when it sees a REVISE: input) in its findings JSON.
  Critic reads revision_round and force-FINALs when >= 1. Prompt
  explicitly tells the critic to call complete_goal after sending
  FINAL, so the goal row gets a proper completion_summary.

### 4. run_task.sh terminal-state detection

Rewrote the poll loop to watch goals.status/completion_summary as
the definitive "done" signal rather than parsing DM bodies. Keeps
a message-based fallback for TRIVIAL/CANNOT paths that don't create
a goal. Treats "Received system trigger..." and "Coalesced
trigger..." as informational (they're `__coalesced__` reactor
synthetic events leaking through the coordinator reply, not real
user-facing output). Bare coordinator replies are terminal only
when no goal was created.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Algis Dumbris
2026-04-15 18:52:44 +03:00
co-authored by Claude Opus 4.6
parent b5b5d6d26c
commit 42f8256df6
10 changed files with 270 additions and 65 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+44 -13
View File
@@ -42,11 +42,31 @@ printf '%s' "$GOAL" | "$BIN" --socket "$SOCKET" messages send \
--to doc-coordinator \
--priority 8 >&2
say "waiting for FINAL: / CANNOT: reply to algis (up to 600s)..."
say "waiting for goal completion or FINAL:/CANNOT: reply to algis (up to 600s)..."
deadline=$(( $(date +%s) + 600 ))
last_seen_id=$BASELINE
while [ "$(date +%s)" -lt "$deadline" ]; do
# Goal completion check (definitive signal — set by complete_goal MCP).
# A goal in 'completed'/'stuck'/'cancelled' state with a
# completion_summary means the critic finalized the verdict.
COMPLETED=$(sqlite3 "$DB" "
SELECT id FROM goals
WHERE status IN ('completed','stuck','cancelled')
AND completion_summary IS NOT NULL
ORDER BY id DESC LIMIT 1
" 2>/dev/null || true)
if [ -n "$COMPLETED" ]; then
say "goal $COMPLETED reached terminal state"
GOAL_SUMMARY=$(sqlite3 "$DB" "SELECT status || ': ' || COALESCE(completion_summary,'') FROM goals WHERE id = $COMPLETED" 2>/dev/null)
say "$GOAL_SUMMARY"
echo "$COMPLETED" > "$SCRIPT_DIR/.last_goal_id"
say "goal id = $COMPLETED — render with ./report.sh"
exit 0
fi
# Message-based fallback (for TRIVIAL/CANNOT paths that skip the
# task tree and never call complete_goal).
NEW_LINES=$(sqlite3 -separator '|' "$DB" "
SELECT id, from_agent, replace(substr(body, 1, 280), char(10), ' ')
FROM messages
@@ -62,20 +82,31 @@ while [ "$(date +%s)" -lt "$deadline" ]; do
say "← [$from #$id] $body"
last_seen_id=$id
case "$body" in
DELEGATED:*|REVISING:*)
DELEGATED:*|REVISING:*|Received\ system\ trigger*|Coalesced\ trigger*)
;; # informational, keep waiting
FINAL:*|CANNOT:*)
say "terminal response received"
GOAL_ID=$(sqlite3 "$DB" 'SELECT id FROM goals ORDER BY id DESC LIMIT 1' 2>/dev/null || echo)
if [ -n "$GOAL_ID" ]; then
echo "$GOAL_ID" > "$SCRIPT_DIR/.last_goal_id"
say "goal id = $GOAL_ID — render with ./report.sh"
fi
exit 0
;;
*)
if [ "$from" = "doc-coordinator" ] || \
[ "${body#FINAL:}" != "$body" ] || \
[ "${body#CANNOT:}" != "$body" ]; then
say "terminal response received"
# Persist last goal id for ./report.sh.
GOAL_ID=$(sqlite3 "$DB" 'SELECT id FROM goals ORDER BY id DESC LIMIT 1' 2>/dev/null || echo)
if [ -n "$GOAL_ID" ]; then
echo "$GOAL_ID" > "$SCRIPT_DIR/.last_goal_id"
say "goal id = $GOAL_ID — render with ./report.sh"
# A bare reply from doc-coordinator with no status
# prefix is a TRIVIAL-triage direct answer. Count
# it as terminal only if no goal was created (i.e.
# the coordinator didn't start a pipeline).
if [ "$from" = "doc-coordinator" ]; then
HAS_GOAL=$(sqlite3 "$DB" 'SELECT COUNT(*) FROM goals' 2>/dev/null || echo 0)
if [ "$HAS_GOAL" = "0" ]; then
say "direct (trivial) response received"
exit 0
fi
exit 0
# Otherwise keep waiting — the coordinator
# already delegated and will finalize via
# complete_goal once the critic runs.
fi
;;
esac
@@ -86,6 +117,6 @@ EOF
sleep 1
done
say "timed out waiting for terminal response (FINAL: or CANNOT:)"
say "timed out waiting for terminal response"
say "check http://localhost:18089/runs and http://localhost:18089/goals"
exit 2
+6 -1
View File
@@ -153,12 +153,17 @@ for name in doc-coordinator docs-inspector docs-critic; do
done
say "configuring reactive trigger mode"
# max_trigger_depth = 4 caps the conversation loop at about two
# inspector↔critic round-trips (each REVISE costs two hops). Prevents
# the agents from spinning forever on a badly-formed report when the
# critic doesn't converge; see configs/critic.json for the structural
# half of the cap.
sqlite3 "$DATA_DIR/synapbus.db" <<SQL
UPDATE agents SET
trigger_mode = 'reactive',
cooldown_seconds = 0,
daily_trigger_budget = 50,
max_trigger_depth = 8
max_trigger_depth = 4
WHERE name IN ('doc-coordinator','docs-inspector','docs-critic');
SQL
+56 -38
View File
@@ -41,34 +41,43 @@ func (h *GoalsHandler) ListGoals(w http.ResponseWriter, r *http.Request) {
}
type goalSummary struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
ChannelID int64 `json:"channel_id"`
OwnerUsername string `json:"owner_username"`
RootTaskID *int64 `json:"root_task_id"`
SpentTokens int64 `json:"spent_tokens"`
SpentDollarsCents int64 `json:"spent_dollars_cents"`
TaskCount int `json:"task_count"`
BudgetTokens *int64 `json:"budget_tokens"`
BudgetDollarsCents *int64 `json:"budget_dollars_cents"`
PercentBudget float64 `json:"percent_budget"`
CreatedAt string `json:"created_at"`
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
ChannelID int64 `json:"channel_id"`
OwnerUsername string `json:"owner_username"`
RootTaskID *int64 `json:"root_task_id"`
SpentTokens int64 `json:"spent_tokens"`
SpentDollarsCents int64 `json:"spent_dollars_cents"`
TaskCount int `json:"task_count"`
BudgetTokens *int64 `json:"budget_tokens"`
BudgetDollarsCents *int64 `json:"budget_dollars_cents"`
PercentBudget float64 `json:"percent_budget"`
CompletionSummary *string `json:"completion_summary,omitempty"`
CompletionMessageID *int64 `json:"completion_message_id,omitempty"`
CompletedAt *string `json:"completed_at,omitempty"`
CreatedAt string `json:"created_at"`
}
out := make([]goalSummary, 0, len(gs))
for _, g := range gs {
s := goalSummary{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
Status: g.Status,
ChannelID: g.ChannelID,
RootTaskID: g.RootTaskID,
BudgetTokens: g.BudgetTokens,
BudgetDollarsCents: g.BudgetDollarsCents,
CreatedAt: g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
Status: g.Status,
ChannelID: g.ChannelID,
RootTaskID: g.RootTaskID,
BudgetTokens: g.BudgetTokens,
BudgetDollarsCents: g.BudgetDollarsCents,
CompletionSummary: g.CompletionSummary,
CompletionMessageID: g.CompletionMessageID,
CreatedAt: g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
}
if g.CompletedAt != nil {
ca := g.CompletedAt.UTC().Format("2006-01-02T15:04:05Z")
s.CompletedAt = &ca
}
_ = h.db.QueryRowContext(r.Context(),
`SELECT username FROM users WHERE id=?`, g.OwnerUserID).Scan(&s.OwnerUsername)
@@ -286,23 +295,32 @@ func (h *GoalsHandler) GetGoal(w http.ResponseWriter, r *http.Request) {
rows.Close()
}
var completedAt *string
if g.CompletedAt != nil {
s := g.CompletedAt.UTC().Format("2006-01-02T15:04:05Z")
completedAt = &s
}
writeJSON(w, http.StatusOK, map[string]any{
"goal": map[string]any{
"id": g.ID,
"slug": g.Slug,
"title": g.Title,
"description": g.Description,
"status": g.Status,
"channel_id": g.ChannelID,
"coordinator_agent_id": g.CoordinatorAgentID,
"root_task_id": g.RootTaskID,
"owner_user_id": g.OwnerUserID,
"owner_username": ownerUsername,
"budget_tokens": g.BudgetTokens,
"budget_dollars_cents": g.BudgetDollarsCents,
"max_spawn_depth": g.MaxSpawnDepth,
"alert_80pct_posted": g.Alert80PctPosted,
"created_at": g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
"id": g.ID,
"slug": g.Slug,
"title": g.Title,
"description": g.Description,
"status": g.Status,
"channel_id": g.ChannelID,
"coordinator_agent_id": g.CoordinatorAgentID,
"root_task_id": g.RootTaskID,
"owner_user_id": g.OwnerUserID,
"owner_username": ownerUsername,
"budget_tokens": g.BudgetTokens,
"budget_dollars_cents": g.BudgetDollarsCents,
"max_spawn_depth": g.MaxSpawnDepth,
"alert_80pct_posted": g.Alert80PctPosted,
"completion_summary": g.CompletionSummary,
"completion_message_id": g.CompletionMessageID,
"created_at": g.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
"completed_at": completedAt,
},
"tasks": out,
"rollup": map[string]any{
+35 -4
View File
@@ -104,6 +104,30 @@ func (s *Service) TransitionStatus(ctx context.Context, goalID int64, newStatus
return s.store.SetStatus(ctx, goalID, newStatus)
}
// Complete transitions a goal to a terminal state (completed / stuck /
// cancelled) and records the critic's completion summary and the
// message id the FINAL message was delivered in. Legal transitions:
//
// draft|active|stuck → completed
// draft|active → stuck
// any → cancelled
//
// If the goal is already in the target state, Complete is a no-op
// (idempotent). It never demotes a completed goal.
func (s *Service) Complete(ctx context.Context, goalID int64, newStatus, summary string, messageID int64) error {
g, err := s.store.Get(ctx, goalID)
if err != nil {
return err
}
if g.Status == newStatus {
return nil
}
if !legalTransition(g.Status, newStatus) {
return fmt.Errorf("illegal goal transition: %s → %s", g.Status, newStatus)
}
return s.store.SetCompletion(ctx, goalID, newStatus, summary, messageID)
}
// BudgetVerdict describes what the budget enforcer wants the caller to do.
type BudgetVerdict struct {
PercentBudget float64 // 0..100+
@@ -145,13 +169,20 @@ func (s *Service) MarkSoftAlertPosted(ctx context.Context, goalID int64) error {
func legalTransition(from, to string) bool {
switch from {
case StatusDraft:
return to == StatusActive || to == StatusCancelled
// A goal can jump straight from draft to a terminal state
// when the workflow never bothers with an explicit active
// transition (e.g. the doc-gardener critic completes the
// goal at the end of a fast single-round inspector pass).
return to == StatusActive || to == StatusCompleted ||
to == StatusStuck || to == StatusCancelled
case StatusActive:
return to == StatusPaused || to == StatusCompleted || to == StatusStuck || to == StatusCancelled
return to == StatusPaused || to == StatusCompleted ||
to == StatusStuck || to == StatusCancelled
case StatusPaused:
return to == StatusActive || to == StatusCancelled
return to == StatusActive || to == StatusCompleted ||
to == StatusStuck || to == StatusCancelled
case StatusStuck:
return to == StatusActive || to == StatusCancelled
return to == StatusActive || to == StatusCompleted || to == StatusCancelled
}
return false
}
+34 -4
View File
@@ -60,11 +60,13 @@ func (s *Store) Get(ctx context.Context, id int64) (*Goal, error) {
err := s.db.QueryRowContext(ctx, `
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
alert_80pct_posted, created_at, updated_at, completed_at
alert_80pct_posted, created_at, updated_at, completed_at,
completion_summary, completion_message_id
FROM goals WHERE id = ?`, id).Scan(
&g.ID, &g.Slug, &g.Title, &g.Description, &g.OwnerUserID, &g.ChannelID, &g.CoordinatorAgentID,
&g.RootTaskID, &g.Status, &g.BudgetTokens, &g.BudgetDollarsCents, &g.MaxSpawnDepth,
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt)
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt,
&g.CompletionSummary, &g.CompletionMessageID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrGoalNotFound
}
@@ -88,13 +90,15 @@ func (s *Store) List(ctx context.Context, ownerUserID *int64, limit int) ([]*Goa
rows, err = s.db.QueryContext(ctx, `
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
alert_80pct_posted, created_at, updated_at, completed_at
alert_80pct_posted, created_at, updated_at, completed_at,
completion_summary, completion_message_id
FROM goals WHERE owner_user_id = ? ORDER BY id DESC LIMIT ?`, *ownerUserID, limit)
} else {
rows, err = s.db.QueryContext(ctx, `
SELECT id, slug, title, description, owner_user_id, channel_id, coordinator_agent_id,
root_task_id, status, budget_tokens, budget_dollars_cents, max_spawn_depth,
alert_80pct_posted, created_at, updated_at, completed_at
alert_80pct_posted, created_at, updated_at, completed_at,
completion_summary, completion_message_id
FROM goals ORDER BY id DESC LIMIT ?`, limit)
}
if err != nil {
@@ -110,6 +114,7 @@ func (s *Store) List(ctx context.Context, ownerUserID *int64, limit int) ([]*Goa
&g.ID, &g.Slug, &g.Title, &g.Description, &g.OwnerUserID, &g.ChannelID, &g.CoordinatorAgentID,
&g.RootTaskID, &g.Status, &g.BudgetTokens, &g.BudgetDollarsCents, &g.MaxSpawnDepth,
&alert, &g.CreatedAt, &g.UpdatedAt, &g.CompletedAt,
&g.CompletionSummary, &g.CompletionMessageID,
); err != nil {
return nil, err
}
@@ -135,6 +140,31 @@ func (s *Store) SetStatus(ctx context.Context, goalID int64, newStatus string) e
return err
}
// SetCompletion records the critic's completion verdict: status
// transition, final summary body, and the message id the summary
// was delivered in. Used by the complete_goal MCP tool. When
// newStatus is "completed", completed_at is populated. For terminal
// failure states ("stuck", "cancelled") completed_at is also set so
// the UI treats them uniformly as "no longer running".
func (s *Store) SetCompletion(ctx context.Context, goalID int64, newStatus, summary string, messageID int64) error {
now := time.Now().UTC()
var completedAt *time.Time
switch newStatus {
case "completed", "stuck", "cancelled":
completedAt = &now
}
_, err := s.db.ExecContext(ctx, `
UPDATE goals SET
status = ?,
completion_summary = ?,
completion_message_id = ?,
completed_at = COALESCE(?, completed_at),
updated_at = ?
WHERE id = ?`,
newStatus, summary, messageID, completedAt, now, goalID)
return err
}
// MarkSoftAlertPosted flips the idempotency flag so the 80 % alert is
// posted only once per goal.
func (s *Store) MarkSoftAlertPosted(ctx context.Context, goalID int64) error {
+7
View File
@@ -34,6 +34,13 @@ type Goal struct {
CreatedAt time.Time
UpdatedAt time.Time
CompletedAt *time.Time
// CompletionSummary is the critic's FINAL paragraph (or the
// failure reason when status=stuck). Populated by complete_goal.
CompletionSummary *string
// CompletionMessageID is the id of the DM that carried the
// completion verdict — lets /goals/<id> deep-link to the full
// findings JSON or report body.
CompletionMessageID *int64
}
// CreateGoalInput captures the public arguments of CreateGoal.
+75 -3
View File
@@ -53,8 +53,8 @@ func NewGoalsToolRegistrar(
}
// RegisterAllOnServer attaches create_goal, propose_task_tree,
// propose_agent, claim_task, request_resource, list_resources to the
// MCP server.
// propose_agent, claim_task, request_resource, list_resources, and
// complete_goal to the MCP server.
func (r *GoalsToolRegistrar) RegisterAllOnServer(s *server.MCPServer) {
s.AddTool(r.createGoalTool(), r.handleCreateGoal)
s.AddTool(r.proposeTaskTreeTool(), r.handleProposeTaskTree)
@@ -62,7 +62,8 @@ func (r *GoalsToolRegistrar) RegisterAllOnServer(s *server.MCPServer) {
s.AddTool(r.claimTaskTool(), r.handleClaimTask)
s.AddTool(r.requestResourceTool(), r.handleRequestResource)
s.AddTool(r.listResourcesTool(), r.handleListResources)
r.logger.Info("spec-018 MCP tools registered", "count", 6)
s.AddTool(r.completeGoalTool(), r.handleCompleteGoal)
r.logger.Info("spec-018 MCP tools registered", "count", 7)
}
// --- Tool Definitions ---
@@ -120,6 +121,16 @@ func (r *GoalsToolRegistrar) listResourcesTool() mcplib.Tool {
)
}
func (r *GoalsToolRegistrar) completeGoalTool() mcplib.Tool {
return mcplib.NewTool("complete_goal",
mcplib.WithDescription("Mark a goal as terminally done. The critic (or coordinator) calls this after the FINAL verdict: status=\"completed\" on success, \"stuck\" when the goal couldn't be finished, \"cancelled\" when the human aborted. Records a summary paragraph for /goals/<id> and links it to the DM that carried the FINAL message. Idempotent when called with the current status."),
mcplib.WithNumber("goal_id", mcplib.Description("Goal id from create_goal"), mcplib.Required()),
mcplib.WithString("status", mcplib.Description("Terminal status: completed | stuck | cancelled"), mcplib.Required()),
mcplib.WithString("summary", mcplib.Description("One-paragraph human-readable completion summary (what was done, top findings, recommended next step). Goes straight into /goals/<id>."), mcplib.Required()),
mcplib.WithNumber("completion_message_id", mcplib.Description("Optional id of the message that carried the verdict. When set, /goals/<id> can deep-link to the full findings JSON in that DM.")),
)
}
// --- Handlers ---
func (r *GoalsToolRegistrar) handleCreateGoal(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
@@ -215,6 +226,13 @@ func (r *GoalsToolRegistrar) handleProposeTaskTree(ctx context.Context, req mcpl
// Also set the goal's root_task_id.
_, _ = r.db.ExecContext(ctx, `UPDATE goals SET root_task_id=? WHERE id=?`, rootID, goalID)
// Auto-transition draft → active. The coordinator would otherwise
// have to call a separate tool just to flip state, which is friction
// we don't need — if there's a task tree, the goal is active. Ignore
// the legal-transition error when the goal isn't in draft (operators
// may have already moved it manually).
_ = r.goals.TransitionStatus(ctx, goalID, goals.StatusActive)
return resultJSON(map[string]any{
"root_task_id": rootID,
"task_count": len(allIDs),
@@ -369,3 +387,57 @@ func (r *GoalsToolRegistrar) handleListResources(ctx context.Context, req mcplib
"count": len(names),
})
}
func (r *GoalsToolRegistrar) handleCompleteGoal(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToolResult, error) {
agentName, ok := extractAgentName(ctx)
if !ok {
return mcplib.NewToolResultError("authentication required"), nil
}
if r.goals == nil {
return mcplib.NewToolResultError("goals service not configured"), nil
}
goalID := int64(req.GetInt("goal_id", 0))
status := req.GetString("status", "")
summary := req.GetString("summary", "")
messageID := int64(req.GetInt("completion_message_id", 0))
if goalID <= 0 || status == "" || summary == "" {
return mcplib.NewToolResultError("goal_id, status, and summary are all required"), nil
}
switch status {
case goals.StatusCompleted, goals.StatusStuck, goals.StatusCancelled:
// ok
default:
return mcplib.NewToolResultError(fmt.Sprintf("invalid status %q — must be completed|stuck|cancelled", status)), nil
}
// Authorization: caller must own the goal (same human owner).
// Coordinator + critic both run as ai agents owned by the same
// user in the doc-gardener example; this check keeps one user's
// agents from closing another user's goals.
callerAgent, err := r.agents.GetAgent(ctx, agentName)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("resolve caller: %s", err)), nil
}
g, err := r.goals.GetGoal(ctx, goalID)
if err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("get goal: %s", err)), nil
}
if callerAgent.OwnerID != g.OwnerUserID {
return mcplib.NewToolResultError("goal is owned by a different user — refusing to transition"), nil
}
if err := r.goals.Complete(ctx, goalID, status, summary, messageID); err != nil {
return mcplib.NewToolResultError(fmt.Sprintf("complete_goal: %s", err)), nil
}
r.logger.Info("goal completed via MCP",
"goal_id", goalID,
"status", status,
"by_agent", agentName,
)
return resultJSON(map[string]any{
"goal_id": goalID,
"status": status,
})
}
@@ -0,0 +1,11 @@
-- 026_goals_completion_summary.sql — record the critic's FINAL summary
-- on the goal row so /goals/<id> can render it without a JOIN through
-- goal_tasks. Also stash the message id the completion came from so
-- the UI can deep-link to the full findings JSON.
--
-- Backfill is unnecessary — existing goals never completed cleanly
-- (the demo had no complete_goal path before this migration).
ALTER TABLE goals ADD COLUMN completion_summary TEXT;
ALTER TABLE goals ADD COLUMN completion_message_id INTEGER
REFERENCES messages(id) ON DELETE SET NULL;