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

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.
/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. Needssh(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).
| Path | When | How |
|---|---|---|
| Graph (default) | graph-engineering MCP connected | graph: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 engine | Graph MCP absent, Workflow tool available | Workflow({ scriptPath: "harness/engine/pipeline.js", ... }) |
| Agent team | Neither | engine/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.
| I want to… | Skill |
|---|---|
| Get a substantial request planned, executed, verified, and gated before it comes back | harness |
| Connect or verify the graph-owned default orchestration path | graph: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-code | install |
| Take harness governance back out of a project | remove |
| Refresh this project's installed harness copies after a plugin version bump | update |
| Delegate an Implement/Test stage to the local Codex CLI from any install layout | codex-control |
harnessThe 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.
installScaffolds 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.
removeUninstalls 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.
updateRefreshes 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-controlThe 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:
| # | Layout | Path |
|---|---|---|
| 1 | Explicit arg | args.codex_adapter_path |
| 2 | Repo-local | harness/engine/codex-exec-adapter.mjs |
| 3 | Embedded install | .claude/harness/engine/codex-exec-adapter.mjs |
| 4 | Plugin mode | derived 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.
Workflow({ scriptPath: "harness/engine/pipeline.js", args: { request: "..." } })goal-spec.md.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.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.
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.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.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.
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)## Harness section appended to the project's CLAUDE.md.claude/.harness-markers/ in .gitignoreThe 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).
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.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 runs | Mod |
|---|---|
| Interactive terminal | on |
| Desktop app, Code tab | on |
claude -p and headless adapters | off (no UI surface) |
| Codex, or no plugin | not applicable, nothing is lost |
Features
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.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).● 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.% bar against the pass bar..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 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).
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/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/KOPlan(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/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 indirectionharness: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_repo/; the patch skill runs _repo/scripts/validate_plugins.pygrep -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 gatedgraph_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.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.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.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.pipeline.js is unchanged.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.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.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.harness:codex-control and mounted it in Workflow Implement/Test Codex delegation. pipeline.js now honors an explicit `hooks/mod.tsx 594 lines1import { 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}
594hooks/draw.tsx 95 lines1/*
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}
95types/index.d.ts 38 lines1export 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