Claude Code edition of the agent harness: adds Claude Code-only features (agent effort, mods, eval suites) on top of agent-harness-kit.

The Claude Code edition of the agent harness. It adds features only Claude Code has, on top of the core agent-harness-kit plugin, which it depends on. The protocol, templates and skills all live in the core plugin; this folder never copies them.
What it adds (see the repo README, "Claude Code edition"):
claude plugin eval (M24-003);Install both:
/plugin marketplace add SedyBenoitPeace/agent-harness-kit
/plugin install agent-harness-kit
/plugin install agent-harness-kit-claudehooks/register.tsx 290 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 addUsage,
5 appendLine,
6 budgetLine,
7 chooseModel,
8 costLine,
9 modelsLine,
10 DEFAULT_FEATURE_USD,
11 DEFAULT_SESSION_TOKENS,
12 DEFAULT_RUN_USD,
13 DECISION_TOOL,
14 decisionSpec,
15 harnessFacts,
16 harnessReport,
17 type LineState,
18 decisionsPath,
19 parseDecision,
20 parseSessionLine,
21 parseVerdict,
22 PLUGIN,
23 RUN_DIR,
24 runCostText,
25 type RunSpend,
26 PARALLEL_LANE,
27 skippedFile,
28 skippedPath,
29 type Tokens,
30 statusLineText,
31 supervisorPrompt,
32 transcriptText,
33 usageLine,
34 usd,
35 verdictStatus,
36 verifyOf,
37} from './lib'
38
39// agent-harness-kit-claude: Claude Code-only additions to the harness.
40// Every feature stays inert outside a harnessed repo (no FEATURES.json).
41// All hooks live in this file: the engine follows `$` only within it.
42
43// M30-003 — the plugin's own status line (beside the person's own one):
44// feature, passing/total, dollars against the feature budget, model,
45// features waiting on a human, and the last notice.
46async function showStatus($: EngineInterface, shown: LineState, featureBase: number | undefined, budget: number) {
47 const facts = harnessFacts(await $.fs.read('FEATURES.json').catch(() => ''))
48 if (!facts) return
49 const cost = await $.session.usage().then(u => u.cost?.usd).catch(() => undefined)
50 const spent = cost !== undefined && featureBase !== undefined ? cost - featureBase : undefined
51 $.ui.status(statusLineText(facts, { ...shown, spent, budget }))
52}
53
54// M31-001 — credit the session cost since the last event to whoever was
55// running through it (any subagent, or the orchestrator alone), and keep the
56// run's split in .harness-run/run-cost for run-report.sh.
57type Ledger = { running: Set<string>; spend: RunSpend; lastCost?: number }
58const sessionCost = ($: EngineInterface) => $.session.usage().then(u => u.cost?.usd).catch(() => undefined)
59async function runStart($: EngineInterface) {
60 return (await $.fs.exists(`${RUN_DIR}/start`)) ? await $.fs.read(`${RUN_DIR}/start`) : undefined
61}
62async function tally($: EngineInterface, l: Ledger) {
63 const cost = await $.session.usage().then(u => u.cost?.usd).catch(() => undefined)
64 if (cost === undefined) return
65 if (l.lastCost !== undefined && cost > l.lastCost) l.spend[l.running.size > 0 ? 'agents' : 'orchestration'] += cost - l.lastCost
66 l.lastCost = cost
67 if (await $.fs.exists(`${RUN_DIR}/start`)) await $.fs.write(`${RUN_DIR}/run-cost`, runCostText(l.spend))
68}
69
70export const register: Register = (on, options) => {
71 let isHarnessed = false
72 const checked = new Set<string>()
73 const num = (v: unknown, d: number) => (typeof v === 'number' && v >= 0 ? v : d)
74 const featureBudget = num(options.feature_budget_usd, DEFAULT_FEATURE_USD)
75 const runBudget = num(options.run_budget_usd, DEFAULT_RUN_USD)
76 const sessionBudget = num(options.session_budget_tokens, DEFAULT_SESSION_TOKENS) // M34-011
77 let featureBase: number | undefined // session cost when the current feature began
78 let runBase: number | undefined // session cost when this session first saw the run
79 let isFeatureFlagged = false
80 let isRunFlagged = false
81 const builderTokens = new Map<string, Tokens>() // per builder subagent, summed over its turns (M32-002)
82 const measured = new Set<string>() // features whose cost is logged since their builder started
83 const shown: { feature?: string; model?: string; notice?: string } = {} // what the status line shows (M30-003)
84 const route = options.route_models !== false
85 const supervisor = options.supervisor === true // M34-004: off by default
86 const lanes = new Set<string>() // parallel-lane builders, never judged
87 const floor = typeof options.model_floor === 'string' ? options.model_floor : 'haiku'
88 // M31-001 — the run's cost split: agents running vs the orchestrator alone
89 const ledger: Ledger = { running: new Set(), spend: { agents: 0, orchestration: 0 } }
90
91
92 // M34-004 — a new .harness-run/start is a new run: its dollars, budgets and
93 // pending builder usage count from here, not from the run before it.
94 let seenStart: string | undefined
95 const applyStart = (start: string | undefined, cost: number | undefined) => {
96 if (start === seenStart) return
97 seenStart = start
98 if (start === undefined) return
99 ledger.spend = { agents: 0, orchestration: 0 }
100 ledger.lastCost = cost
101 runBase = cost
102 featureBase = cost
103 isRunFlagged = false
104 isFeatureFlagged = false
105 builderTokens.clear()
106 measured.clear()
107 }
108
109 on('session.start', async ($, e, next) => {
110 const started = await next(e)
111 isHarnessed = await $.fs.exists('FEATURES.json').catch(() => false)
112 if (isHarnessed) {
113 await $.tool.register(decisionSpec)
114 // budgets count from here (M30-002)
115 featureBase = await $.session.usage().then(u => u.cost?.usd).catch(() => undefined)
116 ledger.lastCost = featureBase
117 if (await $.fs.exists(`${RUN_DIR}/start`)) runBase = featureBase
118 seenStart = await runStart($)
119 await showStatus($, shown, featureBase, featureBudget)
120 }
121 await $.command.register({ name: 'harness', description: 'Harness status: milestones, next feature, what waits on you, cost (no model call)' })
122 return started
123 })
124
125 // M30-003 — /harness: the status from FEATURES.json, no model call
126 on('command.run', { command: 'harness' }, async $ => {
127 const facts = harnessFacts(await $.fs.read('FEATURES.json').catch(() => ''))
128 if (!facts) return { text: 'No harness here: FEATURES.json is missing or not valid JSON.' }
129 const cost = await $.session.usage().then(u => u.cost?.usd).catch(() => undefined)
130 return { text: harnessReport(facts, cost) }
131 })
132
133 // M30-001 — model choice: a harness builder starts on the model its
134 // feature's effort asks for, one tier up per earlier start or evaluator
135 // rejection; the evaluator and sensitive features never below sonnet.
136 // The main session, forks, teammates and workflow agents are left alone.
137 on('agent.spawn', async ($, e, next) => {
138 if (isHarnessed && !e.fork) {
139 applyStart(await runStart($), await sessionCost($))
140 await tally($, ledger)
141 }
142 const track = <T,>(started: T) => {
143 const id = (started as { agentId?: unknown } | undefined)?.agentId
144 if (isHarnessed && !e.fork && typeof id === 'string') ledger.running.add(id)
145 return started
146 }
147 if (!isHarnessed || !route || e.fork || e.isTeammate || e.workflow) return track(await next(e))
148 const features = await $.fs.read('FEATURES.json').catch(() => '')
149 const logPath = `${RUN_DIR}/models.log`
150 const log = (await $.fs.exists(logPath)) ? await $.fs.read(logPath) : ''
151 const choice = chooseModel(e, features, log, floor)
152 if (!choice) return track(await next(e))
153 const started = track(await next({ ...e, model: choice.model }))
154 const laneId = (started as { agentId?: unknown } | undefined)?.agentId
155 if (PARALLEL_LANE.test(e.prompt) && typeof laneId === 'string') lanes.add(laneId)
156 if (choice.feature) {
157 shown.feature = choice.feature
158 shown.model = choice.model
159 await showStatus($, shown, featureBase, featureBudget)
160 measured.delete(choice.feature)
161 await $.fs.write(logPath, log + modelsLine(choice, features))
162 if (!(await $.fs.exists(`${RUN_DIR}/.gitignore`))) await $.fs.write(`${RUN_DIR}/.gitignore`, '*\n')
163 }
164 return started
165 })
166
167 // M25-001 — decision register: one line per call in .harness-run/decisions/<id>.md
168 on('tool.call', { tool: `mcp__${PLUGIN}__${DECISION_TOOL}` }, async ($, e) => {
169 const parsed = parseDecision(e as unknown as Record<string, unknown>)
170 if ('error' in parsed) return { deny: parsed.error }
171 const path = decisionsPath(parsed.feature)
172 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : undefined
173 await $.fs.write(path, appendLine(before, parsed.feature, parsed.line))
174 // A self-ignoring run folder keeps the worktree clean for harness-run.
175 if (!(await $.fs.exists(`${RUN_DIR}/.gitignore`))) await $.fs.write(`${RUN_DIR}/.gitignore`, '*\n')
176 return { result: { recorded: parsed.line, file: path } }
177 })
178
179 // M25-002 — done-check supervisor: a session that ends with its SESSION line
180 // gets one cheap check against the feature's verify. The main loop forks its
181 // own (cached) transcript; a builder subagent's messages are read and sent
182 // to a small model, since a fork only ever sees the main thread.
183 on('turn.complete', async ($, e, next) => {
184 const result = await next(e)
185 if (!isHarnessed) return result
186 applyStart(await runStart($), await sessionCost($))
187 await tally($, ledger)
188 if (e.agentId !== undefined) ledger.running.delete(e.agentId)
189 const line = parseSessionLine(e.answer)
190
191 // M32-002 — tokens for the run report: a builder's turns sum on its
192 // feature at its SESSION line; the orchestrator's own turns are
193 // orchestration. Each subagent has its own usage, so lanes count once.
194 if (e.usage && (await $.fs.exists(`${RUN_DIR}/start`))) {
195 const logUsage = async (scope: string, t: Tokens) => {
196 const path = `${RUN_DIR}/usage.log`
197 const before = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
198 await $.fs.write(path, before + usageLine(scope, t))
199 }
200 if (e.agentId === undefined) await logUsage('orchestration', addUsage(undefined, e.usage))
201 else {
202 const t = addUsage(builderTokens.get(e.agentId), e.usage)
203 builderTokens.set(e.agentId, t)
204 if (line) {
205 builderTokens.delete(e.agentId)
206 await logUsage(line.feature, t)
207 }
208 }
209 }
210
211 // M30-002 — budget guard in dollars: what the feature (since the last
212 // SESSION line) and the run (since this session saw it) have cost.
213 const cost = await $.session.usage().then(u => u.cost?.usd).catch(() => undefined)
214 if (cost !== undefined) {
215 featureBase ??= cost
216 const isRun = await $.fs.exists(`${RUN_DIR}/start`)
217 if (isRun) runBase ??= cost
218 const stop = async (scope: string, spent: number, budget: number) => {
219 shown.notice = `budget: ${scope} ${usd(spent)} of ${usd(budget)}`
220 if (!isRun) return
221 const log = `${RUN_DIR}/budget.log`
222 const before = (await $.fs.exists(log)) ? await $.fs.read(log) : ''
223 await $.fs.write(log, before + budgetLine(new Date().toISOString(), scope, spent, budget))
224 await $.fs.write(`${RUN_DIR}/STOP`, `budget: ${scope} ${usd(spent)} of ${usd(budget)}\n`)
225 }
226 const spent = cost - featureBase
227 if (featureBudget > 0 && spent > featureBudget && !isFeatureFlagged) {
228 isFeatureFlagged = true
229 await stop(`feature ${line?.feature ?? 'in progress'}`, spent, featureBudget)
230 }
231 if (isRun && runBase !== undefined && runBudget > 0 && cost - runBase > runBudget && !isRunFlagged) {
232 isRunFlagged = true
233 await stop('run', cost - runBase, runBudget)
234 }
235 // each builder session's cost, once (an orchestrator echoing its
236 // SESSION line is not a second session)
237 if (line && !measured.has(line.feature)) {
238 measured.add(line.feature)
239 if (isRun) {
240 const log = `${RUN_DIR}/cost.log`
241 const before = (await $.fs.exists(log)) ? await $.fs.read(log) : ''
242 await $.fs.write(log, before + costLine(line.feature, spent))
243 }
244 }
245 if (line) {
246 featureBase = cost
247 isFeatureFlagged = false
248 }
249 }
250
251 // M34-011 — a builder's context at 80% of its session budget: tell the person
252 // (the builder stops itself by the protocol's own tool-call/context rule).
253 if (e.usage && e.agentId !== undefined && sessionBudget > 0) {
254 const ctx = e.usage.input_tokens + e.usage.cache_creation_input_tokens + e.usage.cache_read_input_tokens
255 if (ctx >= sessionBudget * 0.8) shown.notice = `session budget: ${Math.round(ctx / 1000)}k of ${Math.round(sessionBudget / 1000)}k tokens, wrap up`
256 }
257
258 await showStatus($, shown, featureBase, featureBudget)
259 if (!line || !supervisor || (e.agentId !== undefined && lanes.has(e.agentId))) return result
260 // One check per feature outcome: an orchestrator echoing a builder's
261 // SESSION line must not pay for a second check.
262 const key = `${line.feature} ${line.outcome}`
263 if (checked.has(key)) return result
264 checked.add(key)
265 const verify = verifyOf(await $.fs.read('FEATURES.json').catch(() => ''), line.feature)
266 if (verify === undefined) return result
267
268 let reply: string | undefined
269 if (e.agentId === undefined) {
270 const forked = await $.model.fork({ prompt: supervisorPrompt(line, verify) })
271 reply = forked.isAnswered ? forked.text : undefined
272 } else {
273 const messages = await $.session.messages({ agentId: e.agentId })
274 if (Array.isArray(messages)) {
275 const done = await $.model.complete({ model: 'haiku', prompt: supervisorPrompt(line, verify, transcriptText(messages)), maxTokens: 400 })
276 reply = done.isAnswered ? done.text : undefined
277 }
278 }
279
280 const verdict = reply === undefined ? undefined : parseVerdict(reply)
281 if (verdict && verdict.skipped.length > 0) {
282 await $.fs.write(skippedPath(line.feature), skippedFile(line.feature, verdict.skipped))
283 if (!(await $.fs.exists(`${RUN_DIR}/.gitignore`))) await $.fs.write(`${RUN_DIR}/.gitignore`, '*\n')
284 }
285 shown.notice = verdictStatus(line.feature, verdict)
286 await showStatus($, shown, featureBase, featureBudget)
287 return result
288 })
289}
290hooks/lib.ts 350 lines1// Pure logic shared by the hooks in register.ts. Nothing here touches `$`:
2// the engine only follows `$` within the file that registers the hooks.
3
4export const PLUGIN = 'agent-harness-kit-claude'
5export const RUN_DIR = '.harness-run'
6export const FEATURE_ID = /^M\d+-\d{3}$/
7
8const oneLine = (v: unknown) => String(v ?? '').replace(/\s+/g, ' ').trim()
9
10// --- M25-001 decision register -------------------------------------------
11
12export const DECISION_TOOL = 'register_decision'
13
14export const decisionSpec = {
15 name: DECISION_TOOL,
16 description:
17 'Record a decision while building a harness feature (FEATURES.json id like M3-002): ' +
18 'call it each time you pick one option over another or assume something the brief ' +
19 'did not say. One call per decision; it is folded into the PROGRESS.md Decisions line.',
20 inputSchema: {
21 type: 'object',
22 properties: {
23 feature: { type: 'string', description: 'Feature id, e.g. M3-002' },
24 chose: { type: 'string', description: 'What you chose, or the assumption you made' },
25 over: { type: 'string', description: 'The option you rejected (or "nothing" for an assumption)' },
26 why: { type: 'string', description: 'Why, in a few words' },
27 },
28 required: ['feature', 'chose', 'over', 'why'],
29 },
30}
31
32export const decisionsPath = (feature: string) => `${RUN_DIR}/decisions/${feature}.md`
33
34export type Decision = { feature: string; line: string } | { error: string }
35
36export function parseDecision(input: Record<string, unknown>): Decision {
37 const feature = oneLine(input.feature)
38 if (!FEATURE_ID.test(feature)) {
39 return { error: `register_decision: feature must be a FEATURES.json id like M3-002, got "${feature}"` }
40 }
41 return { feature, line: `- chose ${oneLine(input.chose)} over ${oneLine(input.over)}: ${oneLine(input.why)}` }
42}
43
44export function appendLine(before: string | undefined, feature: string, line: string) {
45 const head = before ?? `# Decisions for ${feature}\n\n`
46 return `${head.endsWith('\n') ? head : `${head}\n`}${line}\n`
47}
48
49// --- M25-002 done-check supervisor ---------------------------------------
50
51export type SessionLine = { feature: string; outcome: string; gate: string }
52
53// The protocol's last line of a session: `SESSION: <id> · <outcome> · gate <g> · ...`
54export function parseSessionLine(answer: string): SessionLine | undefined {
55 const m = /SESSION: (M\d+-\d{3}) · (passing|review|blocked) · gate (green|red)/.exec(answer)
56 return m ? { feature: m[1]!, outcome: m[2]!, gate: m[3]! } : undefined
57}
58
59export function verifyOf(featuresJson: string, feature: string): string | undefined {
60 try {
61 const data = JSON.parse(featuresJson) as { features?: { id?: string; verify?: string }[] }
62 return data.features?.find(f => f.id === feature)?.verify
63 } catch {
64 return undefined
65 }
66}
67
68export const SUPERVISOR_RULES =
69 'You check a finished harness coding session against its acceptance criterion. ' +
70 'Answer with one JSON object and nothing else: ' +
71 '{"done": boolean, "blocked": boolean, "skipped_work": string[]}. ' +
72 'done = the verify criterion is demonstrably met by what the session did. ' +
73 'blocked = the session stopped for something only a human can decide. ' +
74 'skipped_work = parts of the verify criterion, edge cases or checks the session ' +
75 'considered or needed and did not do, each in a few words; [] when none. ' +
76 'Judge only from the session; do not assume work you cannot see.'
77
78export function supervisorPrompt(line: SessionLine, verify: string, transcript?: string) {
79 return (
80 `${SUPERVISOR_RULES}\n\nFeature ${line.feature}, reported ${line.outcome}, gate ${line.gate}.\n` +
81 `Verify criterion: ${verify}\n` +
82 (transcript === undefined ? 'The session is the conversation above.' : `The session:\n${transcript}`)
83 )
84}
85
86export type Verdict = { done: boolean; blocked: boolean; skipped: string[] }
87
88export function parseVerdict(text: string): Verdict | undefined {
89 const m = /\{[\s\S]*\}/.exec(text)
90 if (!m) return undefined
91 try {
92 const v = JSON.parse(m[0]) as { done?: unknown; blocked?: unknown; skipped_work?: unknown }
93 if (typeof v.done !== 'boolean' || typeof v.blocked !== 'boolean' || !Array.isArray(v.skipped_work)) return undefined
94 return { done: v.done, blocked: v.blocked, skipped: v.skipped_work.map(x => String(x)).filter(x => x.trim() !== '') }
95 } catch {
96 return undefined
97 }
98}
99
100export const skippedPath = (feature: string) => `${RUN_DIR}/decisions/${feature}.skipped.md`
101
102export function skippedFile(feature: string, skipped: string[]) {
103 return `# Supervisor: work ${feature} may have skipped\n\n${skipped.map(s => `- ${s}`).join('\n')}\n`
104}
105
106export function verdictStatus(feature: string, v: Verdict | undefined) {
107 if (!v) return `supervisor ${feature}: no verdict`
108 const state = v.blocked ? 'blocked' : v.done ? 'done' : 'not done'
109 return v.skipped.length > 0 ? `supervisor ${feature}: ${state}, skipped ${v.skipped.length}` : `supervisor ${feature}: ${state}`
110}
111
112// A subagent's conversation as plain text, newest last, capped for a cheap call.
113export function transcriptText(messages: { role: string; text: string; toolUses?: { tool?: string }[] }[], max = 12000) {
114 const text = messages
115 .map(m => `${m.role}: ${m.text}${m.toolUses?.length ? ` [tools: ${m.toolUses.map(t => t.tool ?? '?').join(', ')}]` : ''}`)
116 .join('\n')
117 return text.length > max ? text.slice(text.length - max) : text
118}
119
120// --- M25-003 / M30-002 budget guard, in dollars ----------------------------
121// Measured from the session's own cost figure (subagents included), so the
122// kit keeps no price table. 0 turns a budget off.
123
124export const DEFAULT_FEATURE_USD = 5
125export const DEFAULT_RUN_USD = 50
126export const DEFAULT_SESSION_TOKENS = 150_000 // M34-011: a builder's context before it wraps up
127
128export const usd = (n: number) => `$${n.toFixed(2)}`
129
130export function budgetLine(when: string, scope: string, spent: number, budget: number) {
131 return `${when} ${scope}: ${usd(spent)} over the ${usd(budget)} budget; run asked to stop after this feature\n`
132}
133
134// One line per builder session in .harness-run/cost.log, read by run-report.sh
135export function costLine(feature: string, spent: number) {
136 return `${feature} ${spent.toFixed(4)}\n`
137}
138
139// --- M32-002 tokens in .harness-run/usage.log ---------------------------------
140// `<scope> claude <model> <input> <cached> <output>`, scope a feature id or
141// `orchestration`; input includes the cache share, cached is the cache read.
142
143export type TurnUsage = {
144 input_tokens: number
145 output_tokens: number
146 cache_creation_input_tokens: number
147 cache_read_input_tokens: number
148 model?: string
149}
150export type Tokens = { input: number; cached: number; output: number; model: string }
151
152export function addUsage(sum: Tokens | undefined, u: TurnUsage): Tokens {
153 const cached = u.cache_read_input_tokens
154 return {
155 input: (sum?.input ?? 0) + u.input_tokens + u.cache_creation_input_tokens + cached,
156 cached: (sum?.cached ?? 0) + cached,
157 output: (sum?.output ?? 0) + u.output_tokens,
158 model: u.model || sum?.model || '-',
159 }
160}
161
162export const usageLine = (scope: string, t: Tokens) => `${scope} claude ${t.model} ${t.input} ${t.cached} ${t.output}\n`
163
164// --- M31-001 where a run's dollars go ------------------------------------------
165// Each slice of the session's cost goes to `agents` when at least one subagent
166// (builder or evaluator) was running through it, else to `orchestration`. Lanes
167// running together share one slice, so nothing is counted twice. Read by
168// run-report.sh from .harness-run/run-cost.
169
170export type RunSpend = { agents: number; orchestration: number }
171
172export function runCostText(s: RunSpend) {
173 return `agents ${s.agents.toFixed(4)}\norchestration ${s.orchestration.toFixed(4)}\n`
174}
175
176// --- M30-001 model choice -----------------------------------------------------
177// Aliases only: each resolves to its family's newest model, so a release
178// (Haiku 5.5, say) is picked up with nothing to maintain.
179
180export const TIERS = ['haiku', 'sonnet', 'opus'] as const
181const SENSITIVE = /auth|payment|billing|migration|secret|credential|personal/i
182export const BUILDER_PROMPT = /harness-session for (M\d+-\d{3})/
183export const PARALLEL_LANE = /as a parallel lane in /
184
185// A model name's tier: haiku 0, sonnet 1, opus 2, above opus 3; -1 unknown.
186export function tierOf(model: string | undefined) {
187 const m = (model ?? '').toLowerCase()
188 if (m.includes('haiku')) return 0
189 if (m.includes('sonnet')) return 1
190 if (m.includes('opus')) return 2
191 if (m.includes('fable') || m.includes('mythos')) return 3
192 return -1
193}
194
195type FeatureEntry = { id?: string; model?: unknown; effort?: unknown; paths?: unknown; eval_attempts?: unknown }
196
197export function featureOf(featuresJson: string, id: string): FeatureEntry | undefined {
198 try {
199 return (JSON.parse(featuresJson) as { features?: FeatureEntry[] }).features?.find(f => f.id === id)
200 } catch {
201 return undefined
202 }
203}
204
205export type SpawnFacts = { subagentType: string; prompt: string; model?: string; parentModel: string }
206export type ModelChoice = { model: string; feature?: string; reason: string }
207
208const floorTier = (floor: string) => Math.max(0, (TIERS as readonly string[]).indexOf(floor))
209
210// The model a harness subagent should start on, or undefined to leave it be.
211export function chooseModel(spawn: SpawnFacts, featuresJson: string, modelsLog: string, floor: string): ModelChoice | undefined {
212 const min = floorTier(floor)
213 if (spawn.subagentType === 'harness-evaluator') {
214 const asked = tierOf(spawn.model ?? spawn.parentModel)
215 const want = Math.max(1, min)
216 return asked >= 0 && asked < want ? { model: TIERS[want]!, reason: 'evaluator never below sonnet' } : undefined
217 }
218 const id = BUILDER_PROMPT.exec(spawn.prompt)?.[1] ?? (spawn.subagentType === 'harness-builder' ? /M\d+-\d{3}/.exec(spawn.prompt)?.[0] : undefined)
219 if (!id) return undefined
220 const f = featureOf(featuresJson, id)
221 if (!f) return undefined
222 const effort = typeof f.effort === 'string' ? f.effort : 'none'
223 // M33-002: a size (or a Claude alias) picks the model; effort only when `model` is absent.
224 // Any other value (another CLI's alias) counts as medium.
225 const size = typeof f.model === 'string' ? f.model.toLowerCase() : ''
226 const sized = size ? { small: 0, medium: 1, large: 2 }[size] ?? (tierOf(size) < 0 ? 1 : Math.min(2, tierOf(size))) : undefined
227 const reasons = [sized !== undefined ? `model ${size}` : `effort ${effort}`]
228 let tier = sized ?? (effort === 'low' ? 0 : effort === 'high' || effort === 'max' ? 2 : 1)
229 const starts = modelsLog.split('\n').filter(l => l.startsWith(`${id} `) && l.endsWith(` notes:${notesKey(featuresJson, id)}`)).length
230 const attempts = Number(f.eval_attempts) > 0 ? Number(f.eval_attempts) : 0
231 if (starts + attempts > 0) {
232 tier += starts + attempts
233 reasons.push(`+${starts + attempts} after ${starts} earlier start(s), ${attempts} evaluator rejection(s)`)
234 }
235 tier = Math.min(2, tier)
236 const paths = Array.isArray(f.paths) ? f.paths.map(String) : []
237 if (tier < 1 && paths.some(p => SENSITIVE.test(p))) {
238 tier = 1
239 reasons.push('sensitive paths')
240 }
241 if (tier < min) {
242 tier = min
243 reasons.push(`floor ${floor}`)
244 }
245 return { model: TIERS[tier]!, feature: id, reason: reasons.join(', ') }
246}
247
248// Short hash of a feature's notes (M34-004): an owner's answer changes the notes.
249export function notesKey(featuresJson: string, id: string) {
250 const n = String(featureOf(featuresJson, id)?.notes ?? '')
251 let h = 5381
252 for (const c of n) h = ((h * 33) ^ c.charCodeAt(0)) >>> 0
253 return h.toString(16)
254}
255
256export function modelsLine(choice: ModelChoice, featuresJson = '') {
257 return `${choice.feature} ${choice.model} ${choice.reason} notes:${notesKey(featuresJson, choice.feature!)}\n`
258}
259
260// --- M30-003 status line and /harness ------------------------------------------
261// Read from FEATURES.json alone: no model call, no shell.
262
263type Entry = { id: string; milestone?: unknown; title?: string; status?: string; notes?: unknown; depends_on?: unknown; effort?: unknown; paths?: unknown }
264
265export type Facts = {
266 passing: number
267 total: number
268 milestones: { key: string; name: string; passing: number; total: number }[]
269 next?: Entry
270 waiting: { id: string; question: string }[]
271 held: { id: string; on: string }[]
272}
273
274const WAITING = /^Needs a human:\s*/
275
276export function harnessFacts(featuresJson: string): Facts | undefined {
277 let data: { milestones?: Record<string, string>; features?: Entry[] }
278 try {
279 data = JSON.parse(featuresJson)
280 } catch {
281 return undefined
282 }
283 const fs = (data.features ?? []).filter(f => typeof f?.id === 'string')
284 const waiting = fs
285 .filter(f => f.status === 'deferred' && WAITING.test(String(f.notes ?? '')))
286 .map(f => ({ id: f.id, question: String(f.notes).replace(WAITING, '') }))
287 // held: failing features whose depends_on reaches a waiting one, transitively
288 const on = new Map<string, string>(waiting.map(w => [w.id, '']))
289 const failing = fs.filter(f => f.status === 'failing')
290 for (let changed = true; changed; ) {
291 changed = false
292 for (const f of failing) {
293 if (on.has(f.id) || !Array.isArray(f.depends_on)) continue
294 const hit = f.depends_on.map(String).find(d => on.has(d))
295 if (hit) {
296 on.set(f.id, hit)
297 changed = true
298 }
299 }
300 }
301 const held = [...on].filter(([, v]) => v !== '').map(([id, v]) => ({ id, on: v }))
302 const order = (f: Entry) => [Number(f.milestone) || 0, f.id] as const
303 const next = failing
304 .filter(f => !on.has(f.id))
305 .sort((a, b) => order(a)[0] - order(b)[0] || order(a)[1].localeCompare(order(b)[1]))[0]
306 const keys = [...new Set(fs.map(f => String(f.milestone)))].sort((a, b) => Number(a) - Number(b))
307 return {
308 passing: fs.filter(f => f.status === 'passing').length,
309 total: fs.length,
310 milestones: keys.map(key => {
311 const ms = fs.filter(f => String(f.milestone) === key)
312 return { key, name: data.milestones?.[key] ?? '?', passing: ms.filter(f => f.status === 'passing').length, total: ms.length }
313 }),
314 next,
315 waiting,
316 held,
317 }
318}
319
320export type LineState = { feature?: string; model?: string; spent?: number; budget?: number; notice?: string }
321
322export function statusLineText(f: Facts, s: LineState) {
323 const parts = [`harness ${s.feature ?? f.next?.id ?? 'done'}`, `${f.passing}/${f.total} passing`]
324 if (s.spent !== undefined) parts.push(s.budget ? `${usd(s.spent)}/${usd(s.budget)}` : usd(s.spent))
325 if (s.model) parts.push(s.model)
326 if (f.waiting.length > 0) parts.push(`${f.waiting.length} waiting on you`)
327 if (s.notice) parts.push(s.notice)
328 return parts.join(' · ')
329}
330
331export function harnessReport(f: Facts, runCost?: number) {
332 const lines = ['Harness status', '']
333 for (const m of f.milestones) lines.push(`M${m.key}: ${m.passing}/${m.total} passing — ${m.name}`)
334 lines.push('', `${f.passing}/${f.total} features passing`)
335 if (f.next) {
336 lines.push('', `Next: ${f.next.id} — ${f.next.title ?? ''}`)
337 if (typeof f.next.effort === 'string') lines.push(` effort: ${f.next.effort}`)
338 if (Array.isArray(f.next.paths) && f.next.paths.length > 0) lines.push(` paths: ${f.next.paths.join(', ')}`)
339 } else {
340 lines.push('', 'Next: nothing eligible')
341 }
342 if (f.waiting.length > 0) {
343 lines.push('', 'Waiting on you:')
344 for (const w of f.waiting) lines.push(` ${w.id} — ${w.question}`)
345 for (const h of f.held) lines.push(` held: ${h.id} (depends on ${h.on})`)
346 }
347 if (runCost !== undefined) lines.push('', `This session so far: ${usd(runCost)}`)
348 return lines.join('\n')
349}
350