generated from coulomb/repo-seed
feat(goals): add domain/repo goal tracking and update_workstream MCP tool
- Migration c5d6e7f8a9b0: domain_goals and repo_goals tables, repo_goal_id FK on workstreams - DomainGoal: one active per domain (partial unique index), status active/archived/superseded - RepoGoal: integer priority, status active/paused/completed/archived, optional domain_goal_id link - WorkstreamUpdate schema and router extended with repo_goal_id and repo_goal_id filter - 6 new MCP goal tools: create_domain_goal, get_domain_goals, activate_domain_goal, create_repo_goal, get_repo_goals, update_repo_goal - update_workstream MCP tool: patch any subset of workstream fields (title, description, owner, due_date, repo_goal_id, status) - get_domain_summary extended with goal_guidance (needs_workplan, alignment_warnings) signals - Dashboard goals.md page and docs/goals.md reference page - CLAUDE.md template updated to act on goal_guidance signals at session start - CUST-WP-0010 workplan for this feature Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -142,7 +142,8 @@ def get_domain_summary(domain_slug: str) -> str:
|
||||
domain_slug: the domain slug, e.g. "railiance", "markitect"
|
||||
|
||||
Returns: topic, active workstreams, open blocking decisions for this
|
||||
topic, 5 most recent progress events, and repo SBOM status for this domain.
|
||||
topic, 5 most recent progress events, repo SBOM status, and goal guidance
|
||||
(needs_workplan signals + alignment warnings).
|
||||
"""
|
||||
topics = _get("/topics")
|
||||
topic = next((t for t in topics if t.get("domain_slug") == domain_slug), None)
|
||||
@@ -156,7 +157,77 @@ def get_domain_summary(domain_slug: str) -> str:
|
||||
recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
|
||||
repos = _get("/repos", {"domain": domain_slug})
|
||||
|
||||
return json.dumps({
|
||||
# ── Goal guidance ──────────────────────────────────────────────────────────
|
||||
# Fetch active repo goals per repo, then cross-reference with workstreams.
|
||||
repo_by_id = {r["id"]: r for r in repos}
|
||||
ws_by_repo_goal: dict[str, list] = {}
|
||||
for ws in workstreams:
|
||||
if ws.get("repo_goal_id"):
|
||||
ws_by_repo_goal.setdefault(ws["repo_goal_id"], []).append(ws)
|
||||
|
||||
# repo_id → list of active workstreams (for alignment check)
|
||||
ws_by_repo: dict[str, list] = {}
|
||||
for ws in workstreams:
|
||||
if ws.get("repo_id"):
|
||||
ws_by_repo.setdefault(ws["repo_id"], []).append(ws)
|
||||
|
||||
needs_workplan: list[dict] = [] # active goal with no linked workstream
|
||||
alignment_warnings: list[dict] = [] # workstreams not linked to active goal
|
||||
|
||||
for repo in repos:
|
||||
repo_slug = repo["slug"]
|
||||
repo_id = repo["id"]
|
||||
active_goals = _get("/repo-goals", {"repo_slug": repo_slug, "status": "active"})
|
||||
if not active_goals:
|
||||
continue
|
||||
active_goal_ids = {g["id"] for g in active_goals}
|
||||
|
||||
for goal in active_goals:
|
||||
linked = ws_by_repo_goal.get(goal["id"], [])
|
||||
if not linked:
|
||||
needs_workplan.append({
|
||||
"repo_slug": repo_slug,
|
||||
"goal_id": goal["id"],
|
||||
"goal_title": goal["title"],
|
||||
"goal_description": goal["description"],
|
||||
"priority": goal["priority"],
|
||||
"action": (
|
||||
f"No workstream is linked to repo goal '{goal['title']}'. "
|
||||
"Create a workplan file in workplans/ and register a workstream "
|
||||
f"with repo_goal_id='{goal['id']}' to start delivering this goal."
|
||||
),
|
||||
})
|
||||
|
||||
# Check if repo has active workstreams not tied to any active goal
|
||||
repo_ws = ws_by_repo.get(repo_id, [])
|
||||
unlinked_ws = [
|
||||
ws for ws in repo_ws
|
||||
if ws.get("repo_goal_id") not in active_goal_ids
|
||||
]
|
||||
if unlinked_ws:
|
||||
# Most recently updated workstream = the one to suggest continuing
|
||||
recent_ws = max(unlinked_ws, key=lambda w: w.get("updated_at", ""))
|
||||
alignment_warnings.append({
|
||||
"repo_slug": repo_slug,
|
||||
"recent_workstream_id": recent_ws["id"],
|
||||
"recent_workstream_title": recent_ws["title"],
|
||||
"active_goal_titles": [g["title"] for g in active_goals],
|
||||
"message": (
|
||||
f"Workstream '{recent_ws['title']}' is not linked to the current "
|
||||
f"repo goal(s) for {repo_slug}. "
|
||||
"Continue this workstream if the work is still relevant, but verify "
|
||||
"alignment with the active goal before committing to new tasks."
|
||||
),
|
||||
})
|
||||
|
||||
goal_guidance: dict = {}
|
||||
if needs_workplan or alignment_warnings:
|
||||
goal_guidance = {
|
||||
"needs_workplan": needs_workplan,
|
||||
"alignment_warnings": alignment_warnings,
|
||||
}
|
||||
|
||||
result: dict = {
|
||||
"domain": domain_slug,
|
||||
"topic_id": topic_id,
|
||||
"topic_title": topic["title"],
|
||||
@@ -164,7 +235,10 @@ def get_domain_summary(domain_slug: str) -> str:
|
||||
"blocking_decisions": blocking,
|
||||
"recent_progress": recent,
|
||||
"repos": [{"slug": r["slug"], "last_sbom_at": r.get("last_sbom_at")} for r in repos],
|
||||
}, indent=2)
|
||||
}
|
||||
if goal_guidance:
|
||||
result["goal_guidance"] = goal_guidance
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@@ -516,6 +590,44 @@ def update_workstream_status(workstream_id: str, status: str) -> str:
|
||||
return json.dumps(ws, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_workstream(
|
||||
workstream_id: str,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
owner: str | None = None,
|
||||
due_date: str | None = None,
|
||||
repo_goal_id: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> str:
|
||||
"""Update fields on an existing workstream.
|
||||
|
||||
Args:
|
||||
workstream_id: UUID of the workstream
|
||||
title: new title (optional)
|
||||
description: new description (optional)
|
||||
owner: new owner (optional)
|
||||
due_date: ISO date string YYYY-MM-DD (optional)
|
||||
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
|
||||
status: active | blocked | completed | archived (optional)
|
||||
"""
|
||||
payload: dict = {}
|
||||
if title is not None:
|
||||
payload["title"] = title
|
||||
if description is not None:
|
||||
payload["description"] = description
|
||||
if owner is not None:
|
||||
payload["owner"] = owner
|
||||
if due_date is not None:
|
||||
payload["due_date"] = due_date
|
||||
if status is not None:
|
||||
payload["status"] = status
|
||||
if repo_goal_id is not None:
|
||||
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
|
||||
ws = _patch(f"/workstreams/{workstream_id}", payload)
|
||||
return json.dumps(ws, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Next-steps suggestion tool (S2.3) — sanctioned write use case #2
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1151,6 +1263,159 @@ def get_licence_report() -> str:
|
||||
return json.dumps(_get("/sbom/report/licences"), indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain goals & repo goals (v0.7)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
def create_domain_goal(domain_slug: str, title: str, description: str) -> str:
|
||||
"""Create a new domain goal and make it active (superseding any existing active goal).
|
||||
|
||||
A domain goal captures the high-level strategic intent for a domain. Only one
|
||||
domain goal can be active at a time; creating a new active one supersedes the
|
||||
previous active goal.
|
||||
|
||||
Args:
|
||||
domain_slug: Slug of the domain (e.g. 'railiance', 'markitect')
|
||||
title: Short goal title
|
||||
description: Full description of the goal and its boundary conditions
|
||||
"""
|
||||
domains = _get("/domains", {"status": "active"})
|
||||
domain = next((d for d in domains if d["slug"] == domain_slug), None)
|
||||
if not domain:
|
||||
return json.dumps({"error": f"Domain '{domain_slug}' not found"})
|
||||
goal = _post("/domain-goals", {
|
||||
"domain_id": domain["id"],
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "active",
|
||||
})
|
||||
_post("/progress", {
|
||||
"event_type": "goal_created",
|
||||
"summary": f"Domain goal created [{domain_slug}]: {title}",
|
||||
"detail": {"goal_id": goal["id"], "domain_slug": domain_slug},
|
||||
})
|
||||
return json.dumps(goal, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_domain_goals(domain_slug: str, status: str | None = None) -> str:
|
||||
"""List domain goals for a domain, optionally filtered by status.
|
||||
|
||||
Args:
|
||||
domain_slug: Slug of the domain (e.g. 'railiance')
|
||||
status: active | archived | superseded (omit for all)
|
||||
"""
|
||||
return json.dumps(_get("/domain-goals", {"domain_slug": domain_slug, "status": status}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def activate_domain_goal(goal_id: str) -> str:
|
||||
"""Set a domain goal as the active goal, superseding any currently active one.
|
||||
|
||||
Args:
|
||||
goal_id: UUID of the domain goal to activate
|
||||
"""
|
||||
goal = _post(f"/domain-goals/{goal_id}/activate", {})
|
||||
_post("/progress", {
|
||||
"event_type": "goal_activated",
|
||||
"summary": f"Domain goal activated: {goal['title']}",
|
||||
"detail": {"goal_id": goal_id, "domain_slug": goal.get("domain_slug")},
|
||||
})
|
||||
return json.dumps(goal, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_repo_goal(
|
||||
repo_slug: str,
|
||||
title: str,
|
||||
description: str,
|
||||
domain_goal_id: str | None = None,
|
||||
priority: int = 100,
|
||||
) -> str:
|
||||
"""Create a new repository goal.
|
||||
|
||||
Repository goals capture what needs to be achieved in a specific repository.
|
||||
Multiple active repo goals can coexist; priority (lower number = higher priority)
|
||||
determines ordering. Optionally link to the parent domain goal.
|
||||
|
||||
Args:
|
||||
repo_slug: Slug of the repository (e.g. 'railiance-bootstrap')
|
||||
title: Short goal title
|
||||
description: Full description including boundary conditions and scope
|
||||
domain_goal_id: UUID of the parent domain goal (optional)
|
||||
priority: Integer priority — lower numbers = higher priority (default 100)
|
||||
"""
|
||||
repos = _get("/repos")
|
||||
repo = next((r for r in repos if r["slug"] == repo_slug), None)
|
||||
if not repo:
|
||||
return json.dumps({"error": f"Repo '{repo_slug}' not found"})
|
||||
goal = _post("/repo-goals", {
|
||||
"repo_id": repo["id"],
|
||||
"title": title,
|
||||
"description": description,
|
||||
"domain_goal_id": domain_goal_id,
|
||||
"priority": priority,
|
||||
"status": "active",
|
||||
})
|
||||
_post("/progress", {
|
||||
"event_type": "goal_created",
|
||||
"summary": f"Repo goal created [{repo_slug}]: {title}",
|
||||
"detail": {"goal_id": goal["id"], "repo_slug": repo_slug, "priority": priority},
|
||||
})
|
||||
return json.dumps(goal, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_repo_goals(repo_slug: str, status: str | None = None) -> str:
|
||||
"""List repository goals for a repo, ordered by priority.
|
||||
|
||||
Args:
|
||||
repo_slug: Slug of the repository (e.g. 'railiance-bootstrap')
|
||||
status: active | paused | completed | archived (omit for all)
|
||||
"""
|
||||
return json.dumps(_get("/repo-goals", {"repo_slug": repo_slug, "status": status}), indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_repo_goal(
|
||||
goal_id: str,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
priority: int | None = None,
|
||||
status: str | None = None,
|
||||
domain_goal_id: str | None = None,
|
||||
) -> str:
|
||||
"""Update a repository goal (title, description, priority, status, or domain link).
|
||||
|
||||
Args:
|
||||
goal_id: UUID of the repo goal
|
||||
title: New title (optional)
|
||||
description: New description (optional)
|
||||
priority: New priority integer — lower = higher priority (optional)
|
||||
status: active | paused | completed | archived (optional)
|
||||
domain_goal_id: Link or re-link to a domain goal UUID (optional)
|
||||
"""
|
||||
updates: dict = {}
|
||||
if title is not None:
|
||||
updates["title"] = title
|
||||
if description is not None:
|
||||
updates["description"] = description
|
||||
if priority is not None:
|
||||
updates["priority"] = priority
|
||||
if status is not None:
|
||||
updates["status"] = status
|
||||
if domain_goal_id is not None:
|
||||
updates["domain_goal_id"] = domain_goal_id
|
||||
goal = _patch(f"/repo-goals/{goal_id}", updates)
|
||||
_post("/progress", {
|
||||
"event_type": "goal_updated",
|
||||
"summary": f"Repo goal updated: {goal['title']}",
|
||||
"detail": {"goal_id": goal_id, "changes": list(updates.keys())},
|
||||
})
|
||||
return json.dumps(goal, indent=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user