jeo-code 0.8.20 → 0.8.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.21] - 2026-07-10
10
+ _"반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping._
11
+
12
+ ### Added
13
+ - **Session draft-restore** (`src/agent/session.ts`, `src/commands/launch/session-slash.ts`, `src/commands/launch.ts`, `src/commands/launch/input.ts`) — an unsent, half-typed prompt now survives a hard exit (Ctrl+D/EOF/disconnected terminal) and is restored into the prompt box on `/resume` (gajae-code parity). The read-side plumbing (`updateSessionDraft`, `header.draft`, `runSessionSlash` surfacing it) already existed; `launch.ts` never consumed it. Added `persistSessionDraft` (mirrors the existing `persistSessionModel` pattern) and a new pure predicate `draftFromUnsentLine` (`launch/input.ts`), wired into all 3 resume paths — the interactive picker, the mid-loop `/session resume` command, and `--resume <id>`/`--continue`'s non-interactive startup path (this third path had NO draft restoration at all before this fix). Live-verified: seeded a real session's draft, launched the real `bun src/cli.ts --resume <id>` under a genuine PTY, confirmed the exact unsent text appeared in the live prompt box.
14
+ - **Evidence-gated goal verification** (`src/agent/goal-verifier.ts`, `src/agent/engine.ts`, `src/commands/launch.ts`) — an LLM-judged `MET` goal verdict is now deterministically downgraded to `NOT_MET` when the turn mutated files with no fresh verification evidence, closing the "gate theater" failure mode (an LLM asserting success without re-checked evidence). `applyEvidenceGate` (pure, already unit-tested with 5 tests) existed but was never called anywhere — the same disconnected-scaffolding pattern `RouteHistory` shipped with in 0.8.16/0.8.17. `onBeforeDone`'s signature now carries the SAME `sawMutation`/`sawVerification`/`verificationStale` signals the engine's own done-gate already computes; `launch.ts`'s goal-verifier closure now gates `verifyGoal`'s raw verdict through it before persisting or acting on it.
15
+
16
+ ### Fixed
17
+ - **`toolTarget` dedup** (`src/agent/step-budget.ts`, `src/agent/task-tool.ts`) — `task-tool.ts` had a private copy of the same coarse-call-target labeler `step-budget.ts` needed for a new edit-thrashing novelty rule (a model rewriting the same file with different content every attempt no longer earns endless step-budget extensions — only the first attempt at a given target counts as novel). Moved to `step-budget.ts` (exported), `task-tool.ts` now imports it — confirmed byte-identical, zero behavior change.
18
+
19
+ ### Added (catalog)
20
+ - **`gpt-5.6`** (`src/ai/model-catalog.ts`) — OpenAI's newest catalogued model, consistent with existing entries; correctly excluded from the OAuth-only `CODEX_MODELS` allow-list (live discovery handles that drift per its own documented contract, not a static edit).
21
+
22
+ ### Verified
23
+ - `bun test test/launch-flags.test.ts test/session-draft-restore.test.ts test/step-budget.test.ts test/goal-verifier.test.ts test/task-tool.test.ts test/doctor.test.ts test/prompt-router-tiers.test.ts test/model-catalog.test.ts` — 128 pass / 0 fail.
24
+ - Full `bun test` — 2906 pass / 0 fail across 291 files.
25
+ - `bun run typecheck` — no errors.
26
+ - Live PTY end-to-end: real session seeded with `updateSessionDraft`, real `bun src/cli.ts --resume <id>` subprocess, confirmed the exact restored text in the live prompt box.
27
+
9
28
  ## [0.8.20] - 2026-07-10
10
29
  _"모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here._
11
30
 
package/README.ja.md CHANGED
@@ -265,11 +265,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
265
265
  ## 変更履歴 (Changelog)
266
266
 
267
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
268
269
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
269
270
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
270
271
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
271
272
  - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
272
- - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
273
273
 
274
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
275
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -266,11 +266,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
266
266
  ## 변경 이력 (Changelog)
267
267
 
