Files

1 line
96 KiB
JSON

{"repo": "andrevianna/aid-methodology", "files": {"dashboard/reader/reader.py": {"170b87d9ff4a2a90563d54430369c7df86dbda10": {"t": 1785846917.374161, "n": 67247, "h": "6175b62fc3dedaa9", "c": "# dashboard/reader/reader.py\n# Public entry point: read_repo(aid_root) -> RepoModel\n#\n# Feature-002 Feature Flow (step numbers match the SPEC.md):\n# 1. RESOLVE verify aid_root/.aid exists; absent -> empty RepoModel + parse_warning\n# 2. LEVEL-0 parse .aid/.aid-manifest.json -> ToolInfo\n# 3. LEVEL-1 parse .aid/settings.yml + stat .aid/knowledge/ -> RepoInfo\n# 4. ENUMERATE list .aid/works/* (all direct subdirs; name not a filter) -> work_id list\n# 5. PER WORK read STATE.md once; parse normalized block + tasks + Q&A -> WorkModel list\n# 6. RECONCILE merge same-work_id roots (work-004 Pillar 5 / SD-2) -> deduplicated list\n# 7. ASSEMBLE RepoModel{tool, repo, works, read=ReadMeta} -> return\n#\n# This module is the ONLY place that performs filesystem I/O for the whole pass.\n# LC-1 Locator is in locator.py; LC-2 Parsers are in parsers.py.\n#\n# No write / no LLM / one read-only `git log` subprocess for KB freshness (FR35).\n# NFR7 (no-LLM): all derivation is deterministic code; no inference, no model call.\n# NFR4 (overhead): single bounded pass; ReadMeta.bytes_read records total bytes read.\n#\n# Python 3.11+ stdlib only. Zero third-party deps.\n#\n# work-004 Pillar 5 -- same-work reconcile (SD-2, task-011):\n# SD2_RANK -- authoritative SD-2 state advancement ordering (most advanced = lowest rank int).\n# Encoded ONCE here; the Node twin (task-012) must mirror this exact ordered list.\n# Order: Done(0) > Canceled(1) > In Review(2) > In Progress(3) >\n# Blocked(4) > Failed(5) > Pending(6) > Unknown(7)\n# _reconcile_same_work() -- merges N same-work_id WorkModels into one (no winner):\n# * per task: most-advanced State by SD2_RANK\n# * work-level Pipeline State: newest Updated; tie -> branch-label sort, main first\n# * derived views (tasks, pending_inputs, deliverables, features): union\n\nfrom __future__ import annotations\n\nimport re as _re_mod\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Optional, Union\n\nfrom .derivation import derive_doc_freshness, derive_kb_status\nfrom .io_bounds import read_bytes_bounded\nfrom .locator import enumerate_worktree_roots, locate_aid_root\nfrom .models import (\n DeferredIssue,\n DeliverableRef,\n Finding,\n Lifecycle,\n LogAvailability,\n PendingInput,\n RawStateRef,\n ReadMeta,\n RepoInfo,\n RepoModel,\n SourceMode,\n TaskDetail,\n TaskLedger,\n TaskModel,\n ToolInfo,\n WorkModel,\n)\nfrom .parsers import (\n ParsedTaskState,\n ParsedWork,\n parse_connectors,\n parse_deferred_issues,\n parse_delivery_gate,\n parse_delivery_state_md,\n parse_execution_graph,\n parse_external_sources,\n parse_kb_baseline,\n parse_kb_state,\n parse_log_availability,\n parse_minimum_grade,\n parse_project_settings,\n parse_quick_check_findings,\n parse_requirements_md,\n parse_spec_md,\n parse_state_md,\n parse_task_short_name,\n parse_task_state_md,\n parse_tasks_lifecycle_md,\n parse_tool_info,\n)\n\n\n# ---------------------------------------------------------------------------\n# work-004 Pillar 5 / SD-2: State advancement ordering (LOCKED)\n#\n# Authoritative ordered list (most-advanced first, index = rank int):\n# Done(0) > Canceled(1) > In Review(2) > In Progress(3) >\n# Blocked(4) > Failed(5) > Pending(6) > Unknown(7)\n#\n# Rationale (from SPEC.md SD-2):\n# Done/Canceled are terminal-resolved (highest rank).\n# In Review is past In Progress (review is a later pipeline stage).\n# Blocked outranks Failed: blocked = recoverable-in-place + needs attention;\n# Failed = completed-but-rejected attempt (parallel branch may have superseded it).\n# Both Blocked and Failed outrank Pending (work was attempted; more informative).\n# Unknown is the reader-only sentinel and is ranked last.\n#\n# The Node twin (task-012) MUST mirror this exact ordering verbatim.\n# ---------------------------------------------------------------------------\nSD2_RANK: dict[str, int] = {\n \"Done\": 0,\n \"Canceled\": 1,\n \"In Review\": 2,\n \"In Progress\": 3,\n \"Blocked\": 4,\n \"Failed\": 5,\n \"Pending\": 6,\n \"Unknown\": 7,\n}\n\n# Sentinel rank for any state string not in SD2_RANK (treat as least advanced)\n_SD2_RANK_DEFAULT = 7\n\n\ndef _sd2_rank(state: \"TaskStatus\") -> int:\n \"\"\"Return the SD-2 rank for a TaskStatus (lower = more advanced).\n\n Uses the .value string so the comparison is independent of the enum wrapper.\n Unknown and unrecognized values return _SD2_RANK_DEFAULT (least advanced).\n Never throws.\n \"\"\"\n try:\n return SD2_RANK.get(state.value, _SD2_RANK_DEFAULT)\n except Exception: # noqa: BLE001\n return _SD2_RANK_DEFAULT\n\n\n# ---------------------------------------------------------------------------\n# work-004 Pillar 5: Same-work reconcile (no winner) -- task-011\n# ---------------------------------------------------------------------------\n\ndef _pipeline_winner_sort_key(\n updated: Optional[str], branch_label: Optional[str]\n) -> tuple:\n \"\"\"Shared Pipeline-State winner-rule sort key (SD-2 / Pillar 5 step 2).\n\n Newest `updated` wins; tie -> `branch_label` lexical sort, \"main\" first.\n\n This is the SINGLE encoding of the winner rule -- used by BOTH\n `_reconcile_same_work` (ranking WorkModel copies when the reader merges\n same-work_id roots) and `resolve_work_dir` (task-002, WT-1: ranking raw\n worktree candidates so a write hits the very copy the reader would\n render). Factoring it out means the \"SAME winner rule\" invariant holds by\n construction rather than by two independently-maintained copies.\n\n Sort ascending by a key where \"better\" (newer timestamp, main branch)\n maps to a SMALLER value, so sorted()[0] is the winner.\n\n Key structure: (tier, inv_updated, secondary)\n tier=0 if timestamp is present, tier=1 if absent (present wins over absent).\n inv_updated: char-complement of the ISO-8601 string so that a LARGER\n (newer) timestamp maps to a SMALLER key (ascending sort picks newest first).\n ISO-8601 characters are ASCII (digits, 'T', 'Z', '-', ':'); guard against\n corrupt non-ASCII by clamping ord(c) to [0, 0x7F] before complement.\n secondary: (0, \"\") for \"main\", else (1, label) for lexical sort.\n \"\"\"\n updated = updated or \"\"\n label = branch_label or \"\"\n secondary = (0, \"\") if label == \"main\" else (1, label)\n if updated:\n inv_updated = \"\".join(chr(0x7F - min(ord(c), 0x7F)) for c in updated)\n return (0, inv_updated, secondary)\n return (1, \"\", secondary)\n\n\ndef _reconcile_same_work(\n copies: list[tuple[\"WorkModel\", str, str]],\n) -> tuple[\"WorkModel\", str, str]:\n \"\"\"Merge N WorkModel copies for the same work_id into one reconciled model.\n\n Args:\n copies: list of (WorkModel, state_text, state_label) tuples,\n one per worktree/main root that contains this work_id.\n The list MUST be non-empty (caller guarantees this).\n\n Returns:\n (reconciled_WorkModel, winning_state_text, winning_state_label)\n\n Reconcile rules (Pillar 5 / SD-2):\n 1. Per-task State: most-advanced by SD2_RANK (lower rank wins).\n 2. Work-level Pipeline State (updated, lifecycle, phase, active_skill,\n pause_reason, block_reason, block_artifact): copy with the newest\n `updated` timestamp. On a tie (equal or both None): break by\n branch_label lexical sort, \"main\" sorts first (before any other label).\n 3. Derived views (tasks, pending_inputs, deliverables, features): UNION\n of all copies' contributions (dedup task_ids by SD-2 in step 1).\n 4. Identity fields (work_id, name, number, title, description, objective,\n work_path, recipe): taken from the Pipeline-State winner (step 2)\n because those fields come from work-level files (REQUIREMENTS/SPEC),\n which are identical across all copies of the same work_id.\n 5. source_mode: if any copy is Normalized -> Normalized; else Fallback.\n\n Merge is DETERMINISTIC and ORDER-INDEPENDENT:\n - Step 1 is a per-task max() by rank -> independent of input order.\n - Step 2 uses a total-order key that never produces a tie: the primary\n key (ISO-8601 updated) is string-comparable; ties are broken by a\n stable secondary key (branch_label sort, \"main\" first).\n - Steps 3-5 are commutative set/union operations.\n\n Read-only. Never throws.\n \"\"\"\n if len(copies) == 1:\n # Trivial case: nothing to merge\n return copies[0]\n\n # ------------------------------------------------------------------\n # Step 1: union tasks, picking the most-advanced state per task_id\n # ------------------------------------------------------------------\n # Map task_id (lower-cased for robustness) -> best TaskModel seen so far\n best_task: dict[str, TaskModel] = {}\n for wm, _, _ in copies:\n for task in wm.tasks:\n tid = task.task_id.lower()\n if tid not in best_task:\n best_task[tid] = task\n else:\n current_rank = _sd2_rank(best_task[tid].status)\n candidate_rank = _sd2_rank(task.status)\n if candidate_rank < current_rank:\n best_task[tid] = task\n # Preserve the original case order from the first copy that introduced each task\n # (stable across shuffles because best_task is keyed by lower-case, but we emit\n # the TaskModel objects which carry their own task_id).\n # Sort deterministically: by task_id string so order is input-order-independent.\n merged_tasks: list[TaskModel] = sorted(\n best_task.values(),\n key=lambda t: t.task_id.lower(),\n )\n\n # ------------------------------------------------------------------\n # Step 2: pick the Pipeline-State winner by newest `updated` timestamp.\n # Tie-break: branch_label lexical sort, \"main\" sorting first.\n # The sort key itself is the shared _pipeline_winner_sort_key (see its\n # docstring above) -- reused verbatim by resolve_work_dir (task-002).\n # ------------------------------------------------------------------\n def _pipeline_winner_key(entry: tuple[WorkModel, str, str]) -> tuple:\n wm = entry[0]\n return _pipeline_winner_sort_key(wm.updated, wm.branch_label)\n\n sorted_copies = sorted(copies, key=_pipeline_winner_key)\n winner_wm, winner_text, winner_label = sorted_copies[0]\n\n # ------------------------------------------------------------------\n # Step 3: union pending_inputs (all copies contribute)\n # ------------------------------------------------------------------\n seen_qids: set[str] = set()\n merged_pending: list[PendingInput] = []\n for wm, _, _ in copies:\n for pi in wm.pending_inputs:\n if pi.question_id not in seen_qids:\n seen_qids.add(pi.question_id)\n merged_pending.append(pi)\n\n # ------------------------------------------------------------------\n # Step 3b: union deliverables (by delivery number; winner's entry wins\n # on duplicate numbers since the winner's Pipeline State is authoritative)\n # ------------------------------------------------------------------\n seen_del: set[int] = set()\n merged_deliverables: list[DeliverableRef] = []\n # Winner's deliverables first (authoritative on duplicates)\n for dr in winner_wm.deliverables:\n if dr.number not in seen_del:\n seen_del.add(dr.number)\n merged_deliverables.append(dr)\n for wm, _, _ in copies:\n if wm is winner_wm:\n continue\n for dr in wm.deliverables:\n if dr.number not in seen_del:\n seen_del.add(dr.number)\n merged_deliverables.append(dr)\n merged_deliverables.sort(key=lambda d: d.number)\n\n # ------------------------------------------------------------------\n # Step 3c: union features (by feature number; winner first)\n # ------------------------------------------------------------------\n seen_feat: set[int] = set()\n merged_features = []\n for fr in winner_wm.features:\n if fr.number not in seen_feat:\n seen_feat.add(fr.number)\n merged_features.append(fr)\n for wm, _, _ in copies:\n if wm is winner_wm:\n continue\n for fr in wm.features:\n if fr.number not in seen_feat:\n seen_feat.add(fr.number)\n merged_features.append(fr)\n\n # ------------------------------------------------------------------\n # Step 4 + 5: build the reconciled WorkModel from the winner's fields,\n # replacing tasks/pending_inputs/deliverables/features with merged views.\n # source_mode: Normalized if any copy is Normalized.\n # ------------------------------------------------------------------\n merged_source_mode = winner_wm.source_mode\n for wm, _, _ in copies:\n if wm.source_mode == SourceMode.Normalized:\n merged_source_mode = SourceMode.Normalized\n break\n\n reconciled = WorkModel(\n work_id=winner_wm.work_id,\n name=winner_wm.name,\n lifecycle=winner_wm.lifecycle,\n phase=winner_wm.phase,\n active_skill=winner_wm.active_skill,\n updated=winner_wm.updated,\n created=winner_wm.created,\n pause_reason=winner_wm.pause_reason,\n block_reason=winner_wm.block_reason,\n block_artifact=winner_wm.block_artifact,\n tasks=merged_tasks,\n pending_inputs=merged_pending,\n source_mode=merged_source_mode,\n number=winner_wm.number,\n title=winner_wm.title,\n description=winner_wm.description,\n objective=winner_wm.objective,\n work_path=winner_wm.work_path,\n recipe=winner_wm.recipe,\n features=merged_features,\n deliverables=merged_deliverables,\n # branch_label: None on a reconciled model (multiple branches contributed);\n # provenance is retained on each TaskModel via its wave/delivery field.\n branch_label=None,\n kind=winner_wm.kind,\n started=winner_wm.started,\n minimum_grade=winner_wm.minimum_grade,\n user_approved=winner_wm.user_approved,\n )\n return reconciled, winner_text, winner_label\n\n\n# ---------------------------------------------------------------------------\n# Worktree-aware work-directory resolver (WT-1) -- task-002\n# ---------------------------------------------------------------------------\n\ndef resolve_work_dir(served_root: Union[str, Path], work_id: str) -> Optional[Path]:\n \"\"\"Resolve work_id to the REAL on-disk work directory (worktree-aware; WT-1).\n\n Reuses `enumerate_worktree_roots` (locator.py) to walk the served repo's git\n worktrees, selects every worktree whose `<wt>/.aid/works/<work_id>` exists, and\n applies the SAME winner rule as `_reconcile_same_work` step 2 (the shared\n `_pipeline_winner_sort_key`: newest `updated` wins; tie -> `branch_label`\n lexical, \"main\" first) -- so the directory returned is the very copy the\n reader would render for this work_id (a write hits exactly what the reader\n rendered).\n\n Returns None when no worktree of the served repo holds work_id (the caller\n maps this to 404 -- the reader would not have rendered this work either).\n Inherits the reader's SD-3 degradation (git absent / non-git -> main-root-\n only) via `enumerate_worktree_roots`, so this resolver can only ever be asked\n to target a work the reader itself surfaced -- consistency by construction.\n\n `served_root` may be the repo root or a path ending in \".aid\" (same convention\n as `read_repo`). The caller is responsible for validating `work_id`'s shape\n (`^work-[0-9]+`) before calling -- this function only resolves an\n already-validated id to a directory; it never reconstructs a served-tree\n path itself (each candidate directory comes verbatim from\n `enumerate_worktree_roots`'s real on-disk `.aid` dir).\n\n Read-only. Never throws.\n \"\"\"\n root = Path(served_root).resolve()\n if root.name == \".aid\":\n root = root.parent\n\n worktree_roots = enumerate_worktree_roots(root)\n\n # (updated, branch_label, work_dir) for every worktree that actually holds work_id.\n candidates: list[tuple[Optional[str], Optional[str], Path]] = []\n for branch_label, wt_aid_dir in worktree_roots:\n work_dir = wt_aid_dir / \"works\" / work_id\n if not work_dir.is_dir():\n continue\n candidates.append((_peek_work_updated(work_dir, work_id), branch_label, work_dir))\n\n if not candidates:\n return None\n\n candidates.sort(key=lambda c: _pipeline_winner_sort_key(c[0], c[1]))\n return candidates[0][2]\n\n\ndef _peek_work_updated(work_dir: Path, work_id: str) -> Optional[str]:\n \"\"\"Best-effort read of a work directory's Pipeline State `updated` field.\n\n Used only by `resolve_work_dir` to break ties between worktree copies of the\n same work_id (the winner rule needs `updated`, not a full WorkModel). Reads\n `work_dir/STATE.md` -- present regardless of monolithic/flat/hierarchical\n layout, since all three read the work-root STATE.md for Pipeline State -- and\n parses it with the SAME `parse_state_md()` the always-on read path uses (so a\n legacy/fallback-format STATE.md derives `updated` identically to a full\n `read_repo` pass).\n\n Returns None on a missing STATE.md or any read/parse failure; never throws.\n A None result only affects tie-break ordering, never candidate inclusion --\n the work_id directory's presence is the sole inclusion test (WT-1).\n \"\"\"\n state_path = work_dir / \"STATE.md\"\n if not state_path.is_file():\n return None\n try:\n raw = read_bytes_bounded(state_path)\n text = raw.decode(\"utf-8\", errors=\"replace\")\n except OSError:\n return None\n try:\n return parse_state_md(text, work_id=work_id, work_dir=work_dir).updated\n except Exception: # noqa: BLE001 -- never throws\n return None\n\n\ndef read_repo(aid_root: Union[str, Path]) -> RepoModel:\n \"\"\"Read AID state for the repo at aid_root and return a normalized RepoModel.\n\n Single pure, idempotent filesystem pass.\n No write / no LLM / one read-only git log subprocess for KB freshness (FR35).\n\n Edge cases:\n - Absent .aid/ -> empty RepoModel with a parse_warning (SPEC AC1).\n - Zero work folders -> works=[] with valid tool/repo/read fields (SPEC AC1).\n - Malformed STATE.md -> parse_warning + best-effort WorkModel; never aborts (SPEC).\n\n Args:\n aid_root: The repo root directory that contains the .aid/ subdirectory.\n May be the repo root (which contains .aid/) or .aid/ itself --\n both are accepted for convenience.\n\n Returns:\n RepoModel with tool, repo, works, and read (ReadMeta) fields populated.\n \"\"\"\n model, _ = _read_repo_full(aid_root)\n return model\n\n\ndef _read_repo_full(\n aid_root: Union[str, Path],\n) -> \"tuple[RepoModel, dict[str, tuple[str, str]]]\":\n \"\"\"Run the full always-on repo read pass and return both the model and a\n per-work STATE.md text cache built as a by-product (zero extra I/O).\n\n Returns:\n (RepoModel, state_text_cache)\n where state_text_cache maps work_id -> (decoded_text, label_str).\n The cache is used by read_repo_detail to satisfy DR-1/DD-3/NFR4\n (raw_state reuses bytes already read; no re-read of STATE.md).\n \"\"\"\n read_at = datetime.now(timezone.utc).isoformat(timespec=\"seconds\")\n parse_warnings: list[str] = []\n bytes_read = 0\n\n # Normalize aid_root: accept either the repo root or a path ending in \".aid\"\n root = Path(aid_root).resolve()\n if root.name == \".aid\":\n root = root.parent\n\n # Step 1: RESOLVE -- locate .aid/ and enumerate work folders (LC-1)\n loc = locate_aid_root(root)\n\n if not loc.aid_exists:\n parse_warnings.append(\n f\"No .aid/ directory found at {root}; returning empty model.\"\n )\n empty_model = RepoModel(\n tool=ToolInfo(manifest_present=False),\n repo=RepoInfo(\n project_name=root.name,\n aid_dir=str(loc.aid_dir),\n kb_state=None,\n ),\n works=[],\n read=ReadMeta(\n read_at=read_at,\n work_count=0,\n fallback_works=[],\n parse_warnings=parse_warnings,\n bytes_read=0,\n ),\n )\n return empty_model, {}\n\n # Step 2: LEVEL-0 -- ToolInfo (LC-2)\n tool_info, br = parse_tool_info(loc.manifest_path)\n bytes_read += br\n\n # Step 3: LEVEL-1 -- RepoInfo + KbStateRef (LC-2)\n project_name, project_description, br = parse_project_settings(loc.settings_path)\n bytes_read += br\n if not project_name:\n project_name = root.name # fallback: folder basename (SPEC DM-7 note)\n\n # feature-002 (work-017 task-005): GLOBAL review.minimum_grade -- its own\n # 'review:'-section scan (a real settings.yml has 'tools:' between 'project:'\n # and 'review:', so the project-section scan above cannot reach it).\n minimum_grade, br = parse_minimum_grade(loc.settings_path)\n bytes_read += br\n\n # task-064: parse kb_baseline from settings.yml (DM-A4)\n kb_baseline, br = parse_kb_baseline(loc.settings_path)\n bytes_read += br\n\n # task-064: parse kb_state with summary_present (stat of .aid/knowledge/kb.html)\n kb_state, br = parse_kb_state(loc.kb_dir)\n bytes_read += br\n\n # task-064: derive 5-state KB status (FF-A3 waterfall) and attach fields\n if kb_state is not None:\n from .models import KbStatus as _KbStatus # avoid circular at module level\n kb_status = derive_kb_status(\n kb_dir=loc.kb_dir,\n summary_approved=kb_state.summary_approved,\n summary_present=kb_state.summary_present,\n kb_baseline=kb_baseline,\n repo_root=root,\n )\n kb_state.status = kb_status\n kb_state.kb_baseline = kb_baseline\n\n # task-042: per-doc freshness (f007) -- additive; FF-A2 git_freshness_check retained\n doc_freshness = derive_doc_freshness(kb_dir=loc.kb_dir, repo_root=root)\n kb_state.doc_freshness = doc_freshness\n kb_state.suspect_count = sum(1 for d in doc_freshness if d.verdict == \"suspect\")\n\n # feature-007 (work-017 task-019): parse the project-level connectors registry\n # (.aid/connectors/*.md), sorted by stem. Missing dir -> [] (non-error).\n connectors, br = parse_connectors(loc.aid_dir / \"connectors\")\n bytes_read += br\n\n # feature-010 (work-017 task-021): parse the project-level external-sources\n # registry (.aid/knowledge/external-sources.md sources: list). A thin wrapper\n # over parse_doc_frontmatter (no new parser); absent file -> [] (non-error).\n external_sources = parse_external_sources(loc.kb_dir)\n\n repo_info = RepoInfo(\n project_name=project_name,\n aid_dir=str(loc.aid_dir),\n kb_state=kb_state,\n project_description=project_description,\n minimum_grade=minimum_grade,\n connectors=connectors,\n external_sources=external_sources,\n )\n\n # Step 4: ENUMERATE -- worktrees + work folders (work-004 Pillar 4 / SD-3)\n # enumerate_worktree_roots returns [(branch_label, aid_dir), ...] with the main\n # root always first. Degrades to main-root-only on any git failure (never throws).\n # For each (branch_label, aid_dir) root, locate_aid_root enumerates work-NNN-*/\n # dirs under that aid_dir. Each resulting WorkModel is tagged with branch_label.\n # Cross-root merge of same work_id is task-011; duplicates are fine here.\n worktree_roots = enumerate_worktree_roots(root)\n\n # Steps 5a-5g: PER WORK -- parse STATE.md; build WorkModel list (pre-reconcile).\n # After enumeration across all worktree roots, Step 6 reconciles same-work_id copies\n # (work-004 Pillar 5 / SD-2 / task-011).\n #\n # Intermediate accumulator: maps work_id -> [(WorkModel, state_text, state_label), ...]\n # so that every copy of a work that appears on multiple roots is gathered before\n # _reconcile_same_work() collapses them into one model.\n work_copies: dict[str, list[tuple[WorkModel, str, str]]] = {}\n\n for branch_label, wt_aid_dir in worktree_roots:\n # Resolve the worktree's repo root from the aid_dir (aid_dir is <root>/.aid)\n wt_root = wt_aid_dir.parent\n wt_loc = locate_aid_root(wt_root)\n\n if not wt_loc.aid_exists:\n # Worktree has no .aid/ -- skip (no work to enumerate there)\n continue\n\n for work_dir in wt_loc.work_dirs:\n work_id = work_dir.name\n work_model, work_warnings, work_bytes, state_text, state_label = _read_work(\n work_dir, work_id\n )\n # Tag the work model with the branch that owns this worktree\n work_model.branch_label = branch_label\n parse_warnings.extend(work_warnings)\n bytes_read += work_bytes\n if work_id not in work_copies:\n work_copies[work_id] = []\n work_copies[work_id].append((work_model, state_text, state_label))\n\n # If worktree enumeration yielded NO results (all worktrees had no .aid/), fall\n # back to the main root so a bare repo without worktrees still renders correctly.\n if not work_copies and loc.aid_exists:\n for work_dir in loc.work_dirs:\n work_id = work_dir.name\n work_model, work_warnings, work_bytes, state_text, state_label = _read_work(\n work_dir, work_id\n )\n work_model.branch_label = None # indeterminate; worktree list gave no data\n parse_warnings.extend(work_warnings)\n bytes_read += work_bytes\n work_copies[work_id] = [(work_model, state_text, state_label)]\n\n # Step 6: RECONCILE -- for each work_id, merge all copies (Pillar 5 / task-011).\n # Single-copy works pass through _reconcile_same_work unchanged (trivial case).\n # Outcome: one WorkModel per work_id, plus the state_text/label from the\n # Pipeline-State winner (newest Updated) for use by read_repo_detail.\n works: list[WorkModel] = []\n fallback_works: list[str] = []\n # DR-1/DD-3/NFR4: read_repo_detail reuses STATE.md text already read this pass;\n # cache key is bare work_id (the detail caller uses work_id, not branch_label/work_id).\n # After reconcile, the cache holds the text from the Pipeline-State winner\n # (newest Updated across all worktree copies of that work_id).\n # For a work that exists only on one root the cache trivially holds that root's text.\n state_text_cache: dict[str, tuple[str, str]] = {}\n\n for work_id, copies in work_copies.items():\n reconciled_wm, winning_text, winning_label = _reconcile_same_work(copies)\n works.append(reconciled_wm)\n state_text_cache[work_id] = (winning_text, winning_label)\n if reconciled_wm.source_mode != SourceMode.Normalized:\n fallback_works.append(work_id)\n\n # Step 7: ASSEMBLE\n repo_model = RepoModel(\n tool=tool_info,\n repo=repo_info,\n works=works,\n read=ReadMeta(\n read_at=read_at,\n work_count=len(works),\n fallback_works=fallback_works,\n parse_warnings=parse_warnings,\n bytes_read=bytes_read,\n ),\n )\n return repo_model, state_text_cache\n\n\ndef read_repo_detail(\n aid_root: Union[str, Path],\n detail_task_ids: Optional[list[str]] = None,\n) -> tuple[RepoModel, dict[str, TaskDetail]]:\n \"\"\"Read AID state and populate TaskDetail for each requested task_id.\n\n LC-TR (feature-008, task-069): the detail sub-parser runs ONLY when\n detail_task_ids is non-empty. The always-on read_repo() path is UNCHANGED.\n\n detail_task_ids: list of composite 'work_id/task_id' strings.\n None or empty -> identical to read_repo() (no TaskDetail).\n\n Returns (RepoModel, details) where:\n details: dict keyed 'work_id/task_id' -> TaskDetail, sorted ascending.\n Empty dict when detail_task_ids is None/empty (NFR4, DD-1).\n\n Read-only / no-LLM / no subprocess (NFR2/NFR7).\n Torn read -> parse_warnings + best-effort TaskDetail; never throws.\n \"\"\"\n # Step 1: run the full always-on read pass; get STATE.md cache as by-product.\n # DR-1/DD-3/NFR4: reuse bytes already read; zero extra disk I/O for raw_state.\n repo_model, state_text_cache = _read_repo_full(aid_root)\n\n # Step 2: if no detail requested, return immediately (NFR4, DD-1)\n if not detail_task_ids:\n return repo_model, {}\n\n # Step 3: LC-TR DETAIL-EXTEND -- only for requested task_ids\n root = Path(aid_root).resolve()\n if root.name == \".aid\":\n root = root.parent\n aid_dir = root / \".aid\"\n\n # Build an index of WorkModel by work_id for quick lookup\n work_index: dict[str, WorkModel] = {w.work_id: w for w in repo_model.works}\n\n details: dict[str, TaskDetail] = {}\n extra_warnings: list[str] = []\n\n for composite_key in detail_task_ids:\n # composite_key = 'work_id/task_id'\n if \"/\" not in composite_key:\n extra_warnings.append(\n f\"detail_task_ids: invalid key '{composite_key}' \"\n f\"(expected 'work_id/task_id'); skipping\"\n )\n continue\n\n slash_idx = composite_key.index(\"/\")\n work_id = composite_key[:slash_idx]\n task_id = composite_key[slash_idx + 1:]\n\n if not work_id or not task_id:\n extra_warnings.append(\n f\"detail_task_ids: empty work_id or task_id in '{composite_key}'; skipping\"\n )\n continue\n\n task_warnings: list[str] = []\n\n # DR-1: get STATE.md text from the always-on pass cache (no disk re-read).\n # If work_id was not enumerated in the always-on pass (detail for a non-enumerated\n # work), use empty text and add a warning -- never re-read from disk (NFR4).\n state_path_label: str = f\".aid/works/{work_id}/STATE.md\"\n if work_id in state_text_cache:\n state_text, state_path_label = state_text_cache[work_id]\n else:\n task_warnings.append(\n f\"{work_id}/{task_id}: work not found in always-on pass; \"\n f\"STATE.md unavailable; raw_state will be empty\"\n )\n state_text = \"\"\n\n raw_state = RawStateRef(\n text=state_text,\n byte_len=len(state_text.encode(\"utf-8\")),\n path=state_path_label,\n )\n\n # DR-2: parse ## Quick Check Findings -> ### task-NNN -> **Findings:** bullets\n findings = parse_quick_check_findings(state_text, task_id, task_warnings)\n\n # DR-3: resolve the task's delivery from the work model\n delivery_id: Optional[str] = None\n work_model = work_index.get(work_id)\n if work_model is not None:\n for task in work_model.tasks:\n if task.task_id.lower() == task_id.lower():\n if task.delivery is not None:\n delivery_id = f\"delivery-{task.delivery:03d}\"\n break\n\n # DR-3: parse ## Delivery Gates -> ### delivery-NNN for grade/tier/ts\n gate_grade: Optional[str] = None\n gate_reviewer_tier: Optional[str] = None\n gate_timestamp: Optional[str] = None\n if delivery_id is not None and state_text:\n gate_grade, gate_reviewer_tier, gate_timestamp = parse_delivery_gate(\n state_text, delivery_id, task_warnings\n )\n\n # DR-4: read .aid/works/{work}/delivery-NNN-issues.md; filter to Source task == task_id\n deferred_issues: list[DeferredIssue] = []\n if delivery_id is not None:\n issues_path = aid_dir / \"works\" / work_id / f\"{delivery_id}-issues.md\"\n deferred_issues = parse_deferred_issues(issues_path, task_id, task_warnings)\n\n ledger = TaskLedger(\n delivery_id=delivery_id,\n grade=gate_grade,\n reviewer_tier=gate_reviewer_tier,\n gate_timestamp=gate_timestamp,\n deferred_issues=deferred_issues,\n )\n\n # DR-5: stat .aid/.temp/dashboard.log + .aid/.heartbeat/\n logs = parse_log_availability(aid_dir)\n\n task_detail = TaskDetail(\n task_id=task_id,\n findings=findings,\n ledger=ledger,\n raw_state=raw_state,\n logs=logs,\n )\n\n details[composite_key] = task_detail\n extra_warnings.extend(task_warnings)\n\n # Append any LC-TR warnings to the model's parse_warnings (best-effort)\n if extra_warnings:\n repo_model.read.parse_warnings.extend(extra_warnings)\n\n # Sort details ascending by composite key (parity requirement, DM-2 key-order)\n sorted_details = dict(sorted(details.items()))\n\n return repo_model, sorted_details\n\n\ndef _task_stop_requested(work_dir: Path, work_id: str, task_id: str) -> bool:\n \"\"\"Derive TaskModel.stop_requested (feature-008-execution-control, work-017\n task-029): a filesystem `stat` of the cooperative stop-signal file\n `write-control-signal.sh` (task-028) creates on `task.stop` / removes on\n `task.resume`.\n\n Computed RELATIVE to work_dir -- the walked worktree copy of\n `.aid/works/<work_id>` this read pass is currently processing (WT-1) --\n NEVER a reconstructed `<served-root>/.aid/.control/<work_id>/` path.\n `work_dir/../../.control/<work_id>/` is the `.aid/.control/<work_id>/`\n sibling of work_dir's own `.aid/works/`, exactly mirroring\n `write-control-signal.sh`'s own path derivation (`dashboard/scripts/\n write-control-signal.sh`: `WORK_DIR/../../.control/WORK_ID`) so the reader\n stats the identical tree the writer and the `aid-execute` poll act on.\n\n Never parsed from / written to STATE.md (the control file is a new\n control-artifact class, outside STATE.md's C1 single-writer scope -- see\n feature-008 SPEC.md \"C1 scope note\"). Fail-safe: a missing `.control/`\n directory, a missing signal file, or any OSError all yield False -- never\n a parse_warning, never a raised exception (mirrors the reader's\n forward-compat posture for every other derived field).\n \"\"\"\n try:\n control_file = work_dir.parent.parent / \".control\" / work_id / f\"{task_id}.stop\"\n return control_file.is_file()\n except OSError:\n return False\n\n\ndef _read_work(\n work_dir: Path, work_id: str\n) -> \"tuple[WorkModel, list[str], int, str, str]\":\n \"\"\"Read and parse a single work folder's STATE.md.\n\n Steps 5a-5g of the Feature Flow, per-work.\n\n Pillar 6 (presence-based detection): if any deliveries/delivery-NNN/tasks/task-NNN/STATE.md\n exists, routes to _read_work_hierarchical. Otherwise, if the FLATTENED single-delivery\n layout is detected (feature-001: work-root BLUEPRINT.md + tasks/task-NNN/DETAIL.md\n directly under the work root, no deliveries/ wrapper), routes to _read_work_flat.\n Otherwise falls back to the legacy monolithic parse (reader.py:363-377 behavior\n preserved).\n\n Returns (WorkModel, parse_warnings, bytes_read, state_text, state_label).\n state_text is the decoded work-level STATE.md content (empty string on error/absent).\n state_label is the relative path label for raw_state (e.g. '.aid/works/work-001/STATE.md').\n Never raises: any exception yields a parse_warning + best-effort WorkModel.\n \"\"\"\n # Pillar 6: hierarchy detection (per-work, presence-based)\n if _detect_hierarchy(work_dir):\n return _read_work_hierarchical(work_dir, work_id)\n\n # feature-001: flattened single-delivery layout (per-work, presence-based;\n # mutually exclusive with the deliveries/ wrapper checked above)\n if _detect_flat(work_dir):\n return _read_work_flat(work_dir, work_id)\n\n # --- Legacy monolithic path (preserved behavior) ---\n state_path = work_dir / \"STATE.md\"\n state_label = f\".aid/works/{work_id}/STATE.md\"\n parse_warnings: list[str] = []\n bytes_read = 0\n\n # Step 5a: read STATE.md once into memory (single file read)\n if not state_path.is_file():\n parse_warnings.append(\n f\"{work_id}: STATE.md not found; returning minimal WorkModel.\"\n )\n return _minimal_work_model(work_id, parse_warnings), parse_warnings, 0, \"\", state_label\n\n try:\n raw = read_bytes_bounded(state_path)\n bytes_read = len(raw)\n text = raw.decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}: STATE.md read error ({exc}); returning minimal WorkModel.\"\n )\n return _minimal_work_model(work_id, parse_warnings), parse_warnings, 0, \"\", state_label\n\n # Steps 5b-5d: parse normalized block, tasks, pending Q&A (LC-2)\n # work_dir is passed for the LC-3 fallback IMPEDIMENT scan (KI-003).\n pw: ParsedWork = parse_state_md(text, work_id=work_id, work_dir=work_dir)\n parse_warnings.extend(pw.parse_warnings)\n\n # Extract display name from work_id slug (strip leading \"work-NNN-\")\n name = _slug_from_work_id(work_id)\n\n # Prototype: parse work number from folder prefix (work-NNN-...)\n work_number = _number_from_work_id(work_id)\n\n # Prototype: parse REQUIREMENTS.md for identity fields\n req_path = work_dir / \"REQUIREMENTS.md\"\n req_title, req_description, req_objective, req_bytes = parse_requirements_md(req_path)\n bytes_read += req_bytes\n\n # PF-8: SPEC.md fallback source for Lite-path works (no REQUIREMENTS.md)\n # Resolution order: REQUIREMENTS.md Name -> SPEC.md Name -> SPEC.md H1 -> de-slug\n # Resolution order: REQUIREMENTS.md Description -> SPEC.md Description -> null\n if req_title is None or req_description is None:\n spec_path = work_dir / \"SPEC.md\"\n spec_title, spec_description, spec_h1, spec_bytes = parse_spec_md(spec_path)\n bytes_read += spec_bytes\n if req_title is None:\n # Prefer SPEC.md Name over H1; de-slug is the final fallback (set by 'name')\n if spec_title is not None:\n req_title = spec_title\n elif spec_h1 is not None:\n req_title = spec_h1\n if req_description is None and spec_description is not None:\n req_description = spec_description\n\n # PF-5: parse PLAN.md execution graph to derive lane per task_id\n plan_path = work_dir / \"PLAN.md\"\n task_lane_map, plan_bytes = parse_execution_graph(plan_path)\n bytes_read += plan_bytes\n\n # PF-3 + PF-5c: enrich each TaskModel with short_name, delivery, lane\n tasks_dir = work_dir / \"tasks\"\n enriched_tasks = []\n import re as _re\n _re_delivery = _re.compile(r\"^delivery-(\\d+)$\", _re.IGNORECASE)\n for task in pw.tasks:\n # PF-5c: delivery from STATE Wave column (\"delivery-NNN\")\n delivery: Optional[int] = None\n if task.wave:\n dm = _re_delivery.match(task.wave.strip())\n if dm:\n delivery = int(dm.group(1))\n\n # PF-5a/5b: lane from PLAN.md wave-map / prose\n lane: Optional[int] = task_lane_map.get(task.task_id.lower())\n\n # PF-3: short_name from tasks/task-NNN.md first line\n short_name: Optional[str] = None\n task_file = tasks_dir / f\"{task.task_id}.md\"\n if task_file.is_file():\n sn, sn_bytes = parse_task_short_name(task_file)\n bytes_read += sn_bytes\n short_name = sn\n\n enriched_tasks.append(TaskModel(\n task_id=task.task_id,\n type=task.type,\n wave=task.wave,\n status=task.status,\n review_grade=task.review_grade,\n elapsed=task.elapsed,\n notes=task.notes,\n short_name=short_name,\n delivery=delivery,\n lane=lane,\n stop_requested=_task_stop_requested(work_dir, work_id, task.task_id),\n ))\n\n # Step 5e: set source_mode; step 5f: lifecycle already set by parsers\n # Step 5g: set updated, pause/block fields\n work_model = WorkModel(\n work_id=work_id,\n name=name,\n lifecycle=pw.lifecycle,\n phase=pw.phase,\n active_skill=pw.active_skill,\n updated=pw.updated,\n created=pw.created,\n pause_reason=pw.pause_reason,\n block_reason=pw.block_reason,\n block_artifact=pw.block_artifact,\n tasks=enriched_tasks,\n pending_inputs=pw.pending_inputs,\n source_mode=pw.source_mode,\n number=work_number,\n title=req_title,\n description=req_description,\n objective=req_objective,\n work_path=pw.work_path,\n recipe=pw.recipe,\n features=pw.features,\n deliverables=pw.deliverables,\n kind=pw.kind,\n started=pw.started,\n minimum_grade=pw.minimum_grade,\n user_approved=pw.user_approved,\n )\n\n return work_model, parse_warnings, bytes_read, text, state_label\n\n\n# ---------------------------------------------------------------------------\n# Hierarchy detection + hierarchical work model assembly (work-004 Pillar 6)\n# ---------------------------------------------------------------------------\n\n_RE_DELIVERY_DIR = _re_mod.compile(r\"^delivery-(\\d+)$\", _re_mod.IGNORECASE)\n_RE_TASK_DIR = _re_mod.compile(r\"^(task-\\d+)$\", _re_mod.IGNORECASE)\n\n\ndef _detect_hierarchy(work_dir: Path) -> bool:\n \"\"\"Return True if this work has the new per-unit STATE.md hierarchy.\n\n Detection rule (Pillar 6): if ANY deliveries/delivery-NNN/tasks/task-NNN/STATE.md\n file exists under work_dir, the work is hierarchical. Otherwise fall back to\n monolithic parse.\n\n Presence-based, per-work: a repo with mixed-vintage works renders all of them.\n Never throws.\n \"\"\"\n try:\n deliveries_dir = work_dir / \"deliveries\"\n for entry in deliveries_dir.iterdir():\n if not (entry.is_dir() and _RE_DELIVERY_DIR.match(entry.name)):\n continue\n tasks_dir = entry / \"tasks\"\n if not tasks_dir.is_dir():\n continue\n try:\n for task_entry in tasks_dir.iterdir():\n if task_entry.is_dir() and _RE_TASK_DIR.match(task_entry.name):\n task_state = task_entry / \"STATE.md\"\n if task_state.is_file():\n return True\n except OSError:\n continue\n except OSError:\n pass\n return False\n\n\ndef _detect_flat(work_dir: Path) -> bool:\n \"\"\"Return True if this work has the FLATTENED single-delivery layout (feature-001).\n\n Detection rule (3-part; identical across all consumers): a work-root\n BLUEPRINT.md exists AND at least one tasks/task-NNN/DETAIL.md exists\n directly under the work root AND no `deliveries/` wrapper exists under the\n work root. This layout has no per-task STATE.md (task cells live in the\n work-root STATE.md `### Tasks lifecycle` table instead); this check does\n not look for one.\n\n Mirrors the SAME 3-part detection rule as `is_flat_layout()` in\n `canonical/aid/scripts/execute/writeback-state.sh` and reader.mjs's\n `_detectFlat` (lockstep Node twin).\n\n Presence-based, per-work. Mutually exclusive with _detect_hierarchy by\n construction (this function explicitly asserts `deliveries/` absence,\n not just call-site ordering). Never throws.\n \"\"\"\n try:\n if not (work_dir / \"BLUEPRINT.md\").is_file():\n return False\n if (work_dir / \"deliveries\").is_dir():\n return False\n tasks_dir = work_dir / \"tasks\"\n if not tasks_dir.is_dir():\n return False\n for entry in tasks_dir.iterdir():\n if entry.is_dir() and _RE_TASK_DIR.match(entry.name):\n if (entry / \"DETAIL.md\").is_file():\n return True\n except OSError:\n pass\n return False\n\n\ndef _read_work_flat(\n work_dir: Path,\n work_id: str,\n) -> \"tuple[WorkModel, list[str], int, str, str]\":\n \"\"\"Assemble a WorkModel from the FLATTENED single-delivery layout (feature-001).\n\n Reads:\n - work_dir/STATE.md -- work-level lifecycle/triage/history, PLUS\n the promoted `## Delivery Lifecycle`\n (`### Tasks lifecycle`) / `## Delivery Gate`\n AUTHORED blocks (single writer; no\n deliveries/ wrapper to disambiguate)\n - work_dir/tasks/task-NNN/DETAIL.md -- task type / short-name (no per-task\n STATE.md -- mutable cells come from the\n work STATE.md `### Tasks lifecycle` table)\n - work_dir/BLUEPRINT.md -- the single delivery's title (for the\n synthesized DeliverableRef name)\n\n Synthesizes exactly ONE DeliverableRef for delivery-001 (every TaskModel gets\n wave=\"delivery-001\", delivery=1) -- there is no deliveries/ wrapper to enumerate.\n\n pending_inputs is taken from pw.pending_inputs ONLY (the single parse_state_md\n call above already captures the work's one shared `## Cross-phase Q&A` section).\n parse_delivery_state_md's own Cross-phase Q&A scan is intentionally NOT unioned\n here -- unlike the hierarchical path (where delivery Q&A lives in a distinct\n per-delivery STATE.md), the flat layout has no separate delivery-level Q&A\n section, so unioning pds.pending_inputs would double-count the same entries.\n\n Returns (WorkModel, parse_warnings, bytes_read, state_text, state_label).\n state_text is the work-level STATE.md text (for raw_state reuse by read_repo_detail).\n Never raises.\n \"\"\"\n state_path = work_dir / \"STATE.md\"\n state_label = f\".aid/works/{work_id}/STATE.md\"\n parse_warnings: list[str] = []\n bytes_read = 0\n\n if not state_path.is_file():\n parse_warnings.append(\n f\"{work_id}: STATE.md not found (flat mode); \"\n f\"work-level lifecycle will be Unknown.\"\n )\n work_text = \"\"\n else:\n try:\n raw = read_bytes_bounded(state_path)\n bytes_read += len(raw)\n work_text = raw.decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}: STATE.md read error ({exc}); \"\n f\"work-level lifecycle will be Unknown.\"\n )\n work_text = \"\"\n\n # Parse work-level STATE.md for pipeline/lifecycle/triage fields + Cross-phase Q&A\n pw: ParsedWork = parse_state_md(work_text, work_id=work_id, work_dir=work_dir)\n parse_warnings.extend(pw.parse_warnings)\n\n name = _slug_from_work_id(work_id)\n work_number = _number_from_work_id(work_id)\n\n # Identity fields: REQUIREMENTS.md -> SPEC.md fallback (PF-8, unchanged)\n req_path = work_dir / \"REQUIREMENTS.md\"\n req_title, req_description, req_objective, req_bytes = parse_requirements_md(req_path)\n bytes_read += req_bytes\n\n if req_title is None or req_description is None:\n spec_path = work_dir / \"SPEC.md\"\n spec_title, spec_description, spec_h1, spec_bytes = parse_spec_md(spec_path)\n bytes_read += spec_bytes\n if req_title is None:\n if spec_title is not None:\n req_title = spec_title\n elif spec_h1 is not None:\n req_title = spec_h1\n if req_description is None and spec_description is not None:\n req_description = spec_description\n\n # PF-5: parse PLAN.md execution graph for lane assignments. The flat PLAN.md's\n # top-level ## Execution Graph carries no wave-map fence / \"### delivery-NNN\n # Execution Graph\" prose header, so this yields an empty map -- lane stays None\n # for every task (harmless; no lane derivation is defined for the flat shape).\n plan_path = work_dir / \"PLAN.md\"\n task_lane_map, plan_bytes = parse_execution_graph(plan_path)\n bytes_read += plan_bytes\n\n # Parse the promoted ## Delivery Lifecycle / ## Delivery Gate blocks from the\n # SAME work-root STATE.md text via the existing parse_delivery_state_md -- it\n # keys on the exact headings regardless of which file they live in. Only\n # pds.delivery_state is used (see docstring for why pds.pending_inputs is not).\n pds = parse_delivery_state_md(work_text, delivery_id=\"delivery-001\")\n parse_warnings.extend(pds.parse_warnings)\n\n # Parse the promoted ### Tasks lifecycle table (replaces per-task STATE.md)\n tasks_lifecycle, tl_warnings = parse_tasks_lifecycle_md(work_text)\n parse_warnings.extend(tl_warnings)\n\n # -----------------------------------------------------------------------\n # Enumerate tasks/task-NNN/ directly under the work root (no deliveries/\n # wrapper -- the flat layout's single delivery is implicit/synthesized)\n # -----------------------------------------------------------------------\n all_tasks: list[TaskModel] = []\n tasks_dir = work_dir / \"tasks\"\n\n try:\n task_dirs = sorted(\n [\n t for t in tasks_dir.iterdir()\n if t.is_dir() and _RE_TASK_DIR.match(t.name)\n ]\n if tasks_dir.is_dir() else [],\n key=lambda t: t.name,\n )\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}: could not enumerate flat task dirs ({exc}); tasks will be empty.\"\n )\n task_dirs = []\n\n for task_dir in task_dirs:\n task_id_str = task_dir.name\n\n # Read task DETAIL.md for short_name and type (no per-task STATE.md here)\n task_detail_path = task_dir / \"DETAIL.md\"\n short_name: Optional[str] = None\n task_type: str = \"\"\n if task_detail_path.is_file():\n try:\n raw = read_bytes_bounded(task_detail_path)\n bytes_read += len(raw)\n detail_text = raw.decode(\"utf-8\", errors=\"replace\")\n short_name = _parse_task_spec_short_name(detail_text)\n task_type = _parse_task_spec_type(detail_text)\n except OSError:\n pass\n\n # Mutable cells from the work-root STATE.md ### Tasks lifecycle table\n pts = tasks_lifecycle.get(task_id_str.lower())\n if pts is None:\n pts = ParsedTaskState()\n\n lane: Optional[int] = task_lane_map.get(task_id_str.lower())\n\n all_tasks.append(TaskModel(\n task_id=task_id_str,\n type=task_type,\n wave=\"delivery-001\",\n status=pts.state,\n review_grade=pts.review,\n elapsed=pts.elapsed,\n notes=pts.notes,\n short_name=short_name,\n delivery=1,\n lane=lane,\n display_name=pts.display_name,\n stop_requested=_task_stop_requested(work_dir, work_id, task_id_str),\n ))\n\n # ---- Synthesize the single DeliverableRef for delivery-001 ----\n blueprint_path = work_dir / \"BLUEPRINT.md\"\n delivery_name = \"delivery-001\"\n if blueprint_path.is_file():\n try:\n raw = read_bytes_bounded(blueprint_path)\n bytes_read += len(raw)\n bp_text = raw.decode(\"utf-8\", errors=\"replace\")\n bp_name = _parse_delivery_spec_title(bp_text)\n if bp_name:\n delivery_name = bp_name\n except OSError:\n pass\n\n deliverables = [DeliverableRef(\n number=1,\n name=delivery_name,\n task_count=len(all_tasks),\n delivery_state=pds.delivery_state,\n )]\n\n work_model = WorkModel(\n work_id=work_id,\n name=name,\n lifecycle=pw.lifecycle,\n phase=pw.phase,\n active_skill=pw.active_skill,\n updated=pw.updated,\n created=pw.created,\n pause_reason=pw.pause_reason,\n block_reason=pw.block_reason,\n block_artifact=pw.block_artifact,\n tasks=all_tasks,\n pending_inputs=pw.pending_inputs,\n source_mode=pw.source_mode,\n number=work_number,\n title=req_title,\n description=req_description,\n objective=req_objective,\n work_path=pw.work_path or \"lite\",\n recipe=pw.recipe,\n features=pw.features,\n deliverables=deliverables,\n kind=pw.kind,\n started=pw.started,\n minimum_grade=pw.minimum_grade,\n user_approved=pw.user_approved,\n )\n\n return work_model, parse_warnings, bytes_read, work_text, state_label\n\n\ndef _read_work_hierarchical(\n work_dir: Path,\n work_id: str,\n) -> \"tuple[WorkModel, list[str], int, str, str]\":\n \"\"\"Assemble a WorkModel from the per-unit STATE.md hierarchy.\n\n Reads:\n - work_dir/STATE.md -- work-level lifecycle/triage/history\n - work_dir/deliveries/delivery-NNN/STATE.md -- delivery lifecycle (SD-8) + gate + Q&A\n - work_dir/deliveries/delivery-NNN/tasks/task-NNN/STATE.md -- per-task mutable cells\n - work_dir/deliveries/delivery-NNN/BLUEPRINT.md -- task listing (for short_name / type)\n - work_dir/deliveries/delivery-NNN/tasks/task-NNN/DETAIL.md -- task short name\n\n Union views assembled:\n - tasks[]: one TaskModel per task, state from per-task STATE.md\n - deliverables[]: one DeliverableRef per delivery, delivery_state from delivery STATE.md\n - pending_inputs: union of all delivery Cross-phase Q&A (Pending entries)\n\n Work-level lifecycle (Pipeline State/Status block) is read from work STATE.md if present;\n otherwise legacy fallback fires (same as monolithic path).\n\n Returns (WorkModel, parse_warnings, bytes_read, state_text, state_label).\n state_text is the work-level STATE.md text (for raw_state reuse by read_repo_detail).\n Never raises.\n \"\"\"\n state_path = work_dir / \"STATE.md\"\n state_label = f\".aid/works/{work_id}/STATE.md\"\n parse_warnings: list[str] = []\n bytes_read = 0\n\n # Read work-level STATE.md (for Pipeline State / Lifecycle History / Triage)\n if not state_path.is_file():\n parse_warnings.append(\n f\"{work_id}: STATE.md not found (hierarchical mode); \"\n f\"work-level lifecycle will be Unknown.\"\n )\n work_text = \"\"\n else:\n try:\n raw = read_bytes_bounded(state_path)\n bytes_read += len(raw)\n work_text = raw.decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}: STATE.md read error ({exc}); \"\n f\"work-level lifecycle will be Unknown.\"\n )\n work_text = \"\"\n\n # Parse work-level STATE.md for pipeline/lifecycle/triage fields\n # (reuses the existing parse_state_md -- the tasks[] from this parse are IGNORED\n # in hierarchical mode; the per-unit task STATE.md files are authoritative)\n pw: ParsedWork = parse_state_md(work_text, work_id=work_id, work_dir=work_dir)\n parse_warnings.extend(pw.parse_warnings)\n\n # Extract display name and number from work_id\n name = _slug_from_work_id(work_id)\n work_number = _number_from_work_id(work_id)\n\n # Parse REQUIREMENTS.md / SPEC.md for identity fields (same as monolithic path)\n req_path = work_dir / \"REQUIREMENTS.md\"\n req_title, req_description, req_objective, req_bytes = parse_requirements_md(req_path)\n bytes_read += req_bytes\n\n if req_title is None or req_description is None:\n spec_path = work_dir / \"SPEC.md\"\n spec_title, spec_description, spec_h1, spec_bytes = parse_spec_md(spec_path)\n bytes_read += spec_bytes\n if req_title is None:\n if spec_title is not None:\n req_title = spec_title\n elif spec_h1 is not None:\n req_title = spec_h1\n if req_description is None and spec_description is not None:\n req_description = spec_description\n\n # Parse PLAN.md for lane assignments\n plan_path = work_dir / \"PLAN.md\"\n task_lane_map, plan_bytes = parse_execution_graph(plan_path)\n bytes_read += plan_bytes\n\n # -----------------------------------------------------------------------\n # Enumerate deliveries and their tasks from the hierarchy\n # -----------------------------------------------------------------------\n all_tasks: list[TaskModel] = []\n all_deliverables: list[DeliverableRef] = []\n all_pending_inputs: list[PendingInput] = []\n\n # Enumerate deliveries/delivery-NNN/ subdirectories\n try:\n deliveries_dir = work_dir / \"deliveries\"\n delivery_dirs = sorted(\n [\n d for d in deliveries_dir.iterdir()\n if d.is_dir() and _RE_DELIVERY_DIR.match(d.name)\n ],\n key=lambda d: d.name,\n )\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}: could not enumerate delivery dirs ({exc}); \"\n f\"tasks will be empty.\"\n )\n delivery_dirs = []\n\n for delivery_dir in delivery_dirs:\n delivery_id = delivery_dir.name\n # Parse delivery number from \"delivery-NNN\"\n dm = _RE_DELIVERY_DIR.match(delivery_id)\n delivery_number = int(dm.group(1)) if dm else 0\n\n # ---- Read delivery-level STATE.md ----\n delivery_state_path = delivery_dir / \"STATE.md\"\n delivery_state_text = \"\"\n if delivery_state_path.is_file():\n try:\n raw = read_bytes_bounded(delivery_state_path)\n bytes_read += len(raw)\n delivery_state_text = raw.decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}/{delivery_id}: STATE.md read error ({exc}); \"\n f\"delivery lifecycle will be unknown.\"\n )\n\n pds = parse_delivery_state_md(delivery_state_text, delivery_id=delivery_id)\n parse_warnings.extend(pds.parse_warnings)\n\n # Accumulate pending Q&A from this delivery's Cross-phase Q&A\n all_pending_inputs.extend(pds.pending_inputs)\n\n # ---- Enumerate tasks under this delivery ----\n tasks_dir = delivery_dir / \"tasks\"\n delivery_task_count = 0\n\n try:\n task_dirs = sorted(\n [\n t for t in tasks_dir.iterdir()\n if t.is_dir() and _RE_TASK_DIR.match(t.name)\n ]\n if tasks_dir.is_dir() else [],\n key=lambda t: t.name,\n )\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}/{delivery_id}: could not enumerate task dirs ({exc}).\"\n )\n task_dirs = []\n\n for task_dir in task_dirs:\n task_id_str = task_dir.name\n delivery_task_count += 1\n\n # Read task-level STATE.md\n task_state_path = task_dir / \"STATE.md\"\n task_state_text = \"\"\n if task_state_path.is_file():\n try:\n raw = read_bytes_bounded(task_state_path)\n bytes_read += len(raw)\n task_state_text = raw.decode(\"utf-8\", errors=\"replace\")\n except OSError as exc:\n parse_warnings.append(\n f\"{work_id}/{delivery_id}/{task_id_str}: STATE.md read error \"\n f\"({exc}); task state will be Unknown.\"\n )\n\n pts = parse_task_state_md(task_state_text, task_id=task_id_str)\n parse_warnings.extend(pts.parse_warnings)\n\n # Read task DETAIL.md for short_name and type\n task_spec_path = task_dir / \"DETAIL.md\"\n short_name: Optional[str] = None\n task_type: str = \"\"\n if task_spec_path.is_file():\n try:\n raw = read_bytes_bounded(task_spec_path)\n bytes_read += len(raw)\n spec_text = raw.decode(\"utf-8\", errors=\"replace\")\n short_name = _parse_task_spec_short_name(spec_text)\n task_type = _parse_task_spec_type(spec_text)\n except OSError:\n pass\n\n # Lane from PLAN.md wave-map\n lane: Optional[int] = task_lane_map.get(task_id_str.lower())\n\n all_tasks.append(TaskModel(\n task_id=task_id_str,\n type=task_type,\n wave=delivery_id, # wave = delivery-NNN in hierarchical works\n status=pts.state,\n review_grade=pts.review,\n elapsed=pts.elapsed,\n notes=pts.notes,\n short_name=short_name,\n delivery=delivery_number,\n lane=lane,\n display_name=pts.display_name,\n stop_requested=_task_stop_requested(work_dir, work_id, task_id_str),\n ))\n\n # ---- Build DeliverableRef for this delivery ----\n # Use the delivery BLUEPRINT.md title as the name, falling back to delivery_id\n delivery_spec_path = delivery_dir / \"BLUEPRINT.md\"\n delivery_name = delivery_id\n if delivery_spec_path.is_file():\n try:\n raw = read_bytes_bounded(delivery_spec_path)\n bytes_read += len(raw)\n spec_text = raw.decode(\"utf-8\", errors=\"replace\")\n spec_name = _parse_delivery_spec_title(spec_text)\n if spec_name:\n delivery_name = spec_name\n except OSError:\n pass\n\n all_deliverables.append(DeliverableRef(\n number=delivery_number,\n name=delivery_name,\n task_count=delivery_task_count,\n delivery_state=pds.delivery_state,\n ))\n\n # -----------------------------------------------------------------------\n # Work-level pending_inputs: union of work-level Q&A + per-delivery Q&A\n # (work-level Q&A is already in pw.pending_inputs from parse_state_md)\n # -----------------------------------------------------------------------\n union_pending_inputs = list(pw.pending_inputs) + all_pending_inputs\n\n # Assemble WorkModel (hierarchical path)\n work_model = WorkModel(\n work_id=work_id,\n name=name,\n lifecycle=pw.lifecycle,\n phase=pw.phase,\n active_skill=pw.active_skill,\n updated=pw.updated,\n created=pw.created,\n pause_reason=pw.pause_reason,\n block_reason=pw.block_reason,\n block_artifact=pw.block_artifact,\n tasks=all_tasks,\n pending_inputs=union_pending_inputs,\n source_mode=pw.source_mode,\n number=work_number,\n title=req_title,\n description=req_description,\n objective=req_objective,\n # work_path: frontmatter `pipeline.path` first; else \"full\" -- the\n # hierarchical deliveries/ wrapper only exists for full multi-delivery\n # works, so layout detection is a sound fallback default here (symmetric\n # with _read_work_flat's `pw.work_path or \"lite\"` fallback below).\n work_path=pw.work_path or \"full\",\n recipe=pw.recipe,\n features=pw.features,\n deliverables=all_deliverables,\n kind=pw.kind,\n started=pw.started,\n minimum_grade=pw.minimum_grade,\n user_approved=pw.user_approved,\n )\n\n return work_model, parse_warnings, bytes_read, work_text, state_label\n\n\ndef _parse_task_spec_short_name(spec_text: str) -> Optional[str]:\n \"\"\"Extract the short name from a task-level DETAIL.md.\n\n Reads the H1 heading: '# task-NNN: {Title}'\n Returns the title portion, or None if absent.\n Never raises.\n \"\"\"\n try:\n import re\n _re = re.compile(r\"^#\\s+task-\\d+\\s*:\\s*(.+)$\", re.IGNORECASE)\n for line in spec_text.splitlines():\n stripped = line.strip()\n if not stripped:\n continue\n m = _re.match(stripped)\n if m:\n title = m.group(1).strip().rstrip(\".\")\n return title if title else None\n break # first non-blank line didn't match\n except Exception: # noqa: BLE001\n pass\n return None\n\n\ndef _parse_task_spec_type(spec_text: str) -> str:\n \"\"\"Extract the task type from a task-level DETAIL.md.\n\n Reads '**Type:** VALUE' line.\n Returns the type string (e.g. 'IMPLEMENT'), or '' if absent.\n Never raises.\n \"\"\"\n try:\n import re\n _re = re.compile(r\"^\\*\\*Type:\\*\\*\\s*(.+)\", re.IGNORECASE)\n for line in spec_text.splitlines():\n m = _re.match(line.strip())\n if m:\n return m.group(1).strip().split()[0] # first word only\n except Exception: # noqa: BLE001\n pass\n return \"\"\n\n\ndef _parse_delivery_spec_title(spec_text: str) -> Optional[str]:\n \"\"\"Extract the delivery title from a delivery-level BLUEPRINT.md.\n\n Reads the H1 heading: '# Delivery BLUEPRINT -- delivery-NNN: {Title}'\n or a shorter form: '# delivery-NNN: {Title}'\n Returns the title portion, or None if absent / no title after colon.\n Never raises.\n \"\"\"\n try:\n import re\n for line in spec_text.splitlines():\n stripped = line.strip()\n if not stripped.startswith(\"#\"):\n continue\n # Match 'Delivery BLUEPRINT -- delivery-NNN: Title' or '# delivery-NNN: Title'\n m = re.match(r\"^#+\\s+.*delivery-\\d+\\s*:\\s*(.+)$\", stripped, re.IGNORECASE)\n if m:\n title = m.group(1).strip()\n # Reject template placeholders\n if title and not title.startswith(\"{\"):\n return title\n break # only check the first heading line\n except Exception: # noqa: BLE001\n pass\n return None\n\n\ndef _minimal_work_model(work_id: str, _warnings: list[str]) -> WorkModel:\n \"\"\"Return a minimal WorkModel for a work folder with no parseable STATE.md.\"\"\"\n return WorkModel(\n work_id=work_id,\n name=_slug_from_work_id(work_id),\n lifecycle=Lifecycle.Unknown,\n source_mode=SourceMode.Fallback,\n number=_number_from_work_id(work_id),\n title=None,\n description=None,\n objective=None,\n created=None,\n work_path=None,\n recipe=None,\n features=[],\n deliverables=[],\n )\n\n\ndef _slug_from_work_id(work_id: str) -> str:\n \"\"\"Extract the display slug from a work_id like 'work-001-aid-dashboard'.\n\n Returns 'aid-dashboard' (strips leading 'work-NNN-').\n Falls back to the full work_id if it doesn't match the expected pattern.\n \"\"\"\n import re\n m = re.match(r\"^work-\\d+-(.+)$\", work_id)\n return m.group(1) if m else work_id\n\n\ndef _number_from_work_id(work_id: str) -> Optional[int]:\n \"\"\"Extract the integer prefix from a work_id like 'work-001-slug'.\n\n Returns 1 for 'work-001-...', None if the pattern does not match.\n \"\"\"\n import re\n m = re.match(r\"^work-(\\d+)-\", work_id)\n if m:\n try:\n return int(m.group(1))\n except ValueError:\n pass\n return None\n"}}, "dashboard/reader/models.py": {"170b87d9ff4a2a90563d54430369c7df86dbda10": {"t": 1785846918.9849463, "n": 27605, "h": "bcb84c3b7028d9f4", "c": "# dashboard/reader/models.py\n# Normalized in-memory display model for the AID state reader (feature-002).\n#\n# These types are the single source of truth for the reader's output shape.\n# They mirror the enum vocabularies declared in feature-001's work-state-template.md (DM-6).\n#\n# Python 3.11+ stdlib only. Read-only data records; no persistence, no I/O.\n\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass, field\nfrom enum import Enum\nfrom typing import Optional\n\n\n# ---------------------------------------------------------------------------\n# DM-6: Enum definitions\n# Single source of truth = feature-001 (canonical/aid/templates/work-state-template.md).\n# Members reproduced here verbatim so the reader's switch is total.\n# The reader adds one reader-only sentinel per enum (Unknown) per DM-6 contract.\n# ---------------------------------------------------------------------------\n\nclass Lifecycle(str, Enum):\n \"\"\"FR16 lifecycle states for a work folder.\n\n Members from feature-001 work-state-template.md:\n Running | Paused-Awaiting-Input | Blocked | Completed | Canceled\n Reader-only:\n Unknown -- returned when the normalized block is absent and no fallback fires.\n Never written to disk. See task-011 for the full fallback adapter.\n \"\"\"\n Running = \"Running\"\n PausedAwaitingInput = \"Paused-Awaiting-Input\"\n Blocked = \"Blocked\"\n Completed = \"Completed\"\n Canceled = \"Canceled\"\n Unknown = \"Unknown\" # reader-only sentinel; never written to disk\n\n\nclass Phase(str, Enum):\n \"\"\"Pipeline phase enum -- the faithful 6-phase pipeline (work-003-state-schema task-010).\n\n Members from feature-001 work-state-template.md:\n Describe | Define | Specify | Plan | Detail | Execute\n NOT a member here: Discover. `aid-discover` is KB-level -- it writes the KB\n discovery-area STATE (`kb_status`), never a work `phase:` -- so Discover is\n surfaced in the dashboard stepper from `KbStateRef.status` (KbStatus), not from\n this enum. NOT a member either: Deploy. The numbered sequence ends at Execute;\n `/aid-deploy` is a separate, independently-invoked path (no longer a work\n `phase:`) -- a deploying work stays at phase Execute and its progress lives in\n `## Deploy State`.\n Reader-only:\n Unknown -- for an unrecognized Phase literal.\n \"\"\"\n Describe = \"Describe\"\n Define = \"Define\"\n Specify = \"Specify\"\n Plan = \"Plan\"\n Detail = \"Detail\"\n Execute = \"Execute\"\n Unknown = \"Unknown\" # reader-only sentinel; never written to disk\n\n\nclass TaskStatus(str, Enum):\n \"\"\"Per-task status enum.\n\n Members from feature-001 work-state-template.md (closed; single source of truth):\n Pending | In Progress | In Review | Blocked | Done | Failed | Canceled\n Reader-only:\n Unknown -- for a row whose Status string matches no enum member (NFR7: never throws).\n Never written to disk.\n \"\"\"\n Pending = \"Pending\"\n InProgress = \"In Progress\"\n InReview = \"In Review\"\n Blocked = \"Blocked\"\n Done = \"Done\"\n Failed = \"Failed\"\n Canceled = \"Canceled\"\n Unknown = \"Unknown\" # reader-only sentinel; never written to disk\n\n\nclass SourceMode(str, Enum):\n \"\"\"Records which derivation path produced WorkModel.lifecycle.\n\n Extended (work-003-state-schema task-002) onto the KB path: KbStateRef.source_mode\n uses the same two values to record whether summary_approved/last_summary_date came\n from the frontmatter `summary_approved` scalar (Normalized) or the legacy\n `## Knowledge Summary Status` bold-line prose / nothing present (Fallback).\n \"\"\"\n Normalized = \"normalized\" # ## Pipeline Status block OR STATE frontmatter was present\n Fallback = \"fallback\" # legacy derivation (task-011)\n Mixed = \"mixed\" # rare: partial migration (task-011)\n\n\n# ---------------------------------------------------------------------------\n# DM-2: Level 0 -- ToolInfo\n# ---------------------------------------------------------------------------\n\n@dataclass\nclass ToolInfo:\n \"\"\"Level-0 tool / installation metadata parsed from .aid/.aid-manifest.json.\n\n manifest_present=False means the manifest file was absent; fields are then None.\n Never errors on absent manifest -- render 'tool info unavailable'.\n \"\"\"\n manifest_present: bool\n aid_version: Optional[str] = None\n installed_at: Optional[str] = None\n tools_installed: list[str] = field(default_factory=list)\n\n\n# ---------------------------------------------------------------------------\n# DM-3: Level 1 -- RepoInfo + KbStateRef\n# ---------------------------------------------------------------------------\n\nclass KbStatus(str, Enum):\n \"\"\"FR32 5-state KB status enum (feature-007 DM-A2).\n\n Derived by the reader (FF-A3 waterfall); never written to disk (NFR2).\n Members:\n pending -- .aid/knowledge/ absent or empty\n generating -- KB present but not yet User Approved: yes (safe default, SPEC residual-#1)\n preparing -- KB approved but kb.html absent OR summary not yet V1-approved\n approved -- KB + kb.html ready, current, approved\n outdated -- approved but default branch has advanced past kb_baseline (FR35)\n Reader-only:\n unknown -- un-derivable combination falls back to pending treatment; never thrown\n \"\"\"\n pending = \"pending\"\n generating = \"generating\"\n preparing = \"preparing\"\n approved = \"approved\"\n outdated = \"outdated\"\n unknown = \"unknown\" # reader-only sentinel; never written to disk\n\n\n@dataclass\nclass KbBaseline:\n \"\"\"Parsed projection of .aid/settings.yml kb_baseline block (DM-A4).\n\n branch: the default branch the KB reflects (e.g. 'master'); None if absent\n tip_date: ISO-8601 commit date of that branch's tip at KB generation time; None if absent\n \"\"\"\n branch: Optional[str] = None\n tip_date: Optional[str] = None\n\n\n@dataclass\nclass DocFreshness:\n \"\"\"Per-doc freshness verdict (feature-007 f007, task-042).\n\n Populated by derive_doc_freshness() in derivation.py; never persisted (NFR2).\n\n doc: doc relative path under .aid/knowledge/ (e.g. \"architecture.md\")\n verdict: \"current\" | \"suspect\" | \"unknown\"\n suspect_sources: list of sources: entries that drifted (empty unless verdict==\"suspect\")\n \"\"\"\n doc: str\n verdict: str # \"current\" | \"suspect\" | \"unknown\"\n suspect_sources: list[str] = field(default_factory=list)\n\n\n@dataclass\nclass KbStateRef:\n \"\"\"KB state reference (feature-007 DM-A1 extended KbStateRef).\n\n Populated from .aid/knowledge/STATE.md + .aid/knowledge/README.md + derivation.\n Absent KB (.aid/knowledge/ missing) -> null in RepoInfo.\n\n Retained fields (feature-002 DM-3):\n summary_approved -- from STATE.md ## Knowledge Summary Status **User Approved:** yes/no\n last_summary_date -- parenthesized date on that line\n doc_count -- rows in README.md ## Completeness table\n\n New fields (feature-007 DM-A1, task-064):\n status -- FR32 5-state KbStatus (derived, never persisted; NFR2)\n summary_present -- True if <repo>/.aid/knowledge/kb.html exists (stat only)\n kb_baseline -- {branch, tip_date} from .aid/settings.yml; None if unset/unparseable\n\n New fields (feature-007 f007, task-042):\n doc_freshness -- per-doc freshness list [{doc, verdict, suspect_sources}, ...]\n suspect_count -- count of docs with verdict==\"suspect\" (badge rollup)\n\n New fields (work-003-state-schema task-002, dual-format frontmatter read):\n source_mode -- which path produced summary_approved/last_summary_date:\n Normalized (frontmatter `summary_approved` key present) or\n Fallback (legacy `## Knowledge Summary Status` bold-line\n prose, or nothing present). Extends the per-work SourceMode\n machinery onto the KB path (it was work-only before this task).\n kb_status -- discovery-workflow status (frontmatter `kb_status` or legacy\n header blockquote `**Status:**`); a raw authored string\n (Initial | In Progress | Approved), distinct from the derived\n 5-state `status` field above (never an enum here -- no reader\n derivation involved).\n kb_grade -- discovery current grade (frontmatter `kb_grade` or legacy\n header blockquote `**Current Grade:**`); e.g. \"A\" or \"Pending\".\n last_kb_review -- discovery last-review date (frontmatter `last_kb_review` or\n legacy header blockquote `**Last KB Review:**`); ISO date or None.\n \"\"\"\n summary_approved: bool\n last_summary_date: Optional[str] = None # from STATE.md \"User Approved: yes (YYYY-MM-DD...)\"\n doc_count: Optional[int] = None # rows in README.md ## Completeness table\n # feature-007 DM-A1 new fields (task-064):\n status: KbStatus = KbStatus.unknown # FR32 5-state derived status\n summary_present: bool = False # True if .aid/knowledge/kb.html exists\n kb_baseline: Optional[KbBaseline] = None # {branch, tip_date} or None\n # feature-007 f007 new fields (task-042):\n doc_freshness: list[DocFreshness] = field(default_factory=list) # per-doc verdicts\n suspect_count: int = 0 # count of suspect docs (badge rollup)\n # work-003-state-schema task-002 new fields (dual-format frontmatter read):\n source_mode: SourceMode = SourceMode.Fallback # Normalized|Fallback; see docstring\n kb_status: Optional[str] = None # raw authored discovery status (never an enum)\n kb_grade: Optional[str] = None # raw authored current grade\n last_kb_review: Optional[str] = None # raw authored last-review date\n\n\n@dataclass\nclass ConnectorRef:\n \"\"\"A single `.aid/connectors/<stem>.md` descriptor (feature-007 DM, task-019).\n\n Read-only, never persisted (NFR2) -- populated by parse_connectors() /\n parseConnectors() from the descriptor's flat-YAML frontmatter: the SAME six\n scalars build-connectors-index.sh's INDEX.md builder (`ef()`) and\n connector-registry.sh's `read` (`read_field`) address. The reader adds no\n enum/derivation here -- a connector has no lifecycle.\n\n Fields (declared order -- also the serializer's field order):\n stem -- descriptor filename stem (<stem>.md); the\n connector.remove op's row key.\n name -- human name; the raw `name:` frontmatter scalar,\n falling back to `stem` when absent (Data Model\n \"human name; defaults to <stem>\").\n connection_type -- raw scalar (mcp | api | ssh | url | cli); no enum.\n endpoint -- informational for `mcp`; the concrete connect\n target for an aid-managed type. None if absent.\n auth_method -- none | token | pat | oauth | ssh-key. None if absent.\n secret_reference -- a REFERENCE literal (env:/file:/keychain: form),\n never a credential value. None if absent (mcp /\n auth_method: none).\n summary -- one-line human guidance. None if absent.\n\n Never reads/serializes the secret VALUE or the `.secrets/` directory\n contents -- descriptor frontmatter only.\n \"\"\"\n stem: str\n name: str\n connection_type: str\n endpoint: Optional[str] = None\n auth_method: Optional[str] = None\n secret_reference: Optional[str] = None\n summary: Optional[str] = None\n\n\n@dataclass\nclass RepoInfo:\n \"\"\"Level-1 project / .aid/ state.\"\"\"\n project_name: str # from .aid/settings.yml project.name; fallback: dir basename\n aid_dir: str # resolved .aid/ root path (as string)\n kb_state: Optional[KbStateRef] = None\n # feature-002 (work-017 task-005): DM-1 exposure of the two settings scalars the\n # redesigned project-header panel displays. Declared here (after aid_dir/kb_state,\n # since dataclass fields with a default must follow those without one) but\n # SERIALIZED before aid_dir/kb_state -- declared field order project_name,\n # project_description, minimum_grade, aid_dir, kb_state (see\n # server.py _ser_repo_info / reader.mjs _buildRepoInfo).\n project_description: Optional[str] = None # .aid/settings.yml project.description;\n # None if absent/unreadable\n minimum_grade: Optional[str] = None # GLOBAL .aid/settings.yml review.minimum_grade,\n # read literally (no resolution); deliberately\n # distinct from WorkModel.minimum_grade (per-work\n # value). None if absent/unreadable.\n # feature-007 (work-017 task-019): DM-1 exposure of the project-level connectors\n # registry (.aid/connectors/*.md) -- connectors are project-level `.aid/` state,\n # so they sit beside kb_state (the other project-level `.aid/` reference).\n # Additive; no schema_version bump (DM-A3/RC-2 precedent). Sorted by stem (the\n # same order connector-registry.sh list / build-connectors-index.sh use).\n # Surfaced ONLY in the DM-1 RepoModel (GET /r/<id>/api/model) -- never the DM-2\n # home model (/api/home).\n connectors: list[ConnectorRef] = field(default_factory=list)\n # feature-010-external-sources-list (work-017 task-021): DM-1 exposure of the\n # project-level external-sources registry (.aid/knowledge/external-sources.md\n # frontmatter `sources:` list). Additive; no schema_version bump (DM-A3/RC-2\n # precedent). Deduped, order-preserved, discovery placeholder `(none)` filtered\n # out (populated by parse_external_sources()/parseExternalSources() -- a thin\n # wrapper over the existing parse_doc_frontmatter(), NOT a new parser).\n # Surfaced ONLY in the DM-1 RepoModel (GET /r/<id>/api/model) -- never the DM-2\n # home model (/api/home).\n external_sources: list[str] = field(default_factory=list)\n\n\n# ---------------------------------------------------------------------------\n# DM-5: Level 3 -- TaskModel\n# ---------------------------------------------------------------------------\n\n@dataclass\nclass TaskModel:\n \"\"\"One row from ## Tasks Status in STATE.md.\n\n Table columns (work-state-template.md):\n # | Task | Type | Wave | Status | Review | Elapsed | Notes\n The _none yet_ placeholder row is skipped by the parser.\n\n PF-3/PF-5 fields (feature-009, schema_version 3):\n short_name -- parsed from tasks/task-NNN.md first line \"# task-NNN: <title>\"\n delivery -- integer parsed from STATE Wave \"delivery-NNN\" (PF-5c; STATE wins)\n lane -- integer derived from PLAN.md wave-map or prose fallback (PF-5a/5b)\n\n feature-005 (display-rename, work-017 task-008):\n display_name -- mutable override of short_name, written via `writeback-state.sh\n --field Name` (task.rename op). Read from the per-task STATE frontmatter\n `display_name` key (nested layout) or the ### Tasks lifecycle table's\n trailing Name column (flat layout). None when unset (fallback to\n short_name -> task_id, home.html label precedence).\n\n feature-008 (execution-control, work-017 task-029):\n stop_requested -- DERIVED (never parsed from / written to STATE.md) boolean:\n True iff a cooperative stop signal is present for this task, i.e. a\n filesystem `stat` of `<walked-work-dir>/../../.control/<work_id>/\n <task_id>.stop` (the `.aid/.control/` sibling of the SAME worktree copy\n the reader is walking, WT-1) finds the file `write-control-signal.sh`\n (task-028) creates on `task.stop` / removes on `task.resume`. A missing\n `.control/` directory or signal file -> False (fail-safe, never throws).\n No schema_version bump (additive, same precedent as display_name).\n \"\"\"\n task_id: str\n type: str\n wave: Optional[str] = None\n status: TaskStatus = TaskStatus.Unknown\n review_grade: Optional[str] = None\n elapsed: Optional[str] = None\n notes: Optional[str] = None\n # schema_version 3 fields (PF-3 / PF-5)\n short_name: Optional[str] = None\n delivery: Optional[int] = None\n lane: Optional[int] = None\n # feature-005 (no schema_version bump; additive optional field)\n display_name: Optional[str] = None\n # feature-008 (no schema_version bump; additive derived field)\n stop_requested: bool = False\n\n\n# ---------------------------------------------------------------------------\n# DM-4: Level 2 -- WorkModel (includes PendingInput)\n# ---------------------------------------------------------------------------\n\n@dataclass\nclass PendingInput:\n \"\"\"A ### Q{N} entry under ## Cross-phase Q&A (Pending) with Status: Pending.\"\"\"\n question_id: str # e.g. \"Q1\"\n category: Optional[str] = None\n impact: Optional[str] = None\n context: Optional[str] = None\n suggested: Optional[str] = None\n\n\n@dataclass\nclass FeatureRef:\n \"\"\"A single row from ## Features Status in STATE.md (prototype field).\"\"\"\n number: int\n name: str\n\n\n@dataclass\nclass DeliverableRef:\n \"\"\"A single row from ## Plan / Deliveries in STATE.md (prototype field).\n\n delivery_state: the SD-8 lifecycle enum from ## Delivery Lifecycle -- the\n delivery-level deliveries/delivery-NNN/STATE.md for full-nested works, or the\n work-root STATE.md's own ## Delivery Lifecycle section for lite-flat works\n (Pending-Spec | Specified | Executing | Gated | Done | Blocked).\n None when the source STATE.md is absent or the field is unparseable (legacy works).\n \"\"\"\n number: int\n name: str\n task_count: int\n delivery_state: Optional[str] = None\n\n\n@dataclass\nclass WorkModel:\n \"\"\"Level-2 work folder state. One per .aid/works/work-NNN-*/ directory.\n\n Retention = folder persistence (FR12): the model contains exactly the work\n folders that exist on disk; completed and in-flight works are represented\n identically.\n \"\"\"\n work_id: str # folder name, e.g. \"work-001-aid-dashboard\"\n name: str # display label (slug portion)\n lifecycle: Lifecycle = Lifecycle.Unknown\n phase: Optional[Phase] = None\n active_skill: Optional[str] = None\n updated: Optional[str] = None # ISO-8601 or None\n created: Optional[str] = None # frontmatter `started` if present (task-002 retires the\n # fragile row-scrape for migrated works), else the legacy\n # ## Lifecycle History \"Work created\" row-scrape fallback\n pause_reason: Optional[str] = None # present only when lifecycle=Paused-Awaiting-Input\n block_reason: Optional[str] = None # present only when lifecycle=Blocked\n block_artifact: Optional[str] = None # e.g. \"IMPEDIMENT-task-NNN.md\"\n tasks: list[TaskModel] = field(default_factory=list)\n pending_inputs: list[PendingInput] = field(default_factory=list)\n source_mode: SourceMode = SourceMode.Fallback\n # --- prototype: work-overview header fields (delivery-002 prototype) ---\n number: Optional[int] = None # from folder prefix work-NNN-... -> NNN\n title: Optional[str] = None # **Name:** from REQUIREMENTS.md\n description: Optional[str] = None # **Description:** from REQUIREMENTS.md\n objective: Optional[str] = None # body under ## 1. Objective in REQUIREMENTS.md\n work_path: Optional[str] = None # **Path:** from STATE.md ## Triage (full/lite)\n recipe: Optional[str] = None # lite-path recipe from Triage, else None\n features: list[FeatureRef] = field(default_factory=list) # from ## Features Status\n deliverables: list[DeliverableRef] = field(default_factory=list) # from ## Plan / Deliveries\n # work-004 Pillar 4: branch label from the worktree that owns this work folder.\n # \"main\" for the main worktree; branch name for persistent worktrees; None if unknown.\n branch_label: Optional[str] = None\n # --- work-003-state-schema task-002: dual-format frontmatter read, new fields ---\n kind: Optional[str] = None # display verb resolved from pipeline.initiator\n # (e.g. \"Refactor\", \"Create api\", \"full path\");\n # None when initiator absent/unrecognized -- the\n # dashboard drops the redundant word in that case.\n started: Optional[str] = None # frontmatter `started` scalar (ISO date); None\n # for un-migrated works (no working legacy prose\n # fallback ever existed for this field -- see\n # `created` below, which retains its own fallback).\n minimum_grade: Optional[str] = None # frontmatter `minimum_grade`, else the legacy\n # header-blockquote `**Minimum Grade:**` scan\n # (derivation._parse_minimum_grade, unchanged --\n # this is a SEPARATE exposure, not a behavior change\n # to the sub-minimum Blocked-gate derivation).\n user_approved: Optional[bool] = None # frontmatter `user_approved` (yes/no/true/false,\n # case-insensitive) or the legacy header-blockquote\n # `**User Approved:**` line; work-level approval,\n # distinct from KB KbStateRef.summary_approved.\n\n\n# ---------------------------------------------------------------------------\n# DM-1 (feature-008): TaskDetail sub-model (LC-TR, task-069)\n# Populated ONLY for requested task_ids (detail_task_ids param).\n# Never persisted (NFR2); all fields are read-derived.\n# ---------------------------------------------------------------------------\n\n@dataclass\nclass Finding:\n \"\"\"One bullet under STATE.md ## Quick Check Findings ### task-NNN **Findings:** (DR-2).\n\n severity: leading bracketed tag -- [CRITICAL] | [HIGH]; lower/unknown -> [MINOR] neutral\n description: bullet text up to the first ' -- ' (em-dash segment separator)\n location: '{source-file:line}' segment, null if absent\n disposition: trailing 'Fixed-on-spot' / 'Deferred-to-gate' token, null if absent\n reviewer_tier: the block's **Reviewer Tier:** line (always 'Small' for a quick check)\n \"\"\"\n severity: str # '[CRITICAL]' | '[HIGH]' | '[MINOR]' (neutral fallback)\n description: str\n location: Optional[str] = None\n disposition: Optional[str] = None\n reviewer_tier: Optional[str] = None\n\n\n@dataclass\nclass DeferredIssue:\n \"\"\"One row from delivery-NNN-issues.md filtered to Source task == task_id (DR-4).\n\n 4-col table: Source task | Severity | Description | Status\n status enum: Open | Resolved | Accepted (unknown literal -> neutral, never throws)\n \"\"\"\n source_task: str\n severity: str\n description: str\n status: str\n\n\n@dataclass\nclass TaskLedger:\n \"\"\"Delivery-level grade context for a task (DR-3/DR-4). NOT a per-task grade.\n\n delivery_id: resolved delivery for this task (from ## Tasks Status Wave); null if unassociated\n grade: per-delivery grade from ## Delivery Gates (verbatim, never re-graded -- NFR7)\n reviewer_tier: delivery reviewer tier\n gate_timestamp: when the delivery gate ran\n deferred_issues: rows from delivery-NNN-issues.md where Source task == task_id\n \"\"\"\n delivery_id: Optional[str] = None\n grade: Optional[str] = None\n reviewer_tier: Optional[str] = None\n gate_timestamp: Optional[str] = None\n deferred_issues: list[DeferredIssue] = field(default_factory=list)\n\n\n@dataclass\nclass RawStateRef:\n \"\"\"Verbatim STATE.md bytes the reader ALREADY read this pass (DR-1, DD-3, NFR4).\n\n text: whole work STATE.md verbatim (reused from memory, no re-read)\n byte_len: len(text) in bytes (corroborates NFR4 payload budget)\n path: '.aid/works/{work}/STATE.md' -- read-only caption label, NOT an edit link\n \"\"\"\n text: str\n byte_len: int\n path: str\n\n\n@dataclass\nclass LogAvailability:\n \"\"\"Honest DM-4 log inventory for a task (DR-5, KI-008).\n\n task_logs: always 'none' (AID persists no per-task execution log)\n server_log_present: stat .aid/.temp/dashboard.log (expected-false on Windows)\n heartbeat_present: stat .aid/.heartbeat/ (liveness signal, corroborating-only, KI-004)\n \"\"\"\n task_logs: str = \"none\" # always \"none\" today (DM-4)\n server_log_present: bool = False\n heartbeat_present: bool = False\n\n\n@dataclass\nclass TaskDetail:\n \"\"\"Forensic sub-model for one drilled task (DM-1, feature-008 LC-TR).\n\n Populated ONLY when detail_task_ids is supplied to read_repo_detail().\n The always-on read_repo() path does NOT populate this; TaskModel is unchanged.\n\n task_id: == TaskModel.task_id (the drill key)\n findings: from ## Quick Check Findings ### task-NNN **Findings:** bullets\n ledger: delivery-level grade join (## Delivery Gates + delivery-NNN-issues.md)\n raw_state: the already-read STATE.md bytes (no re-read, NFR4/DD-3)\n logs: honest DM-4 log inventory\n \"\"\"\n task_id: str\n findings: list[Finding] = field(default_factory=list)\n ledger: TaskLedger = field(default_factory=TaskLedger)\n raw_state: Optional[RawStateRef] = None\n logs: Optional[LogAvailability] = None\n\n\n# ---------------------------------------------------------------------------\n# DM-7: ReadMeta (provenance of this read pass)\n# ---------------------------------------------------------------------------\n\n@dataclass\nclass ReadMeta:\n \"\"\"Provenance and health summary for a single read_repo() pass (DM-7).\n\n read_at: wall-clock of this pass (the only place the reader reads the clock).\n work_count: works enumerated.\n fallback_works: work_ids whose source_mode != normalized (live tech-debt surface, AC4).\n parse_warnings: non-fatal anomalies (missing section, unparseable row) -- never raised.\n bytes_read: total bytes read across all files (corroborates NFR4 low overhead).\n \"\"\"\n read_at: str # ISO-8601\n work_count: int = 0\n fallback_works: list[str] = field(default_factory=list)\n parse_warnings: list[str] = field(default_factory=list)\n bytes_read: int = 0\n\n\n# ---------------------------------------------------------------------------\n# DM-1: Top-level RepoModel\n# ---------------------------------------------------------------------------\n\n@dataclass\nclass RepoModel:\n \"\"\"Top-level normalized in-memory display model returned by read_repo().\n\n RepoModel\n +-- tool: ToolInfo Level 0 -- machine CLI (FR7)\n +-- repo: RepoInfo Level 1 -- project / .aid/ + KB-state hook\n +-- works: list[WorkModel] Level 2 -- one per .aid/works/work-NNN-*/ folder (FR12)\n +-- read: ReadMeta provenance of THIS read pass\n \"\"\"\n tool: ToolInfo\n repo: RepoInfo\n works: list[WorkModel] = field(default_factory=list)\n read: ReadMeta = field(default_factory=lambda: ReadMeta(read_at=\"\"))\n"}}}}