From 0ac04b5c8515ab1c9abdc9477e759d1f54089c04 Mon Sep 17 00:00:00 2001 From: Dennis Date: Tue, 7 Jul 2026 09:57:50 +0300 Subject: [PATCH] Sync generic engine improvements from private Forward-ports the generic weft features developed privately, excluding all company-specific templates and tooling. Added: - weft doctor / wf-doctor: template drift + plugin clone currency - weft analyze / wf-analyze: per-template durations, loops, friction - scripts/wf.py curses TUI + wf-monitor.py plain-text/JSON viewer - unattended-park at human gates (WEFT_UNATTENDED) + executor steps - richer event model: wf.guard_blocked, wf.stalled, paired wf.step_changed, wf.failed - context.md surfacing (description, next action, suggest, insights) - codex-review template; wf-resume / wf-run-step / wf-template skills Fixed: - insights step field now populated into step state (was read by projections but never carried from the template) Changed: - workflow_id carries second resolution (name-YYYYMMDD-HHMMSS) Excluded (company-specific): amonit-triage + address-pr-feedback templates, command-deck, test_amonit_triage. Sanitized default paths and examples in wf-monitor.py, wf-analyze, README to neutral values. 263 tests pass. --- CHANGELOG.md | 13 + README.md | 30 +- core/cli.py | 426 +++++++++++++++++++++- core/dashboard.py | 163 ++++++++- core/guard_engine.py | 11 +- core/projections.py | 36 +- core/state_machine.py | 208 ++++++++++- core/templates.py | 84 +++++ docs/demo.md | 2 +- docs/demos/pitch.cast | 4 +- docs/submissions/README.md | 2 +- hooks/weft-pretooluse.sh | 4 + hooks/weft-sessionstart.sh | 53 +++ scripts/wf-monitor.py | 349 ++++++++++++++++++ scripts/wf.py | 592 +++++++++++++++++++++++++++++++ skills/ev-query/SKILL.md | 3 +- skills/wf-analyze/SKILL.md | 35 ++ skills/wf-dashboard/SKILL.md | 41 --- skills/wf-doctor/SKILL.md | 32 ++ skills/wf-edit-template/SKILL.md | 57 --- skills/wf-new-template/SKILL.md | 62 ---- skills/wf-resume/SKILL.md | 44 +++ skills/wf-run-step/SKILL.md | 69 ++++ skills/wf-start/SKILL.md | 2 +- skills/wf-template/SKILL.md | 85 +++++ templates/codex-review.json | 50 +++ templates/feature-workflow.json | 4 +- tests/test_behavioral.py | 15 +- tests/test_cli.py | 529 +++++++++++++++++++++++++++ tests/test_doctor.py | 69 ++++ tests/test_guard_engine.py | 36 +- tests/test_hooks.py | 304 ++++++++++++++++ tests/test_projections.py | 115 ++++++ tests/test_state_machine.py | 6 +- tests/test_unattended.py | 117 ++++++ uv.lock | 158 +++++++++ 36 files changed, 3604 insertions(+), 206 deletions(-) create mode 100755 scripts/wf-monitor.py create mode 100755 scripts/wf.py create mode 100644 skills/wf-analyze/SKILL.md delete mode 100644 skills/wf-dashboard/SKILL.md create mode 100644 skills/wf-doctor/SKILL.md delete mode 100644 skills/wf-edit-template/SKILL.md delete mode 100644 skills/wf-new-template/SKILL.md create mode 100644 skills/wf-resume/SKILL.md create mode 100644 skills/wf-run-step/SKILL.md create mode 100644 skills/wf-template/SKILL.md create mode 100644 templates/codex-review.json create mode 100644 tests/test_doctor.py create mode 100644 tests/test_unattended.py create mode 100644 uv.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b45d5..2f2279d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to weft are documented here. Format follows [Keep a Changelo ## [Unreleased] ### Added +- **`weft doctor` (`/wf-doctor`) — template currency.** Reports whether a working copy (project-local or user-global) has drifted from the plugin-bundled canonical it was derived from — the one staleness git can't see, since the copies live in different trees. Compares by content hash (key-order/whitespace independent), so only real semantic drift is flagged. Also reports whether the plugin's own git clone is behind upstream (no network; HEAD vs the already-fetched `@{u}`). Read-only, no persisted state. `--json` for machine output. +- **`weft analyze` (`/wf-analyze`) — per-template run insight.** Reads the event log into a per-template rollup: step durations (median/p90 from paired `wf.step_changed` deltas), loop counts, guard-block counts, abandonment + stall counts, slowest step, plus a recurring-friction section joined from `workflow-qa.jsonl` (by `workflow_id`). Turns the event log from a record into a feedback loop. +- **`wf.guard_blocked` events.** The guard engine appends a `wf.guard_blocked` event (`workflow_id`, `step_id`, `step_name`, `pattern`, `command`, `tool`) when it blocks a tool call — previously blocks were silent, so "does this guard ever fire?" was unanswerable from the log. Matching logic unchanged. +- **`wf.stalled` annotation + real session-id capture.** SessionStart writes the live session id to a per-project `/.session-id` (read by `cli.py` as a fallback), fixing events logged with `session_id="unknown"`; a running workflow idle longer than `WEFT_STALL_HOURS` (default 12) gets a pure-annotation `wf.stalled` event (state untouched). +- **Context surfacing in `context.md`.** `generate_context_md` renders the current step's `description`, a computed `Next action:` line (gated on `running`/`waiting`), and two optional per-step fields — `suggest` (commands that fit the step) and `insights` (💡 hard-won findings) — so the right command and the why-it-bites surface when a step goes active and survive compaction. +- **`wf.step_changed` paired start+end events.** `_advance_to_next` now emits a `wf.step_changed` event with `to_status="running"` when each step begins. Auto-skipped optional+requires_skill steps now also emit a `wf.step_changed` with `to_status="skipped"`. `step_loop_back` emits the target step's `wf.step_changed` (pending->running) alongside the existing `wf.loop_iteration` event. Dashboards reading events.jsonl can now compute true per-step duration (paired transition deltas) and per-iteration loop timing. Test updated: feature workflow expects 2 * step_count - 1 `wf.step_changed` events. +- **`wf.failed` terminal event.** When a step's `on_fail=block` policy lands the workflow in `failed` state, `step_fail` emits a `wf.failed` event with `workflow_id`, `blocked_at_step`, `reason`, `policy`. Previously, blocked workflows had no terminal marker so downstream aggregators couldn't compute duration or distinguish "running" from "blocked-and-abandoned". +- **`scripts/wf.py` — interactive TUI** over weft workflows. Curses-based: list view (running workflows + PR/CI status), detail view (per-workflow step + recent events), templates view (discovery). Falls back to the static `wf-monitor.py` table when stdout is not a TTY or invoked with `--list` / `--json` / `--templates`. Reads per-project `.claude/weft/state.json + workflow-context.json` and per-ticket subdirectories. Read-only. +- **`scripts/wf-monitor.py` — read-only viewer** producing a plain-text aligned table over workflows + GitHub PR status. Standalone for scripting (`wf-monitor.py --json | jq`) or as the data layer behind the TUI. - `pyproject.toml` with `weft` console script entry point. - `CONTRIBUTING.md` and GitHub issue templates. - CI workflow running pytest on push and pull requests. @@ -16,7 +25,11 @@ All notable changes to weft are documented here. Format follows [Keep a Changelo - `docs/demo.md` — annotated end-to-end walkthrough captured from a real workflow run. - `Makefile` with `install`, `test`, `lint`, `clean` targets for one-command contributor onboarding. +### Fixed +- **`insights` step field is now actually populated.** `generate_context_md` rendered per-step `insights` (💡 lines) and the field was documented, but `start_workflow`/`rebuild_from_events` never copied it from the template into step state — so it silently rendered nothing. Both build sites now carry `insights`, matching the existing `suggest`-style optional fields. (`suggest` has the same latent gap; wire it the same way if a template ever uses it.) + ### Changed +- **Date-based `workflow_id` now carries second resolution** (`name-YYYYMMDD-HHMMSS`, was `name-YYYYMMDD`). Two same-day runs of one template in one project no longer share an id, so `/wf-analyze` counts them as distinct workflows instead of conflating their step timings. PR-scoped ids (`name-pr`) are unchanged — they stay stable by design. `/wf-analyze` already groups by the `name` field in `wf.started`, not by parsing the id, so the format change is backward-compatible for analytics. - README: corrected license, version, install instructions (was a fictional `claude plugin install` syntax — now uses real `/plugin marketplace add` flow), skill count (10→11, added `wf-compose`), and feature-workflow step count (12→11). - `.claude-plugin/plugin.json` bumped to `0.3.0` to match shipped feature set. diff --git a/README.md b/README.md index 7560894..f9a5e8a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ The plugin auto-registers four hooks (`SessionStart`, `PreToolUse`, `PreCompact` /wf-start generic # plan → implement → verify /wf-status # show current state /wf-step complete # advance to the next step -/wf-dashboard # open the live TUI +/wf-analyze # per-template timing + recurring friction across runs ``` Run `/wf-start` with no args for the guided template picker. @@ -118,12 +118,12 @@ JSON - **Event-sourced state machine** — append-only JSON log; rebuild state with `/wf-rebuild` - **Template system** — `generic` (3 steps) and `feature-workflow` (11 steps) bundled; add your own under `templates/` (project), `~/.weft/templates/` (user-global), or `WEFT_USER_TEMPLATES_DIR` -- **Smart skills** — 11 slash commands (`wf-start`, `wf-step`, `wf-status`, `wf-abort`, `wf-preview`, `wf-rebuild`, `wf-dashboard`, `wf-new-template`, `wf-edit-template`, `wf-compose`, `ev-query`) +- **Smart skills** — 12 slash commands (`wf-start`, `wf-step`, `wf-status`, `wf-resume`, `wf-run-step`, `wf-abort`, `wf-preview`, `wf-rebuild`, `wf-analyze`, `wf-template`, `wf-compose`, `ev-query`) - **Guard engine** — per-step `allowed-tools` and `blocked-commands` enforced via `PreToolUse` hook - **Stop gate** — refuses session exit while steps are incomplete (configurable via `on_fail: block|retry|skip`) - **Compaction-safe** — `PreCompact` hook writes a `context.md` projection so the next session resumes mid-workflow - **Live dashboard** — auto-refreshing TUI for monitoring active workflows -- **Template management** — `/wf-new-template` and `/wf-edit-template` for creating and editing templates in-session +- **Template management** — `/wf-template` for creating and editing templates in-session ## Auditable by design @@ -140,6 +140,29 @@ weft rebuild # reconstruct from events alone Use `weft query --type wf.step_changed`, `--last 50`, or `--workflow ` to filter. +## Interactive TUI + +For watching multiple workflows at once, `scripts/wf.py` is a curses-based TUI over the same state. Three views: list (every running workflow + PR/CI status), detail (per-workflow step + recent events), templates (discovery). + +```bash +python3 $WEFT_ROOT/scripts/wf.py # interactive TUI +python3 $WEFT_ROOT/scripts/wf.py --list # plain-text table (auto when not TTY) +python3 $WEFT_ROOT/scripts/wf.py --json | jq # machine-readable +python3 $WEFT_ROOT/scripts/wf.py --templates # template catalog +``` + +Keys in the list view: `j`/`k` navigate, `enter` open detail, `t` templates, `r` refresh, `q` quit. Detail view: `esc`/`h` back, `r` refresh, `q` quit. Read-only — never mutates weft state. + +The TUI scans the current directory by default; pass a path to scan elsewhere. It picks up both legacy single-workflow layouts (`/.claude/weft/`) and per-ticket layouts (`/.claude/weft/workflows//.claude/weft/`). + +For scripting without curses, `scripts/wf-monitor.py` produces the same aligned-table output and supports `--json`, `--templates`, and an explicit project path argument. + +Suggested shell alias: + +```bash +alias wf='python3 ~/.claude/plugins/cache/local/weft/0.3.0/scripts/wf.py' +``` + ## Repository Layout ``` @@ -147,6 +170,7 @@ Use `weft query --type wf.step_changed`, `--last 50`, or `--workflow ` to fi core/ Python event store, state machine, projections, CLI, dashboard hooks/ SessionStart / PreToolUse / PreCompact / Stop bash hooks + hooks.json skills/ 11 SKILL.md files for the /wf-* and /ev-* slash commands +scripts/ wf.py (curses TUI) + wf-monitor.py (plain-text + JSON viewer) templates/ generic.json, feature-workflow.json (workflow definitions) tests/ pytest suite covering event store, state machine, hooks, CLI, behaviors ``` diff --git a/core/cli.py b/core/cli.py index e14a18e..b132623 100755 --- a/core/cli.py +++ b/core/cli.py @@ -7,7 +7,8 @@ python3 cli.py status [--json] python3 cli.py abort [reason] python3 cli.py rebuild [workflow_id] - python3 cli.py query [--type TYPE] [--tool TOOL] [--last N] [--workflow ID] + python3 cli.py query [--type TYPE] [--last N] [--workflow ID] + python3 cli.py analyze [--template NAME] python3 cli.py guard # reads hook JSON from stdin python3 cli.py gate # reads hook JSON from stdin (stop hook) python3 cli.py context # outputs context.md to stdout @@ -15,16 +16,81 @@ import json import os +import statistics import sys +from datetime import datetime +from pathlib import Path # Ensure the core package is importable -sys.path.insert(0, str(__import__("pathlib").Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent)) from core import event_store, state_machine, projections, templates def _session_id() -> str: - return os.environ.get("CLAUDE_SESSION_ID", "unknown") + """Best-effort detection of the active Claude Code session id. + + Tries (in order): + 1. CLAUDE_SESSION_ID env var (canonical, set explicitly by a hook or wrapper). + 2. CLAUDE_CODE_SESSION_ID env var (alternate name used in some Claude Code builds). + 3. /.claude/weft/.session-id file (writable by a SessionStart hook, + scoped per-project so concurrent sessions don't clobber each other). + 4. CLAUDE_TRANSCRIPT_PATH env var, parsing the basename: transcripts are + typically named ".jsonl" under ~/.claude/projects//. + 5. Parse /private/tmp/claude-/// from + TMPDIR or a known Claude Code temp marker. The deepest UUID-shaped dir + in that hierarchy is the session id. + + Returns "unknown" only if every fallback fails. Detection is intentionally + silent — never raises; never logs. The cost of a bad fallback is one + "unknown" event, not a workflow break. + """ + sid = os.environ.get("CLAUDE_SESSION_ID") + if sid: + return sid + sid = os.environ.get("CLAUDE_CODE_SESSION_ID") + if sid: + return sid + + from core import weft_dir + runtime_file = weft_dir(_project_dir()) / ".session-id" + if runtime_file.is_file(): + try: + sid = runtime_file.read_text().strip() + if sid: + return sid + except OSError: + pass + + transcript = os.environ.get("CLAUDE_TRANSCRIPT_PATH") + if transcript: + stem = Path(transcript).stem + if _looks_like_uuid(stem): + return stem + + tmp_marker = os.environ.get("CLAUDE_CODE_TMPDIR") + if tmp_marker: + for p in Path(tmp_marker).parts: + if _looks_like_uuid(p): + return p + + return "unknown" + + +def _looks_like_uuid(s: str) -> bool: + """Loose UUID v4 shape check: 8-4-4-4-12 hex segments.""" + if not s or len(s) != 36: + return False + parts = s.split("-") + if len(parts) != 5: + return False + if [len(p) for p in parts] != [8, 4, 4, 4, 12]: + return False + try: + int(s.replace("-", ""), 16) + return True + except ValueError: + return False def _project_dir() -> str | None: @@ -162,9 +228,6 @@ def cmd_query(args: list[str]) -> None: if args[i] == "--type" and i + 1 < len(args): kwargs["event_type"] = args[i + 1] i += 2 - elif args[i] == "--tool" and i + 1 < len(args): - kwargs["tool"] = args[i + 1] - i += 2 elif args[i] == "--last" and i + 1 < len(args): try: kwargs["last_n"] = int(args[i + 1]) @@ -205,8 +268,6 @@ def cmd_query(args: list[str]) -> None: et = ev.get("event_type", "?") data = ev.get("data", {}) parts = [f"{ts} {et}"] - if data.get("tool"): - parts.append(f"tool={data['tool']}") if data.get("step_name"): parts.append(f"step={data['step_name']}") if data.get("to_status"): @@ -241,15 +302,25 @@ def cmd_gate(_args: list[str]) -> None: sys.exit(0) status = state.get("status", "idle") - if status in ("idle", "complete", "aborted"): + # 'waiting' = parked at a human gate (unattended on_fail=block). It's a valid + # stop state: let the session end cleanly; resume later with /wf-resume. + if status in ("idle", "complete", "aborted", "waiting"): sys.exit(0) # Session isolation — only block the session that owns the workflow. - # "unknown" means no session tracking; treat as wildcard (block all sessions). state_session = state.get("session_id", "") hook_session = hook_input.get("session_id", "") - if (state_session and state_session != "unknown" - and hook_session and hook_session != "unknown" + # An "unknown" owner means session tracking failed at start. Blocking every + # session on a guess is worse than the occasional missed gate, so warn-not-block: + # print a one-line note and let the Stop through. + if state_session == "unknown": + print( + f"weft: workflow '{state['name']}' has no tracked session — " + f"not blocking Stop (run /weft:wf-status to check).", + file=sys.stderr, + ) + sys.exit(0) + if (state_session and hook_session and hook_session != "unknown" and state_session != hook_session): sys.exit(0) @@ -295,6 +366,42 @@ def cmd_preview(args: list[str]) -> None: print(detail) +def cmd_doctor(args: list[str]) -> None: + """Template currency: drift of working copies vs the bundled canonical, plus + whether the plugin's git clone is behind upstream. Read-only, no network.""" + pdir = _project_dir() + report = templates.doctor(pdir) + repo = templates.plugin_repo_currency() + + if "--json" in args: + print(json.dumps({"templates": report, "plugin_repo": repo}, indent=2)) + return + + if repo["status"] == "behind": + print(f"plugin clone: behind upstream by {repo['behind']} commit(s) " + f"(at {repo.get('head', '?')}) — `git pull` in the weft repo") + elif repo["status"] == "ok": + print(f"plugin clone: up to date ({repo.get('head', '?')})") + else: + print("plugin clone: currency unknown (no git upstream / not fetched)") + + if not report: + print("no shadowed templates — every template lives in one tier only.") + return + + drifted = [r for r in report if r["status"] == "drifted"] + print(f"\nshadowed templates: {len(report)} drifted: {len(drifted)}") + for r in report: + mark = "drifted" if r["status"] == "drifted" else "current" + print(f" [{mark}] {r['name']} ({r['active_tier']})") + if r["status"] == "drifted": + print(f" active: {r['active_path']}") + print(f" canonical: {r['canonical_path']}") + if drifted: + print("\nA drifted copy shadows the bundled version. Reconcile or delete " + "the working copy to pick up the canonical template.") + + def cmd_save_template(_args: list[str]) -> None: """Save a template from JSON on stdin.""" raw = sys.stdin.read() @@ -312,6 +419,295 @@ def cmd_save_template(_args: list[str]) -> None: print(f"Template saved: {path}") +def cmd_resume(args: list[str]) -> None: + """Resume a workflow parked in 'waiting' (unattended on_fail=block gate).""" + reason = " ".join(args) if args else "" + sid = _session_id() + pdir = _project_dir() + state = state_machine.load_state(pdir) + if not state: + print("No active workflow", file=sys.stderr) + sys.exit(1) + if state.get("status") != "waiting": + print(f"Workflow is {state.get('status')}, not waiting — nothing to resume", file=sys.stderr) + sys.exit(1) + try: + state = state_machine.resume(state, reason, sid, pdir) + except ValueError as e: + print(str(e), file=sys.stderr) + sys.exit(1) + projections.write_context_md(state, pdir) + print(projections.generate_context_md(state, pdir)) + + +def cmd_run_result(args: list[str]) -> None: + """Record a Workflow-tool executor result on the current step and auto-transition. + + Reads a JSON payload from --result-file PATH or stdin: + {"run_id": "wf_...", "blocking": false, "verdict": {...}, "reason": "..."} + Used by the /wf-run-step glue: the agent invokes the step's executor Workflow, + then pipes the structured verdict here. weft records the runId + verdict and + transitions (complete if not blocking, fail if blocking). + """ + payload_text = None + if "--result-file" in args: + path = args[args.index("--result-file") + 1] + with open(path) as f: + payload_text = f.read() + else: + payload_text = sys.stdin.read() + + try: + payload = json.loads(payload_text) + except (json.JSONDecodeError, TypeError) as e: + print(f"Invalid JSON payload: {e}", file=sys.stderr) + sys.exit(1) + + run_id = payload.get("run_id", "unknown") + blocking = bool(payload.get("blocking", False)) + verdict = payload.get("verdict") + reason = payload.get("reason", "") + + sid = _session_id() + pdir = _project_dir() + state = state_machine.load_state(pdir) + if not state: + print("No active workflow", file=sys.stderr) + sys.exit(1) + if state["status"] not in ("running", "failed"): + print(f"Workflow is {state['status']}, cannot transition", file=sys.stderr) + sys.exit(1) + + state = state_machine.record_executor_result( + state, run_id, blocking, verdict, reason, sid, pdir) + projections.write_context_md(state, pdir) + print(projections.generate_context_md(state, pdir)) + + +def _parse_ts(ts: str) -> datetime | None: + """Parse an event ISO timestamp (…Z). Returns None on garbage.""" + try: + return datetime.fromisoformat(ts.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + +def _p90(values: list[float]) -> float: + """Nearest-rank p90 (manual, stdlib-only).""" + if not values: + return 0.0 + s = sorted(values) + idx = max(0, min(len(s) - 1, int(round(0.9 * (len(s) - 1))))) + return s[idx] + + +def _fmt_secs(s: float) -> str: + if s >= 3600: + return f"{s / 3600:.1f}h" + if s >= 60: + return f"{s / 60:.1f}m" + return f"{s:.0f}s" + + +def _read_qa(project_dir: str | None) -> list[dict]: + """Read workflow-qa.jsonl from the same weft dir. Empty list if absent. + + Producer: append-workflow-qa.py (consuming repos' .claude/scripts/), invoked + by the workflow-qa self-eval step in templates like address-pr-feedback.json. + Schema we read: workflow_id (template/ticket-id form) plus qa_notes (the field + that carries friction text). Legacy friction_points[]/suggested_improvements[] + are still picked up if present. + """ + from core import weft_dir + path = weft_dir(project_dir) / "workflow-qa.jsonl" + if not path.exists(): + return [] + records = [] + with open(path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue + return records + + +def cmd_analyze(args: list[str]) -> None: + """Per-template rollup from the event log: step durations (median/p90), + loop counts, guard blocks, stalls/abandonment, slowest step. Joins + workflow-qa.jsonl (by template name) for a recurring-friction section if + any qa records carry notes for a template in the rollup.""" + only_template = None + if "--template" in args: + i = args.index("--template") + if i + 1 < len(args): + only_template = args[i + 1] + + pdir = _project_dir() + events = event_store.read_all(pdir) + if not events: + print("No events recorded yet — nothing to analyze.") + return + + # The currently-live workflow has no terminal event yet but isn't abandoned. + live_state = state_machine.load_state(pdir) + live_wid = live_state.get("workflow_id") if live_state else None + + # workflow_id -> template name, from wf.started + wf_template: dict[str, str] = {} + for e in events: + if e.get("event_type") == "wf.started": + wid = e.get("workflow_id") + name = e.get("data", {}).get("name", "unknown") + if wid: + wf_template[wid] = name + + terminal = {"wf.completed", "wf.aborted", "wf.failed"} + # Per template accumulators + tmpls: dict[str, dict] = {} + + def tmpl_bucket(name: str) -> dict: + return tmpls.setdefault(name, { + "step_durations": {}, # step_name -> [secs] + "loops": {}, # step_name -> count + "guards": {}, # step_name -> count + "workflows": set(), + "terminated": set(), + "stalled": set(), + }) + + # Track open step-start timestamps per (workflow_id, step_id). Keying on + # step_id (not step_name) keeps duplicate-named steps from colliding. Value + # carries the step name so durations stay name-keyed for the display table. + open_starts: dict[tuple, tuple] = {} + + def _close(key: tuple, ts: datetime, b: dict) -> None: + started = open_starts.pop(key, None) + if started and ts: + start_ts, step_name = started + b["step_durations"].setdefault(step_name, []).append( + (ts - start_ts).total_seconds()) + + for e in events: + wid = e.get("workflow_id") + name = wf_template.get(wid) + if name is None: + continue + et = e.get("event_type") + data = e.get("data", {}) + b = tmpl_bucket(name) + b["workflows"].add(wid) + + if et == "wf.started": + # Step 0 goes straight to running without a wf.step_changed, so seed + # its start from wf.started or its first-step duration is lost. + tsteps = data.get("steps") or [] + ts = _parse_ts(e.get("ts", "")) + if tsteps and ts: + open_starts[(wid, 0)] = (ts, tsteps[0].get("name", "?")) + elif et == "wf.step_changed": + step = data.get("step_name", "?") + sid = data.get("step_id") + to = data.get("to_status") + ts = _parse_ts(e.get("ts", "")) + if to == "running" and ts: + open_starts[(wid, sid)] = (ts, step) + elif to == "complete" and ts: + _close((wid, sid), ts, b) + elif et == "wf.loop_iteration": + step = data.get("step_name", "?") + sid = data.get("step_id") + b["loops"][step] = b["loops"].get(step, 0) + 1 + # The loop anchor never gets a per-iteration to=complete event — it's + # marked complete only at loop exit. Close its open start here so each + # iteration records one duration instead of one span across the loop. + ts = _parse_ts(e.get("ts", "")) + _close((wid, sid), ts, b) + elif et == "wf.guard_blocked": + step = data.get("step_name", "?") + b["guards"][step] = b["guards"].get(step, 0) + 1 + elif et == "wf.stalled": + b["stalled"].add(wid) + if et in terminal: + b["terminated"].add(wid) + + names = sorted(tmpls) if not only_template else [only_template] + + printed_any = False + for name in names: + b = tmpls.get(name) + if not b: + continue + printed_any = True + print(f"=== {name} ===") + n_wf = len(b["workflows"]) + n_term = len(b["terminated"]) + abandoned = b["workflows"] - b["terminated"] - {live_wid} + print(f"workflows: {n_wf} completed/terminal: {n_term} " + f"abandoned: {len(abandoned)} stalled: {len(b['stalled'])}") + + # Step table + steps = sorted(set(b["step_durations"]) | set(b["loops"]) | set(b["guards"])) + if steps: + hdr = f" {'step':<20} {'median':>8} {'p90':>8} {'loops':>6} {'guards':>7}" + print(hdr) + print(" " + "-" * (len(hdr) - 2)) + slowest = None + slowest_med = -1.0 + for s in steps: + durs = b["step_durations"].get(s, []) + med = statistics.median(durs) if durs else 0.0 + p90 = _p90(durs) + loops = b["loops"].get(s, 0) + guards = b["guards"].get(s, 0) + print(f" {s:<20} {_fmt_secs(med):>8} {_fmt_secs(p90):>8} " + f"{loops:>6} {guards:>7}") + if durs and med > slowest_med: + slowest_med = med + slowest = s + if slowest: + print(f" slowest step: {slowest} ({_fmt_secs(slowest_med)} median)") + print() + + if not printed_any: + print("No matching workflows.") + return + + # Recurring friction join (workflow-qa.jsonl), grouped by template. qa records + # key on "template/ticket-id"; events key on "template-YYYYMMDD". The shared + # key is the template name, so we parse it from the qa workflow_id prefix and + # only join records whose template actually appears in the event rollup. + qa = _read_qa(pdir) + if qa: + friction: dict[str, list[str]] = {} + for rec in qa: + wid = rec.get("workflow_id") or "" + name = wid.split("/", 1)[0] + if name not in tmpls or (only_template and name != only_template): + continue + items = [] + notes = rec.get("qa_notes") + if isinstance(notes, dict): + # Structured shape: surface only the pain, not what_worked. + items += list(notes.get("friction_points", [])) + items += list(notes.get("suggested_improvements", [])) + elif notes: + items.append(notes) + items += list(rec.get("friction_points", [])) + items += list(rec.get("suggested_improvements", [])) + if items: + friction.setdefault(name, []).extend(items) + if friction: + print("=== recurring friction ===") + for name in sorted(friction): + print(f"{name}:") + for item in friction[name]: + print(f" - {item}") + + def cmd_dashboard(_args: list[str]) -> None: """Launch the interactive TUI dashboard.""" from core.dashboard import WeftDashboard @@ -322,18 +718,22 @@ def cmd_dashboard(_args: list[str]) -> None: def main(): if len(sys.argv) < 2: print("Usage: cli.py [args...]", file=sys.stderr) - print("Commands: start, step, status, abort, rebuild, query, preview, save-template, dashboard, guard, gate, context", + print("Commands: start, step, status, abort, rebuild, query, analyze, preview, doctor, save-template, dashboard, guard, gate, context", file=sys.stderr) sys.exit(1) commands = { "start": cmd_start, "step": cmd_step, + "run-result": cmd_run_result, + "resume": cmd_resume, "status": cmd_status, "abort": cmd_abort, "rebuild": cmd_rebuild, "query": cmd_query, + "analyze": cmd_analyze, "preview": cmd_preview, + "doctor": cmd_doctor, "save-template": cmd_save_template, "dashboard": cmd_dashboard, "guard": cmd_guard, diff --git a/core/dashboard.py b/core/dashboard.py index 794902e..ec97d43 100644 --- a/core/dashboard.py +++ b/core/dashboard.py @@ -3,6 +3,7 @@ import json import os +import subprocess import sys from pathlib import Path @@ -58,6 +59,41 @@ def _load_template_detail(name: str) -> dict | None: return None +# Path to the shared Command Deck snapshot lib (read by subprocess, not import, +# to keep weft decoupled from ~/.agents). +DECK_LIB = os.path.expanduser("~/.agents/scripts/claude/agent_status.py") + + +def _load_deck_status(sections: str) -> dict | None: + """Read the Command Deck snapshot via subprocess. + + Returns the parsed JSON dict, or None if the deck lib is absent or the + call fails (caller renders a placeholder rather than crashing). + """ + if not os.path.exists(DECK_LIB): + return None + try: + proc = subprocess.run( + ["python3", DECK_LIB, "--json", "--section", sections], + capture_output=True, + text=True, + timeout=10, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + return None + return json.loads(proc.stdout) + except (subprocess.SubprocessError, OSError, ValueError): + return None + + +def _deck_missing_panel(title: str, border: str) -> Panel: + content = Text() + content.append(" deck lib not found\n\n", style="bold yellow") + content.append(f" Expected at:\n {DECK_LIB}\n\n", style="dim") + content.append(" Install ~/.agents to populate this panel.", style="dim") + return Panel(content, title=title, border_style=border) + + # ── Panel builders ───────────────────────────────────────────────── @@ -282,6 +318,124 @@ def _build_architecture_panel() -> Panel: return Panel(lines, title="[bold white] Architecture [/]", border_style="white") +def _build_multi_workflow_panel(deck: dict | None) -> Panel: + title = "[bold green] All Workflows [/]" + if deck is None: + return _deck_missing_panel(title, "green") + + wf = deck.get("workflows", {}) + if not wf.get("available"): + content = Text(" No workflow data available.", style="dim") + return Panel(content, title=title, border_style="green") + + status_styles = { + "running": "bold green", + "complete": "blue", + "failed": "bold red", + "aborted": "yellow", + "waiting": "bold yellow", + } + status_glyph = { + "running": "►", + "complete": "✓", + "failed": "✗", + "aborted": "–", + "waiting": "⏸", + } + + lines = Text() + counts = wf.get("status_counts", {}) + lines.append(f" {wf.get('count', 0)} workflows", style="bold white") + lines.append(" ", style="") + summary = [] + for st in ("running", "complete", "failed", "aborted"): + n = counts.get(st, 0) + if n: + summary.append((st, n)) + for i, (st, n) in enumerate(summary): + lines.append(f"{n} {st}", style=status_styles.get(st, "white")) + if i < len(summary) - 1: + lines.append(" ", style="dim") + lines.append("\n\n", style="") + + # Show running first, then most recently created; cap the list. + items = list(wf.get("workflows", [])) + order = {"running": 0, "failed": 1, "aborted": 2, "complete": 3} + items.sort(key=lambda w: (order.get(w.get("status"), 9), -(w.get("age") or {}).get("seconds", 0))) + + for w in items[:14]: + st = w.get("status", "?") + style = status_styles.get(st, "white") + glyph = status_glyph.get(st, "?") + lines.append(f" {glyph} ", style=style) + lines.append(f"{w.get('name', '?')}", style=style) + + step_idx = w.get("current_step_index") + n_done = w.get("n_complete", 0) + n_steps = w.get("step_count", 0) + if st == "running" and w.get("current_step_name"): + lines.append(f" {n_done}/{n_steps} ", style="dim") + lines.append(f"@ {w['current_step_name']}", style="cyan") + else: + lines.append(f" {n_done}/{n_steps}", style="dim") + + age = (w.get("age") or {}).get("human") + if age: + lines.append(f" ({age})", style="dim") + lines.append("\n") + + return Panel(lines, title=title, border_style="green") + + +def _build_executors_panel(deck: dict | None) -> Panel: + title = "[bold magenta] Executors [/]" + if deck is None: + return _deck_missing_panel(title, "magenta") + + ex = deck.get("executors", {}) + if not ex.get("available"): + content = Text(" No executor data available.", style="dim") + return Panel(content, title=title, border_style="magenta") + + task_styles = { + "in_progress": "bold cyan", + "pending": "yellow", + "completed": "green", + } + + lines = Text() + lines.append(f" {ex.get('run_count', 0)} runs", style="bold white") + lines.append(f" | {ex.get('total_agent_count', 0)} agents", style="dim") + # session todos = conversation-level todos for the sessions that launched runs + # (NOT a run's own task list) — labelled honestly so it isn't misread as run state. + lines.append(f" | {ex.get('active_task_count', 0)} open session todos", style="dim") + lines.append("\n\n", style="dim") + + runs = list(ex.get("runs", [])) + runs.sort(key=lambda r: r.get("last_activity_iso") or "", reverse=True) + + for r in runs[:10]: + rid = r.get("run_id", "?") + lines.append(f" {rid}", style="bold magenta") + lines.append(f" {r.get('agent_count', 0)} agents", style="dim") + act = r.get("last_activity_iso") or "" + if len(act) >= 19: + lines.append(f" {act[11:19]}", style="dim") + lines.append("\n") + + for t in r.get("tasks", [])[:3]: + st = t.get("status", "?") + tstyle = task_styles.get(st, "white") + lines.append(f" {st:<12}", style=tstyle) + subj = t.get("subject", "?") + lines.append(f"{subj[:48]}\n", style="") + extra = r.get("task_count", 0) - 3 + if extra > 0: + lines.append(f" [dim]+{extra} more tasks[/]\n", style="") + + return Panel(lines, title=title, border_style="magenta") + + # ── TUI App ──────────────────────────────────────────────────────── @@ -289,9 +443,9 @@ class WeftDashboard(App): CSS = """ Screen { layout: grid; - grid-size: 2 3; + grid-size: 2 4; grid-columns: 1fr 1fr; - grid-rows: auto auto auto; + grid-rows: auto auto auto auto; grid-gutter: 1; padding: 1; overflow-y: auto; @@ -318,6 +472,8 @@ def compose(self) -> ComposeResult: yield Static(id="right-mid", classes="panel") yield Static(id="left-bot", classes="panel") yield Static(id="right-bot", classes="panel") + yield Static(id="left-deck", classes="panel") + yield Static(id="right-deck", classes="panel") yield Footer() def on_mount(self) -> None: @@ -333,6 +489,7 @@ def _render_panels(self) -> None: skills = _load_skills() tmpls = templates.list_templates(pdir) hooks_json = _load_hooks_json() + deck = _load_deck_status("workflows,executors") self.query_one("#left-top", Static).update(_build_workflow_panel(state)) self.query_one("#right-top", Static).update(_build_skills_panel(skills)) @@ -340,6 +497,8 @@ def _render_panels(self) -> None: self.query_one("#right-mid", Static).update(_build_hooks_panel(hooks_json)) self.query_one("#left-bot", Static).update(_build_events_panel(pdir)) self.query_one("#right-bot", Static).update(_build_architecture_panel()) + self.query_one("#left-deck", Static).update(_build_multi_workflow_panel(deck)) + self.query_one("#right-deck", Static).update(_build_executors_panel(deck)) def main(): diff --git a/core/guard_engine.py b/core/guard_engine.py index 17e40a0..b476844 100644 --- a/core/guard_engine.py +++ b/core/guard_engine.py @@ -4,7 +4,7 @@ import re import sys -from . import state_machine +from . import event_store, state_machine def evaluate(hook_input: dict, project_dir: str | None = None) -> dict | None: @@ -43,6 +43,15 @@ def evaluate(hook_input: dict, project_dir: str | None = None) -> dict | None: f"(current step: '{current_name}')" if isinstance(guard, dict) and guard.get("message"): msg = guard["message"] + event_store.append( + "wf.guard_blocked", + {"workflow_id": state["workflow_id"], "step_id": step["id"], + "step_name": step_name, "pattern": pattern, + "command": command, "tool": tool_name}, + session_id=state.get("session_id", "unknown"), + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) return {"blocked": True, "reason": msg} except re.error as e: print(f"[weft] Warning: invalid guard pattern '{pattern}': {e}", file=sys.stderr) diff --git a/core/projections.py b/core/projections.py index 265abab..051365f 100644 --- a/core/projections.py +++ b/core/projections.py @@ -33,13 +33,47 @@ def generate_context_md(state: dict, project_dir: str | None = None) -> str: suffix = f" (on_fail: {s['on_fail']}, retries: {s.get('retry_count', 0)})" opt = " [optional]" if s.get("optional") else "" skill = f" → invoke: {s['skill']}" if s.get("skill") else "" + execu = " → run: /wf-run-step" if s.get("executor") else "" loop = "" if s.get("loop_back_to"): loop = f" ↻ loops to {s['loop_back_to']} ({s.get('loop_count', 0)}/{s.get('max_iterations', 3)})" - lines.append(f"- [{mark}] {s['name']} ({s['status']}){suffix}{opt}{skill}{loop}") + lines.append(f"- [{mark}] {s['name']} ({s['status']}){suffix}{opt}{skill}{execu}{loop}") + + if status in ("running", "waiting") and current < total: + cur_step = steps[current] + if cur_step.get("description"): + lines.append("") + lines.append(cur_step["description"]) + if status == "waiting": + # Parked at a human gate (unattended on_fail=block) — resume, don't + # re-run the step's executor that just failed/blocked. + next_action = "/wf-resume" + elif cur_step.get("executor"): + next_action = "/wf-run-step" + else: + next_action = "/wf-step complete" + lines.append("") + lines.append(f"Next action: {next_action}") + + suggest = list(cur_step.get("suggest") or []) + if cur_step.get("skill"): + suggest.insert(0, f"{cur_step['skill']} (this step)") + if suggest: + lines.append(f"Suggested commands: {', '.join(suggest)}") + for insight in cur_step.get("insights") or []: + lines.append(f" 💡 {insight}") if status == "running" and current < total: cur_step = steps[current] + ex = cur_step.get("executor") + if ex: + script = ex.get("script", "?") if isinstance(ex, dict) else "?" + lines.append("") + lines.append( + f"▶ This step has a Workflow executor ({script}). Run /wf-run-step to " + "execute it and auto-transition on the verdict (or advance manually with " + "/wf-step)." + ) guards = cur_step.get("guards", []) if guards: lines.append("") diff --git a/core/state_machine.py b/core/state_machine.py index deb8b2d..81c203b 100644 --- a/core/state_machine.py +++ b/core/state_machine.py @@ -1,11 +1,39 @@ """Workflow state machine — pure state transitions + persistence.""" import json +import os +import subprocess from pathlib import Path from . import weft_dir, now_iso, now_dt_and_iso, event_store +def _detect_pr_number(project_dir: str | None = None) -> str | None: + """Return current PR number as a string, or None. + + Order: CLAUDE_CODE_PR_NUMBER env (set by `claude --from-pr`) → `gh pr view` + against the current branch. Silent on failure. + """ + env_pr = os.environ.get("CLAUDE_CODE_PR_NUMBER") + if env_pr and env_pr.isdigit(): + return env_pr + try: + result = subprocess.run( + ["gh", "pr", "view", "--json", "number", "-q", ".number"], + cwd=project_dir or None, + capture_output=True, + text=True, + timeout=3, + ) + if result.returncode == 0: + num = result.stdout.strip() + if num.isdigit(): + return num + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + def _state_path(project_dir: str | None = None) -> Path: return weft_dir(project_dir) / "state.json" @@ -46,15 +74,26 @@ def start_workflow(template: dict, session_id: str = "unknown", raise ValueError("Template must have at least one step") now_dt, now = now_dt_and_iso() - date_slug = now_dt.strftime("%Y%m%d") name = template["name"] - workflow_id = f"{name}-{date_slug}" + pr_num = _detect_pr_number(project_dir) + if pr_num: + workflow_id = f"{name}-pr{pr_num}" + else: + # Time-of-day in the slug so two same-day runs of one template in one + # project get distinct ids — otherwise both log under name-YYYYMMDD and + # /wf-analyze conflates them into one workflow. PR-scoped ids stay stable + # by design (one workflow per PR across days). + # ponytail: second-resolution; add a nonce only if sub-second concurrent + # starts in the same project ever actually collide. + date_slug = now_dt.strftime("%Y%m%d-%H%M%S") + workflow_id = f"{name}-{date_slug}" steps = [] for i, step_def in enumerate(template["steps"]): step = { "id": i, "name": step_def["name"], + "description": step_def.get("description"), "status": "running" if i == 0 else "pending", "context": step_def.get("context", "inline"), "on_fail": step_def.get("on_fail", "block"), @@ -65,6 +104,11 @@ def start_workflow(template: dict, session_id: str = "unknown", "loop_back_to": step_def.get("loop_back_to"), "max_iterations": step_def.get("max_iterations", 3), "exit_condition": step_def.get("exit_condition"), + "insights": step_def.get("insights"), + "executor": step_def.get("executor"), + "notify": step_def.get("notify"), + "workflow_run_id": None, + "verdict": None, "started_at": now if i == 0 else None, "completed_at": None, "retry_count": 0, @@ -80,6 +124,7 @@ def start_workflow(template: dict, session_id: str = "unknown", "created_at": now, "session_id": session_id, "template": name, + "notify": template.get("notify", False), "steps": steps, "version": 1, } @@ -89,6 +134,7 @@ def start_workflow(template: dict, session_id: str = "unknown", event_store.append( "wf.started", {"workflow_id": workflow_id, "name": name, "step_count": len(steps), + "notify": template.get("notify", False), "steps": template["steps"]}, session_id=session_id, workflow_id=workflow_id, @@ -98,8 +144,16 @@ def start_workflow(template: dict, session_id: str = "unknown", return state -def _advance_to_next(state: dict) -> None: - """Move current_step to the next non-skipped pending step.""" +def _advance_to_next(state: dict, + session_id: str = "unknown", + project_dir: str | None = None) -> None: + """Move current_step to the next non-skipped pending step. + + Emits wf.step_changed events for each auto-skipped step (to_status="skipped") + and for the next running step (to_status="running"). Without these, dashboards + have no signal for when each step BEGAN, only when it ended — making per-step + duration analysis impossible. + """ now = now_iso() idx = state["current_step"] + 1 steps = state["steps"] @@ -110,6 +164,15 @@ def _advance_to_next(state: dict) -> None: if step.get("optional") and step.get("requires_skill"): step["status"] = "skipped" step["completed_at"] = now + event_store.append( + "wf.step_changed", + {"step_id": step["id"], "step_name": step["name"], + "from_status": "pending", "to_status": "skipped", + "reason": "auto-skipped (optional + requires_skill)"}, + session_id=session_id, + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) idx += 1 continue break @@ -118,6 +181,14 @@ def _advance_to_next(state: dict) -> None: state["current_step"] = idx steps[idx]["status"] = "running" steps[idx]["started_at"] = now + event_store.append( + "wf.step_changed", + {"step_id": steps[idx]["id"], "step_name": steps[idx]["name"], + "from_status": "pending", "to_status": "running", "reason": "advance"}, + session_id=session_id, + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) else: state["status"] = "complete" @@ -148,7 +219,7 @@ def step_complete(state: dict, reason: str = "", project_dir=project_dir, ) - _advance_to_next(state) + _advance_to_next(state, session_id, project_dir) if state["status"] == "complete": event_store.append( @@ -171,6 +242,22 @@ def step_fail(state: dict, reason: str = "", policy = step["on_fail"] from_status = step["status"] + # Unattended + block: park the workflow instead of failing it, so a headless + # (shim) run can stop cleanly at the gate and resume later rather than hang. + # The step stays running (work isn't abandoned — it's waiting for a human); + # the workflow goes to 'waiting', which the Stop gate treats as a valid stop. + if policy == "block" and os.environ.get("WEFT_UNATTENDED") == "1": + state["status"] = "waiting" + event_store.append( + "wf.needs_human", + {"step_id": step["id"], "step_name": step["name"], "reason": reason}, + session_id=session_id, + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) + save_state(state, project_dir) + return state + if policy == "retry" and step["retry_count"] < 1: step["retry_count"] += 1 to_status = "running" @@ -178,7 +265,7 @@ def step_fail(state: dict, reason: str = "", elif policy == "continue": step["status"] = "failed" to_status = "failed" - _advance_to_next(state) + _advance_to_next(state, session_id, project_dir) else: step["status"] = "failed" state["status"] = "failed" @@ -194,6 +281,42 @@ def step_fail(state: dict, reason: str = "", project_dir=project_dir, ) + # Emit a workflow-level terminal event when on_fail=block lands a step in failed + # state with no retries left. Without this, blocked workflows have no terminal + # marker in events.jsonl, so dashboards can't compute total duration and downstream + # log aggregators (workflow-qa, weft-monitor, weft-tui) can't distinguish + # "running" from "blocked-and-abandoned". + if state["status"] == "failed": + event_store.append( + "wf.failed", + {"workflow_id": state["workflow_id"], "name": state["name"], + "blocked_at_step": step["name"], "reason": reason, "policy": policy}, + session_id=session_id, + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) + + save_state(state, project_dir) + return state + + +def resume(state: dict, reason: str = "", + session_id: str = "unknown", + project_dir: str | None = None) -> dict: + """Resume a workflow parked in 'waiting' (by unattended on_fail=block). The + current step is still running; this just flips the workflow back to running so + /wf-step can advance it. The human is expected to have resolved the gate.""" + if state.get("status") != "waiting": + raise ValueError(f"resume only valid for a waiting workflow, got: {state.get('status')}") + state["status"] = "running" + step = _current_step(state) + event_store.append( + "wf.resumed", + {"step_id": step["id"], "step_name": step["name"], "reason": reason}, + session_id=session_id, + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) save_state(state, project_dir) return state @@ -216,7 +339,7 @@ def step_skip(state: dict, reason: str = "", project_dir=project_dir, ) - _advance_to_next(state) + _advance_to_next(state, session_id, project_dir) save_state(state, project_dir) return state @@ -247,6 +370,37 @@ def step_retry(state: dict, reason: str = "", return state +def record_executor_result(state: dict, run_id: str, blocking: bool, + verdict: dict | None = None, reason: str = "", + session_id: str = "unknown", + project_dir: str | None = None) -> dict: + """Record the result of a Workflow-tool executor run on the current step, then + auto-transition: step_complete if not blocking, step_fail (on_fail policy) if + blocking. Stores the Workflow runId so a crash-resume can re-invoke the workflow + with resumeFromRunId and replay cached sub-agents. + + `verdict` is the structured summary the executor returned (findings, counts, etc.). + """ + step = _current_step(state) + step["workflow_run_id"] = run_id + step["verdict"] = verdict + + event_store.append( + "wf.executor_result", + {"step_id": step["id"], "step_name": step["name"], + "run_id": run_id, "blocking": blocking, "verdict": verdict}, + session_id=session_id, + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) + + if blocking: + return step_fail(state, reason or "executor: blocking findings", + session_id, project_dir) + return step_complete(state, reason or "executor: passed", + session_id, project_dir) + + def _find_step_by_name(state: dict, name: str) -> int | None: """Find step index by name. Returns None if not found.""" for step in state["steps"]: @@ -302,6 +456,18 @@ def step_loop_back(state: dict, reason: str = "", project_dir=project_dir, ) + # Also emit the step-started event for the target step so dashboards can + # compute per-iteration durations the same way as straight-line advances. + event_store.append( + "wf.step_changed", + {"step_id": target_idx, "step_name": target_name, + "from_status": "pending", "to_status": "running", + "reason": f"loop iteration {step['loop_count']} from {step['name']}"}, + session_id=session_id, + workflow_id=state["workflow_id"], + project_dir=project_dir, + ) + save_state(state, project_dir) return state @@ -373,6 +539,11 @@ def rebuild_from_events(project_dir: str | None = None, "loop_back_to": sd.get("loop_back_to"), "max_iterations": sd.get("max_iterations", 3), "exit_condition": sd.get("exit_condition"), + "insights": sd.get("insights"), + "executor": sd.get("executor"), + "notify": sd.get("notify"), + "workflow_run_id": None, + "verdict": None, "started_at": ev["ts"] if i == 0 else None, "completed_at": None, "retry_count": 0, @@ -395,6 +566,7 @@ def rebuild_from_events(project_dir: str | None = None, "created_at": ev["ts"], "session_id": ev.get("session_id", "unknown"), "template": data.get("name", "unknown"), + "notify": data.get("notify", False), "steps": steps, "version": 1, } @@ -410,6 +582,13 @@ def rebuild_from_events(project_dir: str | None = None, if data.get("to_status") == "running": step["started_at"] = ev["ts"] + elif et == "wf.executor_result" and state: + sid = data.get("step_id", 0) + if 0 <= sid < len(state["steps"]): + step = state["steps"][sid] + step["workflow_run_id"] = data.get("run_id") + step["verdict"] = data.get("verdict") + elif et == "wf.loop_iteration" and state: # Reset steps in the loop range back to pending step_id = data.get("step_id", 0) @@ -434,15 +613,26 @@ def rebuild_from_events(project_dir: str | None = None, elif et == "wf.aborted" and state: state["status"] = "aborted" + elif et == "wf.failed" and state: + state["status"] = "failed" + + elif et == "wf.needs_human" and state: + state["status"] = "waiting" + + elif et == "wf.resumed" and state: + state["status"] = "running" + # Infer current_step from step statuses (instead of relying on # wf.step_changed "running" events, which _advance_to_next never emits). if state: - if state["status"] == "running": + if state["status"] in ("running", "waiting"): + # 'waiting' keeps its status; we only need the cursor to point at the + # parked (running) step, which the same scan finds. inferred = 0 for i, s in enumerate(state["steps"]): if s["status"] in ("pending", "running"): inferred = i - if s["status"] == "pending": + if s["status"] == "pending" and state["status"] == "running": s["status"] = "running" s["started_at"] = s.get("started_at") or now_iso() break diff --git a/core/templates.py b/core/templates.py index 9bed3de..d1e4681 100644 --- a/core/templates.py +++ b/core/templates.py @@ -1,7 +1,9 @@ """Template loading and discovery.""" +import hashlib import json import os +import subprocess from pathlib import Path @@ -78,6 +80,88 @@ def save_template(template: dict, project_dir: str | None = None) -> Path: return path +def _template_hash(path: Path) -> str: + """Content hash of a template, key-order independent. '' on read/parse error.""" + try: + obj = json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return "" + return hashlib.sha256(json.dumps(obj, sort_keys=True).encode()).hexdigest() + + +def doctor(project_dir: str | None = None) -> list[dict]: + """Currency report for templates that shadow the plugin-bundled canonical. + + Git already tells you the state of project-local templates *as files in a + repo*. The one thing it can't see is whether a working copy (project-local + or user-global) has drifted from the plugin-bundled template it was derived + from — those live in different trees. For each template name present in the + plugin tier AND a higher-precedence tier, compare by content hash. + + Returns one dict per shadowed template: + {name, active_tier, active_path, canonical_path, status} + status is "current" (identical) or "drifted" (active copy differs from + bundled). Templates that exist in only one tier are skipped — nothing to + compare. Read-only, no network, no persisted state. + """ + tiers = [ + ("project", _project_templates_dir(project_dir)), + ("user", _user_templates_dir()), + ("plugin", _plugin_templates_dir()), + ] + seen: dict[str, dict[str, tuple[Path, str]]] = {} + for tier_name, d in tiers: + if not d.exists(): + continue + for f in sorted(d.glob("*.json")): + seen.setdefault(f.stem, {})[tier_name] = (f, _template_hash(f)) + + report = [] + for name, by_tier in sorted(seen.items()): + if "plugin" not in by_tier: + continue # no canonical to compare against + canonical_path, canonical_hash = by_tier["plugin"] + # Highest-precedence active copy: project > user (plugin shadows nothing). + for tier_name in ("project", "user"): + if tier_name in by_tier: + active_path, active_hash = by_tier[tier_name] + report.append({ + "name": name, + "active_tier": tier_name, + "active_path": str(active_path), + "canonical_path": str(canonical_path), + "status": "current" if active_hash == canonical_hash else "drifted", + }) + break + return report + + +def plugin_repo_currency() -> dict: + """Is the plugin's own git clone behind its upstream tracking branch? + + No network: compares HEAD against the already-fetched @{u} ref, so the count + is only as fresh as the last `git fetch`. Returns {status, behind?, head?} + with status one of ok|behind|unknown. + ponytail: no auto-fetch — add a --remote flag that fetches first if a stale + tracking ref ever misleads in practice. + """ + repo = _plugin_templates_dir().parent + try: + head = subprocess.run( + ["git", "-C", str(repo), "rev-parse", "HEAD"], + capture_output=True, text=True, timeout=3) + behind = subprocess.run( + ["git", "-C", str(repo), "rev-list", "--count", "HEAD..@{u}"], + capture_output=True, text=True, timeout=3) + if head.returncode != 0 or behind.returncode != 0: + return {"status": "unknown"} + n = int(behind.stdout.strip() or "0") + return {"status": "behind" if n else "ok", "behind": n, + "head": head.stdout.strip()[:8]} + except (FileNotFoundError, subprocess.TimeoutExpired, ValueError): + return {"status": "unknown"} + + def template_detail(name: str, project_dir: str | None = None) -> str | None: """Return a detailed formatted view of a template's steps.""" tmpl = load_template(name, project_dir) diff --git a/docs/demo.md b/docs/demo.md index fb445eb..45dc904 100644 --- a/docs/demo.md +++ b/docs/demo.md @@ -154,4 +154,4 @@ Three template tiers, in precedence order (project beats user beats bundled): /templates/*.json # bundled with weft ``` -Use `/wf-new-template` to scaffold one in-session, or copy `templates/generic.json` from this repo as a starting point. +Use `/wf-template` to scaffold one in-session, or copy `templates/generic.json` from this repo as a starting point. diff --git a/docs/demos/pitch.cast b/docs/demos/pitch.cast index fbfbf36..199d37a 100644 --- a/docs/demos/pitch.cast +++ b/docs/demos/pitch.cast @@ -30,8 +30,8 @@ [0.043, "o", "o"] [0.043, "o", "w"] [0.039, "o", "\r\n"] -[0.335, "o", "Template: feature-workflow\r\nDescription: Full-cycle feature development: ticket to merged PR\r\nSteps: 11\r\n\r\n ├─ gather-context\r\n │ Read Linear ticket + comments + related PRs/tickets. Use Linear MCP tools.\r\n ├─ scope-check guards: git (commit|push)\r\n │ Identify gaps in requirements, prompt user for clarification. Interactive step.\r\n ├─ generate-context\r\n │ Gather real examples, data, context from web, memory, and available MCP tools.\r\n ├─ dump-context [on_fail=continue]\r\n │ Write gathered context to files in .claude/weft/context-files/. May exceed context window.\r\n ├─ plan-and-worktree guards: git push skill: /aot-plan\r\n │ Create feature plan + worktree + AoT decomposition.\r\n ├─ review [on_fail=retry] skill: /staff-review\r\n │ Run /staff-review (and /cursor-review if available) in parallel. Gather findings.\r\n ├─ apply-fixes [on_fail=retry] skill: /fix-polish\r\n │ Feed review findings back. Apply fixes.\r\n ├─ run-tests [on_fail=retry] "] -[0.000, "o", "↻ → review (max 3)\r\n │ Execute test suite. If issues remain, loop back to review.\r\n │ exit: Review passes with no HIGH or MEDIUM issues and tests are green\r\n ├─ sanitize-push\r\n │ Clean up code, sanitize secrets/company names, push to branch.\r\n ├─ update-external [on_fail=continue]\r\n │ Update PR description, Linear ticket status, ping user.\r\n └─ final-update [on_fail=continue]\r\n Final summary with all findings. Update user.\r\n"] +[0.335, "o", "Template: feature-workflow\r\nDescription: Full-cycle feature development: ticket to merged PR\r\nSteps: 11\r\n\r\n ├─ gather-context\r\n │ Read the issue + comments + related PRs/tickets. Use your issue-tracker MCP tools.\r\n ├─ scope-check guards: git (commit|push)\r\n │ Identify gaps in requirements, prompt user for clarification. Interactive step.\r\n ├─ generate-context\r\n │ Gather real examples, data, context from web, memory, and available MCP tools.\r\n ├─ dump-context [on_fail=continue]\r\n │ Write gathered context to files in .claude/weft/context-files/. May exceed context window.\r\n ├─ plan-and-worktree guards: git push skill: /aot-plan\r\n │ Create feature plan + worktree + AoT decomposition.\r\n ├─ review [on_fail=retry] skill: /staff-review\r\n │ Run /staff-review (and /cursor-review if available) in parallel. Gather findings.\r\n ├─ apply-fixes [on_fail=retry] skill: /fix-polish\r\n │ Feed review findings back. Apply fixes.\r\n ├─ run-tests [on_fail=retry] "] +[0.000, "o", "↻ → review (max 3)\r\n │ Execute test suite. If issues remain, loop back to review.\r\n │ exit: Review passes with no HIGH or MEDIUM issues and tests are green\r\n ├─ sanitize-push\r\n │ Clean up code, sanitize secrets/company names, push to branch.\r\n ├─ update-external [on_fail=continue]\r\n │ Update PR description, issue-tracker status, ping user.\r\n └─ final-update [on_fail=continue]\r\n Final summary with all findings. Update user.\r\n"] [4.010, "o", "\u001b[1;32m$\u001b[0m "] [0.000, "o", "w"] [0.042, "o", "e"] diff --git a/docs/submissions/README.md b/docs/submissions/README.md index 2f42057..d94e4c4 100644 --- a/docs/submissions/README.md +++ b/docs/submissions/README.md @@ -33,4 +33,4 @@ Rationale: ## After all submissions land -Update `~/.claude/projects/-Users-dioptx/memory/feedback_public_readme_playbook.md` with which lists actually accepted weft, response times, and any maintainer feedback. That feedback closes the loop on the playbook for the next public launch. +Record which lists actually accepted weft, response times, and any maintainer feedback, to close the loop on the playbook for the next public launch. diff --git a/hooks/weft-pretooluse.sh b/hooks/weft-pretooluse.sh index 126f8ef..6da39e1 100755 --- a/hooks/weft-pretooluse.sh +++ b/hooks/weft-pretooluse.sh @@ -7,5 +7,9 @@ set -euo pipefail STATE="${CLAUDE_PROJECT_DIR:-.}/.claude/weft/state.json" [ -f "$STATE" ] || exit 0 +# Fast bail: no non-empty guards anywhere → no python cold-start. Empty guards +# serialize as `"guards": []`; a non-empty array opens the bracket at line end. +grep -qE '"guards": \[$' "$STATE" || exit 0 + INPUT=$(cat) echo "$INPUT" | python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" guard 2>&1 diff --git a/hooks/weft-sessionstart.sh b/hooks/weft-sessionstart.sh index b629680..3231515 100755 --- a/hooks/weft-sessionstart.sh +++ b/hooks/weft-sessionstart.sh @@ -3,7 +3,60 @@ # Stdout is injected as a systemMessage. set -euo pipefail +INPUT=$(cat || true) + +# Persist Claude's real session id where cli.py reads it as a fallback. This is +# what fixes most events being logged with session_id="unknown". Scope the file +# to this project's weft dir so concurrent sessions in other projects don't +# overwrite each other's id (a machine-global file would race). +SID=$(printf '%s' "$INPUT" | python3 -c 'import sys,json +try: print(json.load(sys.stdin).get("session_id","") or "") +except Exception: pass' 2>/dev/null || true) +if [ -n "$SID" ]; then + WEFT_DIR="${CLAUDE_PROJECT_DIR:-.}/.claude/weft" + mkdir -p "$WEFT_DIR" + printf '%s\n' "$SID" > "$WEFT_DIR/.session-id" +fi + STATE="${CLAUDE_PROJECT_DIR:-.}/.claude/weft/state.json" [ -f "$STATE" ] || exit 0 +# Stall detection: a running workflow whose last event is older than the +# threshold gets a pure-annotation wf.stalled event (state.json is untouched; +# the rebuild reducer ignores unknown event types). +if [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then + WEFT_STALL_HOURS="${WEFT_STALL_HOURS:-12}" python3 - <<'PY' 2>/dev/null || true +import os, sys +from datetime import datetime, timezone +sys.path.insert(0, os.path.dirname(os.environ["CLAUDE_PLUGIN_ROOT"])) +sys.path.insert(0, os.environ["CLAUDE_PLUGIN_ROOT"]) +from core import event_store, state_machine + +pd = os.environ.get("CLAUDE_PROJECT_DIR", ".") +state = state_machine.load_state(pd) +if not state or state.get("status") != "running": + sys.exit(0) + +events = event_store.read_all(pd) +if not events: + sys.exit(0) +last_ts = events[-1].get("ts") +if not last_ts: + sys.exit(0) + +last = datetime.strptime(last_ts, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) +idle_hours = (datetime.now(timezone.utc) - last).total_seconds() / 3600.0 +threshold = float(os.environ.get("WEFT_STALL_HOURS", "12")) +if idle_hours >= threshold: + event_store.append( + "wf.stalled", + {"workflow_id": state["workflow_id"], "last_event_ts": last_ts, + "idle_hours": round(idle_hours, 2), "current_step": state["current_step"]}, + session_id=state.get("session_id", "unknown"), + workflow_id=state["workflow_id"], + project_dir=pd, + ) +PY +fi + python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" context 2>/dev/null diff --git a/scripts/wf-monitor.py b/scripts/wf-monitor.py new file mode 100755 index 0000000..fbf55cb --- /dev/null +++ b/scripts/wf-monitor.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Read-only viewer over weft workflows + GitHub PR status. + +Scans: +- /.claude/weft/state.json + workflow-context.json (current weft v0.3 layout + when CLAUDE_PROJECT_DIR points at a project root) +- /.claude/weft/workflows//.claude/weft/{state.json,workflow-context.json} + (per-ticket layout once Refinement 1 lands) + +Output: aligned plain-text table. No box-drawing, no colors. One workflow per row. + +Usage: + python3 weft-monitor.py # default project: current directory + python3 weft-monitor.py # scan a specific project + python3 weft-monitor.py --json # raw JSON for piping + python3 weft-monitor.py --templates # list available templates with descriptions + +Exit codes: 0 always (read-only, never blocking). +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from pathlib import Path + + +DEFAULT_PROJECTS = [Path.cwd()] +TEMPLATES_DIR = Path.home() / ".claude" / "weft" / "templates" + + +def find_workflows(project: Path) -> list[tuple[dict, dict, str, Path]]: + """Return [(state, workflow_ctx, source_label, weft_dir), ...]. + + weft_dir is the .claude/weft directory holding state.json — used by + callers that need to read sibling files like events.jsonl. + """ + found: list[tuple[dict, dict, str, Path]] = [] + + # Project-level state (weft v0.3 today, with CLAUDE_PROJECT_DIR=) + weft = project / ".claude" / "weft" + state_path = weft / "state.json" + ctx_path = weft / "workflow-context.json" + if state_path.exists() and ctx_path.exists(): + found.append((_load(state_path), _load(ctx_path), "project-root", weft)) + + # Per-ticket dirs (Refinement 1 layout) + per_ticket_root = weft / "workflows" + if per_ticket_root.exists(): + for ticket_dir in sorted(per_ticket_root.iterdir()): + if not ticket_dir.is_dir(): + continue + sub_weft = ticket_dir / ".claude" / "weft" + s = sub_weft / "state.json" + c = sub_weft / "workflow-context.json" + if s.exists() and c.exists(): + found.append((_load(s), _load(c), f"per-ticket:{ticket_dir.name}", sub_weft)) + + return found + + +def _load(p: Path) -> dict: + try: + return json.loads(p.read_text()) + except Exception: + return {} + + +def workflow_name(state: dict) -> str: + """Template name as recorded in state.json. Falls back to '?'.""" + name = state.get("template") or state.get("workflow", {}).get("name") + return name or "?" + + +def identifier(ctx: dict, state: dict) -> str: + """Best-available stable id for a workflow run. Fallback chain: + ticket_id -> pr_number -> project_slug -> workflow_name -> '?'. + """ + if ctx.get("ticket_id"): + return ctx["ticket_id"] + pr = ctx.get("pr_number") + if pr: + return f"PR #{pr}" + if ctx.get("project_slug"): + return ctx["project_slug"] + if ctx.get("project_name"): + return ctx["project_name"][:24] + wf = workflow_name(state) + return wf if wf != "?" else "?" + + +def step_label(state: dict) -> str: + """'N/M name (status)' for the active or last step.""" + steps = state.get("steps", []) + total = len(steps) + if total == 0: + return "—" + for s in steps: + if s.get("status") == "running": + return f"{s['id'] + 1}/{total} {s.get('name')} (running)" + complete = sum(1 for s in steps if s.get("status") == "complete") + if state.get("status") == "complete": + last = steps[-1] + return f"{total}/{total} {last.get('name')} (done)" + if state.get("status") == "aborted": + return f"{complete}/{total} (aborted)" + return f"{complete}/{total} (paused)" + + +def pr_info(pr_number: int | None, cwd: Path) -> tuple[str, str]: + """Return (pr_label, ci_label) — '—' when unknown.""" + if not pr_number: + return "—", "—" + try: + out = subprocess.run( + ["gh", "pr", "view", str(pr_number), + "--json", "state,isDraft,statusCheckRollup"], + cwd=str(cwd), capture_output=True, text=True, timeout=8, + ) + if out.returncode != 0: + return f"#{pr_number}", "—" + data = json.loads(out.stdout) + gh_state = data.get("state", "?").lower() + if gh_state == "open": + pr_label = f"#{pr_number} {'draft' if data.get('isDraft') else 'ready'}" + else: + pr_label = f"#{pr_number} {gh_state}" + checks = data.get("statusCheckRollup") or [] + if not checks: + ci_label = "—" + else: + outcomes = [(c.get("conclusion") or c.get("status") or "").lower() for c in checks] + if all(o == "success" for o in outcomes): + ci_label = "pass" + elif any(o == "failure" for o in outcomes): + ci_label = "fail" + elif any(o in ("pending", "in_progress", "queued", "") for o in outcomes): + ci_label = "pending" + else: + ci_label = "/".join(sorted(set(outcomes))) or "—" + return pr_label, ci_label + except Exception: + return f"#{pr_number}", "—" + + +def age(iso: str | None) -> str: + if not iso: + return "—" + try: + s = iso[:-1] + "+00:00" if iso.endswith("Z") else iso + delta = datetime.now(timezone.utc) - datetime.fromisoformat(s) + secs = int(delta.total_seconds()) + if secs < 60: return f"{secs}s" + if secs < 3600: return f"{secs // 60}m" + if secs < 86400: return f"{secs // 3600}h" + return f"{secs // 86400}d" + except Exception: + return "—" + + +def last_activity(state: dict, ctx: dict) -> str: + """Latest meaningful timestamp from the state.""" + steps = state.get("steps", []) + for ts_key in ("completed_at", "started_at"): + candidates = [s.get(ts_key) for s in steps if s.get(ts_key)] + if candidates: + return age(max(candidates)) + return age(ctx.get("started_at")) + + +def events_tail(weft_dir: Path, n: int = 20) -> list[dict]: + """Read events.jsonl from a workflow dir, return the last n entries.""" + events_path = weft_dir / "events.jsonl" + if not events_path.exists(): + return [] + try: + lines = events_path.read_text().splitlines() + except Exception: + return [] + out: list[dict] = [] + for line in lines[-n:]: + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except Exception: + out.append({"raw": line}) + return out + + +def list_templates() -> list[dict]: + """List weft templates at ~/.claude/weft/templates/*.json. + + Returns [{name, steps, description_one_liner, path}, ...] sorted by name. + """ + rows: list[dict] = [] + if not TEMPLATES_DIR.exists(): + return rows + for f in sorted(TEMPLATES_DIR.glob("*.json")): + try: + t = json.loads(f.read_text()) + except Exception: + continue + name = t.get("name") or f.stem + steps = len(t.get("steps", [])) + desc = (t.get("description") or "").strip() + # First non-empty sentence/paragraph, truncated. + one_liner = "" + for chunk in desc.replace("\r", "").split("\n"): + chunk = chunk.strip() + if chunk: + one_liner = chunk + break + tags = t.get("tags") or [] + tagline = (t.get("tagline") or "").strip() + rows.append({ + "name": name, + "steps": steps, + "tags": tags, + "tagline": tagline, + "description": one_liner, + "full_description": desc, + "step_list": [ + { + "name": s.get("name", "?"), + "phase": s.get("phase", ""), + "on_fail": s.get("on_fail", ""), + "context": s.get("context", ""), + "loop_back_to": s.get("loop_back_to"), + "max_iterations": s.get("max_iterations"), + "description": (s.get("description") or "").strip(), + } + for s in t.get("steps", []) + ], + "path": str(f), + }) + # Sort by step count ascending; ties broken by name + rows.sort(key=lambda r: (r["steps"], r["name"])) + return rows + + +def render_table(rows: list[list[str]]) -> str: + headers = ["WORKFLOW", "ID", "STEP", "PR", "CI", "STARTED", "LAST"] + if not rows: + return "No active workflows." + widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + widths[i] = max(widths[i], len(cell)) + line = lambda r: " ".join(c.ljust(w) for c, w in zip(r, widths)) + out = [line(headers), line(["-" * w for w in widths])] + out.extend(line(r) for r in rows) + return "\n".join(out) + + +def render_templates_table(templates: list[dict]) -> str: + headers = ["STEPS", "TEMPLATE", "TAGS", "TAGLINE"] + if not templates: + return f"No templates found at {TEMPLATES_DIR}." + rows = [ + [ + str(t["steps"]), + t["name"], + ", ".join(t.get("tags") or []), + (t.get("tagline") or t["description"])[:60], + ] + for t in templates + ] + widths = [len(h) for h in headers] + for row in rows: + for i, cell in enumerate(row): + widths[i] = max(widths[i], len(cell)) + # Use 3-space separator between columns for breathing room + line = lambda r: " ".join(c.ljust(w) for c, w in zip(r, widths)) + out = [line(headers), line(["-" * w for w in widths])] + out.extend(line(r) for r in rows) + return "\n".join(out) + + +def collect_rows(projects: list[Path], max_workers: int = 10) -> list[list[str]]: + """Parallelise pr_info across all running workflows.""" + workflows: list[dict] = [] + for project in projects: + if not project.exists(): + continue + for state, ctx, source, weft_dir in find_workflows(project): + workflows.append({ + "state": state, + "ctx": ctx, + "project": project, + "weft_dir": weft_dir, + "source": source, + }) + # Parallel pr_info — each is up to 8s; 11 serial = 88s worst case, 11 parallel = 8s. + with ThreadPoolExecutor(max_workers=max_workers) as ex: + futures = { + ex.submit(pr_info, wf["ctx"].get("pr_number"), wf["project"]): wf + for wf in workflows + } + for fut in futures: + wf = futures[fut] + try: + wf["pr"], wf["ci"] = fut.result() + except Exception: + wf["pr"], wf["ci"] = "—", "—" + return [ + [ + workflow_name(wf["state"]), + identifier(wf["ctx"], wf["state"]), + step_label(wf["state"]), + wf["pr"], + wf["ci"], + age(wf["ctx"].get("started_at")), + last_activity(wf["state"], wf["ctx"]), + ] + for wf in workflows + ] + + +def main(argv: list[str]) -> int: + json_out = "--json" in argv + templates_out = "--templates" in argv + args = [a for a in argv[1:] if not a.startswith("--")] + + if templates_out: + templates = list_templates() + if json_out: + print(json.dumps(templates, indent=2)) + else: + print(render_templates_table(templates)) + return 0 + + projects = [Path(p).expanduser() for p in args] if args else DEFAULT_PROJECTS + rows = collect_rows(projects) + if json_out: + headers = ["workflow", "id", "step", "pr", "ci", "started", "last"] + print(json.dumps([dict(zip(headers, r)) for r in rows], indent=2)) + else: + print(render_table(rows)) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/scripts/wf.py b/scripts/wf.py new file mode 100755 index 0000000..28e2dcd --- /dev/null +++ b/scripts/wf.py @@ -0,0 +1,592 @@ +#!/usr/bin/env python3 +"""Interactive TUI for weft workflows. + +Three views: list (running workflows), detail (per-workflow step + events), templates (discovery). + +Falls back to the static `weft-monitor.py` table when stdout is not a TTY, +or when invoked with `--list` / `--json` / `--templates`. So `wf | jq` and +`wf --json` and `wf --templates` keep working in scripts. + +Keys (list view): + up/down or j/k select enter open detail t templates view r refresh q quit + +Keys (detail view): + esc/h back r refresh q quit + +Keys (templates view): + esc/h back r refresh q quit +""" + +from __future__ import annotations + +import curses +import importlib.util +import sys +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +from pathlib import Path + + +# Load shared logic from sibling wf-monitor.py (dash in filename prevents +# normal `import`; importlib loads it by path) +_monitor_path = Path(__file__).resolve().parent / "wf-monitor.py" +_spec = importlib.util.spec_from_file_location("wf_monitor", _monitor_path) +weft_monitor = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(weft_monitor) # type: ignore[union-attr] + + +def step_duration(step: dict) -> str: + started = step.get("started_at") + if not started: + return "" + try: + s = started[:-1] + "+00:00" if started.endswith("Z") else started + start_ts = datetime.fromisoformat(s) + completed = step.get("completed_at") + if completed: + c = completed[:-1] + "+00:00" if completed.endswith("Z") else completed + secs = int((datetime.fromisoformat(c) - start_ts).total_seconds()) + else: + secs = int((datetime.now(timezone.utc) - start_ts).total_seconds()) + if secs < 60: return f"{secs}s" + if secs < 3600: return f"{secs // 60}m" + if secs < 86400: return f"{secs // 3600}h" + return f"{secs // 86400}d" + except Exception: + return "" + + +def _safe_addstr(win, y: int, x: int, text: str, attr: int = 0) -> None: + try: + win.addstr(y, x, text, attr) + except curses.error: + pass + + +# Color pair indices (set up in TUI.run via init_colors). 0 stays as default. +CP_GREEN = 1 # success / done / CI pass +CP_YELLOW = 2 # pending / running / warning +CP_RED = 3 # failure / aborted +CP_CYAN = 4 # workflow names, IDs +CP_MAGENTA = 5 # tags +CP_BLUE = 6 # headers, separators +CP_DIM = 7 # description, metadata + + +def init_colors() -> None: + """Best-effort color setup. Falls back to monochrome if terminal lacks color.""" + try: + curses.start_color() + curses.use_default_colors() + curses.init_pair(CP_GREEN, curses.COLOR_GREEN, -1) + curses.init_pair(CP_YELLOW, curses.COLOR_YELLOW, -1) + curses.init_pair(CP_RED, curses.COLOR_RED, -1) + curses.init_pair(CP_CYAN, curses.COLOR_CYAN, -1) + curses.init_pair(CP_MAGENTA, curses.COLOR_MAGENTA, -1) + curses.init_pair(CP_BLUE, curses.COLOR_BLUE, -1) + curses.init_pair(CP_DIM, curses.COLOR_WHITE, -1) + except curses.error: + pass + + +def ci_color(ci: str) -> int: + if ci == "pass": + return curses.color_pair(CP_GREEN) + if ci == "fail": + return curses.color_pair(CP_RED) + if ci == "pending": + return curses.color_pair(CP_YELLOW) + return 0 + + +def step_color(step_label: str) -> int: + if "(done)" in step_label: + return curses.color_pair(CP_GREEN) + if "(running)" in step_label: + return curses.color_pair(CP_YELLOW) + if "(aborted)" in step_label: + return curses.color_pair(CP_RED) + return 0 + + +def wrap_text(text: str, width: int, max_lines: int = 2) -> list[str]: + """Word-wrap text to a list of <=max_lines lines, each <=width chars. + Truncates with an ellipsis if the text doesn't fit.""" + text = " ".join(text.replace("\n", " ").split()) + if not text: + return [] + lines: list[str] = [] + remaining = text + while remaining and len(lines) < max_lines: + if len(remaining) <= width: + lines.append(remaining) + break + split_at = remaining.rfind(" ", 0, width) + if split_at <= 0: + split_at = width + lines.append(remaining[:split_at]) + remaining = remaining[split_at:].lstrip() + if remaining and lines: + # Truncate the last line with ellipsis to signal there's more + last = lines[-1] + keep = max(0, width - 1) + lines[-1] = (last[:keep].rstrip() + "…") if len(last) > keep else last + "…" + return lines + + +def on_fail_color(on_fail: str) -> int: + if on_fail == "block": + return curses.color_pair(CP_RED) + if on_fail == "retry": + return curses.color_pair(CP_YELLOW) + if on_fail == "continue": + return curses.color_pair(CP_GREEN) + return 0 + + +def status_color(status: str) -> int: + if status in ("complete", "done", "success"): + return curses.color_pair(CP_GREEN) + if status in ("running", "in_progress"): + return curses.color_pair(CP_YELLOW) | curses.A_BOLD + if status in ("aborted", "failed", "error"): + return curses.color_pair(CP_RED) + return curses.A_DIM + + +def pr_color(pr: str) -> int: + if "ready" in pr: + return curses.color_pair(CP_GREEN) + if "draft" in pr: + return curses.color_pair(CP_YELLOW) + if "closed" in pr or "merged" in pr: + return curses.color_pair(CP_DIM) | curses.A_DIM + return 0 + + +class TUI: + def __init__(self, project_paths: list[Path]) -> None: + self.project_paths = project_paths + self.workflows: list[dict] = [] + self.templates: list[dict] = [] + self.selected = 0 + self.template_selected = 0 + self.view = "list" + self.detail_index = 0 + self.template_detail_index = 0 + self.template_scroll = 0 + + def load(self) -> None: + # Index templates by name for step-description lookup during detail view + if not self.templates: + self.templates = weft_monitor.list_templates() + self._templates_by_name = {t["name"]: t for t in self.templates} + # Collect workflows from disk + scanned: list[dict] = [] + for project in self.project_paths: + if not project.exists(): + continue + for state, ctx, source, weft_dir in weft_monitor.find_workflows(project): + scanned.append({ + "state": state, + "ctx": ctx, + "project": project, + "weft_dir": weft_dir, + "source": source, + }) + # Parallel pr_info — keeps startup snappy with many running workflows. + with ThreadPoolExecutor(max_workers=10) as ex: + futures = { + ex.submit(weft_monitor.pr_info, wf["ctx"].get("pr_number"), wf["project"]): wf + for wf in scanned + } + for fut in futures: + wf = futures[fut] + try: + wf["pr"], wf["ci"] = fut.result() + except Exception: + wf["pr"], wf["ci"] = "—", "—" + # Decorate + for wf in scanned: + wf["workflow"] = weft_monitor.workflow_name(wf["state"]) + wf["id"] = weft_monitor.identifier(wf["ctx"], wf["state"]) + wf["step"] = weft_monitor.step_label(wf["state"]) + wf["started"] = weft_monitor.age(wf["ctx"].get("started_at")) + wf["last"] = weft_monitor.last_activity(wf["state"], wf["ctx"]) + self.workflows = scanned + if self.selected >= len(scanned): + self.selected = max(0, len(scanned) - 1) + + def draw_list(self, stdscr) -> None: + stdscr.erase() + h, w = stdscr.getmaxyx() + header = f" running ({len(self.workflows)})" + _safe_addstr(stdscr, 0, 0, header.ljust(w - 24) + "t templates r refresh ", curses.A_BOLD) + _safe_addstr(stdscr, 1, 0, "─" * w, curses.color_pair(CP_BLUE)) + if not self.workflows: + _safe_addstr(stdscr, 3, 2, "No active workflows.") + _safe_addstr(stdscr, 4, 2, "Scanned: " + " ".join(str(p) for p in self.project_paths)) + col_header = f" {'WORKFLOW':<22} {'ID':<14} {'STEP':<32} {'PR':<14} {'CI':<6} {'LAST':<5}" + _safe_addstr(stdscr, 2, 0, col_header[: w - 1], curses.A_BOLD | curses.color_pair(CP_BLUE)) + for i, wf in enumerate(self.workflows[: h - 5]): + y = 3 + i + selected = i == self.selected + marker = "▸ " if selected else " " + # When selected, paint the entire row in reverse to keep alignment; + # otherwise color individual columns by signal. + if selected: + row = ( + f"{marker}{wf['workflow']:<22} " + f"{wf['id']:<14} " + f"{wf['step']:<32} " + f"{wf['pr']:<14} " + f"{wf['ci']:<6} " + f"{wf['last']:<5}" + ) + _safe_addstr(stdscr, y, 0, row[: w - 1], curses.A_REVERSE) + continue + x = 0 + _safe_addstr(stdscr, y, x, marker); x += len(marker) + _safe_addstr(stdscr, y, x, f"{wf['workflow']:<22}", curses.color_pair(CP_CYAN)); x += 23 + _safe_addstr(stdscr, y, x, f"{wf['id']:<14}", curses.A_BOLD); x += 15 + _safe_addstr(stdscr, y, x, f"{wf['step']:<32}", step_color(wf['step'])); x += 33 + _safe_addstr(stdscr, y, x, f"{wf['pr']:<14}", pr_color(wf['pr'])); x += 15 + _safe_addstr(stdscr, y, x, f"{wf['ci']:<6}", ci_color(wf['ci'])); x += 7 + _safe_addstr(stdscr, y, x, f"{wf['last']:<5}"); x += 6 + _safe_addstr(stdscr, h - 2, 0, "─" * w, curses.color_pair(CP_BLUE)) + _safe_addstr(stdscr, h - 1, 0, " enter open · t templates · r refresh · q quit"[: w - 1]) + stdscr.refresh() + + def draw_detail(self, stdscr) -> None: + stdscr.erase() + h, w = stdscr.getmaxyx() + wf = self.workflows[self.detail_index] + state = wf["state"] + title = f" {wf['workflow']} · {wf['id']} · {state.get('status', '?')} " + _safe_addstr(stdscr, 0, 0, title.ljust(w - 1)[: w - 1], curses.A_BOLD) + _safe_addstr(stdscr, 1, 0, "─" * w, curses.color_pair(CP_BLUE)) + meta = f" PR {wf['pr']} CI {wf['ci']} Started {wf['started']} Last {wf['last']}" + _safe_addstr(stdscr, 2, 0, meta[: w - 1]) + ctx_line = " ticket: " + (wf["ctx"].get("ticket_url") or wf["ctx"].get("project_url") or "—") + _safe_addstr(stdscr, 3, 0, ctx_line[: w - 1], curses.A_DIM) + _safe_addstr(stdscr, 4, 0, "─" * w, curses.color_pair(CP_BLUE)) + + # Look up step descriptions from the source template (state.json doesn't carry them) + template_name = state.get("template") or state.get("workflow", {}).get("name") or "" + template = self._templates_by_name.get(template_name) + descriptions: dict[str, str] = {} + phases: dict[str, str] = {} + if template: + for s in template.get("step_list", []): + descriptions[s["name"]] = s.get("description", "") + phases[s["name"]] = s.get("phase", "") + + # Events tail at bottom: reserve ~10 rows + events_section_height = min(12, h // 3) + steps_start_row = 5 + steps_pane_bottom = h - events_section_height - 2 + + # Header row + col_header = f" # {'PHASE':<8} {'STEP':<26} {'STATUS':<10} {'DUR':<6} LOOPS / RETRIES" + _safe_addstr(stdscr, steps_start_row, 0, col_header[: w - 1], + curses.A_BOLD | curses.color_pair(CP_BLUE)) + y = steps_start_row + 1 + steps = state.get("steps", []) + # Each step block takes 3 rows: header + 1-2 wrapped desc + blank. + BLOCK_ROWS = 3 + DESC_LINES = 1 # 1 wrapped description line per step block in running view (events tail needs space) + max_blocks = max(1, (steps_pane_bottom - y) // BLOCK_ROWS) + for step in steps[:max_blocks]: + sid = step["id"] + 1 + name = step.get("name", "?") + status = step.get("status", "?") + dur = step_duration(step) + retries = step.get("retry_count", 0) + loops = step.get("loop_count", 0) + loop_back = step.get("loop_back_to") + max_iter = step.get("max_iterations", 0) or 0 + phase = phases.get(name, "") + bits = [] + if loop_back: + if max_iter: + bits.append(f"↻ → {loop_back} {loops}/{max_iter}") + else: + bits.append(f"↻ → {loop_back} {loops}") + elif loops: + bits.append(f"loops {loops}") + if retries: + bits.append(f"retries {retries}") + summary = " ".join(bits) + # Header row + x = 1 + _safe_addstr(stdscr, y, x, f"{sid:>2} "); x += 6 + _safe_addstr(stdscr, y, x, f"{phase:<8}", curses.color_pair(CP_MAGENTA)); x += 10 + _safe_addstr(stdscr, y, x, f"{name:<26}", curses.color_pair(CP_CYAN)); x += 28 + _safe_addstr(stdscr, y, x, f"{status:<10}", status_color(status)); x += 11 + _safe_addstr(stdscr, y, x, f"{dur:<6}"); x += 7 + _safe_addstr(stdscr, y, x, summary[: w - x - 1], curses.color_pair(CP_YELLOW)) + y += 1 + # Description wrap + desc = descriptions.get(name, "") + indent = 6 + for dl in wrap_text(desc, w - indent - 2, max_lines=DESC_LINES): + _safe_addstr(stdscr, y, indent, dl, curses.A_DIM) + y += 1 + # Blank line separator + y += 1 + if y >= steps_pane_bottom: + break + + # Events tail + events_start = steps_pane_bottom + _safe_addstr(stdscr, events_start, 0, "─" * w, curses.color_pair(CP_BLUE)) + _safe_addstr(stdscr, events_start + 1, 0, + f" events.jsonl (last {events_section_height - 3}) ", + curses.A_BOLD | curses.color_pair(CP_BLUE)) + events = weft_monitor.events_tail(wf["weft_dir"], n=events_section_height - 3) + for i, ev in enumerate(events): + if events_start + 2 + i >= h - 2: + break + if "raw" in ev: + line = ev["raw"] + else: + ts = (ev.get("timestamp") or ev.get("ts") or "")[:19].replace("T", " ") + ev_type = ev.get("event") or ev.get("type") or "event" + step_name = ev.get("step_name") or ev.get("step") or "" + detail = ev.get("detail") or ev.get("message") or "" + line = f" {ts} {ev_type:<18} {step_name:<22} {detail}" + _safe_addstr(stdscr, events_start + 2 + i, 0, line[: w - 1], curses.A_DIM) + _safe_addstr(stdscr, h - 2, 0, "─" * w, curses.color_pair(CP_BLUE)) + _safe_addstr(stdscr, h - 1, 0, " esc/h back · r refresh · q quit"[: w - 1]) + stdscr.refresh() + + def draw_templates(self, stdscr) -> None: + stdscr.erase() + h, w = stdscr.getmaxyx() + header = f" templates ({len(self.templates)}) · sorted by step count" + _safe_addstr(stdscr, 0, 0, header.ljust(w - 12) + "r refresh ", curses.A_BOLD) + _safe_addstr(stdscr, 1, 0, "─" * w, curses.color_pair(CP_BLUE)) + # Wider tags column (42 chars) + 3-space gap so descriptions don't bleed in. + STEPS_W, NAME_W, TAGS_W = 6, 28, 42 + col_header = ( + f" {'STEPS':<{STEPS_W}} {'TEMPLATE':<{NAME_W}} " + f"{'TAGS':<{TAGS_W}} TAGLINE" + ) + _safe_addstr(stdscr, 2, 0, col_header[: w - 1], curses.A_BOLD | curses.color_pair(CP_BLUE)) + for i, t in enumerate(self.templates[: h - 5]): + y = 3 + i + selected = i == self.template_selected + marker = "▸ " if selected else " " + tags_str = ", ".join(t.get("tags") or []) + steps_str = str(t['steps']) + tagline = t.get("tagline") or t.get("description", "") + if selected: + row = ( + f"{marker}{steps_str:<{STEPS_W}} " + f"{t['name']:<{NAME_W}} " + f"{tags_str:<{TAGS_W}} {tagline}" + ) + _safe_addstr(stdscr, y, 0, row[: w - 1], curses.A_REVERSE) + continue + x = 0 + _safe_addstr(stdscr, y, x, marker); x += len(marker) + _safe_addstr(stdscr, y, x, f"{steps_str:<{STEPS_W}}", + curses.color_pair(CP_YELLOW) | curses.A_BOLD); x += STEPS_W + 3 + _safe_addstr(stdscr, y, x, f"{t['name']:<{NAME_W}}", + curses.color_pair(CP_CYAN)); x += NAME_W + 3 + _safe_addstr(stdscr, y, x, f"{tags_str:<{TAGS_W}}", + curses.color_pair(CP_MAGENTA)); x += TAGS_W + 3 + _safe_addstr(stdscr, y, x, tagline[: w - x - 1]) + _safe_addstr(stdscr, h - 2, 0, "─" * w, curses.color_pair(CP_BLUE)) + _safe_addstr(stdscr, h - 1, 0, " enter open · esc/h back · r refresh · q quit"[: w - 1]) + stdscr.refresh() + + def draw_template_detail(self, stdscr) -> None: + stdscr.erase() + h, w = stdscr.getmaxyx() + if not self.templates or self.template_detail_index >= len(self.templates): + _safe_addstr(stdscr, 0, 0, "No template selected.") + stdscr.refresh() + return + t = self.templates[self.template_detail_index] + title = f" {t['name']} · {t['steps']} steps · {t.get('tagline','')} " + _safe_addstr(stdscr, 0, 0, title.ljust(w - 1)[: w - 1], curses.A_BOLD) + _safe_addstr(stdscr, 1, 0, "─" * w, curses.color_pair(CP_BLUE)) + + # Tags line + tags_str = ", ".join(t.get("tags") or []) + _safe_addstr(stdscr, 2, 0, " tags: ", curses.A_DIM) + _safe_addstr(stdscr, 2, 7, tags_str, curses.color_pair(CP_MAGENTA)) + + # Description excerpt (first ~3 lines wrapped to width) + desc_start_row = 3 + desc = t.get("full_description") or t.get("description", "") + desc_lines: list[str] = [] + for chunk in desc.replace("\r", "").split("\n"): + chunk = chunk.strip() + if not chunk: + continue + # Wrap chunk to width-2 + while len(chunk) > w - 2: + split_at = chunk.rfind(" ", 0, w - 2) + if split_at < 0: + split_at = w - 2 + desc_lines.append(chunk[:split_at]) + chunk = chunk[split_at:].lstrip() + desc_lines.append(chunk) + if len(desc_lines) >= 4: + break + for i, line in enumerate(desc_lines[:4]): + _safe_addstr(stdscr, desc_start_row + i, 1, line[: w - 2], curses.A_DIM) + steps_start_row = desc_start_row + min(len(desc_lines), 4) + 1 + _safe_addstr(stdscr, steps_start_row, 0, "─" * w, curses.color_pair(CP_BLUE)) + + # Steps section — block layout: header + 2-line wrapped description + blank separator + col_y = steps_start_row + 1 + col_header = f" # {'PHASE':<10} {'STEP':<28} {'ON_FAIL':<8} LOOPS" + _safe_addstr(stdscr, col_y, 0, col_header[: w - 1], + curses.A_BOLD | curses.color_pair(CP_BLUE)) + body_start = col_y + 1 + steps = t.get("step_list", []) + BLOCK_ROWS = 4 # header + 2 wrapped desc lines + 1 blank + DESC_LINES = 2 + pane_bottom = h - 2 + max_blocks = max(1, (pane_bottom - body_start) // BLOCK_ROWS) + scroll = self.template_scroll + scroll = max(0, min(scroll, max(0, len(steps) - max_blocks))) + self.template_scroll = scroll + visible = steps[scroll : scroll + max_blocks] + y = body_start + for idx, s in enumerate(visible): + sid = scroll + idx + 1 + phase = s.get("phase") or "" + name = s.get("name", "?") + on_fail = s.get("on_fail") or "" + loop_back = s.get("loop_back_to") + max_iter = s.get("max_iterations") + loop_info = "" + if loop_back: + if max_iter: + loop_info = f"↻ → {loop_back} (max {max_iter})" + else: + loop_info = f"↻ → {loop_back}" + # Header line + x = 1 + _safe_addstr(stdscr, y, x, f"{sid:>2} "); x += 6 + _safe_addstr(stdscr, y, x, f"{phase:<10}", + curses.color_pair(CP_MAGENTA) if phase else 0); x += 12 + _safe_addstr(stdscr, y, x, f"{name:<28}", + curses.color_pair(CP_CYAN)); x += 30 + _safe_addstr(stdscr, y, x, f"{on_fail:<8}", on_fail_color(on_fail)); x += 10 + _safe_addstr(stdscr, y, x, loop_info[: w - x - 1], + curses.color_pair(CP_YELLOW)) + y += 1 + # Description wrap + desc = s.get("description") or "" + indent = 6 + for dl in wrap_text(desc, w - indent - 2, max_lines=DESC_LINES): + if y >= pane_bottom: + break + _safe_addstr(stdscr, y, indent, dl, curses.A_DIM) + y += 1 + # Blank line separator + y += 1 + if y >= pane_bottom: + break + + # Scroll indicator + if len(steps) > max_blocks: + indicator = f" [{scroll + 1}-{scroll + len(visible)} / {len(steps)}] " + _safe_addstr(stdscr, h - 2, w - len(indicator) - 1, indicator, + curses.color_pair(CP_BLUE) | curses.A_BOLD) + + _safe_addstr(stdscr, h - 2, 0, "─" * w, curses.color_pair(CP_BLUE)) + _safe_addstr(stdscr, h - 1, 0, " ↑/↓ scroll · esc/h back · q quit"[: w - 1]) + stdscr.refresh() + + def handle(self, key: int) -> bool: + if key == ord("q"): + return False + if key == 27: # Esc + if self.view in ("detail", "templates"): + self.view = "list" + return True + return False + if key == ord("r"): + # Force template refresh too + self.templates = [] + self.load() + return True + if self.view == "list": + if key in (curses.KEY_DOWN, ord("j")): + if self.workflows: + self.selected = min(self.selected + 1, len(self.workflows) - 1) + elif key in (curses.KEY_UP, ord("k")): + self.selected = max(self.selected - 1, 0) + elif key in (10, 13, curses.KEY_ENTER): + if self.workflows: + self.detail_index = self.selected + self.view = "detail" + elif key == ord("t"): + self.view = "templates" + self.template_selected = 0 + elif self.view == "detail": + if key == ord("h"): + self.view = "list" + elif self.view == "templates": + if key in (curses.KEY_DOWN, ord("j")): + if self.templates: + self.template_selected = min(self.template_selected + 1, len(self.templates) - 1) + elif key in (curses.KEY_UP, ord("k")): + self.template_selected = max(self.template_selected - 1, 0) + elif key in (10, 13, curses.KEY_ENTER): + if self.templates: + self.template_detail_index = self.template_selected + self.template_scroll = 0 + self.view = "template_detail" + elif key == ord("h"): + self.view = "list" + elif self.view == "template_detail": + if key in (curses.KEY_DOWN, ord("j")): + self.template_scroll += 1 + elif key in (curses.KEY_UP, ord("k")): + self.template_scroll = max(0, self.template_scroll - 1) + elif key == ord("h"): + self.view = "templates" + return True + + def run(self, stdscr) -> None: + curses.curs_set(0) + stdscr.timeout(-1) + init_colors() + while True: + if self.view == "list": + self.draw_list(stdscr) + elif self.view == "detail": + self.draw_detail(stdscr) + elif self.view == "template_detail": + self.draw_template_detail(stdscr) + else: + self.draw_templates(stdscr) + try: + key = stdscr.getch() + except KeyboardInterrupt: + break + if not self.handle(key): + break + + +def main(argv: list[str]) -> int: + flags = [a for a in argv[1:] if a.startswith("--")] + positional = [a for a in argv[1:] if not a.startswith("--")] + if "--list" in flags or "--json" in flags or "--templates" in flags or not sys.stdout.isatty(): + return weft_monitor.main(argv) + projects = [Path(p).expanduser() for p in positional] if positional else weft_monitor.DEFAULT_PROJECTS + tui = TUI(projects) + tui.load() + curses.wrapper(tui.run) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/skills/ev-query/SKILL.md b/skills/ev-query/SKILL.md index 6944bf5..97d4aae 100644 --- a/skills/ev-query/SKILL.md +++ b/skills/ev-query/SKILL.md @@ -1,7 +1,7 @@ --- name: ev-query description: "Query the weft event log with filters. Use only when user types /ev-query." -argument-hint: "[event_type] [--tool X] [--last N] [--workflow ID] [--session ID]" +argument-hint: "[event_type] [--last N] [--workflow ID] [--session ID]" allowed-tools: [Bash, Read] --- @@ -27,5 +27,4 @@ $ARGUMENTS - `/ev-query` — count by event type - `/ev-query wf.step_changed` — all step transitions - `/ev-query --last 20` — last 20 events -- `/ev-query --tool Bash --last 10` — last 10 Bash tool events - `/ev-query --workflow adhoc-20260406` — events for specific workflow diff --git a/skills/wf-analyze/SKILL.md b/skills/wf-analyze/SKILL.md new file mode 100644 index 0000000..4be482f --- /dev/null +++ b/skills/wf-analyze/SKILL.md @@ -0,0 +1,35 @@ +--- +name: wf-analyze +description: "Analyze weft workflow runs for timing and friction. Use only when user types /wf-analyze." +argument-hint: "[--template NAME]" +allowed-tools: [Bash, Read] +--- + +# Analyze Weft Workflow Runs + +Read-only. Aggregates the event log into per-template metrics and surfaces recurring friction. + +## Arguments +$ARGUMENTS + +## Instructions + +1. Run: + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" analyze $ARGUMENTS + ``` + +2. With no arguments, it reports across all templates. Pass `--template NAME` to scope to one. + +3. Present results in a readable format, not raw JSON. + +## What it outputs +- Per-template step durations (median and p90) +- Loop counts and guard-block counts per step +- Abandonment and stall counts +- Slowest step per template +- A recurring-friction section, joined from `workflow-qa.jsonl` + +## Examples +- `/wf-analyze` — metrics across all templates +- `/wf-analyze --template feature-workflow` — scope to one template diff --git a/skills/wf-dashboard/SKILL.md b/skills/wf-dashboard/SKILL.md deleted file mode 100644 index 6b73afd..0000000 --- a/skills/wf-dashboard/SKILL.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -name: wf-dashboard -description: "Launch the weft TUI dashboard in a new terminal window. Use only when user types /wf-dashboard." -allowed-tools: [Bash] ---- - -# Weft Dashboard - -Launch the interactive TUI dashboard in a new terminal tab. - -## Instructions - -Run this command to open the dashboard in a new iTerm/Terminal tab: - -```bash -PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/local/plugins/weft}" -PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}" - -osascript -e " -tell application \"System Events\" - set frontApp to name of first application process whose frontmost is true -end tell -if frontApp is \"iTerm2\" then - tell application \"iTerm\" - tell current window - create tab with default profile - tell current session - write text \"PYTHONPATH='$PLUGIN_ROOT' CLAUDE_PROJECT_DIR='$PROJECT_DIR' python3 '$PLUGIN_ROOT/core/dashboard.py'; exit\" - end tell - end tell - end tell -else - tell application \"Terminal\" - do script \"PYTHONPATH='$PLUGIN_ROOT' CLAUDE_PROJECT_DIR='$PROJECT_DIR' python3 '$PLUGIN_ROOT/core/dashboard.py'; exit\" - activate - end tell -end if -" -``` - -Tell the user: "Dashboard opened in a new tab. Press `r` to refresh, `q` to quit." diff --git a/skills/wf-doctor/SKILL.md b/skills/wf-doctor/SKILL.md new file mode 100644 index 0000000..923870b --- /dev/null +++ b/skills/wf-doctor/SKILL.md @@ -0,0 +1,32 @@ +--- +name: wf-doctor +description: "Check weft template currency — drift of working copies vs the bundled canonical, and whether the plugin clone is behind upstream. Use only when user types /wf-doctor." +allowed-tools: [Bash, Read] +--- + +# Weft Template Doctor + +Report whether the user's templates are current. Git already covers project-local +templates as repo files; this surfaces the gap git can't see — a working copy that +has drifted from the plugin-bundled template it was derived from — plus whether the +weft plugin's own git clone is behind upstream. + +## Instructions + +1. Run the currency check: + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" doctor + ``` + +2. If `--json` is in $ARGUMENTS, emit raw JSON instead: + ```bash + python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" doctor --json + ``` + +3. Read the output back to the user: + - "plugin clone behind by N" → suggest `git pull` in the weft repo to refresh bundled templates. + - Any `drifted` template → the working copy differs from the bundled canonical. Show its path and ask whether to reconcile (keep the local edits) or delete the copy to fall back to the canonical version. + - All `current` and clone up to date → say everything's current; nothing to do. + +Notes: read-only, no network. The plugin-clone "behind" count is only as fresh as +the last `git fetch`, so a stale `unknown`/`ok` may just mean it hasn't fetched recently. diff --git a/skills/wf-edit-template/SKILL.md b/skills/wf-edit-template/SKILL.md deleted file mode 100644 index b48e24e..0000000 --- a/skills/wf-edit-template/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: wf-edit-template -description: "Edit an existing weft workflow template. Use only when user types /wf-edit-template." -argument-hint: "" -allowed-tools: [Bash, Read, Write] ---- - -# Edit Weft Template - -Load an existing template, modify it interactively, and save. - -## Arguments -$ARGUMENTS - -## Instructions - -### Step 1: Load -If no arguments, list templates and ask which to edit: -```bash -python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" start -``` - -Load the template preview: -```bash -python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" preview -``` - -### Step 2: Show current state -Display the current template with all step details, guards, and policies. - -### Step 3: Ask what to change -Ask: "What would you like to change?" - -Supported modifications: -- **Add a step**: "add a review step after implement" → insert into steps array -- **Remove a step**: "remove the deploy step" → remove from steps array -- **Rename a step**: "rename test to verify" → change step name -- **Change policy**: "make test retry on failure" → set on_fail -- **Add guard**: "block git push during planning" → add guard to step -- **Remove guard**: "remove the commit guard from scope-check" → remove guard -- **Reorder**: "move review before test" → reorder steps array -- **Change description**: "update the description to ..." → set description - -### Step 4: Preview changes -Show the modified template and ask: "Save these changes?" - -### Step 5: Save -Ask whether to overwrite the original or save as a new name. - -If saving to the project-local directory: -```bash -echo '' | python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" save-template -``` - -If the original is a plugin-bundled template, always save as a project-local override (don't modify plugin files). - -Tell the user the template was saved and how to start it. diff --git a/skills/wf-new-template/SKILL.md b/skills/wf-new-template/SKILL.md deleted file mode 100644 index 333ee8b..0000000 --- a/skills/wf-new-template/SKILL.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: wf-new-template -description: "Create a new weft workflow template interactively. Use only when user types /wf-new-template." -argument-hint: "[template-name]" -allowed-tools: [Bash, Read, Write] ---- - -# Create New Weft Template - -Build a custom workflow template through conversation. - -## Arguments -$ARGUMENTS - -## Instructions - -Walk the user through creating a template step by step: - -### Step 1: Name -If no name in arguments, ask: "What should this workflow be called? (lowercase, hyphens ok)" - -### Step 2: Description -Ask: "One-line description of what this workflow does?" - -### Step 3: Steps -Ask: "List the steps in order. You can describe them naturally and I'll structure them." - -Example user input: "first plan the work, then implement it, run tests, do a code review, then push" -→ Parse into: plan, implement, test, review, push - -### Step 4: Policies (for each step) -For each step, ask if the default `on_fail: block` is ok, or if they want: -- `retry` — auto-retry once on failure -- `continue` — skip failed step and move on -- `block` — halt until manual retry (default) - -Suggest sensible defaults: test steps → retry, optional steps → continue. - -### Step 5: Guards (optional) -Ask: "Should any steps block certain commands? For example, blocking `git push` until tests pass." - -If yes, collect: -- Which step the guard applies to -- The regex pattern to match (e.g., `git push`, `git commit`) -- The message to show when blocked - -### Step 6: Preview and confirm -Build the template JSON and show a preview using: -```bash -echo '' | python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" save-template -``` - -Before saving, show the full template and ask: "Look good? Save it?" - -### Step 7: Save -```bash -echo '' | python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" save-template -``` - -The template is saved to `.claude/weft/templates/.json` in the project directory. - -Tell the user: "Template saved! Start it with `/wf-start `" diff --git a/skills/wf-resume/SKILL.md b/skills/wf-resume/SKILL.md new file mode 100644 index 0000000..1fa09f3 --- /dev/null +++ b/skills/wf-resume/SKILL.md @@ -0,0 +1,44 @@ +--- +name: wf-resume +description: "Resume a weft workflow parked in 'waiting' at a human gate (unattended on_fail=block). Use only when user types /wf-resume." +argument-hint: "[reason]" +allowed-tools: [Bash, Read] +--- + +# Resume a Parked Weft Workflow + +When weft runs unattended (`WEFT_UNATTENDED=1`, e.g. via shim) and a step's `on_fail: block` gate +fires, the workflow parks in `waiting` and emits `wf.needs_human` instead of failing/hanging. The +detached session stops cleanly. This skill resumes it once you've resolved the gate. + +## Steps + +1. See what it's waiting on: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" status --json +``` + +Look for `"status": "waiting"`. The most recent `wf.needs_human` event names the parked step and the +reason: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" query --type wf.needs_human --last 1 +``` + +2. Resolve whatever the gate flagged (fix the code, answer the question, etc.). + +3. Resume — flips the workflow back to `running` at the parked step: + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" resume "" +``` + +4. Advance the (now running) step as normal with `/wf-step complete` (or `/wf-run-step` if it has an + executor). + +## Notes + +- `resume` only works on a `waiting` workflow; it errors otherwise. +- `waiting` is a valid stop state, so a parked workflow never blocks a session from ending. +- It survives `weft rebuild` (replayed from `wf.needs_human` / `wf.resumed`). diff --git a/skills/wf-run-step/SKILL.md b/skills/wf-run-step/SKILL.md new file mode 100644 index 0000000..fd98989 --- /dev/null +++ b/skills/wf-run-step/SKILL.md @@ -0,0 +1,69 @@ +--- +name: wf-run-step +description: "Execute the current weft step via the Claude Code Workflow tool, then auto-transition on the structured verdict. Use only when user types /wf-run-step, or when advancing a step whose template declares an executor." +argument-hint: "[--resume]" +allowed-tools: [Bash, Read, Workflow] +--- + +# Run a Weft Step as a Workflow + +For steps whose template declares an `executor` (a Claude Code Workflow script), this runs the +workflow, captures its structured verdict, and pipes the result to `weft run-result` — which records +the Workflow runId on the step (for crash-resume) and auto-transitions: complete if the verdict is +non-blocking, fail (per `on_fail`) if blocking. + +Steps with **no** executor are not for this skill — drive them with `/wf-step` as normal. The executor +is for mechanical steps (review fan-out, multi-target verification, regression sweeps); judgment gates +stay human-driven. + +## 1. Read the current step's executor + +```bash +python3 "${CLAUDE_PLUGIN_ROOT}/core/cli.py" status --json +``` + +Find the current step (the one with `status: running`). Read its `executor` field: +- `executor.type` — currently only `"workflow"` is supported. +- `executor.script` — script name, resolved to `~/.claude/weft/workflows/