268
268
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
269
+ - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
269
270
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
270
271
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
271
272
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
272
273
  - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
273
- - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
274
274
 
275
275
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
276
276
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -273,11 +273,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
273
273
  ## Changelog
274
274
 
275
275
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
276
+ - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
276
277
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
277
278
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
278
279
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
279
280
  - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
280
- - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
281
281
 
282
282
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
283
283
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -265,11 +265,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
265
265
  ## 更新日志 (Changelog)
266
266
 
267
267
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
268
+ - **[0.8.21]** (2026-07-10) — "반영할꺼 같은방식으로 체크하고 배포까지" — a rescan of the working tree (after the prior audit pass) turned up 15 uncommitted files: 3 genuinely distinct features left unwired by a concurrent session, partially casualty of an earlier accidental `git checkout` incident this session (disclosed in the 0.8.20 entry above). Each was independently traced from its existing plumbing, wired to a real call site, tested, and one live-verified end-to-end over a real PTY before shipping.
268
269
  - **[0.8.20]** (2026-07-10) — "모든 검증 다시 리뷰하고 변경사항 모두 체크해" — 4 fresh, skeptical subagents independently re-audited every change from v0.8.17-0.8.19 (bc8768f..92c6b7d) with zero trust in prior claims: a code-correctness auditor manually traced the Antigravity routing logic against the live catalog, a test-integrity auditor mutation-tested the new tests (confirmed each one genuinely fails when its fix is reverted) and ran the suite fresh, a live-behavior verifier re-reproduced all 4 behavioral claims from scratch with self-generated data (own session ids, own mock server, own HTTP status code), and a docs-accuracy auditor cross-checked every README/CHANGELOG claim against current code in isolated git worktrees. Verdict: all core logic and tests GENUINE/CONFIRMED CORRECT — but the audit surfaced 1 real doc staleness gap, 1 changelog count error, and 2 minor precision gaps, all fixed here.
269
270
  - **[0.8.19]** (2026-07-09) — "프롬프트 라우팅에 안티그라비티 프로바이더의 경우, 안티그라비티용 소넷과 오퍼스도 3.5급으로 라우팅될수있도록해줘" — Antigravity re-exports 3 distinct model families (Anthropic Claude, Google Gemini, OpenAI GPT-OSS) behind one credential, structurally unlike every other provider. `high`/`complex` tier auto-select always resolved to Google's Gemini 3.5 rows: Anthropic's real 64,000-token output ceiling lost a same-thinking-tier tie to Google's 65,536 by a margin with no practical significance, and Gemini's 1M-token context further outranked Claude's real 200K window — so `antigravity/claude-sonnet-4-6`/`antigravity/claude-opus-4-6-thinking` were NEVER reachable through auto-select, even though both were already correctly `sizeClass`-tagged into the `high`/`complex` pools.
270
271
  - **[0.8.18]** (2026-07-09) — "라우팅 매 프롬프트마다 변경되는지, route why 의도대로 동작하는지 검증하고 배포할게있는지 확인해줘" — 2 parallel subagents live-verified per-prompt routing + `/route why` (including the previously-only-unit-tested post-call equivalent-model fallback path, now reproduced against a real mock 500-error server) and audited a disconnected `RouteHistory` class shipped as incomplete scaffolding in 0.8.16/0.8.17 (class + 11 tests existed, but nothing ever called it — no `/route history` subcommand, no wiring into the turn loop). Live testing also surfaced one real bug: the TUI footer's model/provider label never updated after a mid-turn fallback, staying on the pre-fallback model for the rest of that turn's render even though the backend decision (`lastRouteDecision`) was already correct.
271
272
  - **[0.8.17]** (2026-07-09) — "서브에이전트 이용해. 병렬루 부가 나머지모두 개선하고 검증후 배포까지하자" — a prior 4-way parallel subagent cross-verification pass found the README `[!CAUTION]` routing-lock block (added this session for "프롬프트 라우팅이 한번 정해지면 안바뀌는데?") was directionally correct but had 2 real inaccuracies, plus a concrete test-coverage gap: every existing pin/override regression test drove the lock via the `--model` CLI startup flag, never via typing `/model <name>` as an interactive slash command mid-session, and none proved persistence across 3+ consecutive turns.
