Skip to content

Commit 2ae480d

Browse files
authored
fix: read the judge's reply in either shape $.model.complete resolves (#6)
On Claude Code 2.1.280 $.model.complete resolves { isAnswered, text, usage } instead of the reply's text. taskcut passed that object to saysDone, which threw; the catch turned every judgement into "not finished" and logged it only to the debug log, so nothing was ever compacted. tsc against the regenerated types reports it; claude plugin validate does not. The reply is now taken as unknown and read by readReply, which knows the text 2.1.278 resolved and the result 2.1.280 resolves, and treats anything else as no reply. A failed call is logged with its reason. Claude-Session: https://claude.ai/code/session_01Hy8i9XZbHT6uPuVZwAP8vT Co-authored-by: Yangze Luo <14040068+wasd96040501@users.noreply.github.com>
1 parent c352506 commit 2ae480d

5 files changed

Lines changed: 56 additions & 3 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- taskcut is released under the MIT License, in place of Apache-2.0.
1313

14+
### Fixed
15+
16+
- **Nothing was compacted on Claude Code 2.1.280.** `$.model.complete` now
17+
resolves `{ isAnswered, text, usage }` rather than the reply's text; taskcut
18+
read the object as text, every judgement failed, and the failure was logged
19+
only to the debug log. The reply is now read in either shape, so 2.1.278
20+
keeps working, and a failed call is logged with its reason.
21+
1422
## [0.8.0] - 2026-09-22
1523

1624
### Changed

‎docs/compatibility.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ declarations it is written against are generated per Claude Code version by
1111
| `session.start`, `turn.step`, `turn.complete` | hook events | taskcut stops working; `claude plugin validate` reports the unknown event before a session loads it |
1212
| `$.session.usage`, `$.session.messages`, `$.session.compact`, `$.fs.read`, `$.env.get`, `$.model.complete`, `$.turn.abort`, `$.prompt.submit`, `$.ui.log` | engine calls | same: refused at load, named in the validation output |
1313
| a hook's budget counting only its own code, not its `$` calls | engine rule | the judgement inside a step would overrun it, and the engine would skip the hook: taskcut would compact only at the end of a turn, and say so in the transcript |
14+
| what `$.model.complete` resolves to | data shape | `claude plugin validate` does not see it; only `tsc` against regenerated types does. 2.1.278, which taskcut was measured on, resolved the reply's text; 2.1.280 resolves `{ isAnswered, text, usage }`, and 0.8.0 read that object as text, so every judgement failed and nothing was ever compacted. The reply is now taken as `unknown` and read by `readReply`, which knows both shapes and treats anything else as no reply |
1415
| `session.start`'s `isInteractive` | data shape | taskcut would stop ending turns early; a compaction at the end of a turn still works |
1516
| `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS` | early-access flag | **nothing**, by design — see below |
1617

‎hooks/judge.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,24 @@ export function judgePrompt(messages: readonly SessionMessage[], step: Step, mem
155155
* the emphasis a model sometimes puts round it. Anything else, including no
156156
* answer, is not.
157157
*/
158+
export type Reply = { text: string } | { reason: string }
159+
160+
/**
161+
* The judge's reply, whichever shape `$.model.complete` resolved: the reply's
162+
* text up to Claude Code 2.1.278, `{ isAnswered, text }` or
163+
* `{ isAnswered: false, reason }` from 2.1.280. Either is read here, where the
164+
* engine's answer enters, and nothing past this point knows there were two.
165+
*/
166+
export function readReply(answer: unknown): Reply {
167+
if (typeof answer === 'string') return { text: answer }
168+
if (typeof answer === 'object' && answer !== null && 'isAnswered' in answer) {
169+
const { isAnswered, text, reason } = answer as { isAnswered: unknown; text?: unknown; reason?: unknown }
170+
if (isAnswered === true && typeof text === 'string') return { text }
171+
if (isAnswered === false) return { reason: String(reason) }
172+
}
173+
return { reason: 'a reply of no shape taskcut knows' }
174+
}
175+
158176
export function saysDone(answer: string): boolean {
159177
const lines = answer.trim().split('\n')
160178
const last = lines[lines.length - 1] ?? ''

‎hooks/register.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import type { EngineInterface, Register } from 'claude-code'
2727

2828
import { ENV_VAR, INERT, decideActivation, type Activation } from './activation'
2929
import { readConfig, type Config } from './config'
30-
import { ANSWER_TOKENS, JUDGE_SYSTEM, judgePrompt, saysDone, type Step } from './judge'
30+
import { ANSWER_TOKENS, JUDGE_SYSTEM, judgePrompt, readReply, saysDone, type Step } from './judge'
3131

3232
/**
3333
* Resolved once, at `session.start`. It starts inert so that a session in which
@@ -105,8 +105,15 @@ async function memoryFiles($: EngineInterface): Promise<string[]> {
105105
async function judge($: EngineInterface, step: Step, config: Config): Promise<boolean> {
106106
try {
107107
const prompt = judgePrompt(await $.session.messages(), step, await memoryFiles($))
108-
const answer = await $.model.complete({ model: config.model, system: JUDGE_SYSTEM, prompt, maxTokens: ANSWER_TOKENS })
109-
return saysDone(answer)
108+
// `unknown`: what the call resolves to has changed between releases, and
109+
// readReply takes every shape it has had.
110+
const answer: unknown = await $.model.complete({ model: config.model, system: JUDGE_SYSTEM, prompt, maxTokens: ANSWER_TOKENS })
111+
const reply = readReply(answer)
112+
if ('reason' in reply) {
113+
$.ui.log(`could not judge the step (${reply.reason})`, { to: 'debug' })
114+
return false
115+
}
116+
return saysDone(reply.text)
110117
} catch (error) {
111118
$.ui.log(`could not judge the step (${String(error)})`, { to: 'debug' })
112119
return false

‎test/judge.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
beforeStep,
1313
conversationLines,
1414
judgePrompt,
15+
readReply,
1516
saysDone,
1617
} from '../hooks/judge.ts'
1718

@@ -126,6 +127,24 @@ describe('judgePrompt', () => {
126127
})
127128
})
128129

130+
describe('readReply', () => {
131+
test('reads the text $.model.complete resolved up to 2.1.278', () => {
132+
assert.deepEqual(readReply('It reports task 2 complete.\nDONE'), { text: 'It reports task 2 complete.\nDONE' })
133+
})
134+
135+
test('reads the result it resolves from 2.1.280', () => {
136+
const usage = { input_tokens: 1, output_tokens: 1, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
137+
assert.deepEqual(readReply({ isAnswered: true, text: 'WORKING', usage }), { text: 'WORKING' })
138+
assert.deepEqual(readReply({ isAnswered: false, reason: 'api-error', status: 529, error: 'overloaded', usage }), { reason: 'api-error' })
139+
})
140+
141+
test('anything else is no reply, never a verdict', () => {
142+
for (const odd of [undefined, null, 42, {}, { isAnswered: true }, { text: 'DONE' }]) {
143+
assert.ok('reason' in readReply(odd), JSON.stringify(odd))
144+
}
145+
})
146+
})
147+
129148
describe('saysDone', () => {
130149
test('reads the verdict off the last line', () => {
131150
assert.equal(saysDone('It reports task 2 complete and starts task 3.\nDONE'), true)

0 commit comments

Comments
 (0)