Implemented Ad-Hoc Task handling
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user