A band above the prompt showing which part of the harness just acted: steering, a skill, a gate, a guard, a reviewer

A harness works where you can't see it. The session just behaves better, and nobody can say which part did that. This mod draws one line above the prompt naming the layer that just acted:
◆ harness steering: 3 always on + gates skill: review-tests gate: ✗ proposal guard: ✗ refused Bash review: reviewer
It's a Claude Code mod, so it works in Claude Code only: it was seen working in the terminal, the VS Code extension and Claude Desktop's Code tab on 2026-10-23, with Claude Code 2.1.309. It was built and tested on Claude Code 2.1.295. Codex and Kiro don't have mods.
In Claude Code, the lens loads by itself. The harness's .claude/settings.json names this repo as a plugin marketplace (the listing is .claude-plugin/marketplace.json) and turns the lens on, so the line is there the first time you start Claude Code at the root and trust the folder.
Don't see it? Install it from inside a session, at the root:
/plugin marketplace add ./
/plugin install harness-lens@sanvio-harness-starter
It's active as soon as the install says so, no restart. Or load it for one session with claude --plugin-dir examples/mods/harness-lens.
To turn it off just for you, without changing the file everyone shares:
claude plugin disable harness-lens@sanvio-harness-starter --scope local
| Segment | Shows |
|---|---|
| steering | How many steering files load into every session (the @ lines in AGENTS.md), plus any the agent read because they applied |
| skill | The last of this harness's skills the agent followed, whether you typed /name or it read the SKILL.md |
| gate | The last gate run and how it went. A commit the pre-commit hook refused shows as ✗ pre-commit. A commit that got through shows nothing, because the hook is silent when it passes |
| guard | The last tool a guard refused. Never the reason: that's in the transcript, and it can name a file |
| review | The last reviewer agent the work went to |
| scope | Only in a harness that declares one (see Another layout): the client, project or area this session is scoped to, first on the line. none when nothing is set |
Cyan means it happened this turn. A gate or guard result this turn is green or red. Plain text happened earlier in the session, and dim means nothing yet. A guard refusal and a failed gate also pop up a short notice.
/lens prints the whole session, layer by layer: every steering file read, every skill followed, each gate with how many times it passed and failed, and the refusals by tool.
Load it, then:
{{placeholder}} in it, and watch gate go red..env. Usually it refuses on its own, because the steering says so, and the band can't show a refusal that never became a tool call. guard goes red only when a call actually reaches the guard and the guard says no./review-tests, then hand the work to the reviewer agent./lens.It only looks. It never blocks a call or changes one: the guard in .claude/hooks/guard.py and the gates do that, and they work without it.
It shows only this harness's own skills and agents. A skill from a plugin on your machine never appears unless skillPrefixes names that plugin (the starter names Stelliad), so the band is safe to put on a projector. A subagent's own file reads don't count either. The band is about the session you're driving.
It looks for the starter's shape: AGENTS.md beside steering/, skills in skills/ and examples/*/skills/, agents in agents/, gates named *_gate.py. A harness laid out differently says where its layers live in .claude/harness-lens.json at its root. Every key is optional, and a key left out keeps the starter's value:
{
"steering": [".studio/steering"],
"always": ["CLAUDE.md"],
"skills": [".studio/skills"],
"skillPrefixes": ["studio:"],
"agents": [".claude/agents"],
"gates": ["check-gates\\.sh\\s+(\\S+)"],
"scope": [".studio/hooks/.client-scope.{session}.json", ".studio/hooks/.focus.{session}.json"]
}
The starter's own .claude/harness-lens.json sets only skillPrefixes to ["stelliad:"], so a skill from the Stelliad plugin it switches on shows as the harness's.
gates are patterns for the shell command that runs a gate. The first group in the pattern is the gate's name.
scope lists the files that hold the session's scope, if your harness has one. {session} stands for the session id. The lens reads them in order and shows the first that names a scope: {"client": "acme"}, or {"kind": "partner", "name": "fabrikam"}. Put the file a guard enforces first, so a looser one never shows over it. The starter has no scope, so its band has no scope segment.
hooks/logic.ts holds the decisions as plain functions, so they can be tested. hooks/register.tsx reads files, watches the session and calls into them. From examples/mods/:
claude plugin validate harness-lens
claude plugin test harness-lens
python3 -m unittest discover -s tests, and so the loop, runs these wherever Claude Code is installed. CI doesn't install Claude Code, so it skips them.
hooks/register.tsx 211 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { CONFIG_FILE, STARTER, alwaysSteering, bandSegments, emptyLens, gateRun, harnessSkill, lensReport, parseConfig, parseScope, record, relative, remember, scopeFiles, skillFromPath, steeringName } from './logic'
5import type { Config, Tone } from './logic'
6import type { Lens, Scope } from '../types'
7
8const launchAtom = atom({ plugin: 'harness-lens', key: 'launch' } as const, null)
9const lensAtom = atom({ plugin: 'harness-lens', key: 'lens' } as const, null)
10
11// Rebuilt on every load, so a new skill or agent shows up after a reload.
12let config: Config = STARTER
13let skills: string[] = []
14let agents: string[] = []
15
16const COLORS: Record<Tone, { color?: string; bold?: boolean; dimColor?: boolean }> = {
17 now: { color: 'cyan', bold: true },
18 pass: { color: 'green', bold: true },
19 fail: { color: 'red', bold: true },
20 earlier: {},
21 quiet: { dimColor: true },
22}
23
24async function readOr($: EngineInterface, path: string): Promise<string | null> {
25 return $.fs.read(path).catch(() => null)
26}
27
28// The harness root: the nearest folder above the launch directory with a lens config, or with the
29// starter's own shape (AGENTS.md beside steering/). None means this session isn't in a harness.
30async function findRoot($: EngineInterface, from: string): Promise<string | null> {
31 let dir = from.replace(/\/+$/, '')
32 while (dir) {
33 if (await $.fs.exists(`${dir}/${CONFIG_FILE}`)) return dir
34 if ((await $.fs.exists(`${dir}/AGENTS.md`)) && (await $.fs.exists(`${dir}/steering`))) return dir
35 dir = dir.slice(0, dir.lastIndexOf('/'))
36 }
37 return null
38}
39
40// A configured folder, with each `*` part expanded to the folders actually there.
41async function folders($: EngineInterface, root: string, pattern: string): Promise<string[]> {
42 let found = [root]
43 for (const part of pattern.replace(/^\.\//, '').replace(/\/+$/, '').split('/')) {
44 const next: string[] = []
45 for (const dir of found) {
46 if (part !== '*') {
47 next.push(`${dir}/${part}`)
48 continue
49 }
50 const entries = await $.fs.list(dir).catch(() => [])
51 for (const entry of entries) if (entry.kind === 'dir') next.push(`${dir}/${entry.name}`)
52 }
53 found = next
54 }
55 return found
56}
57
58async function names($: EngineInterface, root: string, patterns: readonly string[], kind: 'skill' | 'agent'): Promise<string[]> {
59 const out: string[] = []
60 for (const pattern of patterns) {
61 for (const dir of await folders($, root, pattern)) {
62 for (const entry of await $.fs.list(dir).catch(() => [])) {
63 const name = kind === 'skill' ? (entry.kind !== 'file' ? entry.name : null) : entry.name.endsWith('.md') ? entry.name.slice(0, -3) : null
64 if (name && name.toLowerCase() !== 'readme' && !out.includes(name)) out.push(name)
65 }
66 }
67 }
68 return out
69}
70
71async function load($: EngineInterface): Promise<void> {
72 const launch = await read($, launchAtom)
73 const root = launch ? await findRoot($, launch) : null
74 if (!root) {
75 await update($, lensAtom, () => null)
76 return
77 }
78 config = parseConfig(await readOr($, `${root}/${CONFIG_FILE}`))
79 skills = await names($, root, config.skills, 'skill')
80 agents = await names($, root, config.agents, 'agent')
81 const texts: string[] = []
82 for (const file of config.always) {
83 const text = await readOr($, `${root}/${file}`)
84 if (text !== null) texts.push(text)
85 }
86 const always = alwaysSteering(texts, config)
87 // A reload keeps what the session has already seen; a new root starts clean.
88 await update($, lensAtom, prev => (prev && prev.root === root ? { ...prev, always } : emptyLens(root, always)))
89}
90
91// This session's scope, from the first file `scope` lists that names one. A file can change from a Bash
92// call or another mod's command, so this runs after each Bash call, each turn, and every few seconds.
93async function refreshScope($: EngineInterface): Promise<void> {
94 if (!config.scope.length) return
95 const lens = await read($, lensAtom)
96 if (!lens) return
97 const sessionId = await $.session.id().catch(() => null)
98 let scope: Scope | null = null
99 for (const rel of scopeFiles(config, sessionId)) {
100 const text = await readOr($, `${lens.root}/${rel}`)
101 scope = text === null ? null : parseScope(text)
102 if (scope) break
103 }
104 const same = (a: Scope | null, b: Scope | null) => a?.kind === b?.kind && a?.name === b?.name
105 if (!same(scope, lens.scope ?? null)) await update($, lensAtom, prev => (prev ? { ...prev, scope } : prev))
106}
107
108async function change($: EngineInterface, edit: (lens: Lens) => Lens): Promise<void> {
109 await update($, lensAtom, prev => (prev ? edit(prev) : prev))
110}
111
112export const register: Register = on => {
113 on('session.start', async ($, e, next) => {
114 // A reload fires session.start again; the directory the session started in is the first one.
115 await update($, launchAtom, prev => prev ?? e.cwd)
116 await $.command.register({
117 name: 'lens',
118 description: 'Show what the harness did this session: steering, skills, gates, guard refusals and reviewers',
119 })
120 await load($).catch(() => {})
121 await refreshScope($).catch(() => {})
122 $.clock.every(3_000, () => void refreshScope($).catch(() => {}))
123 return next(e)
124 })
125
126 on('command.run', { command: 'lens' }, async $ => {
127 const lens = await read($, lensAtom)
128 return { text: lens ? lensReport(lens, config.scope.length > 0) : "This session didn't start inside a harness, so there's nothing to show." }
129 })
130
131 on('turn.start', async ($, e, next) => {
132 await change($, lens => ({ ...lens, turn: lens.turn + 1 }))
133 await refreshScope($).catch(() => {})
134 return next(e)
135 })
136
137 on('skill.prompt', async ($, e, next) => {
138 const out = await next(e)
139 const name = harnessSkill(e.skill, skills, config)
140 if (name) await change($, lens => ({ ...lens, skills: remember(lens.skills, { name, turn: lens.turn }) })).catch(() => {})
141 return out
142 })
143
144 // The harness's guards run beneath this hook, and their answer goes back exactly as it came: the lens
145 // only looks. No .catch on purpose: a hook that throws is skipped and the guards' decision stands.
146 on('classic.PreToolUse', async ($, e, next) => {
147 const decision = await next(e)
148 if (decision.deny) {
149 try {
150 const tool = String(e.tool)
151 await change($, lens => ({ ...lens, refusals: record(lens.refusals, { name: tool, turn: lens.turn }) }))
152 $.ui.toast(`🛡 The guard refused ${tool}`)
153 } catch {}
154 }
155 return decision
156 })
157
158 on('tool.call', async ($, e, next) => {
159 const result = await next(e)
160 try {
161 const lens = await read($, lensAtom)
162 // A subagent's own reads are its business. The main session's are what the band is about.
163 if (!lens || result.deny || (e as { agentId?: string }).agentId) return result
164 const input = e as unknown as Record<string, unknown>
165 const tool = String(e.tool)
166 if (tool === 'Read' && typeof input.file_path === 'string') {
167 const rel = relative(input.file_path, lens.root)
168 const steering = rel ? steeringName(rel, config) : null
169 const skill = rel ? skillFromPath(rel, config) : null
170 if (steering && !lens.always.includes(steering)) {
171 await change($, l => ({ ...l, steering: remember(l.steering, { name: steering, turn: l.turn }) }))
172 }
173 if (skill) await change($, l => ({ ...l, skills: remember(l.skills, { name: skill, turn: l.turn }) }))
174 } else if (tool === 'Bash' && typeof input.command === 'string') {
175 await refreshScope($)
176 const run = gateRun(input.command, String(result.text ?? ''), Boolean(result.isError), config)
177 if (run) {
178 await change($, l => ({ ...l, gates: record(l.gates, { ...run, turn: l.turn }) }))
179 if (!run.ok) $.ui.toast(`✗ The ${run.name} gate failed`)
180 }
181 } else if (tool === 'Agent' && typeof input.subagent_type === 'string' && agents.includes(input.subagent_type)) {
182 const name = input.subagent_type
183 await change($, l => ({ ...l, reviews: remember(l.reviews, { name, turn: l.turn }) }))
184 }
185 } catch {}
186 return result
187 })
188
189 // Drawn above the prompt, then whatever another mod draws there: two bands stack, neither hides the other.
190 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
191 const lens = await read($, lensAtom)
192 if (!lens || e.props.hasSurvey) return next(e)
193 const { Box, Text } = $.ui.resolve(e)
194 const below = await next(e)
195 return (
196 <Box flexDirection="column">
197 <Box flexWrap="wrap" columnGap={2}>
198 <Text bold>◆ harness</Text>
199 {bandSegments(lens, config.scope.length > 0).map(segment => (
200 <Text key={segment.label}>
201 <Text dimColor>{segment.label}: </Text>
202 <Text {...COLORS[segment.tone]}>{segment.text}</Text>
203 </Text>
204 ))}
205 </Box>
206 {below}
207 </Box>
208 )
209 })
210}
211hooks/logic.ts 251 lines1// The decisions, as pure functions. register.tsx reads files and watches events, then calls in here,
2// because the test kit can't stand in for the filesystem.
3
4import type { GateHit, Hit, Lens, Scope } from '../types'
5
6// Where a harness keeps each layer, relative to its root. A harness with another layout says so in
7// .claude/harness-lens.json, with any of these keys; a key left out keeps the starter's value.
8export type Config = {
9 // Folders of steering files.
10 steering: string[]
11 // Files whose `@path` lines load steering into every session.
12 always: string[]
13 // Folders whose subfolders are the harness's own skills. `*` stands for one folder name.
14 skills: string[]
15 // Plugin namespaces whose skills count as the harness's too: `studio:` for `studio:phase-gate`.
16 skillPrefixes: string[]
17 // Folders whose `<name>.md` files are the harness's agents.
18 agents: string[]
19 // Patterns for a shell command that runs a gate. The first group, if any, is the gate's name.
20 gates: string[]
21 // Files that hold this session's scope, read in order until one names it. `{session}` stands for the
22 // session id. Put a file a guard enforces first, so a looser one never shows over it. The starter has none.
23 scope: string[]
24}
25
26export const STARTER: Config = {
27 steering: ['steering'],
28 always: ['AGENTS.md'],
29 skills: ['skills', 'examples/*/skills'],
30 skillPrefixes: [],
31 agents: ['agents'],
32 gates: ['([\\w-]+)_gate\\.py'],
33 scope: [],
34}
35
36export const CONFIG_FILE = '.claude/harness-lens.json'
37
38const KEYS = Object.keys(STARTER) as Array<keyof Config>
39
40export function parseConfig(text: string | null): Config {
41 if (text === null) return STARTER
42 let raw: unknown
43 try {
44 raw = JSON.parse(text)
45 } catch {
46 return STARTER
47 }
48 const given = (raw ?? {}) as Record<string, unknown>
49 const config = { ...STARTER }
50 for (const key of KEYS) {
51 const value = given[key]
52 if (Array.isArray(value) && value.every(item => typeof item === 'string')) config[key] = value as string[]
53 }
54 return config
55}
56
57const tidy = (path: string) => path.replace(/^\.\//, '').replace(/\/+$/, '')
58const escape = (text: string) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
59const folderPattern = (folder: string) =>
60 tidy(folder).split('/').map(part => (part === '*' ? '[^/]+' : escape(part))).join('/')
61
62// A path inside the root, relative to it. Anything outside is none of the lens's business.
63export function relative(path: string, root: string): string | null {
64 const base = `${root.replace(/\/+$/, '')}/`
65 return path.startsWith(base) ? path.slice(base.length) : null
66}
67
68export function steeringName(rel: string, config: Config): string | null {
69 for (const folder of config.steering) {
70 const match = rel.match(new RegExp(`^${folderPattern(folder)}/([^/]+)\\.md$`))
71 const name = match?.[1]
72 if (name && name.toLowerCase() !== 'readme') return name
73 }
74 return null
75}
76
77// The steering every session gets: each `@path` line in the always-loaded files that names a steering file.
78export function alwaysSteering(texts: readonly string[], config: Config): string[] {
79 const names: string[] = []
80 for (const text of texts) {
81 for (const line of text.split('\n')) {
82 const match = line.trim().match(/^@(\S+)$/)
83 const path = match?.[1]
84 const name = path ? steeringName(tidy(path), config) : null
85 if (name && !names.includes(name)) names.push(name)
86 }
87 }
88 return names
89}
90
91// A skill followed by reading its file, which is how Codex, Kiro and a model that skips the Skill tool do it.
92export function skillFromPath(rel: string, config: Config): string | null {
93 for (const folder of config.skills) {
94 const name = rel.match(new RegExp(`^${folderPattern(folder)}/([^/]+)/SKILL\\.md$`))?.[1]
95 if (name) return name
96 }
97 return null
98}
99
100// skill.prompt names every skill a session expands, a machine-wide plugin's too. Only the harness's own
101// show, so a projector never lists someone's personal plugins.
102export function harnessSkill(name: string, known: readonly string[], config: Config): string | null {
103 if (known.includes(name)) return name
104 for (const prefix of config.skillPrefixes) {
105 if (prefix && name.startsWith(prefix)) return name.slice(prefix.length)
106 }
107 return null
108}
109
110export type GateRun = { name: string; ok: boolean }
111
112// A line may open with a colour code, as a hook that prints in red does.
113const FAILED = /^(\x1b\[[0-9;]*m)*(FAIL|BLOCKED)\b|Commit blocked|✗/m
114const PASSED = /^(\x1b\[[0-9;]*m)*PASS\b|✓/m
115const REFUSED = /^(\x1b\[[0-9;]*m)*BLOCKED\b|Commit blocked/m
116
117// A shell command that ran a gate, and how it went. None when the command ran no gate, or when the output
118// can't say: a gate piped somewhere else doesn't count as a pass.
119export function gateRun(command: string, output: string, isError: boolean, config: Config): GateRun | null {
120 for (const pattern of config.gates) {
121 let match: RegExpMatchArray | null
122 try {
123 match = command.match(new RegExp(pattern))
124 } catch {
125 continue
126 }
127 if (!match) continue
128 const name = match[1] ?? 'gate'
129 if (isError || FAILED.test(output)) return { name, ok: false }
130 if (PASSED.test(output)) return { name, ok: true }
131 return null
132 }
133 // The pre-commit hook is silent when it passes, so only a refusal can be seen.
134 if (/\bgit\s+commit\b/.test(command) && REFUSED.test(output)) return { name: 'pre-commit', ok: false }
135 return null
136}
137
138export function emptyLens(root: string, always: string[]): Lens {
139 return { root, turn: 0, always, steering: [], skills: [], gates: [], refusals: [], reviews: [], scope: null }
140}
141
142const SESSION = /^[0-9a-fA-F-]{8,64}$/
143
144// The scope files to try, relative to the root. A pattern that needs the session id is skipped without
145// one, rather than read with a guess.
146export function scopeFiles(config: Config, sessionId: string | null): string[] {
147 const id = sessionId && SESSION.test(sessionId) ? sessionId : null
148 const out: string[] = []
149 for (const pattern of config.scope) {
150 if (pattern.includes('{session}') && !id) continue
151 const path = tidy(id ? pattern.split('{session}').join(id) : pattern)
152 if (path && !path.split('/').includes('..') && !out.includes(path)) out.push(path)
153 }
154 return out
155}
156
157// One scope file: `{"client": "acme"}` from a client guard, or `{"kind": "partner", "name": "fabrikam"}`.
158export function parseScope(text: string): Scope | null {
159 let data: unknown
160 try {
161 data = JSON.parse(text)
162 } catch {
163 return null
164 }
165 if (!data || typeof data !== 'object') return null
166 const { client, kind, name } = data as Record<string, unknown>
167 if (typeof client === 'string' && client.trim()) return { kind: 'client', name: client.trim().toLowerCase() }
168 if (typeof kind === 'string' && kind.trim() && typeof name === 'string' && name.trim()) return { kind: kind.trim(), name: name.trim() }
169 return null
170}
171
172export function scopeText(scope: Scope): string {
173 return scope.kind === scope.name ? scope.name : `${scope.kind} ${scope.name}`
174}
175
176const KEEP = 50
177
178// Newest last. A name seen again moves to the end, so the list reads as what happened most recently.
179export function remember<T extends Hit>(list: readonly T[], hit: T): T[] {
180 return [...list.filter(item => item.name !== hit.name), hit].slice(-KEEP)
181}
182
183// Every gate run counts, a repeat included, so the report can say passed 3, failed 1.
184export function record<T extends Hit>(list: readonly T[], hit: T): T[] {
185 return [...list, hit].slice(-KEEP)
186}
187
188export type Tone = 'now' | 'pass' | 'fail' | 'earlier' | 'quiet'
189export type Segment = { label: string; text: string; tone: Tone }
190
191// The band: one segment per layer. A layer that acted this turn stands out; one that acted earlier is dim.
192export function bandSegments(lens: Lens, showScope = false): Segment[] {
193 const now = (hit: Hit | undefined) => hit !== undefined && hit.turn === lens.turn
194 const readNow = lens.steering.filter(now).map(hit => hit.name)
195 const always = lens.always.length ? `${lens.always.length} always on` : 'none always on'
196 const steering: Segment = readNow.length
197 ? { label: 'steering', text: `${always} + ${readNow.join(' · ')}`, tone: 'now' }
198 : { label: 'steering', text: lens.steering.length ? `${always} + ${lens.steering.length} read` : always, tone: 'earlier' }
199
200 const skill = lens.skills.at(-1)
201 const gate = lens.gates.at(-1)
202 const refusal = lens.refusals.at(-1)
203 const review = lens.reviews.at(-1)
204 // Only a harness that declares where its scope lives gets the segment, so the starter's band is unchanged.
205 const scope: Segment[] = showScope
206 ? [lens.scope ? { label: 'scope', text: scopeText(lens.scope), tone: 'earlier' } : { label: 'scope', text: 'none', tone: 'quiet' }]
207 : []
208 return [
209 ...scope,
210 steering,
211 { label: 'skill', text: skill?.name ?? 'none yet', tone: now(skill) ? 'now' : skill ? 'earlier' : 'quiet' },
212 {
213 label: 'gate',
214 text: gate ? `${gate.ok ? '✓' : '✗'} ${gate.name}` : 'none run',
215 tone: now(gate) ? (gate!.ok ? 'pass' : 'fail') : gate ? 'earlier' : 'quiet',
216 },
217 {
218 label: 'guard',
219 text: refusal ? `✗ refused ${refusal.name}` : 'no refusals',
220 tone: now(refusal) ? 'fail' : refusal ? 'earlier' : 'quiet',
221 },
222 { label: 'review', text: review?.name ?? 'none yet', tone: now(review) ? 'now' : review ? 'earlier' : 'quiet' },
223 ]
224}
225
226function counts(list: readonly Hit[]): string {
227 const seen = new Map<string, number>()
228 for (const hit of list) seen.set(hit.name, (seen.get(hit.name) ?? 0) + 1)
229 return [...seen].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name)).join(', ')
230}
231
232// What /lens prints: the whole session, layer by layer.
233export function lensReport(lens: Lens, showScope = false): string {
234 const names = (list: readonly Hit[]) => (list.length ? list.map(hit => hit.name).join(', ') : 'none')
235 const passed = lens.gates.filter(gate => gate.ok)
236 const failed = lens.gates.filter(gate => !gate.ok)
237 const gates = lens.gates.length
238 ? [passed.length ? `passed ${counts(passed)}` : '', failed.length ? `failed ${counts(failed)}` : ''].filter(Boolean).join('; ')
239 : 'none run'
240 return [
241 'What the harness did this session:',
242 ...(showScope ? [` Scope: ${lens.scope ? scopeText(lens.scope) : 'none'}`] : []),
243 ` Steering, always on: ${lens.always.length ? lens.always.join(', ') : 'none'}`,
244 ` Steering, read when it applied: ${names(lens.steering)}`,
245 ` Skills followed: ${names(lens.skills)}`,
246 ` Gates: ${gates}`,
247 ` Guard refusals: ${lens.refusals.length ? counts(lens.refusals) : 'none'} (the reasons are in the transcript)`,
248 ` Reviewers: ${names(lens.reviews)}`,
249 ].join('\n')
250}
251types/index.d.ts 28 lines1// One thing seen: a steering file read, a skill followed, a gate run, a tool a guard refused, a reviewer.
2// `turn` is the session's turn it happened in, so the band can tell this turn from earlier ones.
3export type Hit = { name: string; turn: number }
4export type GateHit = Hit & { ok: boolean }
5
6// The harness's declared scope for this session: a client its guard enforces, or a project or area it
7// only names. Read from the files `scope` lists in .claude/harness-lens.json; the starter declares none.
8export type Scope = { kind: string; name: string }
9
10export type Lens = {
11 root: string
12 turn: number
13 always: string[]
14 steering: Hit[]
15 skills: Hit[]
16 gates: GateHit[]
17 // The tool a guard refused, never its reason: a reason can name a file or a customer.
18 refusals: Hit[]
19 reviews: Hit[]
20 scope: Scope | null
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'harness-lens': { launch: string | null; lens: Lens | null }
26 }
27}
28