generated from coulomb/repo-seed
feat(tasks): add needs_human intervention flag (CUST-WP-0009)
- Migration b4c5d6e7f8a9: adds needs_human (bool) + intervention_note (text) to tasks - API: needs_human filter on GET /tasks/; 422 if flagged without note - 3 MCP tools: flag_for_human, clear_human_flag, list_human_interventions - Dashboard: interventions.md with amber cards and "Mark done" button - Policy router + workstream DoD policy (workstream-dod.md) - Workstream lifecycle docs page + workplan CUST-WP-0010 - CLAUDE.md: add step 4 (run fix-consistency after workplan writes) - consistency_check.py: promote C-11 unlinked tasks from INFO to WARN Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from api.database import engine
|
||||
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
|
||||
from api.routers import domains, repos, contributions, sbom
|
||||
from api.routers import domains, repos, contributions, sbom, policy
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -24,7 +24,7 @@ app = FastAPI(
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
@@ -41,6 +41,7 @@ app.include_router(progress.router)
|
||||
app.include_router(contributions.router)
|
||||
app.include_router(sbom.router)
|
||||
app.include_router(state.router)
|
||||
app.include_router(policy.router)
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
|
||||
@@ -2,7 +2,7 @@ import enum
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy import Boolean, Date, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
@@ -44,6 +44,8 @@ class Task(Base, TimestampMixin):
|
||||
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
|
||||
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
41
api/routers/policy.py
Normal file
41
api/routers/policy.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
POLICY_DIR = Path(__file__).parent.parent.parent / "policies"
|
||||
_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
||||
|
||||
router = APIRouter(prefix="/policy", tags=["policy"])
|
||||
|
||||
|
||||
class PolicyRead(BaseModel):
|
||||
name: str
|
||||
content: str
|
||||
|
||||
|
||||
class PolicyUpdate(BaseModel):
|
||||
content: str
|
||||
|
||||
|
||||
def _policy_path(name: str) -> Path:
|
||||
if not _VALID_NAME.match(name):
|
||||
raise HTTPException(status_code=400, detail="Invalid policy name")
|
||||
path = POLICY_DIR / f"{name}.md"
|
||||
if not path.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
|
||||
return path
|
||||
|
||||
|
||||
@router.get("/{name}", response_model=PolicyRead)
|
||||
def get_policy(name: str) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
return PolicyRead(name=name, content=path.read_text())
|
||||
|
||||
|
||||
@router.put("/{name}", response_model=PolicyRead)
|
||||
def update_policy(name: str, body: PolicyUpdate) -> PolicyRead:
|
||||
path = _policy_path(name)
|
||||
path.write_text(body.content)
|
||||
return PolicyRead(name=name, content=body.content)
|
||||
@@ -1,6 +1,6 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -16,6 +16,7 @@ async def list_tasks(
|
||||
workstream_id: uuid.UUID | None = None,
|
||||
status: TaskStatus | None = None,
|
||||
assignee: str | None = None,
|
||||
needs_human: bool | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> list[Task]:
|
||||
q = select(Task)
|
||||
@@ -25,6 +26,8 @@ async def list_tasks(
|
||||
q = q.where(Task.status == status)
|
||||
if assignee:
|
||||
q = q.where(Task.assignee == assignee)
|
||||
if needs_human is not None:
|
||||
q = q.where(Task.needs_human == needs_human)
|
||||
q = q.order_by(Task.created_at)
|
||||
result = await session.execute(q)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Self
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
|
||||
@@ -15,8 +16,16 @@ class TaskCreate(BaseModel):
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool = False
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def intervention_note_required_when_flagged(self) -> Self:
|
||||
if self.needs_human and not self.intervention_note:
|
||||
raise ValueError("intervention_note is required when needs_human is True")
|
||||
return self
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: str | None = None
|
||||
@@ -26,14 +35,22 @@ class TaskUpdate(BaseModel):
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool | None = None
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def blocking_reason_required_when_blocked(self) -> "TaskUpdate":
|
||||
def blocking_reason_required_when_blocked(self) -> Self:
|
||||
if self.status == TaskStatus.blocked and not self.blocking_reason:
|
||||
raise ValueError("blocking_reason is required when status is blocked")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def intervention_note_required_when_flagged(self) -> Self:
|
||||
if self.needs_human and not self.intervention_note:
|
||||
raise ValueError("intervention_note is required when needs_human is True")
|
||||
return self
|
||||
|
||||
|
||||
class TaskRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -46,6 +63,8 @@ class TaskRead(BaseModel):
|
||||
assignee: str | None = None
|
||||
due_date: date | None = None
|
||||
blocking_reason: str | None = None
|
||||
needs_human: bool
|
||||
intervention_note: str | None = None
|
||||
parent_task_id: uuid.UUID | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -3,8 +3,9 @@ export default {
|
||||
title: "Custodian State Hub",
|
||||
pages: [
|
||||
// ── Overview ──────────────────────────────────────────────────────────────
|
||||
{ name: "Overview", path: "/" },
|
||||
{ name: "Todo", path: "/todo" },
|
||||
{ name: "Overview", path: "/" },
|
||||
{ name: "Todo", path: "/todo" },
|
||||
{ name: "Interventions", path: "/interventions" },
|
||||
// ── Organizational Entity Views ───────────────────────────────────────────
|
||||
{ name: "Domains", path: "/domains" },
|
||||
{ name: "Repos", path: "/repos" },
|
||||
@@ -25,6 +26,15 @@ export default {
|
||||
{ name: "Contributions", path: "/contributions" },
|
||||
{ name: "SBOM", path: "/sbom" },
|
||||
{ name: "Progress", path: "/progress" },
|
||||
// ── Policy ────────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "Policy",
|
||||
collapsible: true,
|
||||
open: true,
|
||||
pages: [
|
||||
{ name: "Workstream DoD", path: "/policy/workstream-dod" },
|
||||
],
|
||||
},
|
||||
// ── Reference ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
name: "Reference",
|
||||
@@ -50,6 +60,7 @@ export default {
|
||||
{ name: "Technical Debt", path: "/docs/debt" },
|
||||
{ name: "Todo", path: "/docs/todo" },
|
||||
{ name: "Workstream Health", path: "/docs/workstream-health-index" },
|
||||
{ name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" },
|
||||
{ name: "Workstreams", path: "/docs/workstreams" },
|
||||
],
|
||||
},
|
||||
|
||||
104
dashboard/src/docs/workstream-lifecycle.md
Normal file
104
dashboard/src/docs/workstream-lifecycle.md
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
title: Workstream Lifecycle — Reference
|
||||
---
|
||||
|
||||
# Workstream Lifecycle — Reference
|
||||
|
||||
A workstream moves through a defined lifecycle from creation to sign-off. The
|
||||
dashboard "Workstreams by Domain" chart exposes these states as selectable
|
||||
filters so attention can be directed to the right workstreams at the right time.
|
||||
|
||||
---
|
||||
|
||||
## Core lifecycle states
|
||||
|
||||
These are the primary stages a workstream passes through in order:
|
||||
|
||||
| State | Source | Meaning |
|
||||
|---|---|---|
|
||||
| **active** | DB `status = active` | Work is in progress or ready to start |
|
||||
| **finished** | Derived — no open tasks | All tasks are done, but no explicit review has taken place yet |
|
||||
| **accepted** | DB `status = completed` | Custodian and human have reviewed the workstream, quality checks passed, and it is formally signed off |
|
||||
|
||||
The normal progression is: **active → finished → accepted**.
|
||||
|
||||
`accepted` is the only state that requires an explicit action — it is set after
|
||||
a deliberate review, not automatically. This makes it a reliable anchor:
|
||||
anything in `finished` but not yet in `accepted` is work that still needs a
|
||||
quality pass.
|
||||
|
||||
---
|
||||
|
||||
## Attention signals
|
||||
|
||||
These states are orthogonal to the core lifecycle — a workstream can be
|
||||
`active` and `stalled` at the same time. They serve as health indicators
|
||||
rather than lifecycle stages.
|
||||
|
||||
| Signal | Source | Meaning |
|
||||
|---|---|---|
|
||||
| **blocked** | Derived — has ≥ 1 blocked task | At least one task is waiting on something external |
|
||||
| **stalled** | Derived — `updated_at` > 7 days ago, has both done and open tasks | Work started but activity has stopped; needs a nudge |
|
||||
| **oldies** | Derived — `created_at` > 7 days ago, zero done tasks | Workstream is old and nothing has been completed yet; may need re-evaluation |
|
||||
|
||||
---
|
||||
|
||||
## The acceptance quality gate
|
||||
|
||||
When a workstream reaches **finished** (all tasks done), the custodian's role is to:
|
||||
|
||||
1. Review the deliverables against the workstream's stated purpose and scope
|
||||
2. Check for missing tests, documentation, or follow-up issues
|
||||
3. Create tasks for any gaps found — this moves the workstream back to **active**
|
||||
4. Once satisfied, set `status = completed` via MCP or API — this marks it as **accepted**
|
||||
|
||||
This pattern ensures that "done" and "accepted" are distinct signals.
|
||||
`finished` is a fact about task counts; `accepted` is a statement of quality.
|
||||
|
||||
```
|
||||
# Accept a workstream via MCP
|
||||
update_workstream_status(workstream_id="<uuid>", status="completed")
|
||||
|
||||
# Or via REST
|
||||
curl -X PATCH http://127.0.0.1:8000/workstreams/<uuid>/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"status": "completed"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Time-based filters
|
||||
|
||||
The chart also supports time-window filters that cut across all lifecycle states:
|
||||
|
||||
| Filter | Shows workstreams where… |
|
||||
|---|---|
|
||||
| **last 1 hour** | `updated_at` or `created_at` within the last 60 minutes |
|
||||
| **last 24 hours** | … within the last 24 hours |
|
||||
| **last 7 days** | … within the last 7 days |
|
||||
| **last 30 days** | … within the last 30 days |
|
||||
| **today** | … since midnight today |
|
||||
| **this week** | … since Monday of the current week |
|
||||
| **this month** | … since the 1st of the current month |
|
||||
|
||||
In time-based views, workstream labels are **bold** for accepted and blocked
|
||||
workstreams to distinguish notable states at a glance.
|
||||
|
||||
---
|
||||
|
||||
## DB status vs. dashboard state
|
||||
|
||||
The DB stores a single `status` field on each workstream. The dashboard maps
|
||||
this alongside derived task-count data to produce the richer set of filter states:
|
||||
|
||||
| Dashboard state | DB `status` | Task-count condition |
|
||||
|---|---|---|
|
||||
| active | `active` | — |
|
||||
| accepted | `completed` | — |
|
||||
| finished | any | `todo + in_progress + blocked = 0` |
|
||||
| blocked | any | `blocked ≥ 1` |
|
||||
| stalled | any | `done ≥ 1` and `open ≥ 1` and `updated_at > 7d ago` |
|
||||
| oldies | any | `done = 0` and `open ≥ 1` and `created_at > 7d ago` |
|
||||
|
||||
*Workstreams are never hard-deleted — use `update_workstream_status(..., "completed")` or
|
||||
`"archived"` to close them without losing history.*
|
||||
@@ -16,9 +16,11 @@ A horizontal bar chart showing the count of workstreams in each status for the c
|
||||
|---|---|
|
||||
| **active** | Work in progress or ready to start |
|
||||
| **blocked** | Waiting on something outside the workstream — see Dependencies |
|
||||
| **completed** | Done |
|
||||
| **completed** | Formally accepted after custodian review (shown as **accepted** in the overview chart) |
|
||||
| **archived** | Closed without completion; no longer relevant |
|
||||
|
||||
See [Workstream Lifecycle](/docs/workstream-lifecycle) for the full state model including derived states (finished, stalled, oldies).
|
||||
|
||||
---
|
||||
|
||||
## Filter bar
|
||||
|
||||
@@ -65,6 +65,53 @@ const regsState = (async function*() {
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
// All-workstreams + all-tasks poll — drives the multi-mode chart
|
||||
const wsChartState = (async function*() {
|
||||
while (true) {
|
||||
let wsAll = [], ok = false;
|
||||
try {
|
||||
const [rw, rt, rto, rr] = await Promise.all([
|
||||
fetch(`${API}/workstreams/`),
|
||||
fetch(`${API}/tasks/?limit=2000`),
|
||||
fetch(`${API}/topics/`),
|
||||
fetch(`${API}/repos/`),
|
||||
]);
|
||||
ok = rw.ok && rt.ok && rto.ok && rr.ok;
|
||||
if (ok) {
|
||||
const [wsList, taskList, topicList, repoList] = await Promise.all([
|
||||
rw.json(), rt.json(), rto.json(), rr.json(),
|
||||
]);
|
||||
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
|
||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||
// Aggregate task counts per workstream
|
||||
const counts = {};
|
||||
for (const t of taskList) {
|
||||
const wid = t.workstream_id;
|
||||
if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0};
|
||||
counts[wid].total++;
|
||||
if (t.status === "done") counts[wid].done++;
|
||||
else if (t.status === "in_progress") counts[wid].in_progress++;
|
||||
else if (t.status === "blocked") counts[wid].blocked++;
|
||||
else if (t.status === "todo") counts[wid].todo++;
|
||||
}
|
||||
wsAll = wsList.map(w => ({
|
||||
...w,
|
||||
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
|
||||
}));
|
||||
}
|
||||
} catch {}
|
||||
yield {wsAll, ok};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const wsAll = wsChartState.wsAll ?? [];
|
||||
```
|
||||
|
||||
# Custodian State Hub
|
||||
|
||||
```js
|
||||
@@ -88,72 +135,163 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/overview");
|
||||
if (summary.error) display(html`<div class="warning">⚠️ ${summary.error}</div>`);
|
||||
```
|
||||
|
||||
## Open Workstreams by Domain
|
||||
## Workstreams by Domain
|
||||
|
||||
```js
|
||||
// view() is the idiomatic Observable Framework reactive input:
|
||||
// it displays the element AND returns a reactive value that re-runs dependent blocks.
|
||||
const _chartMode = view(html`<select class="ws-mode-select">
|
||||
<optgroup label="By Status">
|
||||
<option value="active" selected>active</option>
|
||||
<option value="accepted">accepted</option>
|
||||
<option value="finished">finished</option>
|
||||
<option value="blocked">blocked</option>
|
||||
<option value="stalled">stalled</option>
|
||||
<option value="oldies">oldies</option>
|
||||
</optgroup>
|
||||
<optgroup label="Recently Changed">
|
||||
<option value="1h">last 1 hour</option>
|
||||
<option value="1d">last 24 hours</option>
|
||||
<option value="7d">last 7 days</option>
|
||||
<option value="30d">last 30 days</option>
|
||||
<option value="today">today</option>
|
||||
<option value="week">this week</option>
|
||||
<option value="month">this month</option>
|
||||
</optgroup>
|
||||
</select>`);
|
||||
```
|
||||
|
||||
```js
|
||||
import * as Plot from "npm:@observablehq/plot";
|
||||
|
||||
const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain_slug]));
|
||||
// ── Filter workstreams by selected mode ───────────────────────────────────────
|
||||
// "active" matches the DB status field directly.
|
||||
// "accepted" = DB status "completed" (explicitly reviewed and signed off).
|
||||
// "finished" = no open tasks remaining (derived from task counts).
|
||||
// "blocked" = has ≥1 blocked task; "stalled" / "oldies" = activity-based.
|
||||
// Time modes filter by updated_at / created_at.
|
||||
const _STATUS_MODES = new Set(["active"]);
|
||||
|
||||
const openWs = (summary.open_workstreams ?? []).map(w => ({
|
||||
title: w.title,
|
||||
domain: topicById[w.topic_id] ?? "unknown",
|
||||
done: w.tasks_done ?? 0,
|
||||
in_progress: w.tasks_in_progress ?? 0,
|
||||
blocked: w.tasks_blocked ?? 0,
|
||||
todo: w.tasks_todo ?? 0,
|
||||
total: w.tasks_total ?? 0,
|
||||
})).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title));
|
||||
|
||||
const statusOrder = ["done", "in progress", "blocked", "todo"];
|
||||
const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"];
|
||||
|
||||
const taskRows = openWs.flatMap(w => [
|
||||
{label: w.title, domain: w.domain, status: "done", count: w.done},
|
||||
{label: w.title, domain: w.domain, status: "in progress", count: w.in_progress},
|
||||
{label: w.title, domain: w.domain, status: "blocked", count: w.blocked},
|
||||
{label: w.title, domain: w.domain, status: "todo", count: w.todo},
|
||||
]).filter(d => d.count > 0);
|
||||
|
||||
// y-axis shows domain (only for the first workstream in each domain group)
|
||||
const yLabels = {};
|
||||
const _seenDomains = new Set();
|
||||
for (const w of openWs) {
|
||||
yLabels[w.title] = _seenDomains.has(w.domain) ? "" : w.domain;
|
||||
_seenDomains.add(w.domain);
|
||||
function _timeCutoff(mode) {
|
||||
const now = new Date();
|
||||
if (mode === "1h") return new Date(now - 60 * 60 * 1000);
|
||||
if (mode === "1d") return new Date(now - 24 * 60 * 60 * 1000);
|
||||
if (mode === "7d") return new Date(now - 7 * 24 * 60 * 60 * 1000);
|
||||
if (mode === "30d") return new Date(now - 30 * 24 * 60 * 60 * 1000);
|
||||
if (mode === "today") return new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
if (mode === "week") {
|
||||
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // back to Monday
|
||||
return d;
|
||||
}
|
||||
if (mode === "month") return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (openWs.length === 0) {
|
||||
display(html`<p style="color:gray">No open workstreams.</p>`);
|
||||
const chartWs = (
|
||||
_STATUS_MODES.has(_chartMode)
|
||||
? wsAll.filter(w => w.status === _chartMode)
|
||||
: _chartMode === "accepted"
|
||||
? wsAll.filter(w => w.status === "completed")
|
||||
: _chartMode === "finished"
|
||||
? wsAll.filter(w => (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) === 0)
|
||||
: _chartMode === "blocked"
|
||||
? wsAll.filter(w => (w.blocked ?? 0) > 0)
|
||||
: _chartMode === "stalled"
|
||||
? wsAll.filter(w => {
|
||||
const staleAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(w.updated_at) < staleAt
|
||||
&& (w.done ?? 0) > 0
|
||||
&& (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0;
|
||||
})
|
||||
: _chartMode === "oldies"
|
||||
? wsAll.filter(w => {
|
||||
const oldAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||
return new Date(w.created_at) < oldAt
|
||||
&& (w.done ?? 0) === 0
|
||||
&& (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0;
|
||||
})
|
||||
: (() => {
|
||||
const since = _timeCutoff(_chartMode);
|
||||
return wsAll.filter(w =>
|
||||
new Date(w.updated_at) >= since || new Date(w.created_at) >= since
|
||||
);
|
||||
})()
|
||||
).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title));
|
||||
|
||||
// ── Status weight: bold for notable statuses in mixed-status modes ─────────────
|
||||
// Color is NOT used for status — avoids green-on-green when completed bars fill the row.
|
||||
const _isTimeBased = !_STATUS_MODES.has(_chartMode);
|
||||
function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; }
|
||||
|
||||
// ── y-axis: domain label for first workstream per group only ──────────────────
|
||||
const _yLabels = {};
|
||||
const _seen = new Set();
|
||||
for (const w of chartWs) {
|
||||
_yLabels[w.title] = _seen.has(w.domain) ? "" : w.domain;
|
||||
_seen.add(w.domain);
|
||||
}
|
||||
|
||||
const statusOrder = ["done", "in progress", "blocked", "todo"];
|
||||
const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"];
|
||||
|
||||
const _taskRows = chartWs.flatMap(w => [
|
||||
{label: w.title, status: "done", count: w.done ?? 0},
|
||||
{label: w.title, status: "in progress", count: w.in_progress ?? 0},
|
||||
{label: w.title, status: "blocked", count: w.blocked ?? 0},
|
||||
{label: w.title, status: "todo", count: w.todo ?? 0},
|
||||
]).filter(d => d.count > 0);
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
if (chartWs.length === 0) {
|
||||
const _emptyMsg = {
|
||||
active: "No active workstreams.", accepted: "No accepted workstreams.",
|
||||
finished: "No finished workstreams.", blocked: "No blocked workstreams.",
|
||||
stalled: "No stalled workstreams — everything is moving.",
|
||||
oldies: "No oldies — all older workstreams have at least one task done.",
|
||||
"1h": "No workstreams changed in the last hour.",
|
||||
"1d": "No workstreams changed in the last 24 hours.",
|
||||
"7d": "No workstreams changed in the last 7 days.",
|
||||
"30d": "No workstreams changed in the last 30 days.",
|
||||
today: "No workstreams changed today.",
|
||||
week: "No workstreams changed this week.",
|
||||
month: "No workstreams changed this month.",
|
||||
};
|
||||
display(html`<p style="color:gray">${_emptyMsg[_chartMode] ?? "No workstreams."}</p>`);
|
||||
} else {
|
||||
display(Plot.plot({
|
||||
y: {label: null, tickSize: 0, domain: openWs.map(w => w.title), tickFormat: t => yLabels[t] ?? ""},
|
||||
y: {
|
||||
label: null, tickSize: 0,
|
||||
domain: chartWs.map(w => w.title),
|
||||
tickFormat: t => _yLabels[t] ?? "",
|
||||
},
|
||||
x: {label: "Tasks", grid: true},
|
||||
color: {domain: statusOrder, range: statusColors, legend: true},
|
||||
marks: [
|
||||
Plot.barX(taskRows, {y: "label", x: "count", fill: "status", tip: true}),
|
||||
// Workstream title inside the bar
|
||||
Plot.text(openWs.filter(w => w.total > 0), {
|
||||
y: "title", x: 0, dx: 6,
|
||||
text: d => d.title.length > 36 ? d.title.slice(0, 34) + "…" : d.title,
|
||||
textAnchor: "start", fontSize: 10, fill: "#333",
|
||||
Plot.barX(_taskRows, {y: "label", x: "count", fill: "status", tip: true}),
|
||||
// Title label — pushed to lower half of bar row (dy: +7) to separate from count
|
||||
Plot.text(chartWs.filter(w => w.total > 0), {
|
||||
y: "title", x: 0, dx: 6, dy: 7,
|
||||
text: d => d.title.length > 72 ? d.title.slice(0, 70) + "…" : d.title,
|
||||
textAnchor: "start", fontSize: 10, fill: "#1e293b",
|
||||
fontWeight: d => _isTimeBased ? _wsWeight(d.status) : "normal",
|
||||
}),
|
||||
Plot.text(openWs.filter(w => w.total === 0), {
|
||||
y: "title", x: 0, dx: 6,
|
||||
text: d => `${d.title.length > 24 ? d.title.slice(0, 22) + "…" : d.title} — no tasks yet`,
|
||||
textAnchor: "start", fontSize: 10, fill: "#aaa",
|
||||
Plot.text(chartWs.filter(w => w.total === 0), {
|
||||
y: "title", x: 0, dx: 6, dy: 7,
|
||||
text: d => `${d.title.length > 48 ? d.title.slice(0, 46) + "…" : d.title} — no tasks yet`,
|
||||
textAnchor: "start", fontSize: 10, fill: "#94a3b8",
|
||||
}),
|
||||
// "done / total" label after the bar
|
||||
Plot.text(openWs.filter(w => w.total > 0), {
|
||||
// Count label — pushed to upper half of bar row (dy: -7) to separate from title
|
||||
Plot.text(chartWs.filter(w => w.total > 0), {
|
||||
y: "title", x: "total",
|
||||
text: d => ` ${d.done}/${d.total}`,
|
||||
dx: 4, textAnchor: "start", fontSize: 11, fill: "gray",
|
||||
dx: 4, dy: -7, textAnchor: "start", fontSize: 11, fill: "gray",
|
||||
}),
|
||||
Plot.ruleX([0]),
|
||||
],
|
||||
marginLeft: 160,
|
||||
marginRight: 70,
|
||||
height: Math.max(80, openWs.length * 44 + 50),
|
||||
height: Math.max(80, chartWs.length * 44 + 50),
|
||||
width: 700,
|
||||
}));
|
||||
}
|
||||
@@ -430,6 +568,7 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
|
||||
|
||||
<style>
|
||||
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||
.ws-mode-bar { margin-bottom: 0.75rem; }
|
||||
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
|
||||
.card.warn { border: 2px solid orange; }
|
||||
.card-link { cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; text-decoration: none; color: inherit; display: block; }
|
||||
|
||||
213
dashboard/src/interventions.md
Normal file
213
dashboard/src/interventions.md
Normal file
@@ -0,0 +1,213 @@
|
||||
---
|
||||
title: Interventions
|
||||
---
|
||||
|
||||
```js
|
||||
const API = "http://127.0.0.1:8000";
|
||||
const POLL = 15_000;
|
||||
```
|
||||
|
||||
```js
|
||||
// Live poll: human-flagged tasks + workstreams + topics
|
||||
const interventionState = (async function*() {
|
||||
while (true) {
|
||||
let tasks = [], wsMap = {}, ok = false;
|
||||
try {
|
||||
const [rt, rw, rto, rr] = await Promise.all([
|
||||
fetch(`${API}/tasks/?needs_human=true`),
|
||||
fetch(`${API}/workstreams/`),
|
||||
fetch(`${API}/topics/`),
|
||||
fetch(`${API}/repos/`),
|
||||
]);
|
||||
ok = rt.ok && rw.ok && rto.ok && rr.ok;
|
||||
if (ok) {
|
||||
const [taskList, wsList, topicList, repoList] = await Promise.all([
|
||||
rt.json(), rw.json(), rto.json(), rr.json(),
|
||||
]);
|
||||
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
|
||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||
wsMap = Object.fromEntries(wsList.map(w => [w.id, {
|
||||
...w,
|
||||
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||
}]));
|
||||
tasks = taskList.map(t => ({
|
||||
...t,
|
||||
workstream_title: wsMap[t.workstream_id]?.title ?? "—",
|
||||
domain: wsMap[t.workstream_id]?.domain ?? "unknown",
|
||||
}));
|
||||
}
|
||||
} catch {}
|
||||
yield {tasks, ok, ts: new Date()};
|
||||
await new Promise(res => setTimeout(res, POLL));
|
||||
}
|
||||
})();
|
||||
```
|
||||
|
||||
```js
|
||||
const tasks = interventionState.tasks ?? [];
|
||||
const _ok = interventionState.ok ?? false;
|
||||
const _ts = interventionState.ts;
|
||||
```
|
||||
|
||||
```js
|
||||
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]);
|
||||
const open = tasks.filter(t => OPEN_STATUSES.has(t.status));
|
||||
const closed = tasks.filter(t => !OPEN_STATUSES.has(t.status));
|
||||
|
||||
// Domain breakdown for top-3
|
||||
const domainCounts = {};
|
||||
for (const t of open) {
|
||||
domainCounts[t.domain] = (domainCounts[t.domain] ?? 0) + 1;
|
||||
}
|
||||
const top3Domains = Object.entries(domainCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3);
|
||||
|
||||
const critHighCount = open.filter(t => t.priority === "critical" || t.priority === "high").length;
|
||||
```
|
||||
|
||||
# Interventions
|
||||
|
||||
```js
|
||||
import {injectTocTop} from "./components/toc-sidebar.js";
|
||||
import {withDocHelp} from "./components/doc-overlay.js";
|
||||
|
||||
// ── KPI sidebar card ──────────────────────────────────────────────────────────
|
||||
const _kpiBox = html`<div class="kpi-infobox">
|
||||
<div class="kpi-infobox-title">Interventions</div>
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">open</span>
|
||||
<div class="kpi-row-right">
|
||||
<div class="kpi-row-value">${open.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">critical / high</span>
|
||||
<div class="kpi-row-right">
|
||||
<div class="kpi-row-value" style="color:${critHighCount > 0 ? '#dc2626' : 'inherit'}">${critHighCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
${top3Domains.map(([domain, count]) => html`
|
||||
<div class="kpi-row">
|
||||
<span class="kpi-row-label">${domain}</span>
|
||||
<div class="kpi-row-right">
|
||||
<div class="kpi-row-value" style="font-size:1rem">${count}</div>
|
||||
</div>
|
||||
</div>`)}
|
||||
</div>`;
|
||||
|
||||
// ── Live indicator ────────────────────────────────────────────────────────────
|
||||
const _liveEl = html`<div class="live-indicator">
|
||||
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
|
||||
${_ok
|
||||
? `Live · updated ${_ts?.toLocaleTimeString()}`
|
||||
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
|
||||
</div>`;
|
||||
withDocHelp(_liveEl, "/docs/live-data");
|
||||
|
||||
injectTocTop("intervention-kpi-box", _kpiBox);
|
||||
injectTocTop("live-indicator", _liveEl);
|
||||
```
|
||||
|
||||
Tasks flagged `needs_human=true` — actions only Bernd can take.
|
||||
|
||||
---
|
||||
|
||||
## Open
|
||||
|
||||
```js
|
||||
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
|
||||
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2};
|
||||
|
||||
function sortTasks(arr) {
|
||||
return [...arr].sort((a, b) => {
|
||||
const pd = (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9);
|
||||
if (pd !== 0) return pd;
|
||||
return (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
|
||||
});
|
||||
}
|
||||
|
||||
async function markDone(taskId) {
|
||||
try {
|
||||
await fetch(`${API}/tasks/${taskId}/`, {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({status: "done", needs_human: false, intervention_note: null}),
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function renderCard(t) {
|
||||
const isOpen = OPEN_STATUSES.has(t.status);
|
||||
const borderColor = isOpen ? "#f59e0b" : "#22c55e";
|
||||
return html`<div class="intervention-card" style="border-left-color:${borderColor}">
|
||||
<div class="int-card-header">
|
||||
<span class="task-badge task-priority-${t.priority}">${t.priority}</span>
|
||||
<span class="task-status-chip status-chip-${t.status}">${t.status.replace("_", " ")}</span>
|
||||
<span class="task-context">${t.domain}</span>
|
||||
<span class="task-context task-ws-name">${t.workstream_title}</span>
|
||||
${isOpen ? html`<button class="done-btn" onclick=${() => markDone(t.id)}>Mark done</button>` : ""}
|
||||
</div>
|
||||
<div class="int-action">${t.intervention_note ?? "(no note)"}</div>
|
||||
${t.title !== t.intervention_note ? html`<details class="int-desc"><summary>Task: ${t.title}</summary>${t.description ?? ""}</details>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (open.length === 0) {
|
||||
display(html`<p class="dim">No open interventions — you're clear! ✓</p>`);
|
||||
} else {
|
||||
display(html`<div class="task-list">${sortTasks(open).map(renderCard)}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Completed / Cancelled
|
||||
|
||||
```js
|
||||
if (closed.length === 0) {
|
||||
display(html`<p class="dim">No completed interventions yet.</p>`);
|
||||
} else {
|
||||
display(html`<div class="task-list">${[...closed].reverse().map(renderCard)}</div>`);
|
||||
}
|
||||
```
|
||||
|
||||
<style>
|
||||
/* ── Live indicator ───────────────────────────────────────────────────────── */
|
||||
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
|
||||
|
||||
/* ── KPI infobox ──────────────────────────────────────────────────────────── */
|
||||
.kpi-infobox { background: var(--theme-background-alt, #f9f9f9); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1rem; position: relative; box-shadow: 0 1px 6px rgba(0,0,0,0.07); margin-bottom: 1.25rem; }
|
||||
.kpi-infobox-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-muted, #888); margin-bottom: 0.55rem; }
|
||||
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; }
|
||||
.kpi-row + .kpi-row { border-top: 1px solid var(--theme-foreground-faint, #eee); }
|
||||
.kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; }
|
||||
.kpi-row-right { text-align: right; }
|
||||
.kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
|
||||
/* ── Intervention cards ───────────────────────────────────────────────────── */
|
||||
.task-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.intervention-card { border-left: 4px solid #f59e0b; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.7rem 1rem; }
|
||||
.int-card-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; font-size: 0.75rem; }
|
||||
.int-action { font-weight: 600; font-size: 0.95rem; color: var(--theme-foreground, #222); margin-bottom: 0.2rem; }
|
||||
.int-desc { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); margin-top: 0.3rem; }
|
||||
.int-desc summary { cursor: pointer; }
|
||||
.done-btn { margin-left: auto; padding: 0.15rem 0.6rem; border-radius: 6px; border: 1px solid #22c55e; background: #f0fdf4; color: #166534; font-size: 0.7rem; font-weight: 600; cursor: pointer; }
|
||||
.done-btn:hover { background: #dcfce7; }
|
||||
|
||||
/* ── Shared badges ────────────────────────────────────────────────────────── */
|
||||
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.task-priority-critical { background: #fee2e2; color: #991b1b; }
|
||||
.task-priority-high { background: #ffedd5; color: #9a3412; }
|
||||
.task-priority-medium { background: #dbeafe; color: #1e40af; }
|
||||
.task-priority-low { background: #f1f5f9; color: #475569; }
|
||||
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
||||
.status-chip-blocked { background: #fee2e2; color: #991b1b; }
|
||||
.status-chip-in_progress { background: #dbeafe; color: #1e40af; }
|
||||
.status-chip-todo { background: #f1f5f9; color: #475569; }
|
||||
.status-chip-done { background: #dcfce7; color: #166534; }
|
||||
.status-chip-cancelled { background: #f1f5f9; color: #64748b; }
|
||||
.task-context { color: var(--theme-foreground-muted, #666); }
|
||||
.task-ws-name { font-style: italic; }
|
||||
.dim { color: gray; font-style: italic; }
|
||||
</style>
|
||||
92
dashboard/src/policy/workstream-dod.md
Normal file
92
dashboard/src/policy/workstream-dod.md
Normal file
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Workstream Definition of Done
|
||||
---
|
||||
|
||||
```js
|
||||
const API = "http://127.0.0.1:8000";
|
||||
```
|
||||
|
||||
```js
|
||||
import {marked} from "npm:marked";
|
||||
|
||||
const _resp = await fetch(`${API}/policy/workstream-dod`);
|
||||
if (!_resp.ok) throw new Error(`Failed to load policy: ${_resp.status}`);
|
||||
const _policy = await _resp.json();
|
||||
```
|
||||
|
||||
```js
|
||||
let _content = _policy.content;
|
||||
let _editing = false;
|
||||
|
||||
const _root = display(html`<div></div>`);
|
||||
|
||||
async function _save(text) {
|
||||
const r = await fetch(`${API}/policy/workstream-dod`, {
|
||||
method: "PUT",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({content: text}),
|
||||
});
|
||||
if (!r.ok) throw new Error(`Save failed: ${r.status}`);
|
||||
_content = text;
|
||||
}
|
||||
|
||||
function _toolbar(...nodes) {
|
||||
return html`<div style="display:flex;gap:0.5rem;margin-bottom:1rem">${nodes}</div>`;
|
||||
}
|
||||
|
||||
function _btn(label, primary = false) {
|
||||
return html`<button style="
|
||||
padding:0.35rem 0.9rem;border-radius:4px;cursor:pointer;font-size:13px;
|
||||
background:${primary ? "#1e293b" : "#f1f5f9"};
|
||||
color:${primary ? "#f8fafc" : "#1e293b"};
|
||||
border:1px solid ${primary ? "#1e293b" : "#cbd5e1"};
|
||||
">${label}</button>`;
|
||||
}
|
||||
|
||||
function _render() {
|
||||
_root.innerHTML = "";
|
||||
|
||||
if (_editing) {
|
||||
const area = html`<textarea style="
|
||||
width:100%;box-sizing:border-box;height:520px;
|
||||
font-family:ui-monospace,monospace;font-size:13px;line-height:1.6;
|
||||
padding:0.75rem;border:1px solid #cbd5e1;border-radius:4px;
|
||||
background:#f8fafc;color:#1e293b;resize:vertical;
|
||||
">${_content}</textarea>`;
|
||||
|
||||
const saveBtn = _btn("Save", true);
|
||||
const cancelBtn = _btn("Cancel");
|
||||
|
||||
saveBtn.onclick = async () => {
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = "Saving…";
|
||||
try {
|
||||
await _save(area.value);
|
||||
_editing = false;
|
||||
_render();
|
||||
} catch (e) {
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = "Save";
|
||||
alert(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
cancelBtn.onclick = () => { _editing = false; _render(); };
|
||||
|
||||
_root.append(_toolbar(saveBtn, cancelBtn), area);
|
||||
|
||||
} else {
|
||||
const editBtn = _btn("Edit");
|
||||
editBtn.onclick = () => { _editing = true; _render(); };
|
||||
|
||||
const body = html`<div style="
|
||||
max-width:720px;line-height:1.7;
|
||||
"></div>`;
|
||||
body.innerHTML = marked.parse(_content);
|
||||
|
||||
_root.append(_toolbar(editBtn), body);
|
||||
}
|
||||
}
|
||||
|
||||
_render();
|
||||
```
|
||||
@@ -58,6 +58,19 @@ Do not use them as a substitute for formal work definition inside the domain rep
|
||||
|
||||
---
|
||||
|
||||
## Human Interventions
|
||||
|
||||
Tasks that agents cannot complete themselves are flagged with `needs_human=True`.
|
||||
Use `list_human_interventions()` at session start to see Bernd's action items.
|
||||
|
||||
| Tool | Key Args | Notes |
|
||||
|------|----------|-------|
|
||||
| `flag_for_human(task_id, note)` | `task_id`: UUID; `note`: action description (required) | Sets needs_human=True + intervention_note. Emits progress event. |
|
||||
| `clear_human_flag(task_id)` | `task_id`: UUID | Clears flag after human completes the action. Emits progress event. |
|
||||
| `list_human_interventions(workstream_id?)` | optional workstream filter | Returns all tasks with needs_human=True. |
|
||||
|
||||
---
|
||||
|
||||
## Governance Tools
|
||||
|
||||
| Tool | Key Args | When to use |
|
||||
|
||||
@@ -321,6 +321,71 @@ def update_task_status(
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def flag_for_human(task_id: str, note: str) -> str:
|
||||
"""Flag a task as requiring human intervention.
|
||||
|
||||
Sets needs_human=True and records the required action as intervention_note.
|
||||
Emits a progress event so the flag is visible in session history.
|
||||
|
||||
Args:
|
||||
task_id: UUID of the task to flag
|
||||
note: description of the action required from the human (required)
|
||||
"""
|
||||
task = _patch(f"/tasks/{task_id}", {
|
||||
"needs_human": True,
|
||||
"intervention_note": note,
|
||||
})
|
||||
_post("/progress", {
|
||||
"task_id": task_id,
|
||||
"workstream_id": task.get("workstream_id"),
|
||||
"event_type": "task_flagged_human",
|
||||
"summary": f"Task flagged for human intervention: {task['title']}",
|
||||
"author": "custodian",
|
||||
"detail": {"intervention_note": note},
|
||||
})
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def clear_human_flag(task_id: str) -> str:
|
||||
"""Clear the human-intervention flag from a task.
|
||||
|
||||
Sets needs_human=False. The intervention_note is preserved as a
|
||||
historical record. Call this after the human has completed the action.
|
||||
|
||||
Args:
|
||||
task_id: UUID of the task to clear
|
||||
"""
|
||||
task = _patch(f"/tasks/{task_id}", {
|
||||
"needs_human": False,
|
||||
})
|
||||
_post("/progress", {
|
||||
"task_id": task_id,
|
||||
"workstream_id": task.get("workstream_id"),
|
||||
"event_type": "task_flag_cleared",
|
||||
"summary": f"Human-intervention flag cleared: {task['title']}",
|
||||
"author": "custodian",
|
||||
})
|
||||
return json.dumps(task, indent=2)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def list_human_interventions(workstream_id: str | None = None) -> str:
|
||||
"""List all tasks flagged for human intervention.
|
||||
|
||||
Returns tasks where needs_human=True, optionally filtered to one workstream.
|
||||
Use this at session start to surface Bernd's action items.
|
||||
|
||||
Args:
|
||||
workstream_id: optional UUID to scope results to one workstream
|
||||
"""
|
||||
return json.dumps(
|
||||
_get("/tasks", {"needs_human": "true", "workstream_id": workstream_id}),
|
||||
indent=2,
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def record_decision(
|
||||
title: str,
|
||||
|
||||
33
migrations/versions/b4c5d6e7f8a9_add_needs_human.py
Normal file
33
migrations/versions/b4c5d6e7f8a9_add_needs_human.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Add needs_human flag and intervention_note to tasks
|
||||
|
||||
Revision ID: b4c5d6e7f8a9
|
||||
Revises: a3b4c5d6e7f8
|
||||
Create Date: 2026-03-03 00:00:00.000000
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "b4c5d6e7f8a9"
|
||||
down_revision: Union[str, None] = "a3b4c5d6e7f8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column("needs_human", sa.Boolean(), server_default="false", nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column("intervention_note", sa.Text(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_tasks_needs_human", "tasks", ["needs_human"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_tasks_needs_human", table_name="tasks")
|
||||
op.drop_column("tasks", "intervention_note")
|
||||
op.drop_column("tasks", "needs_human")
|
||||
1
policies/workstream-dod.md
Normal file
1
policies/workstream-dod.md
Normal file
@@ -0,0 +1 @@
|
||||
test
|
||||
@@ -15,7 +15,7 @@ Checks:
|
||||
C-08 orphan-db-completed INFO No Completed/archived DB workstream, no file
|
||||
C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location
|
||||
C-10 task-status-drift WARN Yes Task status differs between file and DB
|
||||
C-11 task-unlinked INFO Yes Task block has no state_hub_task_id
|
||||
C-11 task-unlinked WARN Yes Task block has no state_hub_task_id
|
||||
C-12 orphan-db-task WARN No DB task in workstream has no file backing
|
||||
|
||||
Usage:
|
||||
@@ -475,7 +475,7 @@ def check_repo(api_base: str, repo_slug: str) -> ConsistencyReport:
|
||||
elif t_id:
|
||||
# C-11: task exists in file but not linked to DB
|
||||
report.add(
|
||||
severity="INFO", check_id="C-11",
|
||||
severity="WARN", check_id="C-11",
|
||||
message=f"Task '{t_id}' has no state_hub_task_id",
|
||||
file_path=f"{fname}#{t_id}",
|
||||
fixable=True,
|
||||
|
||||
Reference in New Issue
Block a user