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

@@ -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
# ---------------------------------------------------------------------------