272
- - **[0.8.16]** (2026-07-09) — "jeo code의 computer use 기능은 / 명령어로 설정할수있도록하고, 실동작검증까지진행해줘" — the desktop-automation `computer` tool was only togglable by hand-editing `computer.enabled` in `~/.jeo/config.json`; there was no in-session way to enable it for a single run without leaving a permanent config change behind.
273
273
 
274
274
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
275
275
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.20",
3
+ "version": "0.8.21",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -27,7 +27,8 @@ import { isContextOverflowError, isRefusalError, friendlyProviderError } from ".
27
27
  import { runPreToolHooks, runPostTurnHooksForBatch } from "./hooks";
28
28
  import { truncateToolOutput, formatToolResultBody } from "./tool-output";
29
29
  export { TOOL_OUTPUT_MAX, READ_OUTPUT_MAX, TOOL_SPILL_THRESHOLD, MAX_TOOL_ARTIFACTS, truncateToolOutput, spillToolResult } from "./tool-output";
30
- import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, type StepBudgetConfig } from "./step-budget";
30
+ import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSignature, toolTarget, type StepBudgetConfig } from "./step-budget";
31
+
31
32
  import { historyTokens, trimToolResultsInPlace, stripReasoningArtifactsInPlace } from "./compaction";
32
33
  import { jeoEnv } from "../util/env";
33
34
  import { GUARD_LIMITS, isVerificationSignal, repeatHint, classifyDoneGate } from "./loop-guards";
@@ -1058,6 +1059,22 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1058
1059
  // once per call here (instead of joining full sigs, hashing, then re-hashing
1059
1060
  // inside budget.record) avoids re-scanning a large write body 3× per step.
1060
1061
  const callSigs = toolCalls.map(c => hashSignature(`${c.tool}:${JSON.stringify(c.arguments ?? {})}`));
1062
+ // Coarse per-call TARGET (e.g. `edit src/x.ts`) — feeds the budget's novelty rule
1063
+ // separately from the exact-args digest above. A model that keeps rewriting the
1064
+ // SAME file/command with slightly different content each attempt produces a fresh
1065
+ // `callSigs` entry every time (never "seen" before) but the SAME `callTargets`
1066
+ // entry, so that thrashing stops earning step-budget extensions after its first try.
1067
+ // `toolTarget` falls back to the bare tool name when it can't find an identifying
1068
+ // field (e.g. a custom/dynamic tool with no filePath/command/pattern argument) — that
1069
+ // generic case carries NO real identity ("work" for every call), so treat it as "no
1070
+ // coarse target" and let `budget.record` fall back to the exact-args signature
1071
+ // instead (unchanged legacy novelty for tools this helper doesn't specifically know).
1072
+ const callTargets = toolCalls.map(c => {
1073
+ const t = toolTarget(c.tool, c.arguments);
1074
+ return t.toLowerCase() === (c.tool || "").toLowerCase() ? undefined : t;
1075
+ });
1076
+
1077
+
1061
1078
  const sig = callSigs.length === 1 ? callSigs[0] : hashSignature(callSigs.join(" | "));
1062
1079
  if (sig === lastSig) repeatCount++;
