witan hooks, workflow UX & progression — implementation spec¶
Status: spec (accepted; implementation-ready for the P1 slice)
Project: wp-witan-hooks-workflow-ux-progression-improvements-852aaf
Basis: witan-workflow-hooks-elicitation-evaluation.md
(discovery findings, item ids A1–C5) and the discovery-validation memory
ctx-discovery-validation-witan-hooks-ux-elicitation--2e372f.
All source anchors below are against origin/main @ 8af6c62 (the delivered
surface-refinement base, PR #81), where this branch is rooted.
This spec turns the evaluation's what/why into concrete how: shared modules, function signatures, return schemas, CLI shapes, and a per-item test plan. The P1 slice (§2–§4) is specified to implementation depth; P2/P3 (§6) are design sketches that defer detail to the eval until they are scheduled.
1. Slices & task mapping¶
| Slice | Items | Tasks |
|---|---|---|
| S1 — correctness sweep | A1, B6, B7 | tk-fix-context-hook-double-emission-…, tk-fix-tmp-session-state-path-…, tk-unify-the-two-divergent-ready-task-… |
| S2 — continuity value | B4, B1 | tk-add-workflow-project-status-…, B1 task created this phase (see §4.2) |
| S3 — P2 batch | A2, A3, B2, B3, B5, C1–C4 | truncation / latency / memory-nudge / staleness / CLI transitions / elicitation tasks |
| S4 — P3 cleanup | A4, A5, B8, B9, C5 | debug / repo-detection / task-phase / skill-refresh / repo-elicit tasks |
Order: S1 → S2 → S3 → S4 (eval "Suggested sequencing"). S1 lands one PR; S2 a second. S2 depends on nothing in S1 but is sequenced after so the trust fix (double-emission) ships first.
2. Shared infrastructure¶
Two leaf modules, both pure (no import of server/context, so no cycle)
— they hold the single source of truth the divergence bugs violated.
2.1 witan/session_state.py — one temp-path helper (fixes B6)¶
import os, tempfile
from pathlib import Path
_STATE_FILE_PREFIX = "workflow-session-"
def session_state_dir() -> Path:
"""Temp dir for session-state files. Honors TMPDIR/TEMP/TMP with the
stdlib fallback+writability chain, matching tempfile everywhere."""
return Path(tempfile.gettempdir())
def session_state_path(session_id: str) -> Path:
return session_state_dir() / f"{_STATE_FILE_PREFIX}{session_id}.json"
def iter_session_state_files() -> list[Path]:
return sorted(session_state_dir().glob(f"{_STATE_FILE_PREFIX}*.json"))
Callers rewired to this module:
server.py:1231-1235(_STATE_FILE_PREFIX,_session_state_path) → re-export/delegate.server.py:2098-2099cleanup glob →iter_session_state_files().context.py:195-196(session_checkpoint) →session_state_path(session_id). This is the actual bug site: it hardcodesos.environ.get("TMPDIR","/tmp"), which diverges fromtempfile.gettempdir()whenTMPDIRis unset butTEMP/TMPis set, or when/tmpis non-writable and stdlib falls back.skills/witan-workflow/SKILL.mdglob guidance → describe "your temp dir (TMPDIR, else the platform default)", not a literal/tmp.
2.2 witan/readiness.py — one ready-task filter (fixes B7)¶
Lifts the lease logic out of server.py (_CLAIM_LEASE_SECONDS,
_lease_expired at server.py:195-206) so both the tool and the hook share it.
from datetime import datetime, timezone
CLAIM_LEASE_SECONDS = 3600
_PRIORITY = {"p0": 0, "p1": 1, "p2": 2, "p3": 3}
def lease_expired(claimed_at: str | None, *, now: datetime | None = None) -> bool: ...
def is_ready(task: dict, status_by_slug: dict[str, str], *, now=None) -> bool:
"""Ready == blockers all closed AND the task is claimable:
- status in {open, blocked}; OR
- status == in_progress with an expired lease (reclaimable)."""
if any(
status_by_slug.get(b, "closed") != "closed"
for b in (task.get("blocked_by") or [])
):
return False
status = task.get("status")
if status in ("open", "blocked"):
return True
return status == "in_progress" and lease_expired(task.get("claimed_at"), now=now)
def filter_ready(tasks: list[dict], *, now=None) -> list[dict]:
status_by_slug = {t["slug"]: t.get("status") for t in tasks}
ready = [t for t in tasks if is_ready(t, status_by_slug, now=now)]
ready.sort(key=lambda t: _PRIORITY.get(t.get("priority", "p3"), 9))
return ready
Callers rewired:
context.py:76-86(the hook's inlinestatus in (open, blocked) and all blockers closed+_PRIORITYsort) →readiness.filter_ready(tasks). This is the divergence: the hook currently omits thein_progress+expired-lease reclaim path thattask_readyhonors.server.pytask_ready(:2559, lease check at:2615) →is_ready/filter_ready, keeping its own fetch/query but delegating the predicate.
Behavior change to note in the PR: the injected "Ready Tasks" list will now
include reclaimable-lease in_progress tasks, matching task_ready. Intended.
3. S1 correctness sweep — remaining item¶
3.1 A1 — context block double-emission¶
Mechanism (confirmed): adapters/claude.py:merge_hooks (:48-72) dedups on the
exact command string per event. witan setup registers the bare
witan inject-context (setup.py:49); docs/agent-memory.md:102,1550,1561-1567
still tell users to register bash ~/.claude/hooks/workflow-context-inject.sh,
whose body runs witan inject-context (hooks/workflow-context-inject.sh:4).
Different strings → both survive → block prints twice (reproduced live in the
discovery session's prompt context).
Fix (three parts):
- Stable hook identity + legacy prune. Add an optional
identity: str | NonetoDeclarativeHook(defaultNone→ falls back tocommand, so no existing behavior changes).merge_hooksdedups onidentity or command;witan/setup.pysetsidentity="witan:inject-context"/"witan:session-checkpoint". Add alegacy_commands: list[str]to the witan hooks and have the apply path callremove_hooksfor them (the inverse already exists atclaude.py:75-103) beforemerge_hooks, pruning any pre-existing.sh-wrapper entry — a targeted migration, not blanket--prune. Legacy commands to prune:bash ~/.claude/hooks/workflow-context-inject.sh,bash ~/.claude/hooks/workflow-session-checkpoint.sh, and the$REPO/configs/hooks/…symlink forms. - Docs. Rewrite
docs/agent-memory.md:102,1550,1561-1567to register the barewitan inject-context/witan session-checkpointcommands; drop theln -sf …workflow-context-inject.shstep. - Collapse source-of-truth dirs. The
.shwrappers exist in bothwitan/witan/hooks/and (per docs)configs/hooks/. Keep exactly one — the packagedwitan/witan/hooks/— as an optional escape hatch, and stop the docs/setup pointing atconfigs/hooks/.
Tests: tests/test_setup.py — applying twice yields one entry per event;
applying over a pre-existing legacy .sh entry replaces (not appends) it.
4. S2 continuity value¶
4.1 B4 — workflow_project_status tool + witan project status CLI¶
New MCP tool (sync; no elicitation):
@mcp.tool
def workflow_project_status(slug: str) -> dict:
"""One-call resume view: phase + ready tasks + last session + blockers."""
Return schema:
{
"project": {"slug", "title", "phase", "status", "repos", "github_pr"},
"ready_tasks": [{"slug","title","priority","status","assignee"}], // via readiness.filter_ready(list_tasks_by_project)
"last_session": {"slug","summary","ended_at","open": bool} | null, // latest of list_sessions_by_project, open == ended_at is null
"blockers": ["wp-…"], // project.blocked_by
"counts": {"ready": N, "open_tasks": M}
}
Reuses existing read queries only — get_workflow_project,
list_tasks_by_project, list_sessions_by_project (all present in
queries/read.gq). "Latest session" = max by ended_at else created_at.
CLI: add project status <slug> to cli/projects.py (alongside
_project_show at :74), rendering the same four sections via the existing
_render_project helper (:233) plus a ready-task table and the last-session
line. Human-readable default; --format json passes the tool payload through
(per the omnigraph-v0.7 CLI convention in memory feedback_omnigraph_v070_cli).
4.2 B1 — surface last session summary on resume (context hook)¶
The highest-value continuity gap: inject_context (context.py:27-149) never
reads sessions, so the written handoff summary is invisible on resume. No task
existed for this — created this phase (see task creation below).
Design: after the "Active Workflow Projects" block, for each shown project
(top 3), fetch list_sessions_by_project, take the latest, and append:
Wrap the session fetch/render in its own try/except → skip block, isolated
exactly like the existing CodeBranch block (context.py:58-73), so a query
failure never blanks the projects/ready context that already works. Truncate
the summary to one line / ~200 chars. Depends on nothing in B4 but shares the
"latest session" selection rule — factor a tiny latest_session(sessions)
helper (in context.py, or readiness.py if reused by B4's tool).
Tests: tests/test_context.py — with a session present, the block contains the
summary line and the open/ended marker; with the session query raising, the
projects/tasks block is still emitted intact.
5. Cross-cutting constraints¶
- Elicitation is additive-only and out of the P1 slice. When S3 adds it
(C1–C4), introduce
_elicit_or(ctx, ..., default)(tryctx.elicit, catch unsupported/declined → default) and convert only those four tools toasync defwithctx: Context. Never gate the headless paths (session_start/_end,project_list,memory_store,code_reindex,workflow_trace_mine) on it. - Hooks must stay fault-tolerant: every new hook code path keeps the
except Exception: return ""/passenvelope; new work goes in isolated try-blocks so one failing query can't blank the rest.
6. P2/P3 design sketches (deferred detail — see eval)¶
- A2 honest truncation (
context.py:97,130,133): print "showing top 5 of N". - A3 latency (
context.py:65,165-169): settimeout_secondson the declarative hook; add a short-TTL repo/branch cache mirroring witan-code's_cached_git; consider collapsing reads. - B2 no-session memory nudge (
server.py:846-847):memory_storeresult flags when it stored without an active session; hook nudges when a repo has an active project but no session-state file. - B3 staleness + soft advance-validation (
server.py:1431): hook staleness line;workflow_project_advancereturns a note on backward/skip transitions (no hard block). - B5 CLI transitions: add
project advance|complete|block,session end,task close|claim|release|link|updateto the CLI — the transition surface is MCP-only today. - A4
inject-context --debug; A5 repo detection beyondorigin; B8 optional taskphasetag; B9 skill refresh +workflow_trace_get; C5 elicit repo onNonewith headless fallback.
7. Definition of done (P1 slice)¶
session_state.py+readiness.pyexist;server.pyandcontext.pyboth import them; no inline duplicate of either path/predicate remains.witan setupapplied twice → exactly one hook entry per event; a pre-existing legacy.shentry is pruned;docs/agent-memory.mdno longer registers.sh.workflow_project_statustool +witan project statusCLI return the four sections; injected context shows the last-session summary line.pre-commit,mypy, and the witan test suite pass; new tests per §3.1/§4.