Files
base/scripts/update-tasks
2026-10-08 18:29:45 -07:00

575 lines
23 KiB
Python
Executable File

#!/usr/bin/env python3
"""
update-tasks
Synchronizes and regenerates Documentation/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. OT-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](#ot-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", "OT"))
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'<a\s+[^>]*\bid=["\']tasks-list["\'][^>]*></a>', 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 <a id="task-details"></a> 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'<a\s+[^>]*\bid=["\']task-details["\'][^>]*>', between_table_and_detail, re.IGNORECASE))
if has_task_details_anchor:
part_c = "\n\n---\n\n<a id=\"task-details\"></a>\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"(?=<a\s+[^>]*\bid=|<id=)", detail_body)
task_chunks = [c.strip() for c in task_chunks if c.strip()]
if not task_chunks:
raise ValueError("No task chunks found in Detailed Tasks")
extracted_chunks = []
for chunk in task_chunks:
m = re.search(TASK_ID_PATTERN, chunk, re.IGNORECASE)
if not m:
raise ValueError(f"Could not find task ID in chunk:\n{chunk[:150]}")
num = int(m.group(2))
extracted_chunks.append((num, chunk))
seen_nums = {}
for num, chunk in extracted_chunks:
seen_nums.setdefault(num, []).append(chunk)
duplicates = [num for num, chunks in seen_nums.items() if len(chunks) > 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+["\'][^>]*></a>\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'<a id="{normalized_aid}" class="task" data-project="{proj_key}" data-subproject="{subproj_key}" data-status="{status_key}" data-task-type="{type_key}"></a>'
subproj_line = f"**Subproject:** {expected_subproj}\n"
else:
anchor_tag_str = f'<a id="{normalized_aid}" class="task" data-project="{proj_key}" data-status="{status_key}" data-task-type="{type_key}"></a>'
subproj_line = ""
if epic_key:
anchor_tag_str = anchor_tag_str.replace('></a>', f' data-epic="{epic_key}"></a>')
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).*?(<!-- Table Sort Injection -->)"
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()