#!/usr/bin/env python3 """ update-tasks Synchronizes and regenerates docs/tasks.md: 1. Parses YAML frontmatter for projects, epics, task-statuses, task-types, and task-id-prefix. 2. Extracts all task chunks and sorts the Detailed Tasks section strictly by numeric task ID. 3. Updates the Detailed Tasks section: - Normalizes all task IDs to uniform digit width (minimum 3 digits, e.g. EX-001). - Synchronizes task detail blocks (including optional **Epic:**) with anchor tags (SSOT). 4. Re-renders the Overall Progress Table (tasks-list) from the sorted, updated task details: - Bold (**Title**) for pending tasks - Italic (*Title*) for planning and triage tasks - Plain (Title) for fixed/done/other tasks - Type column links ([Type](#ex-xxx)) 5. Re-renders the metadata derived enum tables at the end of the document: - Projects (Rendered from frontmatter projects) - Task Statuses (Rendered from frontmatter task-statuses) - Task Types (Rendered from frontmatter task-types) 6. Assembles and writes the updated document. """ import sys import os import re from pathlib import Path def find_tasks_file(explicit_path=None): explicit_path = explicit_path or os.environ.get("TASKS_FILE") if explicit_path: p = Path(explicit_path) if p.is_file(): return p raise FileNotFoundError(f"Specified file not found: {explicit_path}") # Search upwards or relative to script / cwd script_dir = Path(__file__).resolve().parent candidates = [ Path.cwd() / "docs" / "tasks.md", Path.cwd() / "docs" / "Tasks.md", Path.cwd() / "Documentation" / "tasks.md", Path.cwd() / "Documentation" / "Tasks.md", Path.cwd() / "tasks.md", Path.cwd() / "Tasks.md", script_dir.parent / "docs" / "tasks.md", script_dir.parent / "docs" / "Tasks.md", script_dir.parent / "Documentation" / "tasks.md", script_dir.parent / "Documentation" / "Tasks.md", script_dir / "docs" / "tasks.md", script_dir / "docs" / "Tasks.md", script_dir / "Documentation" / "tasks.md", script_dir / "Documentation" / "Tasks.md", ] for c in candidates: if c.is_file(): return c raise FileNotFoundError("Could not locate docs/tasks.md or docs/Tasks.md") def parse_yaml_frontmatter(fm_text): sections = {} current_section = None current_item = None for line in fm_text.splitlines(): if line and not line.startswith(" ") and ":" in line and not line.endswith(":"): key, value = line.split(":", 1) sections[key.strip()] = value.strip().strip("\"'") current_section = None current_item = None elif line and not line.startswith(" ") and line.endswith(":"): current_section = line[:-1].strip() sections[current_section] = [] current_item = None elif current_section is not None: stripped = line.strip() if not stripped: continue if line.startswith(" - "): current_item = {} sections[current_section].append(current_item) kv = line[4:].strip() if ":" in kv: k, v = kv.split(":", 1) current_item[k.strip()] = v.strip().strip("\"'") elif line.startswith(" ") and current_item is not None: kv = line.strip() if ":" in kv: k, v = kv.split(":", 1) current_item[k.strip()] = v.strip().strip("\"'") return sections TASK_ID_PATTERN = r'\bid=["\']([a-z][a-z0-9_-]*-(\d+))["\']' TASK_ANCHOR_PATTERN = r'<(?:a\s+)?([^>]*' + TASK_ID_PATTERN + r'[^>]*)>' def read_frontmatter(text): match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) if not match: raise ValueError("YAML frontmatter not found in tasks.md") return parse_yaml_frontmatter(match.group(1)) def get_task_id_prefix(fm_data): prefix = os.environ.get("TASK_ID_PREFIX", fm_data.get("task-id-prefix", "EX")) if not isinstance(prefix, str) or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]*", prefix): raise ValueError("Task ID prefix must start with a letter and contain only letters, digits, underscores, or hyphens") return prefix.upper() def format_task_id(number, prefix, digit_width=3): return f"{prefix}-{number:0{max(3, digit_width, len(str(number)))}d}" def parse_task_id(id_str): match = re.fullmatch(r"(?:[a-z][a-z0-9_-]*-)?(\d+)", str(id_str).strip(), re.IGNORECASE) if not match: raise ValueError(f"Invalid task ID '{id_str}'; expected a task number or PREFIX-number") return int(match.group(1)) def task_id_matches(id_str, anchor_id, prefix): number = parse_task_id(id_str) task_id = str(id_str).strip() anchor_prefix, anchor_number = anchor_id.rsplit("-", 1) return number == int(anchor_number) and ( task_id.isdigit() or task_id.rsplit("-", 1)[0].lower() in (anchor_prefix.lower(), prefix.lower()) ) def resolve_epic(epic, fm_data): if epic is None or epic.strip().lower() in ("", "none", "null", "clear"): return "", "" for item in fm_data.get("epics", []): value = item.get("value", "").strip() label = item.get("label", "").strip() or value.replace("-", " ").replace("_", " ").title() if value and epic.strip().lower() in (value.lower(), label.lower()): return value, label raise ValueError(f"Unknown epic '{epic}'; use a value or label from frontmatter epics") def build_markdown_table(headers, rows, alignments=None): if not rows: return "" num_cols = len(headers) widths = [len(h) for h in headers] for row in rows: for i, val in enumerate(row): widths[i] = max(widths[i], len(str(val))) if alignments is None: alignments = [":---"] * num_cols header_line = "| " + " | ".join(f"{h:<{widths[i]}}" for i, h in enumerate(headers)) + " |\n" separator_parts = [] for i, a in enumerate(alignments): w = max(widths[i], 3) if a.startswith(":") and a.endswith(":"): separator_parts.append(":" + "-" * (w - 2) + ":") elif a.endswith(":"): separator_parts.append("-" * (w - 1) + ":") else: separator_parts.append(":" + "-" * (w - 1)) separator_line = "|" + "|".join(separator_parts) + "|\n" row_lines = [] for row in rows: line = "| " + " | ".join(f"{str(row[i]):<{widths[i]}}" for i in range(num_cols)) + " |\n" row_lines.append(line) return header_line + separator_line + "".join(row_lines) def update_tasks_document(tasks_path): tasks_path = Path(tasks_path) with open(tasks_path, "r", encoding="utf-8") as f: text = f.read() # 1. Parse frontmatter fm_match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) if not fm_match: raise ValueError("YAML frontmatter not found in tasks.md") fm_text = fm_match.group(1) fm_data = parse_yaml_frontmatter(fm_text) prefix = get_task_id_prefix(fm_data) project_list = fm_data.get("projects", []) epic_list = fm_data.get("epics", []) subproject_list = fm_data.get("subprojects", []) status_list = fm_data.get("task-statuses", []) type_list = fm_data.get("task-types", []) # Maps for lookup (both value and label matching) project_map = {} project_value_map = {} for p in project_list: val = p.get("value", "").strip() lbl = p.get("label", "").strip() if val: project_map[val.lower()] = lbl or val.capitalize() project_value_map[val.lower()] = val if lbl: project_map[lbl.lower()] = lbl if val: project_value_map[lbl.lower()] = val subproject_map = {} subproject_value_map = {} for sp in subproject_list: val = sp.get("value", "").strip() lbl = sp.get("label", "").strip() if val: subproject_map[val.lower()] = lbl or val.replace("-", " ").title() subproject_value_map[val.lower()] = val if lbl: subproject_map[lbl.lower()] = lbl if val: subproject_value_map[lbl.lower()] = val status_map = {s.get("value", "").lower(): s.get("label", "") for s in status_list} status_value_map = {s.get("value", "").lower(): s.get("value", "") for s in status_list} for s in status_list: lbl = s.get("label", "").strip() val = s.get("value", "").strip() if lbl: status_map[lbl.lower()] = lbl if val: status_value_map[lbl.lower()] = val # Accept 'done' or 'fixed' fallbacks if not defined in frontmatter status_map.setdefault("fixed", status_map.get("done", "Done")) status_map.setdefault("done", status_map.get("fixed", "Done")) status_value_map.setdefault("fixed", "done") status_value_map.setdefault("done", "done") type_map = {t.get("value", "").lower(): t.get("label", "") for t in type_list} type_value_map = {t.get("value", "").lower(): t.get("value", "") for t in type_list} for t in type_list: lbl = t.get("label", "").strip() val = t.get("value", "").strip() if lbl: type_map[lbl.lower()] = lbl if val: type_value_map[lbl.lower()] = val # 2. Locate boundaries m_detail = re.search(r"^## Detailed Tasks\s*$", text, re.MULTILINE) if not m_detail: raise ValueError("## Detailed Tasks heading not found") detail_start = m_detail.start() detail_header_len = len(m_detail.group(0)) m_notes = re.search(r"(?:\n\s*---\s*)?\n\s*## Notes\s*$", text, re.MULTILINE) if not m_notes: raise ValueError("## Notes section marker not found") notes_start = m_notes.start() # Determine part_a (everything before the summary table) tasks_list_m = re.search(r']*\bid=["\']tasks-list["\'][^>]*>', text, re.IGNORECASE) progress_m = re.search(r'^## Overall Progress\s*$', text, re.MULTILINE) if tasks_list_m and tasks_list_m.start() < detail_start: part_a = text[:tasks_list_m.end()].rstrip() + "\n\n" table_search_start = tasks_list_m.end() elif progress_m and progress_m.start() < detail_start: part_a = text[:progress_m.end()].rstrip() + "\n\n" table_search_start = progress_m.end() else: table_start_marker = "| ID" table_start = text.find(table_start_marker) if table_start == -1 or table_start >= detail_start: raise ValueError("Overall Progress Table (| ID) not found") # Strip any leading git conflict markers right before | ID from part_a raw_part_a = text[:table_start] raw_part_a = re.sub(r'<{7}\s+[^\n]*\n*$', '', raw_part_a) part_a = raw_part_a.rstrip() + "\n\n" table_search_start = table_start # Determine part_c (the transition into ## Detailed Tasks) # Check if was present between table and ## Detailed Tasks between_table_and_detail = text[table_search_start:detail_start] has_task_details_anchor = bool(re.search(r']*\bid=["\']task-details["\'][^>]*>', between_table_and_detail, re.IGNORECASE)) if has_task_details_anchor: part_c = "\n\n---\n\n\n\n## Detailed Tasks\n" else: part_c = "\n\n---\n\n## Detailed Tasks\n" detail_body = text[detail_start + detail_header_len : notes_start] # 3. Extract and Sort Task Chunks by Numeric ID task_chunks = re.split(r"(?=]*\bid=| 1] if duplicates: prefix = get_task_id_prefix(fm_data) dup_ids = [format_task_id(num, prefix) for num in sorted(duplicates)] raise ValueError(f"Duplicate numeric task IDs found in Detailed Tasks: {', '.join(dup_ids)}") # Sort task detail section chunks by numeric ID extracted_chunks.sort(key=lambda x: x[0]) max_id_num = extracted_chunks[-1][0] if extracted_chunks else 1 digit_width = max(3, len(str(max_id_num))) # 4. Update the Task Detail Section (First) updated_detailed_chunks = [] updated_task_records = [] header_block_pattern = r'<(?:a\s+[^>]*\bid=|\bid=)["\'][a-zA-Z][a-zA-Z0-9_-]*-\d+["\'][^>]*>\s*\n###\s+[^\n]+\n\*\*ID:\*\*[^\n]+\n\*\*Project:\*\*[^\n]+(?:\n\*\*(?:Subproject|Epic):\*\*[^\n]*)*\n\*\*Status:\*\*[^\n]+\n\*\*Type:\*\*[^\n]+' anchor_replacements = {} for num, chunk in extracted_chunks: anchor_match = re.search(TASK_ANCHOR_PATTERN, chunk, re.IGNORECASE) if not anchor_match: raise ValueError(f"Anchor tag missing in task chunk {num}") anchor_tag = anchor_match.group(1) proj_m = re.search(r'data-project=["\']([^"\']+)["\']', anchor_tag) subproj_m = re.search(r'data-subproject=["\']([^"\']*)["\']', anchor_tag) status_m = re.search(r'data-status=["\']([^"\']+)["\']', anchor_tag) type_m = re.search(r'data-task-type=["\']([^"\']+)["\']', anchor_tag) epic_m = re.search(r'data-epic=["\']([^"\']*)["\']', anchor_tag) epic_key, expected_epic = resolve_epic(epic_m.group(1) if epic_m else None, fm_data) # Also check for **Subproject:** in chunk if anchor tag didn't have data-subproject if not subproj_m: subproj_field_m = re.search(r'^\*\*Subproject:\*\*\s*([^\n]+)$', chunk, re.MULTILINE) subproj_val = subproj_field_m.group(1).strip() if subproj_field_m else None else: subproj_val = subproj_m.group(1).strip() if subproj_m.group(1).strip() else None proj_raw = proj_m.group(1).strip() if proj_m else "shared" status_raw = status_m.group(1).strip() if status_m else "triage" type_raw = type_m.group(1).strip() if type_m else "feature" proj_key = project_value_map.get(proj_raw.lower(), proj_raw.lower()) status_key = status_value_map.get(status_raw.lower(), status_raw.lower()) type_key = type_value_map.get(type_raw.lower(), type_raw.lower()) expected_proj = project_map.get(proj_key, project_map.get(proj_raw.lower(), proj_raw.capitalize())) expected_status = status_map.get(status_key, status_map.get(status_raw.lower(), status_raw.capitalize())) expected_type = type_map.get(type_key, type_map.get(type_raw.lower(), type_raw.capitalize())) subproj_key = None expected_subproj = None if subproj_val and subproj_val.lower() not in ("", "none", "null"): subproj_key = subproject_value_map.get(subproj_val.lower(), subproj_val.lower()) expected_subproj = subproject_map.get(subproj_key, subproject_map.get(subproj_val.lower(), subproj_val.replace("-", " ").replace("_", " ").title())) title_m = re.search(r'^###\s+(.+)$', chunk, re.MULTILINE) title = title_m.group(1).strip() if title_m else f"Task {num}" normalized_id = format_task_id(num, prefix, digit_width) normalized_aid = normalized_id.lower() anchor_replacements[anchor_match.group(2).lower()] = normalized_aid if subproj_key: anchor_tag_str = f'' subproj_line = f"**Subproject:** {expected_subproj}\n" else: anchor_tag_str = f'' subproj_line = "" if epic_key: anchor_tag_str = anchor_tag_str.replace('>', f' data-epic="{epic_key}">') epic_line = f"**Epic:** {expected_epic}\n" if epic_key else "" new_header_block = ( f'{anchor_tag_str}\n' f"### {title}\n" f"**ID:** {normalized_id}\n" f"**Project:** {expected_proj}\n" f"{subproj_line}" f"{epic_line}" f"**Status:** {expected_status}\n" f"**Type:** {expected_type}" ) if re.search(header_block_pattern, chunk): updated_chunk = re.sub(header_block_pattern, new_header_block, chunk, count=1) else: # Fallback if header spacing or fields differed slightly rest = chunk.split("**Type:**", 1)[1].lstrip().split("\n", 1)[1] if "**Type:**" in chunk else "" updated_chunk = f"{new_header_block}\n\n{rest}".rstrip() updated_detailed_chunks.append(updated_chunk) updated_task_records.append({ "num": num, "id": normalized_id, "aid": normalized_aid, "title": title, "project": expected_proj, "subproject": expected_subproj or "", "epic": expected_epic, "status": expected_status, "type": expected_type, }) # Assemble sorted, updated Detailed Tasks section sorted_detailed_tasks = "\n\n".join(updated_detailed_chunks) # 5. Rebuild Summary Table (Rendered from the sorted, updated task details) has_subprojects = any(bool(t.get("subproject")) for t in updated_task_records) if has_subprojects: summary_headers = ["ID", "Title", "Project", "Subproject", "Status", "Type"] alignments = [":---", ":---", ":---", ":---", ":---", ":---"] else: summary_headers = ["ID", "Title", "Project", "Status", "Type"] alignments = [":---", ":---", ":---", ":---", ":---"] has_epics = bool(epic_list) if has_epics: summary_headers.insert(-2, "Epic") alignments.insert(-2, ":---") summary_rows = [] for task in updated_task_records: clean_title = task["title"] while (clean_title.startswith("**") and clean_title.endswith("**")) or ( clean_title.startswith("*") and clean_title.endswith("*") ): if clean_title.startswith("**") and clean_title.endswith("**"): clean_title = clean_title[2:-2].strip() elif clean_title.startswith("*") and clean_title.endswith("*"): clean_title = clean_title[1:-1].strip() st_lower = task["status"].lower() if st_lower == "pending": formatted_title = f"**{clean_title}**" elif st_lower in ("triage", "planning"): formatted_title = f"*{clean_title}*" else: formatted_title = clean_title formatted_type = f"[{task['type']}](#{task['aid']})" row = [task["id"], formatted_title, task["project"]] if has_subprojects: row.append(task["subproject"]) if has_epics: row.append(task["epic"]) row.extend([task["status"], formatted_type]) summary_rows.append(row) new_summary_table = build_markdown_table( summary_headers, summary_rows, alignments=alignments ).rstrip("\n") # 8. Re-render metadata derived enum tables at end of document end_text = text[notes_start:] # Projects table: Value | Label | Prefix | Path project_headers = ["Value", "Label", "Prefix", "Path"] project_rows = [ [p.get("value", ""), p.get("label", ""), p.get("prefix", ""), p.get("path", "")] for p in project_list ] projects_table = build_markdown_table(project_headers, project_rows, [":---", ":---", ":---", ":---"]).strip() # Statuses table: Value | Label | Description status_headers = ["Value", "Label", "Description"] status_rows = [ [s.get("value", ""), s.get("label", ""), s.get("description", "")] for s in status_list ] statuses_table = build_markdown_table(status_headers, status_rows, [":---", ":---", ":---"]).strip() # Types table: Value | Label | Prefix | Description type_headers = ["Value", "Label", "Prefix", "Description"] type_rows = [ [t.get("value", ""), t.get("label", ""), t.get("prefix", ""), t.get("description", "")] for t in type_list ] types_table = build_markdown_table(type_headers, type_rows, [":---", ":---", ":---", ":---"]).strip() # Subprojects table if present in frontmatter subproject_section = "" if subproject_list: subproject_headers = ["Value", "Label", "Description"] subproject_rows = [ [sp.get("value", ""), sp.get("label", ""), sp.get("description", "")] for sp in subproject_list ] subprojects_table = build_markdown_table(subproject_headers, subproject_rows, [":---", ":---", ":---"]).strip() subproject_section = f"\n\n#### Subprojects (Rendered from frontmatter subprojects)\n{subprojects_table}" epic_section = "" if epic_list: epic_rows = [[e.get("value", ""), e.get("label", ""), e.get("description", "")] for e in epic_list] epics_table = build_markdown_table(["Value", "Label", "Description"], epic_rows).strip() epic_section = f"\n\n#### Epics (Rendered from frontmatter epics)\n{epics_table}" # Replace enum tables in end_text enum_tables_pattern = r"(#### Projects \(Rendered from frontmatter projects\)\n).*?()" new_enum_tables = ( "#### Projects (Rendered from frontmatter projects)\n" f"{projects_table}\n\n" "#### Task Statuses (Rendered from frontmatter task-statuses)\n" f"{statuses_table}\n\n" "#### Task Types (Rendered from frontmatter task-types)\n" f"{types_table}" f"{subproject_section}{epic_section}\n\n" r"\2" ) updated_end_text = re.sub(enum_tables_pattern, new_enum_tables, end_text, flags=re.DOTALL) # 9. Assemble full document new_full_text = ( part_a + new_summary_table + part_c + "\n" + sorted_detailed_tasks + "\n\n" + updated_end_text.lstrip("\n") ) new_full_text = re.sub( r'(\]\(|href=["\']|^\[[^\]\n]+\]:[ \t]*)#([a-z][a-z0-9_-]*-\d+)(?=[\s)"\']|$)', lambda m: m.group(1) + "#" + anchor_replacements.get(m.group(2).lower(), m.group(2)), new_full_text, flags=re.IGNORECASE | re.MULTILINE, ) with open(tasks_path, "w", encoding="utf-8") as f: f.write(new_full_text) return len(updated_task_records) def main(): import argparse parser = argparse.ArgumentParser(description="Synchronize and update Documentation/tasks.md") parser.add_argument( "-f", "--file", dest="file", default=None, help="Path to tasks.md (defaults to searching workspace)" ) args = parser.parse_args() try: tasks_file = find_tasks_file(args.file) count = update_tasks_document(tasks_file) print(f"Successfully synchronized and updated {count} tasks in {tasks_file}") except Exception as e: print(f"Error updating tasks: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()