1063
1080
  else {
@@ -1313,7 +1330,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1313
1330
  // edits plus one trivial successful read must not look like a progressing
1314
1331
  // step to the extension heuristic (that loophole earned endless extensions).
1315
1332
  for (let i = 0; i < toolCalls.length; i++) {
1316
- if (results[i].executed) budget.record(callSigs[i], results[i].success);
1333
+ if (results[i].executed) budget.record(callSigs[i], results[i].success, callTargets[i]);
1334
+
1317
1335
  }
1318
1336
  // done-verification guard bookkeeping: write/edit successes mark the turn as
1319
1337
  // mutating; a successful bash whose command/output looks like a test/build run
@@ -168,17 +168,29 @@ export class StepBudget {
168
168
  }
169
169
 
170
170
  /** Record an executed tool call (ring-buffered to the scoring window).
171
- * Stored as a fixed-size digest — see `hashSignature` (memory bound). */
172
- record(signature: string, success: boolean): void {
171
+ * Stored as a fixed-size digest — see `hashSignature` (memory bound).
172
+ *
173
+ * `targetKey` (optional) is the COARSE target (e.g. `edit src/x.ts`) the novelty rule
174
+ * scores against, instead of `signature`'s full-args hash. Without it, retrying an
175
+ * edit on the SAME file with slightly different content each attempt looks "novel"
176
+ * forever (the exact args hash never repeats) and the extension heuristic reads that
177
+ * edit-thrashing loop as endless progress. With it, only the FIRST attempt at a given
178
+ * target counts as novel; grinding on the same file/command afterwards does not, so a
179
+ * stuck rewrite loop stops earning extensions even though every individual call
180
+ * "succeeds" and every exact signature is technically unique. Falls back to `signature`
181
+ * when omitted (unchanged behavior for existing callers/tests). */
182
+ record(signature: string, success: boolean, targetKey?: string): void {
173
183
  const sig = hashSignature(signature);
174
- if (!this.seen.has(sig)) {
175
- this.seen.add(sig);
184
+ const noveltyKey = hashSignature(targetKey ?? signature);
185
+ if (!this.seen.has(noveltyKey)) {
186
+ this.seen.add(noveltyKey);
176
187
  this.novelSinceExtension++;
177
188
  }
178
189
  this.window.push({ signature: sig, success });
179
190
  if (this.window.length > this.cfg.windowSize) this.window.shift();
180
191
  }
181
192
 
193
+
182
194
  /** A mid-turn steering message arrived — fresh, user-driven work. Grant headroom
183
195
  * (capped at the hard cap, without consuming the extension budget) and clear the
184
196
  * scoring window so the new instruction is never declined by the previous
@@ -239,3 +251,31 @@ export class StepBudget {
239
251
  };
240
252
  }
241
253
  }
254
+ /**
255
+ * A concise, gjc-style label for a tool call's actual TARGET (file / command / glob) —
256
+ * not just the bare tool name plus its full argument blob. Two calls that touch the SAME
257
+ * target (e.g. `edit src/x.ts` twice with different bodies) collapse to the same target
258
+ * key here even though `hashSignature` of their full arguments differs. Shared by the
259
+ * budget's novelty rule (below) and `task-tool.ts`'s live subagent monitor label — kept
260
+ * pure/local (no TUI dependency in the agent layer).
261
+ */
262
+ export function toolTarget(tool: string, rawArgs: unknown): string {
263
+ const a = (rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {}) as Record<string, unknown>;
264
+ const t = (tool || "").toLowerCase();
265
+ const str = (...keys: string[]): string => {
266
+ for (const k of keys) { const v = a[k]; if (typeof v === "string" && v.length > 0) return v; }
267
+ return "";
268
+ };
269
+ if (t === "bash") {
270
+ const cmd = str("command", "cmd").split("\n")[0]!.trim();
271
+ return cmd ? `bash: ${cmd.length > 80 ? cmd.slice(0, 79) + "…" : cmd}` : "bash";
272
+ }
273
+ if (t === "read" || t === "write" || t === "edit") {
274
+ const f = str("filePath", "path");
275
+ return f ? `${t} ${f}` : t;
276
+ }
277
+ if (t === "find") { const g = str("globPattern", "pattern"); return g ? `find ${g}` : "find"; }
278
+ if (t === "search") { const p = str("pattern"); return p ? `search ${p}` : "search"; }
279
+ if (t === "task") { const r = str("role"); return r ? `task ${r}` : "task"; }
280
+ return tool || "tool";
281
+ }
@@ -10,6 +10,8 @@
10
10
  * `task` itself, so delegation cannot recurse infinitely.
11
11
  */
12
12
  import { runAgentLoop, type ToolHandler } from "./engine";
13
+ import { toolTarget } from "./step-budget";
14
+
13
15
  import type { ToolResult } from "./tools";
14
16
  import type { Message } from "./loop";
15
17
  import { loadProjectContext, withProjectContext } from "./context-files";
@@ -146,31 +148,6 @@ export function taskToolProtocolLine(config?: Pick<Config, "subagents">): string
146
148
  );
147
149
  }
148
150
 
149
- /**
150
- * A concise, gjc-style label for a subagent's tool call — the actual TARGET (file / command /
151
- * glob), not just the bare tool name — so the parent's live monitor shows "read src/x.ts" or
152
- * "bash: bun test" instead of "read"/"bash". Kept local (no TUI dependency in the agent layer).
153
- */
154
- function toolTarget(tool: string, rawArgs: unknown): string {
155
- const a = (rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) ? rawArgs : {}) as Record<string, unknown>;
156
- const t = (tool || "").toLowerCase();
157
- const str = (...keys: string[]): string => {
158
- for (const k of keys) { const v = a[k]; if (typeof v === "string" && v.length > 0) return v; }
159
- return "";
160
- };
161
- if (t === "bash") {
162
- const cmd = str("command", "cmd").split("\n")[0]!.trim();
163
- return cmd ? `bash: ${cmd.length > 80 ? cmd.slice(0, 79) + "…" : cmd}` : "bash";
164
- }
165
- if (t === "read" || t === "write" || t === "edit") {
166
- const f = str("filePath", "path");
167
- return f ? `${t} ${f}` : t;
168
- }
169
- if (t === "find") { const g = str("globPattern", "pattern"); return g ? `find ${g}` : "find"; }
170
- if (t === "search") { const p = str("pattern"); return p ? `search ${p}` : "search"; }
171
- if (t === "task") { const r = str("role"); return r ? `task ${r}` : "task"; }
172
- return tool || "tool";
173
- }
174
151
 
