#!/usr/bin/env python3 """ add-task Appends a new task to docs/tasks.md and re-synchronizes the task list. Usage: add-task "My Title Goes Here" --project=shared --type=chore add-task "My Title Goes Here" -p example-project -t feature -s triage -d "Description text" """ import argparse import importlib.machinery import importlib.util import os import re import sys from pathlib import Path def load_update_tasks_module(): script_dir = Path(__file__).resolve().parent update_script = script_dir / "update-tasks" if not update_script.exists(): raise FileNotFoundError(f"Could not find update-tasks at {update_script}") loader = importlib.machinery.SourceFileLoader("update_tasks", str(update_script)) spec = importlib.util.spec_from_loader("update_tasks", loader) if spec is None: raise ImportError(f"Could not load spec for {update_script}") mod = importlib.util.module_from_spec(spec) loader.exec_module(mod) return mod def add_task(title, project="shared", subproject=None, task_type="feature", status="triage", description=None, tasks_file=None, epic=None): update_mod = load_update_tasks_module() tasks_path = update_mod.find_tasks_file(tasks_file) with open(tasks_path, "r", encoding="utf-8") as f: text = f.read() # 1. Parse frontmatter for valid labels and values fm_match = re.match(r"^---\n(.*?)\n---", text, re.DOTALL) if not fm_match: raise ValueError("YAML frontmatter not found in tasks.md") fm_data = update_mod.parse_yaml_frontmatter(fm_match.group(1)) prefix = update_mod.get_task_id_prefix(fm_data) epic_key, epic_label = update_mod.resolve_epic(epic, fm_data) project_list = fm_data.get("projects", []) subproject_list = fm_data.get("subprojects", []) status_list = fm_data.get("task-statuses", []) type_list = fm_data.get("task-types", []) 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 status_map["fixed"] = "Fixed" status_map["done"] = "Fixed" status_value_map["fixed"] = "done" status_value_map["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 # Normalize values proj_input = project.strip().lower() type_input = task_type.strip().lower() status_input = status.strip().lower() proj_key = project_value_map.get(proj_input, proj_input) type_key = type_value_map.get(type_input, type_input) status_key = status_value_map.get(status_input, status_input) proj_label = project_map.get(proj_key, project_map.get(proj_input, project.strip().capitalize())) type_label = type_map.get(type_key, type_map.get(type_input, task_type.strip().capitalize())) status_label = status_map.get(status_key, status_map.get(status_input, status.strip().capitalize())) subproj_key = None subproj_label = None if subproject and subproject.strip() and subproject.strip().lower() not in ("", "none", "null"): sp_in = subproject.strip().lower() subproj_key = subproject_value_map.get(sp_in, sp_in) subproj_label = subproject_map.get(subproj_key, subproject_map.get(sp_in, subproject.strip().replace("-", " ").replace("_", " ").title())) # 2. Find max task ID detail_start = text.find("## Detailed Tasks\n") if detail_start == -1: raise ValueError("## Detailed Tasks heading not found") ids = [int(m.group(2)) for m in re.finditer(update_mod.TASK_ID_PATTERN, text[detail_start:], re.IGNORECASE)] next_num = (max(ids) + 1) if ids else 1 # Digit width calculation (minimum 3 digits) digit_width = max(3, len(str(next_num))) formatted_id = update_mod.format_task_id(next_num, prefix, digit_width) formatted_aid = formatted_id.lower() # 3. Format description if description and description.strip(): desc_lines = description.strip().splitlines() desc_body = "\n".join(desc_lines) if not any(line.strip().startswith("- [") for line in desc_lines): desc_body += "\n- [ ] " else: desc_body = "Describe task objectives and implementation requirements here.\n- [ ] " if subproj_key: anchor_attrs = f'id="{formatted_aid}" class="task" data-project="{proj_key}" data-subproject="{subproj_key}" data-status="{status_key}" data-task-type="{type_key}"' subproj_line = f"**Subproject:** {subproj_label}\n" else: anchor_attrs = f'id="{formatted_aid}" class="task" data-project="{proj_key}" data-status="{status_key}" data-task-type="{type_key}"' subproj_line = "" if epic_key: anchor_attrs += f' data-epic="{epic_key}"' epic_line = f"**Epic:** {epic_label}\n" if epic_key else "" new_task_chunk = ( f'\n' f"### {title.strip()}\n" f"**ID:** {formatted_id}\n" f"**Project:** {proj_label}\n" f"{subproj_line}" f"{epic_line}" f"**Status:** {status_label}\n" f"**Type:** {type_label}\n\n" f"**Description:**\n" f"{desc_body}\n" ) # 4. Insert before Notes marker notes_marker = "\n---\n\n## Notes\n" notes_pos = text.find(notes_marker) if notes_pos == -1: raise ValueError("Could not find ## Notes marker to insert task before") updated_text = text[:notes_pos].rstrip() + "\n\n" + new_task_chunk + "\n---\n\n## Notes\n" + text[notes_pos + len(notes_marker):] with open(tasks_path, "w", encoding="utf-8") as f: f.write(updated_text) # 5. Re-run update_tasks_document to sort, standardize, and refresh tables update_mod.update_tasks_document(tasks_path) return formatted_id, title.strip(), proj_label, subproj_label, status_label, type_label, tasks_path def main(): parser = argparse.ArgumentParser(description="Add a new task to tasks.md and update tables.") parser.add_argument("title", help="Title of the new task") parser.add_argument("--epic", default=None, help="Epic value or label from frontmatter (optional)") parser.add_argument( "-p", "--project", default="shared", help="Project name or value (default: shared)" ) parser.add_argument( "-S", "--subproject", default=None, help="Subproject name or category (optional, e.g. actions, expressions, schema)" ) parser.add_argument( "-t", "--type", default="feature", help="Task type (default: feature, e.g. chore, bug, feature, lint, docs)" ) parser.add_argument( "-s", "--status", default="triage", help="Initial task status (default: triage, e.g. planning, triage, pending, fixed)" ) parser.add_argument( "-d", "--description", default=None, help="Task description and requirements" ) parser.add_argument( "-f", "--file", default=None, help="Path to tasks.md (optional)" ) args = parser.parse_args() try: new_id, title, proj, subproj, status, ttype, tasks_path = add_task( title=args.title, project=args.project, subproject=args.subproject, task_type=args.type, status=args.status, description=args.description, tasks_file=args.file, epic=args.epic, ) tag_info = f"{proj} | {subproj} | {status} | {ttype}" if subproj else f"{proj} | {status} | {ttype}" print(f"Successfully added {new_id}: \"{title}\" [{tag_info}] to {tasks_path}") except Exception as e: print(f"Error adding task: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()