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

@@ -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. | | `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. | | `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:** **Token note taxonomy:**

View File

@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import os import os
import re import re
import socket
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path 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() @mcp.tool()
def record_interactive_task( def record_adhoc_task(
title: str, title: str,
repo_slug: str, repo_slug: str,
tokens_in: Optional[int] = None, tokens_in: Optional[int] = None,
@@ -2342,10 +2445,11 @@ def record_interactive_task(
description: Optional[str] = None, description: Optional[str] = None,
session_id: Optional[str] = None, session_id: Optional[str] = None,
) -> str: ) -> 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, Finds or creates today's workplans/ADHOC-YYYY-MM-DD.md and matching
creates the task, marks it done immediately, and records a token event. adhoc-YYYY-MM-DD workstream, appends a task block, marks the task done, and
records token consumption through the task API.
Token note convention: Token note convention:
"measured" — exact counts read from the Claude Code status bar (default when "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) "userbased" — counts provided by a human (pass note="userbased" explicitly)
"heuristic" — server fallback when no counts given (automatic) "heuristic" — server fallback when no counts given (automatic)
Use this for work done outside a formal workplan: quick fixes, config changes, Use this for work done outside a formal workplan: quick fixes, config
code reviews, one-off investigations, or any session work worth tracking. changes, code reviews, one-off investigations, or any session work worth
tracking. Promote work needing analysis/design/approval into a normal
workplan instead.
Args: Args:
title: Short description of the work done title: Short description of the work done
@@ -2377,33 +2483,11 @@ def record_interactive_task(
repo_id = repo["id"] repo_id = repo["id"]
domain_slug = repo.get("domain_slug") or repo.get("domain") domain_slug = repo.get("domain_slug") or repo.get("domain")
ws_slug = f"interactive-{repo_slug}" ensured = _ensure_adhoc_workplan(repo, repo_slug, domain_slug, agent)
if isinstance(ensured, dict) and "error" in ensured:
# Find or create the interactive workstream return json.dumps(ensured)
existing = _get("/workstreams/", {"slug": ws_slug}) ws, wp_file, adhoc_id = ensured
ws = existing[0] if isinstance(existing, list) and existing else None task_block_id = _next_adhoc_task_id(wp_file, adhoc_id)
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)
# Create task # Create task
task = _post("/tasks", { task = _post("/tasks", {
@@ -2431,16 +2515,65 @@ def record_interactive_task(
_patch(f"/tasks/{task['id']}", body) _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") effective_note = note or ("measured" if tokens_in is not None else "heuristic")
return json.dumps({ return json.dumps({
"task_id": task["id"], "task_id": task["id"],
"workstream_id": ws["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, "title": title,
"token_note": effective_note, "token_note": effective_note,
}, indent=2) }, 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 # Token events
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -42,6 +42,7 @@ import socket
import subprocess import subprocess
import sys import sys
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -64,6 +65,7 @@ except ImportError:
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL) _TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE) _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_WP_STATUSES = {"active", "completed", "archived"}
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} 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) 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 # Data types
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -462,36 +486,49 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
# Parse workplan files # Parse workplan files
workplan_infos: list[tuple[Path, dict, str]] = [] workplan_infos: list[tuple[Path, dict, str]] = []
file_ws_ids: dict[str, tuple[Path, dict, str]] = {} # ws_id → (file, meta, body) 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: try:
text = wp_file.read_text(encoding="utf-8") text = wp_file.read_text(encoding="utf-8")
except OSError as e: except OSError as e:
report.add(severity="FAIL", check_id="C-02", 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 continue
if not text.startswith("---"): if not text.startswith("---"):
report.add(severity="FAIL", check_id="C-02", 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 continue
meta, body = parse_frontmatter(text) meta, body = parse_frontmatter(text)
if not meta or meta.get("_parse_error"): if not meta or meta.get("_parse_error"):
report.add(severity="FAIL", check_id="C-02", 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 continue
workplan_infos.append((wp_file, meta, body)) workplan_infos.append((wp_file, meta, body))
ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"') ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"')
if ws_id: if ws_id:
file_ws_ids[ws_id] = (wp_file, meta, body) 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 # Per-workplan checks
for wp_file, meta, body in workplan_infos: 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('"') ws_id = str(meta.get("state_hub_workstream_id", "")).strip().strip('"')
file_status = str(meta.get("status", "")).strip() file_status = str(meta.get("status", "")).strip()
file_title = str(meta.get("title", "")).strip() file_title = str(meta.get("title", "")).strip()
file_domain = str(meta.get("domain", "")).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: if not ws_id:
# C-06: workplan not linked to any DB workstream # C-06: workplan not linked to any DB workstream
report.add( 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) # 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 # 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 # 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( 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: ) -> None:
"""Flag DB workstreams with repo_id=this_repo that have no backing workplan file.""" """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}) all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id})
if not isinstance(all_ws, list): if not isinstance(all_ws, list):
return return
for ws in all_ws: for ws in all_ws:
ws_id = ws["id"] ws_id = ws["id"]
if ws_id in file_ws_ids:
continue
ws_status = ws.get("status", "") 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", "") ws_slug = ws.get("slug", "")
if ws_status == "active": if ws_status == "active":
report.add( report.add(
@@ -1447,6 +1491,57 @@ def fix_all_remote(
return reports 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 # Output / rendering
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1549,6 +1644,12 @@ def main() -> None:
"Implies --fix.") "Implies --fix.")
parser.add_argument("--no-writeback", action="store_true", dest="no_writeback", parser.add_argument("--no-writeback", action="store_true", dest="no_writeback",
help="Disable DB→file status writeback (C-15) while keeping other fixes") 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, parser.add_argument("--repo-path", metavar="PATH", default=None,
help="Override the local repo path (useful when the DB has a different " help="Override the local repo path (useful when the DB has a different "
"machine's path). Takes priority over host_paths and local_path.") "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)] reports = [fix_repo(args.api_base, inferred_slug, git_root, no_writeback=no_wb)]
else: else:
reports = [check_repo(args.api_base, inferred_slug, git_root)] 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 # --remote --all: smart pull+fix across all repos
elif args.remote and args.all: elif args.remote and args.all:
reports = fix_all_remote(args.api_base, no_writeback=no_wb) reports = fix_all_remote(args.api_base, no_writeback=no_wb)
@@ -1631,6 +1735,11 @@ def main() -> None:
else: else:
reports = [check_repo(args.api_base, slug, path_override) for slug in repo_slugs] 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: if args.as_json:
output = ( output = (
report_to_dict(reports[0]) 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` **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:** **Frontmatter:**
```yaml ```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. 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 — 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 visible at session start. Pick one up by creating the workplan file, then registering
the workstream. 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_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
_WP_ID_RE = re.compile(r"^[A-Z]+-WP-\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+-T\d+$") _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) _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: def _check_workplan_file(wp_file: Path, report: Report) -> dict | None:
"""Validate one workplan file. Returns parsed frontmatter on success.""" """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: try:
text = wp_file.read_text(encoding="utf-8") text = wp_file.read_text(encoding="utf-8")
except OSError as e: 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) report.add(Level.PASS, "frontmatter-id-format", f"id={wp_id}", fname)
# filename prefix # 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", report.add(Level.WARN, "filename-id-prefix",
f"Filename should start with id '{wp_id}', got {fname!r}", fname) f"Filename should start with id '{wp_id}', got {fname!r}", fname)
elif wp_id: 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]: def check_files(workplans_dir: Path, report: Report) -> list[dict]:
"""Check all workplan .md files in workplans_dir.""" """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: if not md_files:
report.add(Level.WARN, "workplans-not-empty", report.add(Level.WARN, "workplans-not-empty",
"workplans/ directory exists but contains no .md files") "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: for wp_file in md_files:
meta = _check_workplan_file(wp_file, report) meta = _check_workplan_file(wp_file, report)
if meta: if meta:
meta["_active_file"] = wp_file.parent == workplans_dir
metas.append(meta) metas.append(meta)
return metas 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 # Verify each state_hub_workstream_id reference
file_ws_ids: set[str] = set() file_ws_ids: set[str] = set()
active_file_ws_ids: set[str] = set()
for meta in metas: for meta in metas:
ws_id = str(meta.get("state_hub_workstream_id", "")).strip() ws_id = str(meta.get("state_hub_workstream_id", "")).strip()
if not ws_id: 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", ""))) str(meta.get("id", "")))
continue continue
file_ws_ids.add(ws_id) 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}") ws = _api_get(api_base, f"/workstreams/{ws_id}")
if ws is None: if ws is None:
report.add(Level.FAIL, "workstream-ref-exists", 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 continue
ws_id = ws["id"] ws_id = ws["id"]
ws_slug = ws.get("slug", "") ws_slug = ws.get("slug", "")
if ws_id not in file_ws_ids: if ws_id not in active_file_ws_ids:
report.add( report.add(
Level.FAIL, "orphan-workstream", Level.FAIL, "orphan-workstream",
f"Active workstream '{ws_slug}' (id={ws_id[:8]}…, domain={t_domain}) " f"Active workstream '{ws_slug}' (id={ws_id[:8]}…, domain={t_domain}) "

View File

@@ -30,7 +30,10 @@ from consistency_check import (
_git_pull, _git_pull,
_patch_task_status_in_file, _patch_task_status_in_file,
_report_needs_action, _report_needs_action,
archive_closed_workplans,
canonical_workplan_filename,
get_tasks_from_workplan, get_tasks_from_workplan,
iter_workplan_files,
normalise_workstream_status, normalise_workstream_status,
parse_frontmatter, parse_frontmatter,
parse_task_blocks, parse_task_blocks,
@@ -168,6 +171,63 @@ class TestGetTasksFromWorkplan:
tasks = get_tasks_from_workplan(meta, body) tasks = get_tasks_from_workplan(meta, body)
assert tasks == [] 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): def test_ignores_non_list_frontmatter_tasks(self):
meta = {"tasks": "not-a-list"} meta = {"tasks": "not-a-list"}
body = "# No blocks\n" body = "# No blocks\n"

View 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.

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Ad Hoc Tasks and Workplan Archiving" title: "Ad Hoc Tasks and Workplan Archiving"
domain: custodian domain: custodian
repo: the-custodian repo: the-custodian
status: todo status: done
owner: custodian owner: custodian
topic_slug: custodian topic_slug: custodian
created: "2026-05-01" created: "2026-05-01"
@@ -49,7 +49,7 @@ failure; cleaner token/accounting review for opportunistic work.
```task ```task
id: CUST-WP-0036-T01 id: CUST-WP-0036-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "b9ca840e-c66f-4bce-ab83-2bec68a4c0c3" 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 ```task
id: CUST-WP-0036-T02 id: CUST-WP-0036-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "8ab029f6-8cc8-4a7c-9505-1dcd5df16f00" state_hub_task_id: "8ab029f6-8cc8-4a7c-9505-1dcd5df16f00"
``` ```
@@ -107,7 +107,7 @@ without a root-level workplan still fails.
```task ```task
id: CUST-WP-0036-T03 id: CUST-WP-0036-T03
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "6922752a-6034-479a-921a-ed1ba12c740a" state_hub_task_id: "6922752a-6034-479a-921a-ed1ba12c740a"
``` ```
@@ -131,7 +131,7 @@ and a subsequent consistency run remains clean for that workplan.
```task ```task
id: CUST-WP-0036-T04 id: CUST-WP-0036-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "a5a0f1d9-70eb-4ef4-9081-2dd6a556a89e" state_hub_task_id: "a5a0f1d9-70eb-4ef4-9081-2dd6a556a89e"
``` ```
@@ -157,7 +157,7 @@ not create a C-07 consistency failure.
```task ```task
id: CUST-WP-0036-T05 id: CUST-WP-0036-T05
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "4bc4edec-a97d-45f0-8929-5da0395a21c0" state_hub_task_id: "4bc4edec-a97d-45f0-8929-5da0395a21c0"
``` ```
@@ -179,7 +179,7 @@ failure, and the migration preserves enough history for token review.
```task ```task
id: CUST-WP-0036-T06 id: CUST-WP-0036-T06
status: todo status: done
priority: medium priority: medium
state_hub_task_id: "ac480949-2ae4-4e6f-9a85-c72b18b96d2f" state_hub_task_id: "ac480949-2ae4-4e6f-9a85-c72b18b96d2f"
``` ```