feat: add fast forge work-record reconciliation
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 24s

Assistant: codex
Assistant-Model: gpt-5.6-sol
Assistant-Session: 01a053ff-1d6f-7fe2-ac1c-a6eb40a42a0c
This commit is contained in:
tegwick 2026-08-30 22:38:54 +02:00
parent a65cef02cf
commit 34f5cb3fc3
22 changed files with 799 additions and 162 deletions

View file

@ -125,6 +125,10 @@ class DerivedTask:
title: str | None
status: str | None
priority: str | None
description: str | None = None
needs_human: bool = False
intervention_note: str | None = None
blocking_reason: str | None = None
@dataclass
@ -135,6 +139,8 @@ class DerivedWorkplan:
status: str | None
relative_path: str
archived: bool
owner: str | None = None
description: str | None = None
tasks: list[DerivedTask] = field(default_factory=list)
@ -180,6 +186,8 @@ class DerivedProjection:
"uuid": w.uuid,
"title": w.title,
"status": w.status,
"owner": w.owner,
"description": w.description,
"relative_path": w.relative_path,
"archived": w.archived,
"tasks": [
@ -189,6 +197,10 @@ class DerivedProjection:
"title": t.title,
"status": t.status,
"priority": t.priority,
"description": t.description,
"needs_human": t.needs_human,
"intervention_note": t.intervention_note,
"blocking_reason": t.blocking_reason,
}
for t in w.tasks
],
@ -279,6 +291,11 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
if not title:
prev = [t for pos, t in headings if pos < m.start()]
title = prev[-1] if prev else None
following_headings = [pos for pos, _title in headings if pos > m.end()]
description_end = min(following_headings) if following_headings else len(body)
description = str(block.get("description") or "").strip()
if not description:
description = body[m.end() : description_end].strip()
out.append(
DerivedTask(
# A bare `T01` is not an identifier: it is unique only within
@ -298,11 +315,36 @@ def _parse_tasks(body: str, workplan_id: str) -> list[DerivedTask]:
title=title,
status=(str(block["status"]).strip() if block.get("status") else None),
priority=(str(block["priority"]).strip() if block.get("priority") else None),
description=description or None,
needs_human=bool(block.get("needs_human", False)),
intervention_note=(
str(block["intervention_note"]).strip()
if block.get("intervention_note")
else None
),
blocking_reason=(
str(block["blocking_reason"]).strip()
if block.get("blocking_reason")
else None
),
)
)
return out
def _workplan_description(body: str) -> str | None:
"""Return bounded prose under ``## Goal`` when one is present."""
match = re.search(r"^##\s+Goal\s*$", body, re.MULTILINE | re.IGNORECASE)
if match is None:
return None
remainder = body[match.end() :]
next_heading = re.search(r"^##\s+", remainder, re.MULTILINE)
if next_heading is not None:
remainder = remainder[: next_heading.start()]
value = remainder.strip()
return value[:4000] or None
def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> DerivedProjection:
"""Derive a projection from an already-materialised checkout."""
proj = DerivedProjection(repo_slug=repo_slug, commit=commit)
@ -331,6 +373,12 @@ def derive_from_checkout(repo_root: Path, repo_slug: str, commit: str) -> Derive
status=(str(meta["status"]).strip() if meta.get("status") else None),
relative_path=str(path.relative_to(repo_root).as_posix()),
archived=path.parent.name == "archived",
owner=(str(meta["owner"]).strip() if meta.get("owner") else None),
description=(
str(meta["description"]).strip()
if meta.get("description")
else _workplan_description(body)
),
tasks=_parse_tasks(body, rid),
)
)
@ -696,6 +744,10 @@ def _sync_existing_workplan_tasks(
"workplan_id": row.id,
"record_id": dt.record_id,
"title": (dt.title or dt.record_id),
"description": dt.description,
"needs_human": dt.needs_human,
"intervention_note": dt.intervention_note,
"blocking_reason": dt.blocking_reason,
}
st = _coerce_task_status(dt.status)
if st is not None:
@ -715,10 +767,30 @@ def _sync_existing_workplan_tasks(
if dt.title and dt.title.strip() and ht.title != dt.title.strip():
ht.title = dt.title.strip()
changed = True
if getattr(ht, "description", None) != dt.description:
ht.description = dt.description
changed = True
st = _coerce_task_status(dt.status)
if st is not None and ht.status != st:
ht.status = st
changed = True
if getattr(ht, "needs_human", False) != dt.needs_human:
ht.needs_human = dt.needs_human
changed = True
if getattr(ht, "intervention_note", None) != dt.intervention_note:
ht.intervention_note = dt.intervention_note
changed = True
if getattr(ht, "blocking_reason", None) != dt.blocking_reason:
ht.blocking_reason = dt.blocking_reason
changed = True
if dt.priority:
try:
task_priority = TaskPriority(dt.priority.strip().lower())
except ValueError:
task_priority = None
if task_priority is not None and getattr(ht, "priority", None) != task_priority:
ht.priority = task_priority
changed = True
if changed:
outcome.updated_tasks.append(dt.record_id)
@ -1054,7 +1126,9 @@ async def reset_repository_projection(
topic_id=repo.topic_id,
slug=w.record_id.lower(),
title=w.title or w.record_id,
description=w.description,
status=w.status or "proposed",
owner=w.owner,
backing_filename=w.relative_path.rsplit("/", 1)[-1],
backing_relative_path=w.relative_path,
backing_archived=w.archived,
@ -1071,8 +1145,12 @@ async def reset_repository_projection(
workplan_id=row.id,
record_id=t.record_id,
title=t.title or t.record_id,
description=t.description,
status=t.status or "todo",
priority=t.priority or "medium",
needs_human=t.needs_human,
intervention_note=t.intervention_note,
blocking_reason=t.blocking_reason,
)
)
outcome.created.append(w.record_id)
@ -1090,6 +1168,12 @@ async def reset_repository_projection(
if w.title and w.title.strip() and row.title != w.title.strip():
row.title = w.title.strip()
changed = True
if w.description and getattr(row, "description", None) != w.description:
row.description = w.description
changed = True
if getattr(row, "owner", None) != w.owner:
row.owner = w.owner
changed = True
if w.status and row.status != w.status:
row.status = w.status
changed = True

View file

@ -41,6 +41,12 @@ WRITE_ROUTE_RULES: tuple[WriteRouteRule, ...] = (
WriteRouteRule("POST", r"/decisions", "append", "record decision"),
WriteRouteRule("PATCH", r"/tasks/[^/]+", "replace", "update task"),
WriteRouteRule("POST", r"/tasks/bulk-status-sync", "replace", "bulk task status sync"),
WriteRouteRule(
"POST",
r"/repos/[^/]+/work-record-projection/reconcile",
"replace",
"reconcile one forge-derived repository projection",
),
WriteRouteRule("PATCH", r"/decisions/[^/]+", "replace", "update decision"),
WriteRouteRule("POST", r"/decisions/[^/]+/resolve", "replace", "resolve decision"),
WriteRouteRule("PATCH", r"/workplans/[^/]+", "replace", "update workplan"),