SLOPSHOPPER

harness

Harness — a lightweight reasoning floor. Pass a raw request; a fixed six-stage engine runs Plan(opus) → SetGoal(opus, adversarial critic) →…

newpanebandcommandstatusprocess
★ 3v1.27.0MITupdated 2026-10-08newkayak12/claude-skills/harness
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · harness
│ ┃ Harness ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Harness gate │ ⏺ Read(src/auth.ts) │ ┃ │ │ ⎿ Read 6 lines │ ┃ │ 1: Run 2: Units 3: ● Gate │ ⏺ Update(src/auth.ts) │ ┃ │ │ ⎿ Added 2 lines, removed 1 line │ ┃ │ No gate configured: │ ⏺ Bash(bun test) │ ┃ │ .claude/harness-gate.json is not in this │ ⎿ 3 pass, 1 fail │ ┃ │ project. │ │ ┃ ╰──────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /harness-gate │ ⎿ harness: pane opened │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Harness
╭──────────────────────────────────────────────────────────╮ │ Harness gate │ │ │ │ 1: Run 2: Units 3: ● Gate │ │ │ │ No gate configured: .claude/harness-gate.json is not in │ │ this project. │ ╰──────────────────────────────────────────────────────────╯
README

harness

English · 한국어

A reasoning floor for substantial requests. Not a quality maximizer — a filter that removes repetition and below-threshold answers by forcing every request through six staged roles with the judge separated from the actor. Planning and judging are pinned to Opus; code execution and deterministic verification are provider-routed to the local Codex CLI when it is available.

Plan(opus) → SetGoal(opus) → Implement(Codex when enabled) → Test(Codex when enabled) → QualityGate(opus, loop) → Report(sonnet)

The plugin also ships an opt-in PreToolUse edit gate, so a project can require harness engagement before anyone edits the paths it cares about.

Install & Uninstall

/plugin install harness@newkayak12-claude-skills
/plugin uninstall harness@newkayak12-claude-skills

trophy rides along. From this version, the first interactive session after you install or update this plugin installs trophy (achievements) once, in user scope, if you don't have it. Nothing is sent until you say yes; uninstalling trophy is respected (it is never reinstalled). To opt out beforehand: mkdir -p ~/.claude/plugins/.newkayak12-trophy-ride.done. Needs sh (Windows without one is not covered).

Marketplace install alone enforces nothing — it makes the skills available. Run the install skill inside a target project to make governance ambient (see Installing into a project).

Orchestration paths

PathWhenHow
Graph (default)graph-engineering MCP connectedgraph:orchestrate — the graph engine owns the flow; the main session only loops graph_next / graph_run / graph_submit. No transport subagents, no polling. Open with allocation: "balanced" for stages to actually be distributed — omit it and it falls back to legacy ordered, running everything in one session.
Workflow engineGraph MCP absent, Workflow tool availableWorkflow({ scriptPath: "harness/engine/pipeline.js", ... })
Agent teamNeitherengine/fallback.md

The graph engine exists because the Workflow path had to spend a subagent per Implement/Test node just to drive a CLI through Bash, and a subagent waiting on a process can only poll. Measured on one real run the transport layer cost more than the reasoning layer (6.87M vs 2.06M input tokens, zero edits by the transport). See graph/README.md.

Which skill do I want?

I want to…Skill
Get a substantial request planned, executed, verified, and gated before it comes backharness
Connect or verify the graph-owned default orchestration pathgraph:install
Make the harness ambient in a project — gate, hook, conventions, CLAUDE.md block; optionally derive conventions from a reference project via develop:like-my-codeinstall
Take harness governance back out of a projectremove
Refresh this project's installed harness copies after a plugin version bumpupdate
Delegate an Implement/Test stage to the local Codex CLI from any install layoutcodex-control

Skills

harness

The engine entry point. Hands the raw request to engine/pipeline.js, which plans, authors and critiques its own goal-spec, executes each subgoal with skill-equipped executors, verifies each one with a separate deterministic Test agent, and gates both the subgoals and the assembled whole before writing a report. Reach for it when the bar is "verified, not plausible". Not for trivial edits or Q&A — the six stages cost more than the answer is worth there.

Run the harness on this: our order-sync job silently drops rows when the upstream page
size changes. Fix it properly and prove each part independently before reporting.

Invocation, mode B (the default):

Workflow({ scriptPath: "harness/engine/pipeline.js", args: {
  request: "<the request>",
  context: "<optional constraints>",
  max_retries: 2,
  codex_provider: "off"      // default "off"; "auto" | "required" opt in to Codex
}})

Returns report, all_passed, failed[], and goal_gate — relayed to you as-is, failures included. Beyond the three statically mounted skills (agents:agent-task-decomposer at Plan, think:devils-advocate at the spec critic and QualityGate, completion:verification-before-completion at Test), SetGoal may map harness-aware repo skills (write:plans, planning:executing-plans, agents:subagent-driven-development, develop:test-driven-development, write:writing-skills, agents:dispatching-parallel-agents, think:brainstorming) onto subgoals. All optional; a run using none of them is valid.

install

Scaffolds project-owned harness governance so enforcement does not depend on the plugin staying installed. Judgment (gate patterns, embedding choice, conventions, the CLAUDE.md block) stays with the skill; the deterministic file work runs through install.mjs. Everything is idempotent and non-destructive — existing files are reported kept, never overwritten. It does not run the engine; use the harness skill for that.

이 프로젝트에 하네스 설치하고 게이트 켜줘 — Kotlin 소스만 게이트 대상으로.
node "<plugin>/skills/install/install.mjs" '{
  "projectDir": "<abs project root>",
  "gate": { "patterns": ["src/.*\\.kt$"], "window_hours": 2 },
  "embed": { "runtime": true, "skills": [ { "name": "...", "src": "<abs>" } ] }
}'

