Implemented Ad-Hoc Task handling

This commit is contained in:
2026-05-01 21:27:52 +02:00
parent d015dc2e8a
commit 5d50dee3f1
10 changed files with 464 additions and 57 deletions

View File

@@ -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])

View File

@@ -108,6 +108,15 @@ read/cache/index layer that rebuilds from files.
**File location:** `workplans/{WP_PREFIX}-NNNN-<slug>.md`
**Archived location:** completed workplans may move to
`workplans/archived/YYMMDD-{WP_PREFIX}-NNNN-<slug>.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

View File

@@ -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-<slug>.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.

View File

@@ -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}) "