175
152
  function firstUsefulLine(output: string | undefined): string {
176
153
  if (!output) return "";
@@ -415,6 +415,19 @@ export function captureLivePromptInputChunk(state: PromptInputQueue, chunk: stri
415
415
  return accepted;
416
416
  }
417
417
 
418
+ /**
419
+ * Whether an in-flight readline line buffer (captured right before a hard
420
+ * exit — Ctrl+D/EOF/disconnected terminal) should be persisted as an unsent
421
+ * draft (gajae-code "unsent prompt survives /resume" parity). Whitespace-only
422
+ * or empty input is never a real draft. Pure predicate so the exit-path
423
+ * wiring in launch.ts (which owns the actual persistence side effect) stays
424
+ * thin and this decision is independently testable.
425
+ */
426
+ export function draftFromUnsentLine(line: string | undefined): string | undefined {
427
+ const trimmed = (line ?? "").trim();
428
+ return trimmed ? trimmed : undefined;
429
+ }
430
+
418
431
  /**
419
432
  * TTY "new input first" contract: fold any queued FULL lines (stray
420
433
  * Enter-terminated buffer noise, or older persisted queues) into the editable
@@ -50,7 +50,7 @@ import { readGlobalConfig, saveConfigPatch, resolveWikiRoot } from "../agent/sta
50
50
  import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recency";
51
51
  import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
52
52
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
53
- import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
53
+ import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
54
54
  import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, type PromptTier, type RouteDecision } from "../agent/prompt-router";
55
55
  import { RouteHistory } from "../agent/route-history";
56
56
 
@@ -110,6 +110,7 @@ import {
110
110
  latestSessionId,
111
111
  exportSession,
112
112
  updateSessionModel,
113
+ updateSessionDraft,
113
114
  appendCompaction,
114
115
  resolveSessionRef,
115
116
  } from "../agent/session";
@@ -163,6 +164,7 @@ import {
163
164
  boxVerticalNavAction,
164
165
  queuePromptInputChunk,
165
166
  captureLivePromptInputChunk,
167
+ draftFromUnsentLine,
166
168
  restoreQueuedLinesToPrefill,
167
169
  createInFlightAbortHarness,
168
170
  classifyMidTurnLine,
@@ -262,6 +264,7 @@ export {
262
264
  boxVerticalNavAction,
263
265
  queuePromptInputChunk,
264
266
  captureLivePromptInputChunk,
267
+ draftFromUnsentLine,
265
268
  restoreQueuedLinesToPrefill,
266
269
  createInFlightAbortHarness,
267
270
  classifyMidTurnLine,
@@ -687,6 +690,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
687
690
  let lastUserInput = "";
688
691
  // Full untruncated text of the last assistant reply — surfaced in detail by Ctrl+O.
689
692
  let lastReply = "";
693
+ // Unsent draft restored from a NON-interactive resume path (`--resume <id>`,
694
+ // `--continue`/`-c`) — the interactive-picker and mid-loop `/session resume`
695
+ // paths feed `queuedPromptInput.partial` directly (declared much later); this
696
+ // one exists because `queuedPromptInput` isn't in scope yet at startup. Read
697
+ // once by `queuedPromptInput`'s own initializer below, then not touched again.
698
+ let startupDraft: string | undefined;
690
699
  // Full untruncated output of the most recent tool call — the clipped forge
691
700
  // card's `⟦Ctrl+O for more⟧` hint resolves here.
692
701
  let lastToolDetail: { tool: string; output: string } | null = null;
@@ -808,6 +817,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
808
817
  // Restore the model this session was last using unless the CLI explicitly
809
818
  // pinned one (flags.model/role/provider → initialSessionModel wins).
810
819
  if (!initialSessionModel && header.model) sessionModel = header.model;
820
+ // Restore the unsent input-box draft (gajae-code parity) — see
821
+ // startupDraft's own doc comment for why this path can't feed
822
+ // queuedPromptInput.partial directly yet.
823
+ if (header.draft) startupDraft = header.draft;
811
824
  const modelNote = sessionModel ? ` · model ${sessionModel}` : "";
812
825
  console.log(`Resumed session ${id} (${messages.length} messages).${modelNote}`);
813
826
  } catch (err) {
@@ -841,6 +854,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
841
854
  } catch { /* best-effort */ }