Omit gate to skip the gate write, embed to skip standalone embedding. After a plugin version bump, re-run with "refresh": true — it re-copies only plugin-owned files (goal-gate.mjs, .claude/harness/**) and never touches your gate, conventions, CLAUDE.md, or settings.json.

remove

Uninstalls project-local harness governance: the hook, its settings.json registration, .claude/harness-gate.json, .claude/.harness-last-decision.json, .claude/harness/, .claude/.harness-markers/, the fenced CLAUDE.md block, and the .gitignore line. .claude/conventions/ is project-owned and is preserved by default — purging it requires explicit confirmation. A malformed settings.json or unmatched CLAUDE markers are left in place and reported for manual cleanup rather than deleted to force completion.

Uninstall the harness from this project, but keep .claude/conventions/ — we've edited those.
node "<plugin>/skills/remove/remove.mjs" '{
  "projectDir": "<abs project root>",
  "purgeConventions": false
}'

Idempotent: a second run reports absent rather than failing.

update

Refreshes a project's installed harness copies after the plugin was bumped. It detects the install mode from disk (.claude/harness/ present → embedded), re-derives the embed config from what is embedded, and runs install.mjs with "refresh": true — no new script. Plugin-owned copies (goal-gate.mjs, .claude/harness/**) are reported refreshed or unchanged; your gate, conventions, CLAUDE.md block, and settings.json are never touched. An embed source it cannot resolve stops the run and is named.

하네스 플러그인 올렸는데 이 프로젝트 복사본도 최신으로 맞춰줘.

Maintainers cutting a patch release of this source use node _repo/scripts/patch-harness.mjs (see the repo CLAUDE.md Update Workflow); it is not a user skill.

codex-control

The adapter-discovery contract used by Codex-enabled Implement/Test stages, so delegation works without assuming the project embedded .claude/harness/**. It resolves the first existing codex-exec-adapter.mjs, and when none exists it records Codex as unavailable and lets the stage continue on the normal Claude path.

Harness Implement stage needs to run Codex from plugin mode — resolve the adapter first.

Resolution order:

#LayoutPath
1Explicit argargs.codex_adapter_path
2Repo-localharness/engine/codex-exec-adapter.mjs
3Embedded install.claude/harness/engine/codex-exec-adapter.mjs
4Plugin modederived from the scriptPath in the project's CLAUDE.md Harness block

Run contract: always --detect before delegating; separate Codex processes for Implement and Test; Implement may use --sandbox workspace-write; Test prompts are verification-only and must never edit implementation files or trust the Implement narrative without command/file evidence.

How it works

  1. You pass a raw request. Workflow({ scriptPath: "harness/engine/pipeline.js", args: { request: "..." } })
  2. The engine plans and authors the goal-spec itself (Opus, with an adversarial critic pass), so spec quality doesn't depend on the main-session model. Schema: goal-spec.md.
  3. Each subgoal loops Implement → Test → QualityGate (bounded by max_retries): executors invoke this repo's skills; a separate Test agent produces deterministic evidence (runs commands, reads artifacts); a separate Opus judge gates on evidence.
  4. A goal-level gate scores the assembled whole against the goal (0-100 match_pct, pass requires >= 90%; below threshold triggers a repair pass and re-gate), then a Report stage synthesizes.

With codex_provider: "auto" or "required", Codex is the default route for every Implement/Test stage: a minimal Sonnet controller resolves the adapter via codex-control, runs a separate local codex exec --json process, and converts its output into the normal HANDOFF or evidence JSON — it must not redo the work itself on success. auto may explicitly degrade to Sonnet on route failure; required reports provider failure instead. off forces plain Sonnet. implement_provider / test_provider in the goal-spec are trace hints, not prerequisites.

Modes

  • B (default): raw request + the fixed engine.
  • DW-off fallback: when Dynamic Workflow is disabled or the Workflow tool is absent, form a role-isolated Agent Team and run the same six roles through the file-backed fallback contract (engine/fallback.md). The team lead only coordinates; Implement, Test, and QualityGate remain separate teammates exchanging file paths through a run directory, and engine/fallback-check.mjs is the objective done-signal.
  • M (meta): the harness generates a bespoke Workflow when the request needs control flow the fixed stages can't express (tournament, escalation, loop-until-dry) — it copies templates/meta-skeleton.js, rewrites only the [META] Work block, and runs it. The skeleton's contract (judge ≠ actor, provider routing, bounded loops, deterministic Test, goal-level gate) stays verbatim.
  • A (manual): you author the bespoke Workflow yourself — see templates/.

When the active orchestrator is Codex itself, do not recurse through codex, codex-exec-adapter.mjs, or codex-runner.mjs — run the six-stage contract directly with native Codex tools per the repository AGENTS.md.

Installing into a project

Marketplace install alone enforces nothing. Run the install skill (skills/install/SKILL.md) from the target project to make governance ambient — it scaffolds project-owned copies (never overwrites existing files):

  • .claude/harness-gate.json — activates the edit gate on confirmed path patterns
  • .claude/hooks/goal-gate.mjs + a merged .claude/settings.json PreToolUse entry — the self-contained gate hook, committed so it enforces team-wide without depending on the plugin install (engine still lives in the plugin — see the install skill's gap note)
  • .claude/conventions/{coding,verification,boundaries}.md — default ruleset the engine reads (SetGoal → acceptance/test, Implement → follows) If you have a reference project, install can fill coding.md/boundaries.md from it via develop:like-my-code (skipped when develop is absent)
  • a fenced ## Harness section appended to the project's CLAUDE.md
  • .claude/.harness-markers/ in .gitignore

The project owns the copies afterward. Lifecycle skills only change them when explicitly invoked: install with refresh:true refreshes plugin-owned copies, and remove cleans up the installation.

Enforcement is an opt-in PreToolUse gate (hooks/): a project lists gated paths in .claude/harness-gate.json, and Write|Edit|MultiEdit|NotebookEdit there — or a Bash command that writes there — requires harness engagement. Engagement is a record (a Workflow/graph/teams tool call that ran, an open broker node, or a fallback run with plan, goal-spec and a sound critique on disk), never a string in the transcript. The gate's own config, hook and settings are always gated. Fail-open everywhere (v0 lesson).

Lifecycle helpers

  • harness:remove removes the installed hook, registration, gate, embedded runtime, marker cache, CLAUDE.md block, and gitignore entry. Project-owned conventions are preserved unless their removal is explicitly requested.
  • harness:update refreshes a project's installed copies after a plugin bump by running install.mjs with "refresh": true; user-owned files are never touched.

Mod (Claude Code live UI)

harness ships a small mod: a status line and a pipeline band above the prompt with the open runs per stage, and a pane with the run, its units and the gate. It is early access and optional. The gate itself is the command hook and works without it, and the mod does not change how it decides.

Version. Modules load on Claude Code 2.1.292 and newer. The module API is early access and may change between releases. An older build skips the module: 2.1.284 was checked, it prints one stderr line (hooks module not loaded: …) and the command hooks, MCP and CLIs work unchanged. If a build says modules are not turned on for installed plugins, set CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Where it runsMod
Interactive terminalon
Desktop app, Code tabon
claude -p and headless adaptersoff (no UI surface)
Codex, or no pluginnot applicable, nothing is lost

Features

  • Status line: open runs per stage in this worktree (other worktrees belong to other sessions), fallback runs first, then graph runs — Plan(6) / Implement(11) · graph Implement(3). A run untouched for 12 h is left out; with nothing open the line is empty. Gate decisions are not shown here; open /harness-gate.
  • Band above the prompt: one pipeline row per kind with open runs (harness, then graph), Plan ━ Setgoal ━ Critique ━ Implement ━ Test ━ Gate ━ Report (Test for harness only). A stage with open runs is a bold chip with its count (Implement 11), empty stages are dim; labels shorten to fit 80 columns. Hover a chip for a card listing its runs: slug, a ▰▱ passed/total bar and N failed. Hidden when nothing is open.
  • /harness-gate opens the pane on the Gate tab. The tabs are Run, Units and Gate (keys 1-3; the selected one is bright with a dot).
  • Run draws the six stages as a rail (● Plan ━━ ● SetGoal ━━ ● Critique ━━ ◉ Implement/Test ┄┄ ○ Gate ┄┄ ○ Report: solid up to where the run is, dotted after), what it is doing now, one line per subgoal and a progress bar.
  • Units is a board with To do, Doing and Done columns for the subgoals, with tries and the goal-gate match as a % bar against the pass bar.
  • Gate is the existing view: the gated patterns, the engagement window, the last decision (allow or deny, tool, target, age, reason) and how to engage the harness. Without a gate config it says so.
  • Where the data comes from. The run view reads .harness-run/<run>/ in the session folder (plan, goal spec, critique, gate files and the subgoal folders) read-only, and follows the newest live run, else the newest run. The last decision comes from .claude/.harness-last-decision.json, written by the gate hook. It is a local runtime file, gitignored by install, and removed by remove. A gate config that cannot be read shows an invalid-config notice on the Gate tab.
  • Language. English by default; Korean when Claude Code's language setting is Korean.

Known limitations: the status reads the gate config relative to the session's working directory. A session that started with no UI surface (headless or SDK-hosted) keeps the mod off even if a client attaches later; start a new session to get it (a reload of an unchanged mod does not re-fire session.start).

Status

  • v1.27.0 — install asks whether a reference project exists (default No); if yes, develop:like-my-code fills .claude/conventions/coding.md and boundaries.md from its style, each rule with repo evidence and a cited source; skipped when develop is absent
  • v1.26.2 — trophy rides along: the first interactive session after this update installs trophy once if missing (uninstall respected)
  • v1.26.1 — Mod: the band and status line count only this worktree's runs (other worktrees belong to other sessions)
  • v1.26.0 — Mod: the /harness-gate pane becomes a run view in the teams 0.46.0 style: tabs Run / Units / Gate (keys 1-3), a six-stage rail, per-unit state, a goal-gate match bar and what runs now; /harness-gate opens on Gate (gate info unchanged, plus a line for an unreadable gate config). The 1.25.0 status line and pipeline band are kept. English by default, Korean with Claude Code's language. Mod tests 38; real Claude Code captures EN/KO
  • v1.25.1 — Mod: the band hover card opens in the flow under the stage rows (an absolute card above the band was clipped; checked live); graph runs are named by their request
  • v1.25.0 — Mod: status line counts open runs per stage (fallback + graph, every worktree: Plan(6) / Implement(11) · graph Implement(3)); pipeline band above the prompt with stage chips and a hover card of per-run progress; Ink-style /harness-gate pane. Gate decisions now live only in the pane
  • v1.24.0 — Mod (Claude Code 2.1.292+, early access): gate status line and /harness-gate pane; the goal gate records its last decision to .claude/.harness-last-decision.json (install/remove manage the file) and resolves write targets through indirection
  • v1.23.0 — harness:patch leaves the user surface (maintainer script moved to _repo/scripts/patch-harness.mjs); new harness:update refreshes installed copies via install.mjs "refresh": true
  • v1.22.8 — codex-control gains a Related Skills section and Korean scenarios (repo audit)
  • v1.22.7 — repo scripts moved under _repo/; the patch skill runs _repo/scripts/validate_plugins.py
  • v1.22.6 — harness-aware skill write:writing-plans renamed write:plans
  • v1.22.5 — All five skills now carry a standard What Claude Does / What You Do table.
  • v1.22.4 — goal gate Bash judgement is deny by default without false positives: a write verb is exempt only as a plain argument of a read-only command that owns the whole simple command (grep -n cp x.mjs; not rg --pre, less -o, $(…)); wrappers and an interpreter/shell anywhere in a command are judged; git --output counts; an inline or heredoc script counts named paths only when it can write, a heredoc used as data only its redirect (unless its body writes, or its file is code or run later); heredocs are found outside quotes and an unclosed one is judged whole; the broker ledger .harness-run/broker/ is gated
  • v1.22.3 — goal gate engages only on records (a tool call that ran, a broker node, a fallback run with a sound critique) - never transcript text; root from the target file (subdirs, sibling worktrees); gates its own config/hook/settings; judges Bash writes; ignores future timestamps; install widens old matchers and ignores .harness-run/
  • v1.22.2 — graph path opens in balanced mode: step 0 documented graph_open({request, cwd, vendor, isolated}), omitting allocation. The broker defaults it to "ordered", where vendor: "auto" stays on self — so a caller following this signature silently ran every stage in one session instead of distributing them. The call now names allocation: "balanced", host_vendor and host_model.
  • v1.22.1 — stable line. Claude-only by default: codex_provider now defaults to "off" in pipeline.js, the Agent Team fallback skips provider detection unless a run opts in, and the graph's vendor: "auto" resolves to self instead of probing Codex. No external provider is contacted unless a caller names one. Codex delegation is fully preserved and reachable by opting in (codex_provider: "auto"|"required", or vendor: "codex"). Primary path DW/Workflow, secondary Agent Team; the six stages, model pins, retry bounds, and the goal-level gate are unchanged.
  • v1.22.0 — Graph plugin separation: the graph-engineering MCP moved from the short-lived broker namespace to the independently versioned graph plugin. Harness now discovers graph-engineering and delegates its default loop to graph:orchestrate; setup and connection verification live in graph:install. The Workflow and Agent Team paths remain fallbacks.
  • v1.20.0 — Lifecycle helpers: added harness:remove for deterministic, idempotent project cleanup with user-owned conventions preserved by default, and harness:patch for synchronized patch-version bumps across both manifests plus the README Status entry. Fixture tests cover mixed-setting preservation, malformed-file safety, idempotence, dry-run, and mismatch refusal.
  • v1.19.0 — DW-off Agent Team fallback: when Dynamic Workflow is disabled or the Workflow tool is absent, the fallback now explicitly requires a role-isolated Agent Team. A thin team lead declares Plan, SetGoal/Critic, Implement, Test, QualityGate, and Report ownership in the run manifest; teammates exchange only file paths through the run directory, and actor/judge separation remains mandatory. Native team primitives are preferred, with an explicit logical team of role-separated agents as the portable equivalent. pipeline.js is unchanged.
  • v1.18.0 — Codex-first Implement/Test routing: codex_provider: "auto" / "required" now routes every Workflow Implement/Test stage through the Codex controller by default. implement_provider: "codex" and test_provider: "codex" remain optional trace hints in the goal-spec, but missing fields no longer keep a subgoal on Sonnet. This makes the graph shape explicit: Claude plans, sets goals, judges, and reports; Codex owns leaf implementation and deterministic verification whenever the local CLI route is available. Fallback mode documents the same default-provider rule when RUN/providers.json says Codex is ready.
  • v1.17.0 — Workflow Codex provider routing semantics: implement_provider: "codex" and test_provider: "codex" now mean runtime delegation, not trace hints. The Workflow path still uses a tiny Sonnet controller because Workflow scripts cannot spawn providers directly, but that controller only resolves the adapter, invokes Codex, and converts Codex output into the normal handoff/evidence shape. On Codex success it must not redo implementation or verification with Sonnet. codex_provider: "auto" allows an explicit degraded Sonnet fallback; required mode reports provider failure instead of silently falling back. Goal-level repairs also prefer the Codex route when delegation is enabled.
  • v1.16.2 — Codex session compatibility boundary: added AGENTS.md guidance that an active Codex session must run the harness contract directly with native Codex tools, not recurse through codex, codex-exec-adapter.mjs, or codex-runner.mjs. The Codex CLI adapter remains only for Claude-orchestrated Workflow/fallback delegation and external automation. The Claude Workflow path (engine/pipeline.js) is unchanged.
  • v1.16.1 — Codex plugin-mode adapter discovery: added harness:codex-control and mounted it in Workflow Implement/Test Codex delegation. pipeline.js now honors an explicit `
Source 3 files
hooks/mod.tsx 594 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Decision, OpenRun, OpenRuns, RunInfo, RunSub, StageKey } from '../types'
5import { MARK, TINT, bar, board, cap, fmt, isKorean, rail, tabs } from './draw'
6import type { Mark } from './draw'
7
8const last = atom({ plugin: 'harness', key: 'last' } as const, null as Decision | null)
9const armed = atom({ plugin: 'harness', key: 'armed' } as const, false)
10const patterns = atom({ plugin: 'harness', key: 'patterns' } as const, [] as string[])
11const windowHours = atom({ plugin: 'harness', key: 'windowHours' } as const, 2)
12// the config file exists but is not JSON
13const broken = atom({ plugin: 'harness', key: 'broken' } as const, false)
14const runs = atom({ plugin: 'harness', key: 'runs' } as const, { harness: [], graph: [] } as OpenRuns)
15const run = atom({ plugin: 'harness', key: 'run' } as const, null as RunInfo | null)
16// 'auto' opens on Run when a run is found, else on Gate
17const view = atom({ plugin: 'harness', key: 'view' } as const, 'auto' as 'auto' | 'run' | 'units' | 'gate')
18const lang = atom({ plugin: 'harness', key: 'lang' } as const, 'en' as 'en' | 'ko')
19
20const PANE = 'harness-gate'
21const CONFIG = '.claude/harness-gate.json'
22const DECISION = '.claude/.harness-last-decision.json'
23const TICK_MS = 5000
24const RUNS = '.harness-run'
25const LIVE_MS = 2 * 60 * 60 * 1000
26const MAX_READ = 4 * 1024 * 1024
27const PASS_PCT = 90 // the pass bar of the run checker (engine fallback check)
28const BAR = 24
29const WORK_MAX = 6
30const RUN_STAGES: StageKey[] = ['plan', 'setgoal', 'critique', 'implement', 'gate', 'report']
31
32const en = {
33  paneTitle: 'Harness gate', tabRun: 'Run', tabUnits: 'Units', tabGate: 'Gate',
34  stagePlan: 'Plan', stageSetgoal: 'SetGoal', stageCritique: 'Critique', stageImplement: 'Implement/Test', stageGate: 'Gate', stageReport: 'Report',
35  now: 'Now', nowImplement: 'Implementing', nowTest: 'Testing', nowGate: 'Gating', nowRetry: 'Retrying',
36  nowStage: 'Working on {stage}', nowDone: 'All done', attempt: ' (attempt {n})',
37  stateRunning: 'running', stateStalled: 'stalled', stateComplete: 'finished', headLine: '{state} · {done}/{total}',
38  tries: '{n} tries', goalGate: 'Goal gate', passBar: '{pct}% (pass ≥ {bar})', more: '+{n} more',
39  colTodo: 'To do', colDoing: 'Doing', colDone: 'Done',
40  noRun: 'No harness run in this folder.',
41  cmdDesc: 'Why the harness gate denied the last call, and how to engage it', paneOpened: 'pane opened',
42  noGate: 'No gate configured: .claude/harness-gate.json is not in this project.',
43  badGate: 'Gate config .claude/harness-gate.json could not be read (invalid JSON).',
44  gated: 'Gated patterns ({n}): {list}', window: 'Engagement window: {h} h',
45  noDecision: 'No gated call decided yet.', lastDecision: 'Last decision: {decision} ({tool} {target}, {ago})', reason: 'Reason: {reason}',
46  agoMin: '{n} min ago', agoHour: '{n} h ago',
47  // the deny message of the gate hook, after the gated paths
48  engage:
49    'Engage the harness before editing it: invoke the harness skill and follow its Process - ' +
50    'the graph MCP, the Workflow engine, or an Agent Team fallback run whose plan, goal-spec and ' +
51    'sound critique are on disk. A mention in text does not engage it.',
52}
53
54// one table, two languages; the type keeps the keys identical
55export const STRINGS: Record<'en' | 'ko', Record<keyof typeof en, string>> = {
56  en,
57  ko: {
58    paneTitle: '하네스 게이트', tabRun: '실행', tabUnits: '단위', tabGate: '게이트',
59    stagePlan: '계획', stageSetgoal: '목표 설정', stageCritique: '비평', stageImplement: '구현/테스트', stageGate: '게이트', stageReport: '보고',
60    now: '지금', nowImplement: '구현 중', nowTest: '테스트 중', nowGate: '게이트 판정 중', nowRetry: '재시도 중',
61    nowStage: '{stage} 진행 중', nowDone: '모두 끝남', attempt: ' ({n}번째 시도)',
62    stateRunning: '진행 중', stateStalled: '멈춤', stateComplete: '완료', headLine: '{state} · {done}/{total}',
63    tries: '{n}회 시도', goalGate: '목표 게이트', passBar: '{pct}% (통과 ≥ {bar})', more: '+{n}건 더',
64    colTodo: '대기', colDoing: '진행', colDone: '완료',
65    noRun: '이 폴더에 하네스 실행이 없습니다.',
66    cmdDesc: '하네스 게이트가 마지막 호출을 막은 이유와 개입 방법', paneOpened: '창을 열었습니다',
67    noGate: '게이트 설정 없음: 이 프로젝트에 .claude/harness-gate.json 이 없습니다.',
68    badGate: '게이트 설정 .claude/harness-gate.json 을 읽을 수 없습니다 (JSON 오류).',
69    gated: '게이트 대상 ({n}): {list}', window: '개입 유효 시간: {h}시간',
70    noDecision: '아직 판정한 게이트 호출이 없습니다.', lastDecision: '마지막 판정: {decision} ({tool} {target}, {ago})', reason: '이유: {reason}',
71    agoMin: '{n}분 전', agoHour: '{n}시간 전',
72    engage:
73      '편집 전에 하네스를 켜세요: harness 스킬을 호출해 Process를 따르면 됩니다 - ' +
74      'graph MCP, Workflow 엔진, 또는 계획·목표 명세·통과한 비평이 디스크에 있는 Agent Team 대체 실행. ' +
75      '글로 언급하는 것만으로는 켜지지 않습니다.',
76  },
77}
78
79type Key = keyof typeof en
80type S = (key: Key, vars?: Record<string, string | number>) => string
81const strings = (l: 'en' | 'ko'): S => (key, vars) => fmt(STRINGS[l][key], vars)
82
83// an unparseable or partial decision file is no decision
84function parseDecision(text: string): Decision | null {
85  try {
86    const d = JSON.parse(text) as Partial<Decision> | null
87    if (d === null || typeof d !== 'object') return null
88    if (typeof d.ts !== 'number' || typeof d.tool !== 'string' || typeof d.target !== 'string') return null
89    if (d.decision !== 'allow' && d.decision !== 'deny') return null
90    return { ts: d.ts, session_id: d.session_id, tool: d.tool, target: d.target, decision: d.decision, reason: typeof d.reason === 'string' ? d.reason : '' }
91  } catch {
92    return null
93  }
94}
95
96const ago = (s: S, ms: number) => {
97  const m = Math.max(0, Math.round(ms / 60000))
98  return m < 60 ? s('agoMin', { n: m }) : s('agoHour', { n: Math.round(m / 60) })
99}
100
101type Ent = { name: string; kind: string; size: number; mtimeMs: number }
102const asRecord = (v: unknown): Record<string, unknown> | undefined =>
103  typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined
104
105// a list failure means nothing is there
106async function ls($: EngineInterface, path: string): Promise<Ent[]> {
107  try {
108    return (await $.fs.list(path)) as Ent[]
109  } catch {
110    return []
111  }
112}
113
114// a missing, oversized or unparseable file is no file
115async function readJson($: EngineInterface, path: string, ent?: Ent): Promise<Record<string, unknown> | undefined> {
116  if (ent !== undefined && ent.size > MAX_READ) return undefined
117  try {
118    return asRecord(JSON.parse(await $.fs.read(path)))
119  } catch {
120    return undefined
121  }
122}
123
124const firstLine = (v: unknown) => (typeof v === 'string' ? (v.split('\n')[0] ?? '').trim().slice(0, 120) : '')
125
126// the highest n of <prefix>-<n>.<ext> among the names, 0 when none
127const maxN = (names: string[], prefix: string) =>
128  Math.max(0, ...names.map(n => Number(new RegExp(`^${prefix}-(\\d+)\\.`).exec(n)?.[1] ?? 0)))
129
130const records = (v: unknown) =>
131  (Array.isArray(v) ? (v as unknown[]) : []).map(asRecord).filter((r): r is Record<string, unknown> => r !== undefined && typeof r.id === 'string')
132
133// The current run of .harness-run/<run>/: the newest live run, else the newest by time. Read only.
134async function scanRun($: EngineInterface): Promise<RunInfo | null> {
135  const now = await $.clock.now()
136  const cands: { slug: string; files: Ent[]; subs: Record<string, Ent[]>; time: number; finished: boolean }[] = []
137  for (const d of await ls($, RUNS)) {
138    if (d.kind !== 'dir' || d.name === 'broker') continue
139    const base = `${RUNS}/${d.name}`
140    const files = await ls($, base)
141    const subs: Record<string, Ent[]> = {}
142    if (files.some(f => f.name === 'subgoals' && f.kind === 'dir')) {
143      for (const sg of await ls($, `${base}/subgoals`)) if (sg.kind === 'dir') subs[sg.name] = await ls($, `${base}/subgoals/${sg.name}`)
144    }
145    // a run's time counts its subgoal files too
146    const all = [...files, ...Object.values(subs).flat()].filter(f => f.kind === 'file')
147    cands.push({
148      slug: d.name, files, subs,
149      time: Math.max(0, ...all.map(f => f.mtimeMs)),
150      finished: files.some(f => f.name === '05-report.md'),
151    })
152  }
153  const isLive = (c: (typeof cands)[number]) => !c.finished && now - c.time < LIVE_MS
154  const newest = (list: typeof cands) => [...list].sort((a, b) => b.time - a.time)[0]
155  const pick = newest(cands.filter(isLive)) ?? newest(cands)
156  if (pick === undefined) return null
157
158  const base = `${RUNS}/${pick.slug}`
159  const has = (name: string) => pick.files.some(f => f.name === name)
160  const get = (name: string) => (has(name) ? readJson($, `${base}/${name}`, pick.files.find(f => f.name === name)) : Promise.resolve(undefined))
161  const manifest = await get('manifest.json')
162  const spec = await get('02-goal-spec.json')
163  const critique = await get('02-critique.json')
164  const gate = await get('04-goal-gate.json')
165
166  // subgoal ids: the manifest's order, then the spec's, then any directory
167  const fromManifest = records(manifest?.subgoals).sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))
168  const fromSpec = records(spec?.subgoals)
169  const titles = new Map(fromSpec.map(r => [String(r.id), typeof r.title === 'string' ? r.title : '']))
170  const ids = [...new Set([...fromManifest.map(r => String(r.id)), ...fromSpec.map(r => String(r.id)), ...Object.keys(pick.subs)])]
171
172  const subs: RunSub[] = []
173  let doing: RunInfo['now'] | undefined
174  for (const id of ids) {
175    const sub = pick.subs[id] ?? []
176    const names = sub.map(f => f.name)
177    const impl = maxN(names, 'impl')
178    const test = maxN(names, 'test')
179    const gn = maxN(names, 'gate')
180    const dir = `${base}/subgoals/${id}`
181    const result = names.includes('result.json') ? await readJson($, `${dir}/result.json`, sub.find(f => f.name === 'result.json')) : undefined
182    const lastGate = gn > 0 ? await readJson($, `${dir}/gate-${gn}.json`, sub.find(f => f.name === `gate-${gn}.json`)) : undefined
183    const attempt = Math.max(1, impl, test, gn)
184    const title = titles.get(id) ?? ''
185    let state: Mark = impl > 0 ? 'running' : 'pending'
186    if (result?.passed === true) state = 'done'
187    else if (result?.passed === false) state = 'failed'
188    subs.push({
189      id, title, state,
190      reason: state === 'failed' && typeof lastGate?.reason === 'string' ? lastGate.reason : '',
191      tries: Math.max(Number(result?.attempts ?? 0) || 0, attempt),
192    })
193    // what the first unfinished subgoal is doing
194    if (doing === undefined && result === undefined) {
195      if (impl === 0) doing = { kind: 'implement', id, title, attempt }
196      else if (test < impl) doing = { kind: 'test', id, title, attempt }
197      else if (gn < test) doing = { kind: 'gate', id, title, attempt }
198      else if (lastGate?.pass === false) doing = { kind: 'retry', id, title, attempt: gn + 1 }
199      else doing = { kind: 'gate', id, title, attempt }
200    }
201  }
202
203  const match = typeof gate?.match_pct === 'number' ? gate.match_pct : null
204  const goalPass = gate === undefined ? null : typeof gate.pass === 'boolean' ? gate.pass : match !== null && match >= PASS_PCT
205  const implDone = subs.length > 0 && subs.every(x => x.state === 'done' || x.state === 'failed')
206  const raw: { done: boolean; failed?: boolean }[] = [
207    { done: has('01-plan.md') },
208    { done: spec !== undefined },
209    { done: critique !== undefined && critique.sound !== false },
210    { done: implDone, failed: implDone && subs.some(x => x.state === 'failed') },
211    { done: gate !== undefined, failed: goalPass === false },
212    { done: has('05-report.md') },
213  ]
214  // done up to the first stage that is not; that one runs (or has failed), the rest wait
215  let reached = false
216  const stages = RUN_STAGES.map((key, i) => {
217    const r = raw[i]!
218    let state: Mark = 'pending'
219    if (r.failed) state = 'failed'
220    else if (r.done) state = 'done'
221    else if (!reached) state = 'running'
222    if (state === 'running' || state === 'failed') reached = true
223    return { key, state }
224  })
225  const current = stages.find(g => g.state === 'running' || g.state === 'failed')
226  const nowInfo: RunInfo['now'] =
227    pick.finished || current === undefined
228      ? { kind: 'done' }
229      : current.key === 'implement' && doing !== undefined
230        ? doing
231        : { kind: 'stage', stage: current.key }
232
233  return {
234    slug: pick.slug,
235    title: firstLine(manifest?.request) || firstLine(spec?.goal) || pick.slug,
236    live: isLive(pick), finished: pick.finished, stages, subs,
237    done: subs.filter(x => x.state === 'done').length, total: subs.length,
238    now: nowInfo, match, pass: goalPass,
239  }
240}
241
242// Open runs per stage, in flow order, this worktree: fallback runs (.harness-run/<slug>/, staged by
243// the first missing file: 01-plan.md, 02-goal-spec.json, a sound 02-critique.json, every subgoal's
244// result.json, 04-goal-gate.json) and graph runs (.harness-run/broker/runs/<id>.json).
245const STAGES = ['plan', 'setgoal', 'critique', 'implement', 'test', 'gate', 'report'] as const
246type Stage = (typeof STAGES)[number]
247// A run untouched for 12 h is abandoned, not open.
248const STALE_MS = 12 * 60 * 60 * 1000
249const FINISHED = new Set(['done', 'skipped', 'unreachable'])
250
251export type StageCounts = { harness: Partial<Record<Stage, number>>; graph: Partial<Record<Stage, number>> }
252
253export type { OpenRuns }
254
255const label = (s: Stage) => s.charAt(0).toUpperCase() + s.slice(1)
256const counted = (c: Partial<Record<Stage, number>>) =>
257  STAGES.filter(s => (c[s] ?? 0) > 0).map(s => `${label(s)}(${c[s]})`).join(' / ')
258
259// `Plan(6) / Implement(11) · graph Implement(3)`; nothing open is no status
260export function stageStatus(c: StageCounts): string | undefined {
261  const parts = [counted(c.harness), counted(c.graph) ? `graph ${counted(c.graph)}` : ''].filter(Boolean)
262  return parts.length > 0 ? parts.join(' · ') : undefined
263}
264
265async function countStages($: EngineInterface, cwd: string, now: number): Promise<{ counts: StageCounts; runs: OpenRuns }> {
266  const out: StageCounts = { harness: {}, graph: {} }
267  const detail: OpenRuns = { harness: [], graph: [] }
268  const add = (kind: 'harness' | 'graph', s: Stage, r: Omit<OpenRun, 'stage'>) => {
269    out[kind][s] = (out[kind][s] ?? 0) + 1
270    detail[kind].push({ ...r, stage: s })
271  }
272  const json = async (p: string) => { try { return JSON.parse(await $.fs.read(p)) } catch { return null } }
273  const list = async (p: string) => { try { return await $.fs.list(p) } catch { return [] } }
274  // this session's worktree only: runs of other worktrees belong to other sessions
275  let trees = [cwd]
276  try {
277    const top = await $.process.run(['git', '-C', cwd, 'rev-parse', '--show-toplevel'])
278    if (top.exitCode === 0 && top.stdout.trim()) trees = [top.stdout.trim()]
279  } catch {
280    // not a repo: this cwd alone
281  }
282  for (const tree of trees) {
283    const base = `${tree}/.harness-run`
284    for (const run of await list(base)) {
285      if (run.kind !== 'dir') continue
286      const dir = `${base}/${run.name}`
287      if (run.name === 'broker') {
288        for (const f of await list(`${dir}/runs`)) {
289          if (!f.name.endsWith('.json') || now - f.mtimeMs > STALE_MS) continue
290          const g = await json(`${dir}/runs/${f.name}`)
291          const nodes = (Array.isArray(g?.nodes) ? g.nodes : []) as { stage?: string; state?: string }[]
292          if (nodes.some(n => n.stage === 'report' && n.state === 'done')) continue
293          const stage = STAGES.find(s => nodes.some(n => n.stage === s && !FINISHED.has(String(n.state))))
294          // the request's first line names the run for a person; the run id when there is none
295          const request = typeof g?.request === 'string' ? g.request.trim().split('\n')[0].slice(0, 60) : ''
296          if (stage) add('graph', stage, { slug: request || f.name.replace(/\.json$/, ''), passed: 0, failed: 0, total: 0 })
297        }
298        continue
299      }
300      const files = await list(dir)
301      const has = (name: string) => files.some(f => f.name === name)
302      if (!has('manifest.json') || has('05-report.md')) continue
303      const subs = await list(`${dir}/subgoals`)
304      if (now - Math.max(0, ...files.map(f => f.mtimeMs), ...subs.map(f => f.mtimeMs)) > STALE_MS) continue
305      const spec = await json(`${dir}/02-goal-spec.json`)
306      const crit = await json(`${dir}/02-critique.json`)
307      const ids = (Array.isArray(spec?.subgoals) ? spec.subgoals : []).map((s: { id?: unknown }) => String(s.id))
308      let passed = 0
309      let failed = 0
310      for (const id of ids) {
311        const r = await json(`${dir}/subgoals/${id}/result.json`)
312        if (r?.passed === true) passed++
313        else if (r?.passed === false) failed++
314      }
315      const judged = passed + failed
316      add('harness', !has('01-plan.md') ? 'plan'
317        : !spec ? 'setgoal'
318        : crit?.sound !== true ? 'critique'
319        : judged < ids.length ? 'implement'
320        : !has('04-goal-gate.json') ? 'gate'
321        : 'report', { slug: run.name, passed, failed, total: ids.length })
322    }
323  }
324  return { counts: out, runs: detail }
325}
326
327const SHORT: Record<Stage, string> = { plan: 'Plan', setgoal: 'Goal', critique: 'Crit', implement: 'Impl', test: 'Test', gate: 'Gate', report: 'Rpt' }
328const CELLS = 10
329
330// passed ▰ in success, failed ▰ in error, the rest ▱ dim; at least one cell for any run judged
331export function barCells(r: OpenRun): { ok: number; bad: number; rest: number } {
332  if (r.total === 0) return { ok: 0, bad: 0, rest: CELLS }
333  const ok = Math.min(CELLS, Math.round((r.passed / r.total) * CELLS))
334  const bad = Math.min(CELLS - ok, Math.round((r.failed / r.total) * CELLS))
335  return { ok, bad, rest: CELLS - ok - bad }
336}
337
338// a person's words for what the run is on now
339function nowSentence(s: S, r: RunInfo): string {
340  const n = r.now
341  if (n.kind === 'done') return s('nowDone')
342  if (n.kind === 'stage') return s('nowStage', { stage: s(`stage${cap(n.stage ?? 'plan')}` as Key) })
343  const what = `${s(`now${cap(n.kind)}` as Key)} ${n.id}${n.title ? `: ${n.title}` : ''}`
344  return n.attempt !== undefined && (n.attempt > 1 || n.kind === 'retry') ? `${what}${s('attempt', { n: n.attempt })}` : what
345}
346
347export const register: Register = on => {
348  on('session.start', async ($, e, next) => {
349    if ((await $.session.surfaces()).length === 0) return next(e)
350
351    async function tick() {
352      try {
353        // the status line: open runs per stage; the gate's decisions live in /harness-gate
354        const open = await countStages($, await $.session.cwd(), await $.clock.now())
355        $.ui.status(stageStatus(open.counts))
356        await update($, runs, () => open.runs)
357        try {
358          const found = await scanRun($)
359          await update($, run, () => found)
360        } catch {
361          // an unreadable run dir leaves the last run as it was
362        }
363        if (!(await $.fs.exists(CONFIG))) {
364          await update($, armed, () => false)
365          await update($, last, () => null)
366          await update($, broken, () => false)
367          return
368        }
369        let cfg: { patterns?: unknown; window_hours?: unknown }
370        try {
371          cfg = JSON.parse(await $.fs.read(CONFIG)) as typeof cfg
372        } catch (err) {
373          if (err instanceof SyntaxError) await update($, broken, () => true)
374          throw err
375        }
376        await update($, broken, () => false)
377        const list = Array.isArray(cfg.patterns) ? cfg.patterns.map(String) : []
378        const hours = Number(cfg.window_hours) > 0 ? Number(cfg.window_hours) : 2
379        let decision: Decision | null = null
380        if (await $.fs.exists(DECISION)) decision = parseDecision(await $.fs.read(DECISION))
381        await update($, patterns, () => list)
382        await update($, windowHours, () => hours)
383        await update($, armed, () => true)
384        await update($, last, () => decision)
385      } catch {
386        // a read error leaves the gate state as it was
387      }
388    }
389
390    // Claude Code's own language setting, read once per session
391    try {
392      const language = (await $.settings.read()).language
393      await update($, lang, () => (isKorean(language) ? 'ko' : 'en'))
394    } catch {
395      // unreadable settings: English
396    }
397    await $.command.register({ name: 'harness-gate', description: STRINGS[await read($, lang)].cmdDesc })
398    await tick()
399    $.clock.every(TICK_MS, tick)
400
401    return next(e)
402  })
403
404  // asked for by the person: the pane seats at any width; the command answers why the gate denied
405  on('command.run', { command: 'harness-gate' }, async $ => {
406    await update($, view, () => 'gate' as const)
407    await $.ui.open({ id: PANE, title: 'Harness' })
408    return { text: STRINGS[await read($, lang)].paneOpened }
409  })
410
411  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
412    const { Box, Button, Text } = $.ui.resolve(e)
413    const ui = { Box, Text, Button }
414    const s = strings(await read($, lang))
415    const r = await read($, run)
416    const picked = await read($, view)
417    const kind = picked === 'auto' ? (r === null ? 'gate' : 'run') : picked
418
419    const items = (['run', 'units', 'gate'] as const).map(k => ({ key: k, label: s(`tab${cap(k)}` as Key) }))
420    const tabRow = tabs(ui, items, kind, k => update($, view, () => k as 'run' | 'units' | 'gate'))
421
422    let body
423    if (kind === 'gate') {
424      if (!(await read($, armed))) {
425        body = <Text>{s((await read($, broken)) ? 'badGate' : 'noGate')}</Text>
426      } else {
427        const list = await read($, patterns)
428        const hours = await read($, windowHours)
429        const d = await read($, last)
430        const now = await $.clock.now()
431        body = (
432          <Box flexDirection="column">
433            <Text>{s('gated', { n: list.length, list: list.join(', ') })}</Text>
434            <Text>{s('window', { h: hours })}</Text>
435            {d === null ? (
436              <Text>{s('noDecision')}</Text>
437            ) : (
438              <Box flexDirection="column">
439                <Text>{s('lastDecision', { decision: d.decision, tool: d.tool, target: d.target, ago: ago(s, now - d.ts) })}</Text>
440                <Text>{s('reason', { reason: d.reason })}</Text>
441              </Box>
442            )}
443            <Text>{s('engage')}</Text>
444          </Box>
445        )
446      }
447    } else if (r === null) {
448      body = <Text>{s('noRun')}</Text>
449    } else {
450      const unit = (x: RunSub, i: number) => (
451        <Box key={`u${i}`} flexDirection="column">
452          <Text wrap="truncate-end">
453            <Text color={TINT[x.state]}>{MARK[x.state]}</Text>
454            {` ${x.id}${x.title ? ` ${x.title}` : ''}${kind === 'run' && x.tries > 1 ? `  ${s('tries', { n: x.tries })}` : ''}`}
455          </Text>
456          {x.state === 'failed' && x.reason !== '' && <Text color="error" wrap="truncate-end">{`  ${x.reason}`}</Text>}
457        </Box>
458      )
459      const progress =
460        r.match !== null
461          ? (
462            <Box>
463              <Text>{`${s('goalGate')} `}</Text>
464              {bar(ui, r.match, 100, BAR, s('passBar', { pct: r.match, bar: PASS_PCT }))}
465            </Box>
466          )
467          : bar(ui, r.done, r.total, BAR, `${r.done}/${r.total}`)
468      if (kind === 'run') {
469        body = (
470          <Box flexDirection="column">
471            {rail(ui, r.stages.map(g => ({ label: s(`stage${cap(g.key)}` as Key), state: g.state })))}
472            <Box flexDirection="column" marginTop={1}>
473              <Text color="claude" bold wrap="truncate-end">{`▶ ${s('now')} · ${nowSentence(s, r)}`}</Text>
474            </Box>
475            <Box flexDirection="column" marginY={1}>
476              {r.subs.slice(0, WORK_MAX).map(unit)}
477              {r.subs.length > WORK_MAX && <Text dimColor>{`  ${s('more', { n: r.subs.length - WORK_MAX })}`}</Text>}
478            </Box>
479            {progress}
480          </Box>
481        )
482      } else {
483        body = (
484          <Box flexDirection="column">
485            {board(
486              ui,
487              [
488                { label: s('colTodo'), tint: 'inactive', items: r.subs.filter(x => x.state === 'pending' || x.state === 'failed') },
489                { label: s('colDoing'), tint: 'claude', items: r.subs.filter(x => x.state === 'running') },
490                { label: s('colDone'), tint: 'success', items: r.subs.filter(x => x.state === 'done') },
491              ],
492              unit,
493            )}
494            {progress}
495          </Box>
496        )
497      }
498    }
499
500    const state = r === null ? '' : r.finished ? s('stateComplete') : r.live ? s('stateRunning') : s('stateStalled')
501    return (
502      <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1}>
503        <Box>
504          <Box flexShrink={1}><Text bold wrap="truncate-end">{r === null ? s('paneTitle') : r.title}</Text></Box>
505          {r !== null && (
506            <Box flexShrink={0} marginLeft={2}>
507              <Text color="claude">{s('headLine', { state, done: r.done, total: r.total })}</Text>
508            </Box>
509          )}
510        </Box>
511        <Box marginY={1} justifyContent="space-between">
512          {tabRow}
513          {r !== null && <Text dimColor>{r.slug}</Text>}
514        </Box>
515        {body}
516      </Box>
517    )
518  })
519
520  // the band: one pipeline row per kind with open runs; a stage with runs is a chip with a hover card
521  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
522    const open = await read($, runs)
523    const kinds = (['harness', 'graph'] as const).filter(k => open[k].length > 0)
524    if (e.props.hasSurvey || kinds.length === 0) return next(e)
525    const { Box, Text } = $.ui.resolve(e)
526    return (
527      <Box flexDirection="column">
528        {kinds.map(kind => {
529          const stages = STAGES.filter(s => kind === 'graph' || s !== 'test')
530          const count = (s: Stage) => open[kind].filter(r => r.stage === s).length
531          // full labels while the row fits 80 columns, short ones past that
532          const width = (names: (s: Stage) => string) =>
533            9 + stages.reduce((n, s) => n + names(s).length + (count(s) > 0 ? String(count(s)).length + 1 : 0), 0) + 3 * (stages.length - 1)
534          const name = width(label) <= 76 ? label : (s: Stage) => SHORT[s]
535          return (
536            <Box key={kind} flexShrink={1}>
537              <Text dimColor>{kind.padEnd(8)} </Text>
538              {stages.map((s, i) => {
539                const rows = open[kind].filter(r => r.stage === s)
540                return (
541                  <Box key={s} flexShrink={1} hover={rows.length > 0 ? { scope: `${kind}-${s}` } : undefined}>
542                    {i > 0 && <Text dimColor>{' ━ '}</Text>}
543                    {rows.length === 0 ? (
544                      <Text dimColor color="inactive" wrap="truncate-end">{name(s)}</Text>
545                    ) : (
546                      <Text bold color="claude" wrap="truncate-end">{`${name(s)} ${rows.length}`}</Text>
547                    )}
548                  </Box>
549                )
550              })}
551            </Box>
552          )
553        })}
554        {/* one hidden card per lit chip, in the flow under the rows: the band's region holds it */}
555        {kinds.flatMap(kind =>
556          STAGES.map(s => ({ s, rows: open[kind].filter(r => r.stage === s) }))
557            .filter(c => c.rows.length > 0)
558            .map(({ s, rows }) => (
559              <Box
560                key={`card-${kind}-${s}`}
561                display="none"
562                hover={{ scope: `${kind}-${s}`, display: 'flex' }}
563                flexDirection="column"
564                borderStyle="round"
565                borderColor="claude"
566                paddingX={1}
567              >
568                <Text dimColor>{`${kind} · ${label(s)}`}</Text>
569                {rows.map(r => {
570                  const b = barCells(r)
571                  return (
572                    <Box key={r.slug} gap={1}>
573                      <Text wrap="truncate-end">{r.slug}</Text>
574                      {kind === 'harness' && (
575                        <Text>
576                          <Text color="success">{'▰'.repeat(b.ok)}</Text>
577                          <Text color="error">{'▰'.repeat(b.bad)}</Text>
578                          <Text dimColor>{'▱'.repeat(b.rest)}</Text>
579                          {` ${r.passed}/${r.total}`}
580                        </Text>
581                      )}
582                      {r.failed > 0 && <Text color="error">{`${r.failed} failed`}</Text>}
583                    </Box>
584                  )
585                })}
586              </Box>
587            )),
588        )}
589        {await next(e)}
590      </Box>
591    )
592  })
593}
594
hooks/draw.tsx 95 lines
1/*
2 * Drawing kit for mods: stage rail, progress bar, tabs, board.
3 * Origin: teams/hooks/mod.tsx (teams 0.46.0). Copied per plugin because a mod imports only its own
4 * plugin's files; copies may drift, so change one and diff the other.
5 */
6export type Mark = 'done' | 'running' | 'pending' | 'failed'
7
8export const MARK: Record<Mark, string> = { done: '✔', running: '●', pending: '○', failed: '✘' }
9// the stage rail: a dot per stage, a solid rail up to where the run is, dotted after
10export const DOT: Record<Mark, string> = { done: '●', running: '◉', pending: '○', failed: '✘' }
11export const TINT: Record<Mark, string> = { done: 'success', running: 'claude', pending: 'inactive', failed: 'error' }
12
13// a key the table lacks formats to ''
14export const fmt = (text: string | undefined, vars: Record<string, string | number> = {}) =>
15  (text ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? ''))
16
17export const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
18
19// Claude Code's `language` setting
20export const isKorean = (language: unknown) => typeof language === 'string' && /^(ko|korean|한국어)/i.test(language)
21
22// filled cells of a bar `width` wide, clamped to 0..width
23export const cells = (done: number, total: number, width: number) =>
24  total > 0 ? Math.min(width, Math.max(0, Math.round((width * done) / total))) : 0
25
26// between two stages: solid once the next stage has started, dotted before
27export const connector = (nextState: Mark) => (nextState !== 'pending' ? ' ━━ ' : ' ┄┄ ')
28
29// the resolved components from $.ui.resolve(e)
30export type Ui = { Box: any; Text: any; Button: any }
31
32export function rail(ui: Ui, stages: { label: string; state: Mark }[]) {
33  const { Box, Text } = ui
34  return (
35    <Box flexWrap="wrap">
36      {stages.map((g, i) => {
37        const next = stages[i + 1]
38        return (
39          <Box key={`g${i}`}>
40            <Text color={TINT[g.state]} bold={g.state === 'running'}>{`${DOT[g.state]} ${g.label}`}</Text>
41            {next !== undefined && (
42              <Text color={next.state !== 'pending' ? 'success' : 'inactive'}>{connector(next.state)}</Text>
43            )}
44          </Box>
45        )
46      })}
47    </Box>
48  )
49}
50
51export function bar(ui: Ui, done: number, total: number, width: number, suffix?: string) {
52  const { Box, Text } = ui
53  const filled = cells(done, total, width)
54  return (
55    <Box>
56      <Text color="success">{'━'.repeat(filled)}</Text>
57      <Text color="inactive">{'─'.repeat(width - filled)}</Text>
58      {suffix !== undefined && <Text bold>{`  ${suffix}`}</Text>}
59    </Box>
60  )
61}
62
63// plain tabs with hotkeys 1-9: the selected one in full strength with a dot, the rest dim
64export function tabs(ui: Ui, items: { key: string; label: string }[], current: string, onPick: (key: string) => void) {
65  const { Box, Button } = ui
66  return (
67    <Box columnGap={3}>
68      {items.map((one, i) => (
69        <Button key={one.key} plain hotkey={String(i + 1)} dimColor={current !== one.key}
70          label={`${current === one.key ? '● ' : ''}${one.label}`} onPress={() => onPick(one.key)} />
71      ))}
72    </Box>
73  )
74}
75
76// columns of bordered cards, each with a count in its title
77export function board<T>(
78  ui: Ui,
79  cols: { label: string; tint: string; items: T[] }[],
80  render: (item: T, i: number) => unknown,
81) {
82  const { Box, Text } = ui
83  const width = `${Math.floor(100 / Math.max(cols.length, 1))}%`
84  return (
85    <Box>
86      {cols.map(col => (
87        <Box key={col.label} flexDirection="column" width={width} borderStyle="round" borderColor={col.tint} paddingX={1}>
88          <Text bold color={col.tint}>{`${col.label} ${col.items.length}`}</Text>
89          {col.items.map(render)}
90        </Box>
91      ))}
92    </Box>
93  )
94}
95
types/index.d.ts 38 lines
1export type Decision = { ts: number; session_id?: string; tool: string; target: string; decision: 'allow' | 'deny'; reason: string }
2export type OpenRun = { slug: string; stage: string; passed: number; failed: number; total: number }
3export type OpenRuns = { harness: OpenRun[]; graph: OpenRun[] }
4
5export type StageKey = 'plan' | 'setgoal' | 'critique' | 'implement' | 'gate' | 'report'
6export type StageMark = 'done' | 'running' | 'pending' | 'failed'
7export type RunSub = { id: string; title: string; state: StageMark; reason: string; tries: number }
8// the current run of .harness-run/<run>/, as the mod reads it
9export type RunInfo = {
10  slug: string
11  title: string
12  live: boolean
13  finished: boolean
14  stages: { key: StageKey; state: StageMark }[]
15  subs: RunSub[]
16  done: number
17  total: number
18  now: { kind: 'implement' | 'test' | 'gate' | 'retry' | 'stage' | 'done'; id?: string; title?: string; attempt?: number; stage?: StageKey }
19  match: number | null
20  pass: boolean | null
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    harness: {
26      last: Decision | null
27      armed: boolean
28      patterns: string[]
29      windowHours: number
30      runs: OpenRuns
31      run: RunInfo | null
32      broken: boolean
33      view: 'auto' | 'run' | 'units' | 'gate'
34      lang: 'en' | 'ko'
35    }
36  }
37}
38