Implemented Ad-Hoc Task handling
This commit is contained in:
@@ -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-<repo>` 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:**
|
||||
|
||||
|
||||
@@ -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-<repo>' 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}) "
|
||||
|
||||
@@ -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"
|
||||
|
||||
67
workplans/archived/260329-ADHOC-2026-03-29.md
Normal file
67
workplans/archived/260329-ADHOC-2026-03-29.md
Normal file
@@ -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.
|
||||
@@ -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"
|
||||
```
|
||||
Reference in New Issue
Block a user