842
855
  };
843
856
 
857
+ // Persist (or clear) the unsent input-box draft into the session header so a
858
+ // later `/resume` restores it into the prompt (gajae-code parity: an
859
+ // in-progress prompt survives a quit + resume instead of being silently
860
+ // lost). Best-effort, same contract as persistSessionModel above — a
861
+ // header-rewrite failure must never abort the exit path that calls this.
862
+ const persistSessionDraft = async (draft: string): Promise<void> => {
863
+ if (flags.noSession || !sessionId) return;
864
+ try {
865
+ await updateSessionDraft(sessionId, draft, cwd);
866
+ } catch { /* best-effort */ }
867
+ };
868
+
844
869
  // `step N/M` display seed: the explicit --max-steps cap, else the dynamic budget's
845
870
  // rolling base — the engine's onBudget event keeps the denominator honest as it grows.
846
871
  const initialStepLimit = flags.maxSteps > 0 ? flags.maxSteps : initialDynamicStepLimit();
@@ -1282,7 +1307,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1282
1307
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
1283
1308
  // because nothing ever forced the model to update item statuses).
1284
1309
  let turnTodos: { title: string; status: string }[] = [];
1285
- const onBeforeDone = async (reason: string): Promise<string | null> => {
1310
+ const onBeforeDone = async (
1311
+ reason: string,
1312
+ evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
1313
+ ): Promise<string | null> => {
1286
1314
  const unfinished = turnTodos.filter(t => t.status !== "done");
1287
1315
  if (turnTodos.length > 0 && unfinished.length > 0) {
1288
1316
  return (
@@ -1304,7 +1332,12 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1304
1332
  }
1305
1333
 
1306
1334
  if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
1307
- const verdict = await verifyGoal(goalState.condition, history, activeModel);
1335
+ const llmVerdict = await verifyGoal(goalState.condition, history, activeModel);
1336
+ // Deterministic downgrade: a fresh MET from the transcript-only LLM
1337
+ // judge still needs mutation/verification evidence to back it up —
1338
+ // see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
1339
+ // IMPOSSIBLE pass through unchanged; this only ever tightens MET.
1340
+ const verdict = applyEvidenceGate(llmVerdict, evidence);
1308
1341
 
1309
1342
  goalState.verdicts.push({
1310
1343
  at: Date.now(),
@@ -2182,7 +2215,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2182
2215
  // IMMEDIATELY on its next iteration, bypassing the "new input first" prefill contract
2183
2216
  // (the user explicitly invoked them — no second Enter).
2184
2217
  const pendingMidTurnCommands: string[] = [];
2185
- const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: "", pastedLines: [], inPaste: false };
2218
+ const queuedPromptInput: PromptInputQueue = { pendingLines: pendingStdinLines, partial: startupDraft ?? "", pastedLines: [], inPaste: false };
2186
2219
  queueBusyInput = (chunk: string) => captureLivePromptInputChunk(queuedPromptInput, chunk);
2187
2220
  queueBusyPasteActive = () => queuedPromptInput.inPaste;
2188
2221
  queueBusySnapshot = () => ({
@@ -2251,6 +2284,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2251
2284
  // hard break, not a graceful `/exit`. Some Bun/tmux paths leave stdin.isTTY
2252
2285
  // false while stdout is still a TTY, so accept either side as interactive.
2253
2286
  if ((process.stdin.isTTY || process.stdout.isTTY || previewEnabled) && !gracefulReadlineClose) {
2287
+ // Best-effort, fire-and-forget: persist whatever's still typed but unsent
2288
+ // (Ctrl+D / EOF / disconnected terminal — no explicit discard gesture, unlike
2289
+ // Ctrl+C's own "clear the box" contract, which stays untouched here) so
2290
+ // `/resume` can restore it (see persistSessionDraft above). Must not block
2291
+ // process.exit() below.
2292
+ const draft = draftFromUnsentLine((rl as unknown as { line?: string }).line);
2293
+ if (draft) void persistSessionDraft(draft);
2254
2294
  hardExitOnLoopEnd = true;
2255
2295
  forceExitFromCtrlC();
2256
2296
  }
@@ -3695,6 +3735,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3695
3735
  if (pickResult.sessionModel !== undefined) sessionModel = pickResult.sessionModel;
3696
3736
  if (pickResult.lastUserInput !== undefined) lastUserInput = pickResult.lastUserInput;
3697
3737
  if (pickResult.lastReply !== undefined) lastReply = pickResult.lastReply;
3738
+ // Restore the unsent input-box draft left over at last save (gajae-code
3739
+ // parity) — feeds the SAME "new input first" prefill path queued mid-turn
3740
+ // lines already use, so it's visible in the box, Enter to run, Esc/Ctrl+U
3741
+ // to discard, on the very first prompt of this resumed session.
3742
+ if (pickResult.draft) queuedPromptInput.partial = pickResult.draft;
3698
3743
  }
3699
3744
 
3700
3745
  // Remote (Telegram) free-text reply / config command routing — wired here,
@@ -3898,6 +3943,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3898
3943
  if (result.sessionModel !== undefined) sessionModel = result.sessionModel;
3899
3944
  if (result.lastUserInput !== undefined) lastUserInput = result.lastUserInput;
3900
3945
  if (result.lastReply !== undefined) lastReply = result.lastReply;
3946
+ // Restore the unsent input-box draft (see the resumeInteractive call
3947
+ // site above for the full rationale) — `continue` below immediately
3948
+ // re-enters the loop top, which consumes `queuedPromptInput.partial`
3949
+ // as this next prompt's prefill.
3950
+ if (result.draft) queuedPromptInput.partial = result.draft;
3901
3951
  continue;
3902
3952
  }
3903
3953
  if (input === "/retry") {