From 5d50dee3f141fe6a73df8d9f81f9af9d9c375efa Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 1 May 2026 21:27:52 +0200 Subject: [PATCH] Implemented Ad-Hoc Task handling --- state-hub/mcp_server/TOOLS.md | 3 +- state-hub/mcp_server/server.py | 201 +++++++++++++++--- state-hub/scripts/consistency_check.py | 127 ++++++++++- .../project_rules/agents-codex.template | 9 + .../workplan-convention.template | 10 + state-hub/scripts/validate_repo_adr.py | 30 ++- state-hub/tests/test_consistency_check.py | 60 ++++++ workplans/archived/260329-ADHOC-2026-03-29.md | 67 ++++++ ...-CUST-WP-0034-scope-md-delegation-prep.md} | 0 ...-0036-adhoc-tasks-and-workplan-archive.md} | 14 +- 10 files changed, 464 insertions(+), 57 deletions(-) create mode 100644 workplans/archived/260329-ADHOC-2026-03-29.md rename workplans/{CUST-WP-0034-scope-md-delegation-prep.md => archived/260501-CUST-WP-0034-scope-md-delegation-prep.md} (100%) rename workplans/{CUST-WP-0036-adhoc-tasks-and-workplan-archive.md => archived/260501-CUST-WP-0036-adhoc-tasks-and-workplan-archive.md} (98%) diff --git a/state-hub/mcp_server/TOOLS.md b/state-hub/mcp_server/TOOLS.md index d732a0d..915bb2a 100644 --- a/state-hub/mcp_server/TOOLS.md +++ b/state-hub/mcp_server/TOOLS.md @@ -85,7 +85,8 @@ Agents should call `record_token_event` (or pass `tokens_in`/`tokens_out` via |------|----------|-------| | `record_token_event(tokens_in, tokens_out, ...)` | `task_id`?, `workstream_id`?, `repo_id`?, `model`?, `agent`?, `ref_type`?, `ref_id`?, `note`?, `session_id`? | POSTs to `/token-events/`. `workstream_id` auto-filled from task. Returns event id + running total. | | `get_token_summary(scope, id)` | `scope`: task\|workstream\|repo\|commit\|release\|session; `id`: UUID or ref string | Returns formatted table of tokens_in/out/total, event_count, by_model, by_agent. | -| `record_interactive_task(title, repo_slug, ...)` | `tokens_in`?, `tokens_out`?, `note`?, `model`?, `agent`?, `description`?, `session_id`? | Find-or-create `interactive-` workstream, create task, mark done, record token event. | +| `record_adhoc_task(title, repo_slug, ...)` | `tokens_in`?, `tokens_out`?, `note`?, `model`?, `agent`?, `description`?, `session_id`? | Find-or-create today's file-backed `ADHOC-YYYY-MM-DD` workplan/workstream, append task block, mark done, record token event. | +| `record_interactive_task(title, repo_slug, ...)` | same as `record_adhoc_task` | Deprecated compatibility alias; use `record_adhoc_task`. | **Token note taxonomy:** diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index dfa2e14..ac4341e 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/mcp_server/server.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import os import re +import socket import sys from datetime import datetime, timezone from pathlib import Path @@ -2330,8 +2331,110 @@ def get_doi_summary() -> str: # --------------------------------------------------------------------------- +def _resolve_repo_path_for_host(repo: dict) -> str: + hostname = socket.gethostname() + host_paths = repo.get("host_paths") or {} + candidates = [] + if host_paths.get(hostname): + candidates.append(host_paths[hostname]) + if repo.get("local_path"): + candidates.append(repo["local_path"]) + for raw in candidates: + p = Path(raw).expanduser() + if p.is_dir(): + return str(p) + return "" + + +def _read_adhoc_workstream_id(wp_file: Path) -> str: + if not wp_file.exists(): + return "" + match = re.search(r'^state_hub_workstream_id:\s*"?([^"\n]+)"?', wp_file.read_text(encoding="utf-8"), re.MULTILINE) + return match.group(1).strip() if match else "" + + +def _next_adhoc_task_id(wp_file: Path, adhoc_id: str) -> str: + if not wp_file.exists(): + return f"{adhoc_id}-T01" + text = wp_file.read_text(encoding="utf-8") + nums = [int(m.group(1)) for m in re.finditer(rf"\b{re.escape(adhoc_id)}-T(\d+)\b", text)] + return f"{adhoc_id}-T{(max(nums) + 1 if nums else 1):02d}" + + +def _ensure_adhoc_workplan( + repo: dict, + repo_slug: str, + domain_slug: str, + agent: str | None, +) -> tuple[dict, Path, str] | dict: + repo_path = _resolve_repo_path_for_host(repo) + if not repo_path: + return {"error": f"No accessible local path for repo {repo_slug!r} on host {socket.gethostname()}."} + + today = datetime.now().date().isoformat() + adhoc_id = f"ADHOC-{today}" + ws_slug = f"adhoc-{today}" + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + workplans_dir.mkdir(exist_ok=True) + wp_file = workplans_dir / f"{adhoc_id}.md" + + ws_id = _read_adhoc_workstream_id(wp_file) + ws = _get(f"/workstreams/{ws_id}") if ws_id else None + if not isinstance(ws, dict) or "error" in ws: + existing = _get("/workstreams/", {"slug": ws_slug}) + ws = existing[0] if isinstance(existing, list) and existing else None + + if not ws: + topics = _get("/topics/") + topic = next( + (t for t in (topics if isinstance(topics, list) else []) + if t.get("domain_slug") == domain_slug or t.get("domain") == domain_slug), + None, + ) + if not topic: + return {"error": f"No topic found for domain {domain_slug!r} — cannot create adhoc workstream."} + ws = _post("/workstreams", { + "topic_id": topic["id"], + "slug": ws_slug, + "title": f"Ad Hoc Tasks — {today}", + "description": "Small opportunistic tasks discovered during active sessions.", + "owner": agent or "custodian", + "repo_id": repo["id"], + }) + if "error" in ws: + return ws + + if not wp_file.exists(): + wp_file.write_text( + f"""--- +id: {adhoc_id} +type: workplan +title: "Ad Hoc Tasks — {today}" +domain: {domain_slug} +repo: {repo_slug} +status: active +owner: {agent or "custodian"} +topic_slug: {domain_slug} +created: "{today}" +updated: "{today}" +state_hub_workstream_id: "{ws["id"]}" +--- + +# {adhoc_id} — Ad Hoc Tasks + +Small opportunistic tasks discovered during active work. Promote anything that +requires analysis, design, approval, dependencies, or multiple phases into a +normal workplan. +""", + encoding="utf-8", + ) + + return ws, wp_file, adhoc_id + + @mcp.tool() -def record_interactive_task( +def record_adhoc_task( title: str, repo_slug: str, tokens_in: Optional[int] = None, @@ -2342,10 +2445,11 @@ def record_interactive_task( description: Optional[str] = None, session_id: Optional[str] = None, ) -> str: - """Record ad-hoc interactive work as a task with token consumption. + """Record small opportunistic work as a file-backed Ad Hoc task. - Finds or creates a persistent 'interactive-' workstream for the repo, - creates the task, marks it done immediately, and records a token event. + Finds or creates today's workplans/ADHOC-YYYY-MM-DD.md and matching + adhoc-YYYY-MM-DD workstream, appends a task block, marks the task done, and + records token consumption through the task API. Token note convention: "measured" — exact counts read from the Claude Code status bar (default when @@ -2353,8 +2457,10 @@ def record_interactive_task( "userbased" — counts provided by a human (pass note="userbased" explicitly) "heuristic" — server fallback when no counts given (automatic) - Use this for work done outside a formal workplan: quick fixes, config changes, - code reviews, one-off investigations, or any session work worth tracking. + Use this for work done outside a formal workplan: quick fixes, config + changes, code reviews, one-off investigations, or any session work worth + tracking. Promote work needing analysis/design/approval into a normal + workplan instead. Args: title: Short description of the work done @@ -2377,33 +2483,11 @@ def record_interactive_task( repo_id = repo["id"] domain_slug = repo.get("domain_slug") or repo.get("domain") - ws_slug = f"interactive-{repo_slug}" - - # Find or create the interactive workstream - existing = _get("/workstreams/", {"slug": ws_slug}) - ws = existing[0] if isinstance(existing, list) and existing else None - - if not ws: - # Find a topic for this domain to satisfy the FK - topics = _get("/topics/") - topic = next( - (t for t in (topics if isinstance(topics, list) else []) - if t.get("domain_slug") == domain_slug or t.get("domain") == domain_slug), - None, - ) - if not topic: - return json.dumps({"error": f"No topic found for domain {domain_slug!r} — cannot create workstream."}) - - ws = _post("/workstreams", { - "topic_id": topic["id"], - "slug": ws_slug, - "title": f"Interactive — {repo_slug}", - "description": "Ad-hoc tasks created outside a formal workplan.", - "owner": "custodian", - "repo_id": repo_id, - }) - if "error" in ws: - return json.dumps(ws) + ensured = _ensure_adhoc_workplan(repo, repo_slug, domain_slug, agent) + if isinstance(ensured, dict) and "error" in ensured: + return json.dumps(ensured) + ws, wp_file, adhoc_id = ensured + task_block_id = _next_adhoc_task_id(wp_file, adhoc_id) # Create task task = _post("/tasks", { @@ -2431,16 +2515,65 @@ def record_interactive_task( _patch(f"/tasks/{task['id']}", body) + now_day = datetime.now().date().isoformat() + with wp_file.open("a", encoding="utf-8") as f: + f.write( + f""" + +## {title} + +```task +id: {task_block_id} +status: done +priority: medium +state_hub_task_id: "{task["id"]}" +``` + +{description or "Recorded as an Ad Hoc Task."} +""" + ) + text = wp_file.read_text(encoding="utf-8") + text = re.sub(r'^updated:\s*".*"$', f'updated: "{now_day}"', text, count=1, flags=re.MULTILINE) + wp_file.write_text(text, encoding="utf-8") + effective_note = note or ("measured" if tokens_in is not None else "heuristic") return json.dumps({ "task_id": task["id"], "workstream_id": ws["id"], - "workstream_slug": ws_slug, + "workstream_slug": ws["slug"], + "workplan_file": str(wp_file), + "task_block_id": task_block_id, "title": title, "token_note": effective_note, }, indent=2) +@mcp.tool() +def record_interactive_task( + title: str, + repo_slug: str, + tokens_in: Optional[int] = None, + tokens_out: Optional[int] = None, + note: Optional[str] = None, + model: Optional[str] = None, + agent: Optional[str] = None, + description: Optional[str] = None, + session_id: Optional[str] = None, +) -> str: + """Deprecated alias for record_adhoc_task.""" + return record_adhoc_task( + title=title, + repo_slug=repo_slug, + tokens_in=tokens_in, + tokens_out=tokens_out, + note=note, + model=model, + agent=agent, + description=description, + session_id=session_id, + ) + + # --------------------------------------------------------------------------- # Token events # --------------------------------------------------------------------------- diff --git a/state-hub/scripts/consistency_check.py b/state-hub/scripts/consistency_check.py index 5d0c9b6..037ad74 100644 --- a/state-hub/scripts/consistency_check.py +++ b/state-hub/scripts/consistency_check.py @@ -42,6 +42,7 @@ import socket import subprocess import sys from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import Any @@ -64,6 +65,7 @@ except ImportError: _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) _HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE) +_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") VALID_WP_STATUSES = {"active", "completed", "archived"} VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} @@ -92,6 +94,28 @@ def normalise_workstream_status(status: str) -> str: return FILE_TO_DB_WORKSTREAM_STATUS.get(status, status) +def canonical_workplan_filename(path: Path) -> str: + """Return the workplan filename without an archive completion-date prefix.""" + return _ARCHIVED_WP_RE.sub(r"\1", path.name) + + +def workplan_display_path(repo_dir: Path, path: Path) -> str: + """Stable relative path for reports, including archived/ when applicable.""" + try: + return str(path.relative_to(repo_dir)) + except ValueError: + return path.name + + +def iter_workplan_files(workplans_dir: Path, include_archived: bool = True) -> list[Path]: + """Return active root workplans plus archived workplans when requested.""" + files = sorted(workplans_dir.glob("*.md")) + archived_dir = workplans_dir / "archived" + if include_archived and archived_dir.is_dir(): + files.extend(sorted(archived_dir.glob("*.md"))) + return files + + # --------------------------------------------------------------------------- # Data types # --------------------------------------------------------------------------- @@ -462,36 +486,49 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N # Parse workplan files workplan_infos: list[tuple[Path, dict, str]] = [] file_ws_ids: dict[str, tuple[Path, dict, str]] = {} # ws_id → (file, meta, body) + active_file_ws_ids: set[str] = set() - for wp_file in sorted(workplans_dir.glob("*.md")): + for wp_file in iter_workplan_files(workplans_dir): try: text = wp_file.read_text(encoding="utf-8") except OSError as e: report.add(severity="FAIL", check_id="C-02", - message=f"Cannot read file: {e}", file_path=wp_file.name) + message=f"Cannot read file: {e}", file_path=workplan_display_path(repo_dir, wp_file)) continue if not text.startswith("---"): report.add(severity="FAIL", check_id="C-02", - message="No YAML frontmatter found", file_path=wp_file.name) + message="No YAML frontmatter found", file_path=workplan_display_path(repo_dir, wp_file)) continue meta, body = parse_frontmatter(text) if not meta or meta.get("_parse_error"): report.add(severity="FAIL", check_id="C-02", - message="YAML frontmatter parse error", file_path=wp_file.name) + message="YAML frontmatter parse error", file_path=workplan_display_path(repo_dir, wp_file)) continue workplan_infos.append((wp_file, meta, body)) ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') if ws_id: file_ws_ids[ws_id] = (wp_file, meta, body) + if wp_file.parent == workplans_dir: + active_file_ws_ids.add(ws_id) # Per-workplan checks for wp_file, meta, body in workplan_infos: - fname = wp_file.name + fname = workplan_display_path(repo_dir, wp_file) + archived_file = wp_file.parent.name == "archived" ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') file_status = str(meta.get("status", "")).strip() file_title = str(meta.get("title", "")).strip() file_domain = str(meta.get("domain", "")).strip() + if archived_file and normalise_workstream_status(file_status) == "active": + report.add( + severity="FAIL", check_id="C-18", + message="Archived workplan file has active/todo status", + file_path=fname, + file_value=file_status, + fixable=False, + ) + if not ws_id: # C-06: workplan not linked to any DB workstream report.add( @@ -725,7 +762,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N ) # C-07 / C-08: orphan DB workstreams (have repo_id=this_repo but no backing file) - _check_orphan_db(api_base, repo_id, set(file_ws_ids.keys()), report) + _check_orphan_db(api_base, repo_id, set(file_ws_ids.keys()), report, active_file_ws_ids) # C-14: ghost duplicate — active workstream on same topic with no repo_id whose # title matches a file-backed workstream. Root cause: create_workstream() called @@ -737,17 +774,24 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N def _check_orphan_db( - api_base: str, repo_id: str, file_ws_ids: set[str], report: ConsistencyReport + api_base: str, + repo_id: str, + file_ws_ids: set[str], + report: ConsistencyReport, + active_file_ws_ids: set[str] | None = None, ) -> None: """Flag DB workstreams with repo_id=this_repo that have no backing workplan file.""" + active_file_ws_ids = active_file_ws_ids or file_ws_ids all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) if not isinstance(all_ws, list): return for ws in all_ws: ws_id = ws["id"] - if ws_id in file_ws_ids: - continue ws_status = ws.get("status", "") + if ws_status == "active" and ws_id in active_file_ws_ids: + continue + if ws_status in ("completed", "archived") and ws_id in file_ws_ids: + continue ws_slug = ws.get("slug", "") if ws_status == "active": report.add( @@ -1447,6 +1491,57 @@ def fix_all_remote( return reports +def archive_closed_workplans( + repo_path: str, + completion_date: str | None = None, + workplan: str | None = None, +) -> list[str]: + """Move closed root workplans into workplans/archived/ with YYMMDD prefix. + + Only root-level files whose frontmatter status normalises to completed or + archived are moved. Files with any open task blocks are left in place. + """ + repo_dir = Path(repo_path) + workplans_dir = repo_dir / "workplans" + archived_dir = workplans_dir / "archived" + if not workplans_dir.is_dir(): + return [] + + date_prefix = completion_date or datetime.now().strftime("%y%m%d") + archived_dir.mkdir(exist_ok=True) + moved: list[str] = [] + + for wp_file in sorted(workplans_dir.glob("*.md")): + text = wp_file.read_text(encoding="utf-8") + if not text.startswith("---"): + continue + meta, body = parse_frontmatter(text) + if not meta or meta.get("_parse_error"): + continue + if workplan: + wanted = workplan.removesuffix(".md") + if wanted not in {str(meta.get("id", "")), wp_file.stem, wp_file.name}: + continue + status = normalise_workstream_status(str(meta.get("status", "")).strip()) + if status not in ("completed", "archived"): + continue + tasks = get_tasks_from_workplan(meta, body) + open_tasks = [ + t for t in tasks + if str(t.get("status", "")).strip() not in ("done", "cancelled") + ] + if open_tasks: + continue + + target = archived_dir / f"{date_prefix}-{canonical_workplan_filename(wp_file)}" + if target.exists(): + raise FileExistsError(f"Archived workplan already exists: {target}") + wp_file.rename(target) + moved.append(f"{wp_file.relative_to(repo_dir)} -> {target.relative_to(repo_dir)}") + + return moved + + # --------------------------------------------------------------------------- # Output / rendering # --------------------------------------------------------------------------- @@ -1549,6 +1644,12 @@ def main() -> None: "Implies --fix.") parser.add_argument("--no-writeback", action="store_true", dest="no_writeback", help="Disable DB→file status writeback (C-15) while keeping other fixes") + parser.add_argument("--archive-closed", action="store_true", + help="Move closed root workplans to workplans/archived/YYMMDD-*.md") + parser.add_argument("--archive-workplan", metavar="ID_OR_FILE", default=None, + help="When archiving, only move the matching workplan id or filename") + parser.add_argument("--archive-date", metavar="YYMMDD", default=None, + help="Completion date prefix for --archive-closed (default: today)") parser.add_argument("--repo-path", metavar="PATH", default=None, help="Override the local repo path (useful when the DB has a different " "machine's path). Takes priority over host_paths and local_path.") @@ -1582,6 +1683,9 @@ def main() -> None: reports = [fix_repo(args.api_base, inferred_slug, git_root, no_writeback=no_wb)] else: reports = [check_repo(args.api_base, inferred_slug, git_root)] + if args.archive_closed: + moved = archive_closed_workplans(git_root, args.archive_date, args.archive_workplan) + reports[0].fixes_applied.extend(f"archive: {m}" for m in moved) # --remote --all: smart pull+fix across all repos elif args.remote and args.all: reports = fix_all_remote(args.api_base, no_writeback=no_wb) @@ -1631,6 +1735,11 @@ def main() -> None: else: reports = [check_repo(args.api_base, slug, path_override) for slug in repo_slugs] + if args.archive_closed: + for report in reports: + moved = archive_closed_workplans(report.repo_path, args.archive_date, args.archive_workplan) + report.fixes_applied.extend(f"archive: {m}" for m in moved) + if args.as_json: output = ( report_to_dict(reports[0]) diff --git a/state-hub/scripts/project_rules/agents-codex.template b/state-hub/scripts/project_rules/agents-codex.template index 64ee108..1f12858 100644 --- a/state-hub/scripts/project_rules/agents-codex.template +++ b/state-hub/scripts/project_rules/agents-codex.template @@ -108,6 +108,15 @@ read/cache/index layer that rebuilds from files. **File location:** `workplans/{WP_PREFIX}-NNNN-.md` +**Archived location:** completed workplans may move to +`workplans/archived/YYMMDD-{WP_PREFIX}-NNNN-.md`. The `YYMMDD` prefix is +the completion/archive date; the frontmatter `id` does not change. + +**Ad Hoc Tasks:** small opportunistic fixes discovered during a session use +`workplans/ADHOC-YYYY-MM-DD.md` with task ids `ADHOC-YYYY-MM-DD-T01`, etc. Use +this only for low-risk work completed directly; create a normal workplan for +anything needing analysis, design, approval, dependencies, or multiple phases. + **Frontmatter:** ```yaml diff --git a/state-hub/scripts/project_rules/workplan-convention.template b/state-hub/scripts/project_rules/workplan-convention.template index d0754e7..c358a25 100644 --- a/state-hub/scripts/project_rules/workplan-convention.template +++ b/state-hub/scripts/project_rules/workplan-convention.template @@ -5,6 +5,16 @@ ID prefix: `{WP_PREFIX}` Work items originate as files in this repo **before** being registered in the hub. +Closed workplans may be moved to `workplans/archived/` with a completion-date +prefix: `YYMMDD-{REPO_SLUG}-WP-NNNN-.md`. The frontmatter id remains +unchanged; the prefix is only for quick visual reference. + +Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**: +`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids +`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed +directly. Promote anything requiring analysis, design, approval, dependencies, or +multiple planned phases into a normal workplan. + Ecosystem todos from other agents arrive as `[repo:{REPO_SLUG}]` hub tasks — visible at session start. Pick one up by creating the workplan file, then registering the workstream. diff --git a/state-hub/scripts/validate_repo_adr.py b/state-hub/scripts/validate_repo_adr.py index 64e0b1f..c2f6643 100644 --- a/state-hub/scripts/validate_repo_adr.py +++ b/state-hub/scripts/validate_repo_adr.py @@ -62,9 +62,22 @@ VALID_WP_STATUSES = {"active", "completed", "archived"} VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} -_WP_ID_RE = re.compile(r"^[A-Z]+-WP-\d+$") -_TASK_ID_RE = re.compile(r"^[A-Z]+-WP-\d+-T\d+$") +_WP_ID_RE = re.compile(r"^(?:[A-Z]+-WP-\d+|ADHOC-\d{4}-\d{2}-\d{2})$") +_TASK_ID_RE = re.compile(r"^(?:[A-Z]+-WP-\d+|ADHOC-\d{4}-\d{2}-\d{2})-T\d+$") _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) +_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") + + +def canonical_workplan_filename(path: Path) -> str: + return _ARCHIVED_WP_RE.sub(r"\1", path.name) + + +def iter_workplan_files(workplans_dir: Path, include_archived: bool = True) -> list[Path]: + files = sorted(workplans_dir.glob("*.md")) + archived_dir = workplans_dir / "archived" + if include_archived and archived_dir.is_dir(): + files.extend(sorted(archived_dir.glob("*.md"))) + return files # --------------------------------------------------------------------------- @@ -148,7 +161,8 @@ def parse_task_blocks(body: str) -> list[dict]: def _check_workplan_file(wp_file: Path, report: Report) -> dict | None: """Validate one workplan file. Returns parsed frontmatter on success.""" - fname = wp_file.name + fname = str(wp_file.relative_to(Path(report.repo_path))) + canonical_fname = canonical_workplan_filename(wp_file) try: text = wp_file.read_text(encoding="utf-8") except OSError as e: @@ -199,7 +213,7 @@ def _check_workplan_file(wp_file: Path, report: Report) -> dict | None: report.add(Level.PASS, "frontmatter-id-format", f"id={wp_id}", fname) # filename prefix - if wp_id and not fname.startswith(wp_id): + if wp_id and not canonical_fname.startswith(wp_id): report.add(Level.WARN, "filename-id-prefix", f"Filename should start with id '{wp_id}', got {fname!r}", fname) elif wp_id: @@ -252,7 +266,7 @@ def _check_workplan_file(wp_file: Path, report: Report) -> dict | None: def check_files(workplans_dir: Path, report: Report) -> list[dict]: """Check all workplan .md files in workplans_dir.""" - md_files = sorted(workplans_dir.glob("*.md")) + md_files = iter_workplan_files(workplans_dir) if not md_files: report.add(Level.WARN, "workplans-not-empty", "workplans/ directory exists but contains no .md files") @@ -261,6 +275,7 @@ def check_files(workplans_dir: Path, report: Report) -> list[dict]: for wp_file in md_files: meta = _check_workplan_file(wp_file, report) if meta: + meta["_active_file"] = wp_file.parent == workplans_dir metas.append(meta) return metas @@ -295,6 +310,7 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None, # Verify each state_hub_workstream_id reference file_ws_ids: set[str] = set() + active_file_ws_ids: set[str] = set() for meta in metas: ws_id = str(meta.get("state_hub_workstream_id", "")).strip() if not ws_id: @@ -304,6 +320,8 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None, str(meta.get("id", ""))) continue file_ws_ids.add(ws_id) + if meta.get("_active_file", True): + active_file_ws_ids.add(ws_id) ws = _api_get(api_base, f"/workstreams/{ws_id}") if ws is None: report.add(Level.FAIL, "workstream-ref-exists", @@ -349,7 +367,7 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None, continue ws_id = ws["id"] ws_slug = ws.get("slug", "") - if ws_id not in file_ws_ids: + if ws_id not in active_file_ws_ids: report.add( Level.FAIL, "orphan-workstream", f"Active workstream '{ws_slug}' (id={ws_id[:8]}…, domain={t_domain}) " diff --git a/state-hub/tests/test_consistency_check.py b/state-hub/tests/test_consistency_check.py index a459449..500a8f0 100644 --- a/state-hub/tests/test_consistency_check.py +++ b/state-hub/tests/test_consistency_check.py @@ -30,7 +30,10 @@ from consistency_check import ( _git_pull, _patch_task_status_in_file, _report_needs_action, + archive_closed_workplans, + canonical_workplan_filename, get_tasks_from_workplan, + iter_workplan_files, normalise_workstream_status, parse_frontmatter, parse_task_blocks, @@ -168,6 +171,63 @@ class TestGetTasksFromWorkplan: tasks = get_tasks_from_workplan(meta, body) assert tasks == [] + +class TestArchivedWorkplans: + def test_canonical_workplan_filename_strips_archive_date_prefix(self): + assert canonical_workplan_filename(Path("260501-CUST-WP-0001-demo.md")) == "CUST-WP-0001-demo.md" + assert canonical_workplan_filename(Path("CUST-WP-0001-demo.md")) == "CUST-WP-0001-demo.md" + + def test_iter_workplan_files_includes_archived_directory(self, tmp_path): + workplans = tmp_path / "workplans" + archived = workplans / "archived" + archived.mkdir(parents=True) + active_file = workplans / "CUST-WP-0001-active.md" + archived_file = archived / "260501-CUST-WP-0000-old.md" + active_file.write_text("---\nid: CUST-WP-0001\n---\n", encoding="utf-8") + archived_file.write_text("---\nid: CUST-WP-0000\n---\n", encoding="utf-8") + + assert iter_workplan_files(workplans) == [active_file, archived_file] + + def test_archive_closed_workplans_moves_done_file_with_date_prefix(self, tmp_path): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + wp = workplans / "CUST-WP-0001-demo.md" + wp.write_text( + "---\n" + "id: CUST-WP-0001\n" + "type: workplan\n" + "title: Demo\n" + "domain: custodian\n" + "status: done\n" + "owner: codex\n" + "created: \"2026-05-01\"\n" + "---\n" + "```task\nid: CUST-WP-0001-T01\nstatus: done\npriority: medium\n```\n", + encoding="utf-8", + ) + + moved = archive_closed_workplans(str(repo), completion_date="260501") + + assert moved == ["workplans/CUST-WP-0001-demo.md -> workplans/archived/260501-CUST-WP-0001-demo.md"] + assert not wp.exists() + assert (workplans / "archived" / "260501-CUST-WP-0001-demo.md").exists() + + def test_archive_closed_workplans_leaves_open_tasks_in_place(self, tmp_path): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + wp = workplans / "CUST-WP-0001-demo.md" + wp.write_text( + "---\nid: CUST-WP-0001\ntype: workplan\ntitle: Demo\ndomain: custodian\n" + "status: done\nowner: codex\ncreated: \"2026-05-01\"\n---\n" + "```task\nid: CUST-WP-0001-T01\nstatus: todo\npriority: medium\n```\n", + encoding="utf-8", + ) + + assert archive_closed_workplans(str(repo), completion_date="260501") == [] + assert wp.exists() + def test_ignores_non_list_frontmatter_tasks(self): meta = {"tasks": "not-a-list"} body = "# No blocks\n" diff --git a/workplans/archived/260329-ADHOC-2026-03-29.md b/workplans/archived/260329-ADHOC-2026-03-29.md new file mode 100644 index 0000000..27170f6 --- /dev/null +++ b/workplans/archived/260329-ADHOC-2026-03-29.md @@ -0,0 +1,67 @@ +--- +id: ADHOC-2026-03-29 +type: workplan +title: "Ad Hoc Tasks — 2026-03-29" +domain: custodian +repo: the-custodian +status: done +owner: custodian +topic_slug: custodian +created: "2026-03-29" +updated: "2026-03-29" +state_hub_workstream_id: "370c2481-6806-41eb-a917-f8874f03184f" +--- + +# ADHOC-2026-03-29 — Ad Hoc Tasks + +Migrated from the legacy `interactive-the-custodian` pseudo-workstream. These +tasks were completed before the Ad Hoc Tasks file-backed convention existed. + +## Three-tier token recording on task done + +```task +id: ADHOC-2026-03-29-T01 +status: done +priority: medium +state_hub_task_id: "83919aef-7e93-44a2-97f6-d4f57b71acce" +``` + +Added heuristic fallback (1000/500), workplan proration tier, and exact-count +tier to update_task_status. Token event always created on done. + +## Add record_interactive_task MCP tool + +```task +id: ADHOC-2026-03-29-T02 +status: done +priority: medium +state_hub_task_id: "55eb2176-fa4c-4abb-bd1a-88ab87749b91" +``` + +New MCP tool that found or created an interactive workstream per repo and +recorded ad-hoc tasks with token consumption in a single call. + +## Token note taxonomy and seed record correction + +```task +id: ADHOC-2026-03-29-T03 +status: done +priority: medium +state_hub_task_id: "ba2543ef-2ae9-4870-8d28-4578c2ef30c4" +``` + +Introduced measured/userbased/workplan/heuristic note taxonomy. Fixed two +null-note seed records to userbased. Added token_note field to TaskUpdate +schema and note param to both MCP tools. + +## Post-WP-0030 fixes and improvements + +```task +id: ADHOC-2026-03-29-T04 +status: done +priority: medium +state_hub_task_id: "f1512b0a-3f04-4c8b-a26f-ce82cbdd7390" +``` + +Fixed deep-link prefix handling, FileAttachment to fetch migration, missing +landing pages, and FK link cells with async title help-tips after WP-0030. diff --git a/workplans/CUST-WP-0034-scope-md-delegation-prep.md b/workplans/archived/260501-CUST-WP-0034-scope-md-delegation-prep.md similarity index 100% rename from workplans/CUST-WP-0034-scope-md-delegation-prep.md rename to workplans/archived/260501-CUST-WP-0034-scope-md-delegation-prep.md diff --git a/workplans/CUST-WP-0036-adhoc-tasks-and-workplan-archive.md b/workplans/archived/260501-CUST-WP-0036-adhoc-tasks-and-workplan-archive.md similarity index 98% rename from workplans/CUST-WP-0036-adhoc-tasks-and-workplan-archive.md rename to workplans/archived/260501-CUST-WP-0036-adhoc-tasks-and-workplan-archive.md index 7cde19a..1abbe68 100644 --- a/workplans/CUST-WP-0036-adhoc-tasks-and-workplan-archive.md +++ b/workplans/archived/260501-CUST-WP-0036-adhoc-tasks-and-workplan-archive.md @@ -4,7 +4,7 @@ type: workplan title: "Ad Hoc Tasks and Workplan Archiving" domain: custodian repo: the-custodian -status: todo +status: done owner: custodian topic_slug: custodian created: "2026-05-01" @@ -49,7 +49,7 @@ failure; cleaner token/accounting review for opportunistic work. ```task id: CUST-WP-0036-T01 -status: todo +status: done priority: high state_hub_task_id: "b9ca840e-c66f-4bce-ab83-2bec68a4c0c3" ``` @@ -80,7 +80,7 @@ decide whether a discovered item belongs in Ad Hoc Tasks or a normal workplan. ```task id: CUST-WP-0036-T02 -status: todo +status: done priority: high state_hub_task_id: "8ab029f6-8cc8-4a7c-9505-1dcd5df16f00" ``` @@ -107,7 +107,7 @@ without a root-level workplan still fails. ```task id: CUST-WP-0036-T03 -status: todo +status: done priority: medium state_hub_task_id: "6922752a-6034-479a-921a-ed1ba12c740a" ``` @@ -131,7 +131,7 @@ and a subsequent consistency run remains clean for that workplan. ```task id: CUST-WP-0036-T04 -status: todo +status: done priority: high state_hub_task_id: "a5a0f1d9-70eb-4ef4-9081-2dd6a556a89e" ``` @@ -157,7 +157,7 @@ not create a C-07 consistency failure. ```task id: CUST-WP-0036-T05 -status: todo +status: done priority: medium state_hub_task_id: "4bc4edec-a97d-45f0-8929-5da0395a21c0" ``` @@ -179,7 +179,7 @@ failure, and the migration preserves enough history for token review. ```task id: CUST-WP-0036-T06 -status: todo +status: done priority: medium state_hub_task_id: "ac480949-2ae4-4e6f-9a85-c72b18b96d2f" ```