From 2522464ceda51732f620d6888e295daabaa4fa18 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 18 Mar 2026 08:05:07 +0100 Subject: [PATCH] fix(consistency_check): heading titles + workstream-aware task guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parse_task_blocks() now injects the nearest preceding ### heading text as `title` — tasks no longer stored with bare IDs as their title - C-11 fix skips creating tasks when workstream is completed/archived (prevents duplicate task creation on repeated fix-consistency runs) - C-12 is now fixable: auto-cancels open orphan DB tasks when the backing workstream is finished (completed/archived) Co-Authored-By: Claude Sonnet 4.6 --- scripts/consistency_check.py | 89 ++++++++++++++++++++++++++---------- 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 1790aca..8b8fcd3 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -58,6 +58,7 @@ except ImportError: # --------------------------------------------------------------------------- _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) +_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE) VALID_WP_STATUSES = {"active", "completed", "archived"} VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} @@ -148,8 +149,17 @@ def parse_frontmatter(text: str) -> tuple[dict, str]: def parse_task_blocks(body: str) -> list[dict]: - """Extract all ```task ... ``` YAML blocks from a workplan body.""" - return [_parse_yaml_block(m.group(1).strip()) for m in _TASK_BLOCK_RE.finditer(body)] + """Extract task blocks, injecting title from the nearest preceding ### heading.""" + headings = [(m.start(), m.group(1).strip()) for m in _HEADING_RE.finditer(body)] + results = [] + for m in _TASK_BLOCK_RE.finditer(body): + task = _parse_yaml_block(m.group(1).strip()) + if "title" not in task: + prev = [(pos, text) for pos, text in headings if pos < m.start()] + if prev: + task["title"] = prev[-1][1] + results.append(task) + return results def get_tasks_from_workplan(meta: dict, body: str) -> list[dict]: @@ -524,6 +534,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N ) elif t_id: # C-11: task exists in file but not linked to DB + ws_status = ws.get("status", "") report.add( severity="WARN", check_id="C-11", message=f"Task '{t_id}' has no state_hub_task_id", @@ -531,6 +542,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N fixable=True, _fix_context={ "ws_id": ws_id, + "ws_status": ws_status, "task": task, "wp_file": str(wp_file), "meta": meta, @@ -540,17 +552,27 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N # C-12: DB tasks with no file backing if isinstance(db_tasks, list): + ws_status = ws.get("status", "") + ws_finished = ws_status in ("completed", "archived") for db_t in db_tasks: if db_t["id"] not in file_task_sh_ids: + db_t_status = db_t.get("status", "") + open_task = db_t_status not in ("done", "cancelled") + # Auto-cancel fixable when workstream is finished and task is open + fixable = ws_finished and open_task report.add( severity="WARN", check_id="C-12", message=( f"DB task '{db_t.get('title', '')}' " - f"(id={db_t['id'][:8]}…, status={db_t.get('status', '')}) " + f"(id={db_t['id'][:8]}…, status={db_t_status}) " f"in workstream '{ws.get('slug')}' has no file backing" ), db_id=db_t["id"], - fixable=False, + fixable=fixable, + _fix_context={ + "task_id": db_t["id"], + "ws_finished": ws_finished, + }, ) # C-13: all DB tasks done but workstream still active — worker forgot to close @@ -810,34 +832,51 @@ def fix_repo(api_base: str, repo_slug: str, repo_path_override: str | None = Non elif issue.check_id == "C-11": ws_id = ctx["ws_id"] + ws_status = ctx.get("ws_status", "") task = ctx["task"] wp_file = Path(ctx["wp_file"]) meta = ctx["meta"] body = ctx.get("body", "") t_id = str(task.get("id", "")).strip() - t_status = str(task.get("status", "todo")).strip() - if t_status not in VALID_TASK_STATUSES: - t_status = "todo" - t_priority = str(task.get("priority", "medium")).strip() - if t_priority not in VALID_TASK_PRIORITIES: - t_priority = "medium" - t_data = _api_post(api_base, "/tasks", { - "workstream_id": ws_id, - "title": str(task.get("title", t_id)).strip() or t_id, - "status": t_status, - "priority": t_priority, - "assignee": task.get("assignee") or None, - }) - if t_data: - t_db_id = t_data["id"] - injected = _inject_task_id_into_block( - wp_file, "state_hub_task_id", t_db_id, t_id - ) - if not injected: - _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + # Skip creating tasks for finished workstreams — the workstream is + # done/archived so unlinked tasks are stale file artefacts, not gaps. + if ws_status in ("completed", "archived"): report.fixes_applied.append( - f"C-11 fixed: task '{t_id}' → {t_db_id[:8]}…" + f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created" ) + else: + t_status = str(task.get("status", "todo")).strip() + if t_status not in VALID_TASK_STATUSES: + t_status = "todo" + t_priority = str(task.get("priority", "medium")).strip() + if t_priority not in VALID_TASK_PRIORITIES: + t_priority = "medium" + t_data = _api_post(api_base, "/tasks", { + "workstream_id": ws_id, + "title": str(task.get("title", t_id)).strip() or t_id, + "status": t_status, + "priority": t_priority, + "assignee": task.get("assignee") or None, + }) + if t_data: + t_db_id = t_data["id"] + injected = _inject_task_id_into_block( + wp_file, "state_hub_task_id", t_db_id, t_id + ) + if not injected: + _inject_task_id_frontmatter_list(wp_file, t_db_id, t_id) + report.fixes_applied.append( + f"C-11 fixed: task '{t_id}' → {t_db_id[:8]}…" + ) + + elif issue.check_id == "C-12": + task_id = ctx["task_id"] + if ctx.get("ws_finished"): + result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancelled"}) + if result is not None: + report.fixes_applied.append( + f"C-12 fixed: orphan task {task_id[:8]}… cancelled (workstream finished)" + ) except Exception as e: report.fixes_applied.append(f"{issue.check_id} ERROR: {e}")