jeo-code 0.12.10 → 0.12.11

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/README.zh.md CHANGED
@@ -1,12 +1,12 @@
1
1
  <p align="center">
2
- <img src="assets/hero.png" alt="jeo-code 自主编码代理主视觉插图" width="100%" />
2
+ <img src="assets/hero.png" alt="发光的奇幻风景中,jeo-code 红色小龙虾魔法师与毛绒伙伴" width="100%" />
3
3
  </p>
4
4
 
5
5
  <h1 align="center">jeo-code (jeo)</h1>
6
6
 
7
7
  <p align="center">
8
8
  <strong>Encode intention. Decode software.</strong><br />
9
- 基于 Bun 的 AI 编码代理 CLI — 需求访谈、经评审的计划、带门禁的执行、诚实的验证。
9
+ 基于 Bun 的 AI 编码代理 CLI — 需求访谈、经评审的计划、带门禁的执行、基于证据的检查。
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -32,6 +32,8 @@
32
32
 
33
33
  - [双向消息网关(Discord、Telegram、Slack)](docs/integrations/messenger.md)
34
34
 
35
+ **消息网关** — `jeo messenger` 独立于通知守护进程,支持白名单内的 Discord、Telegram 和 Slack 会话。本地工具默认关闭;明确启用 `computer` 可访问共享主机桌面,启用 `bash` 可访问 shell,两者均非沙箱。详见[消息网关指南](docs/integrations/messenger.md)。
36
+
35
37
  📖 **[使用指南](docs/usage-guide.md)** — 安装、TUI 操作(↑ 历史、Ctrl+O、`!` shell)、斜杠命令、`/resume`、规格优先工作流,附演示视频。
36
38
 
37
39
  <video src="https://raw.githubusercontent.com/akillness/jeo-code/main/docs/jeo-code-promo.mp4" controls muted playsinline width="100%"></video>
@@ -43,7 +45,7 @@
43
45
  - **多提供商、单一循环** — Anthropic / OpenAI(+Codex) / Gemini / Antigravity / Ollama / LM Studio,以及 20+ 个 OpenAI、Anthropic 兼容云服务(Groq、DeepSeek、Mistral、OpenRouter、xAI、Kimi、z.ai 等),统一在一个 JSON 工具循环中 — **还包括你自己注册的端点**: `jeo provider add --id my-proxy --base-url https://…`(或 `--preset litellm|vllm|sglang|azure-openai|…`)让 LiteLLM 代理、自托管 vLLM、企业内部 Anthropic 网关拥有各自的路由前缀、模型列表和凭据,而不是劫持内置的 `openai` 提供商。输入框内直接 OAuth 登录(`/provider login`),每次模型选择都会持久化为新的默认值,而 prompt routing 只会自动选择真实可用的凭据路径: Gemini OAuth 会走 provider-qualified 的 `antigravity/*` 代理模型集,**以账号当前实际提供的为准**(实时发现 — 例如 Gemini 3.6 Flash 各档、Gemini 3.1 Pro、Claude Sonnet/Opus 4.6;非对话模型与已弃用的 id 会被过滤掉,列表为空时也不会用静态目录顶替),绝不选择需要 `GEMINI_API_KEY` 的 public `google/gemini-*` 行;如果已配置的路由指向尚未就绪的提供商,jeo 会先切换到同层级的等价凭据模型,再退回默认模型。
44
46
  - **编辑完整性** — read 输出携带内容锚点(`42ab|`);带锚点的编辑会与当前文件校验、行移动时自动重映射、不匹配时连同最新内容一起拒绝 — 绝不污染文件。
45
47
  - **自我修正的验证循环** — 配置 post-edit 钩子(tsc / eslint / 测试),代理会*亲自读取*诊断并在循环内修复;钩子未通过时 `done` 会被阻断。
46
- - **没有表演的真实门禁** — `ralplan` 共识由真正读取仓库的 critic 子代理执行,`[OKAY]` 裁决被持久化且 `jeo approve` *强制要求*它;`ultragoal` 诚实报告(套件运行只是全局信号,绝不伪造逐条通过)。
48
+ - **真实门禁,不做表演** — `ralplan` 共识由真正读取仓库的 critic 子代理执行,`[OKAY]` 裁决被持久化且 `jeo approve` *强制要求*它;`ultragoal` 将套件运行作为全局信号,不伪造逐条通过。这不能证明测试运行器真实可信或工作区正确。
47
49
  - **崩溃耐久、本地优先** — 全部状态位于 `.jeo/`,原子写入、跨进程运行锁、失败任务标记 + 恢复时的部分编辑警告。
48
50
  - **动态步数预算** — 只要近期工具调用展现新的进展就持续延长,停滞时优雅收敛为总结;子代理保持精确的步数契约。
49
51
  - **内联 TUI** — 已完成的工作流入真实滚动缓冲区(回合中也可用 tmux 滚轮),代理运行时普通查询输入框仍保持可见并可编辑。Ctrl+O 详细信息切换、主题、剪贴板图片粘贴(Ctrl+V)、CJK/表情安全的宽度计算。
@@ -52,9 +54,9 @@
52
54
  - **低价位评分模型路由** — `/goal` 验证器、`critic` 子代理角色,以及未锁定的 `task` 扇出批次,现在默认使用低价位、已配置凭证的模型,而不是悄悄搭乘与它们所评分/执行的工作相同的全价模型(`resolveVerifierModel`,针对浏览器 `verify` 动作按视觉能力过滤,确保纯文本的低价模型不会悄悄丢弃附带的截图)。
53
55
  - **`jeo routine init`** — 生成一个 GitHub Actions 工作流,在 schedule/issue/PR 触发器上以无头模式运行 jeo(`jeo "<prompt>" -p`),运行在 GitHub 自己的 runner 上 — 不需要笔记本电脑,并且不会给 jeo 自身增加任何新的攻击面(没有进程内调度器或 webhook 监听器)。`--dry-run` 可预览,`--no-pr` 则改为直接提交而非默认的每次运行一个 PR。
54
56
  - **远程监控与控制(Telegram、Discord、Slack)** — 配对一次机器人(`jeo notify setup --provider telegram|discord|slack`)后,一个共享的 `jeo daemon start` 会在所有活跃会话中、子代理每次状态变化(启动 → 完成/失败/取消)时推送消息,并接受来自人类 allowlist 的 `/subagents`、`/steer`、`/cancel`(Discord/Slack 上还有 `/sessions` 和 `/send`)回传。Telegram 额外提供论坛主题、内联键盘和图片附件;Discord 使用 REST + Gateway WebSocket;Slack 使用 Web API + Socket Mode。附带各平台详细配置指南及可安装的 Agent Skill(`channel-notify`)。
55
- - **无法绕过的门禁** — `approve` 会重新校验它正在批准的那份计划的精确摘要,`team` 会拒绝已审阅摘要发生变化的计划;`done` 调用会对照最新的验证证据重新检查(后来的失败检查会使先前的通过失效,三次纠正往返后硬性拒绝);`autopilot` ratchet 会把失败的回滚记录为 `rollback_failed` 并以非零状态停止,而不是声称该步骤已回退。
57
+ - **有边界的工作流门禁,不是证明** — `approve` 会重新校验其批准的确切计划摘要,`team` 会拒绝审核后摘要已变化的计划。调用 `done` 时,当前回合的变更与验证信号可以阻止或降级完成;`/goal` 另加一次基于对话/工具历史的 LLM 判断和确定性证据门禁。两者都不会独立重新读取工作区;工具/测试输出是证据,并不能证明运行器或结果可信。这些检查也不保证回合结束后后台任务不会修改文件。`autopilot` ratchet 会将回滚失败记为 `rollback_failed` 并以非零状态停止。
56
58
  - **不阻塞回合的会话级异步执行** — 通过 `task` 工具真实的 `tasks` 数组扇出独立工作,不阻塞父回合。detached 子代理、后台 job 和逐行 monitor 可在后续回合中继续使用 `subagent`/`job`/`monitor` 的 `list`、`inspect`、`await`、`cancel`、`tail` 控制。内联 TUI 为每个 worker 保留独立的实时 activity 槽位,并在会话退出或 Ctrl-C 时清理全部 registry。
57
- - **独立验证器,真正强制执行** — 计划再也无法跳过 architect/critic 步骤: `PlanSchema` 会拒绝任何以未验证变更结尾的计划(把验证器放在它该检查的变更之前也不算数),在 `ralplan` 起草阶段和 `team`/`approve` 执行阶段都强制生效。每一次 architect/critic 裁决也都必须展示真实证据 — 观测到的 `read`/`search`/`find`/`ast_grep`/`lsp` 调用为零,无论文本怎么声称,裁决都会被拦下。
59
+ - **计划 critic 门禁** — 计划不能跳过 architect/critic 步骤:`PlanSchema` 会拒绝以未验证变更结束的计划(变更之前的 verifier 不算),并在 `ralplan` 起草阶段以及 `team`/`approve` 执行阶段生效。critic 裁决需要观察到仓库读取/搜索证据;这提高可审查性,但不等于独立验证工作区。
58
60
  - **安全边界自动模型回退** — 未分类的安全拒绝(可能是分类器误报,而非真实的内容策略命中)现在会切换到真正不同提供商的模型,而不是在同一个模型上无限退让 — 与现有的 rate-limit 快速回退是同一套模式。`Refusal (<category>)` 这种形态的确定性拒绝不受影响,仍然零回退硬失败。
59
61
  - **内存: 应得的信任** — 概念的验证日期现在只在蒸馏阶段被明确标记为已验证时才会写入,而不是每次写入都记录;`isConceptStale` 把未验证(或超过 30 天过期)的概念视为需要重新验证,而不是信任一个被动的时间戳。
60
62
  - **动态工作流(`eval` 工具)** — 围绕子代理派遣编写真正的 JS 控制流: `task(role, taskText, context?)`、`parallel(thunks)`、`pipeline(items, ...stages)`、`log(message)`,组合出 `task` 单阶段 `tasks[]` 批次无法表达的顺序/分支编排。运行在隔离的 Worker 线程中,具备真正的抢占式超时(`worker.terminate()`,而非同进程内的竞争) — 与 `bash` 同等的全进程信任级别,不假装沙箱,受同一个 interview 变更锁门控。
@@ -70,7 +72,6 @@ bun install -g jeo-code
70
72
  jeo --version
71
73
  ```
72
74
 
73
- > 从重命名前的版本升级?旧 CLI 名称 `joc` 的二进制文件现在会被 `scripts/install.sh` / `scripts/uninstall.sh` 自动移除;手动移除: `rm -f ~/.local/bin/joc ~/.bun/bin/joc`。
74
75
 
75
76
  ## 快速开始
76
77
 
@@ -155,7 +156,7 @@ jeo launch --model agy/gemini-4-argon-low --plan --no-tui --no-session --no-skil
155
156
 
156
157
  ## Spec-first 工作流
157
158
 
158
- 需求 → 计划 → 批准 → 执行 → 验证,经由 `.jeo/state/` 串联,每次交接都有**可阻断的真实门禁**:
159
+ 需求 → 计划 → 批准 → 执行 → 验证由 `.jeo/state/` 管理。各阶段门禁可以阻止相应流程,但不保证每次交接或最终完成结果都正确。
159
160
 
160
161
  ```bash
161
162
  jeo deep-interview "描述你想构建的东西"
@@ -164,6 +165,8 @@ jeo approve <计划路径>
164
165
  jeo team
165
166
  jeo ultragoal
166
167
  ```
168
+ ![Interview → reviewed plan → approval → execution → verification](assets/workflow.svg)
169
+
167
170
  ```
168
171
  ┌──────────────────────┐
169
172
  │ deep-interview │ Socratic ambiguity gate · seed frozen when concrete
@@ -186,15 +189,17 @@ jeo ultragoal
186
189
  │ all tasks done
187
190
  ▼
188
191
  ┌──────────────────────┐
189
- │ ultragoal │ Honest verification — suite once, no fabrication
192
+ │ ultragoal │ 基于证据的报告 — 套件运行一次,不伪造逐项通过
190
193
  └──────────────────────┘
191
194
  ```
192
195
 
193
196
  - **deep-interview** — 基于歧义度评分的苏格拉底循环;只有标准足够具体才冻结种子(纯含糊标准会被拒绝),且种子必须通过自身解析器的往返校验。新想法绝不会静默复用已完成的访谈。
194
197
  - **ralplan** — 起草阶段 + **真正读取仓库的 critic 子代理门禁**: 强制并持久化 `[OKAY]`/`[ITERATE]`/`[REJECT]` 裁决。无效计划(schema、未知角色)不会被标记为 complete。
195
198
  - **approve** — 校验 `team` 执行的确切契约(schema+角色),并要求持久化的 `[OKAY]` 共识裁决。
196
- - **team** — 串行计划执行器: 跨进程运行锁、过期计划重置、按任务的子代理契约、父侧变更审计(零写入的"完成"会被标记)、失败标记 + 恢复时的部分编辑警告。
197
- - **ultragoal** — 诚实验证: 套件作为全局信号只运行一次,标准只被记录,绝不伪造为逐条通过。
199
+ - **team** — 在跨进程运行锁下执行已审阅的步骤。具有相同 `parallel_group` 的连续步骤在独立 Git worktree 中并行执行并按顺序合并;任务契约、变更审计和失败标记会暴露未完成的工作。
200
+ - **ultragoal** — 基于证据的报告:套件运行一次作为全局信号,不伪造逐项通过。这不能证明测试运行器真实可信或工作区满足目标。
201
+
202
+ `/goal` 会通过单独的 LLM 调用判断对话和已记录的工具历史,并对当前回合的修改与新验证信号应用确定性门禁。它不会重新读取工作区,也不会验证测试输出是否真实;launch 完成门禁也不检查仍在运行的后台任务。
198
203
 
199
204
  ## 验证钩子(自我修正)
200
205
 
@@ -416,10 +421,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
416
421
 
417
422
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
418
423
  - **[0.12.10]** (2026-11-02)
424
+ - **[0.12.11]** (2026-11-02)
419
425
  - **[0.12.9]** (2026-11-02)
420
426
  - **[0.12.7]** (2026-10-18) — Repair desktop screenshot vision feedback, macOS scrolling, complete drag gestures, and keyboard normalization.
421
427
  - **[0.12.6]** (2026-10-17) — Preserve OpenAI subscription authentication behind configured proxies, isolate account catalogs, and give provider-specific recovery instructions while retaining Antigravity OAuth calls.
422
- - **[0.12.5]** (2026-10-17) — Antigravity and Kimi provider support in interactive setup, Kimi live model cache rehydration, immediate discovery caching on provider probing.
423
428
 
424
429
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
425
430
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
+
6
7
  "main": "src/cli.ts",
7
8
  "bin": {
8
9
  "jeo": "src/cli.ts"
@@ -71,6 +71,7 @@ export function createAstEditTool(): ToolHandler {
71
71
 
72
72
  const perFile: { file: string; replaced: number }[] = [];
73
73
  let totalReplaced = 0;
74
+ let mutationMayHaveOccurred = false;
74
75
 
75
76
  for (const abs of files) {
76
77
  let original: string;
@@ -107,8 +108,9 @@ export function createAstEditTool(): ToolHandler {
107
108
 
108
109
  const rel = path.relative(cwd, abs) || abs;
109
110
  const res = await writeTool(rel, newText, cwd);
111
+ if (res.success) mutationMayHaveOccurred = true;
110
112
  if (!res.success) {
111
- return { success: false, output: "", error: `${rel}: ${res.error ?? "write failed"}` };
113
+ return { success: false, output: "", error: `${rel}: ${res.error ?? "write failed"}`, mutationMayHaveOccurred: res.mutationMayHaveOccurred === true || mutationMayHaveOccurred };
112
114
  }
113
115
  perFile.push({ file: rel, replaced: selected.length });
114
116
  totalReplaced += selected.length;
@@ -121,6 +121,40 @@ const READONLY_TOOL_NAMES: Record<string, true> = {
121
121
  web_search: true,
122
122
  calc: true,
123
123
  };
124
+ /** Invoked tools that can change the workspace and therefore invalidate old verification. */
125
+ const MUTATING_TOOL_NAMES: Record<string, true> = {
126
+ write: true,
127
+ edit: true,
128
+ mkdir: true,
129
+ delete: true,
130
+ ast_edit: true,
131
+ lsp_rename: true,
132
+ eval: true,
133
+ };
134
+ /** Debug actions that execute or alter the target process rather than inspect it. */
135
+ const MUTATING_DEBUG_ACTIONS: Record<string, true> = {
136
+ launch: true,
137
+ continue: true,
138
+ step_over: true,
139
+ step_in: true,
140
+ step_out: true,
141
+ pause: true,
142
+ evaluate: true,
143
+ terminate: true,
144
+ write_memory: true,
145
+ custom_request: true,
146
+ };
147
+ /** Computer actions affect external UI state; only observation and waiting are read-only. */
148
+ function computerActionMayAffectState(action: unknown): boolean {
149
+ if (!action || typeof action !== "object") return true;
150
+ const value = action as { action?: unknown; actions?: unknown };
151
+ if (value.action === "screenshot" || value.action === "wait") return false;
152
+ if (value.action === "batch") {
153
+ return !Array.isArray(value.actions) || value.actions.some(computerActionMayAffectState);
154
+ }
155
+ return true;
156
+ }
157
+
124
158
  /** Tool-protocol description injected into the system prompt. */
125
159
  export const TOOL_PROTOCOL = [
126
160
  "You have these tools (call exactly ONE per step, or batch multiple independent calls):",
@@ -301,6 +335,8 @@ export interface AgentLoopEvents {
301
335
  reason: string,
302
336
  evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean }
303
337
  ): Promise<string | null> | string | null;
338
+ /** Updated after each executed tool batch so callers can persist mutation evidence across turns. */
339
+ onEvidence?(evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean }): Promise<void> | void;
304
340
 
305
341
  /** Fired when a mid-turn steering message (an additional user query typed while
306
342
  * the turn is running) is injected into the live history. `text` is the raw
@@ -1216,7 +1252,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1216
1252
  let success: boolean;
1217
1253
  let output: string;
1218
1254
  let images: import("../ai/types").ImageAttachment[] | undefined;
1219
-
1255
+ let mutationMayHaveOccurred: boolean | undefined;
1220
1256
  if (tool === "done") {
1221
1257
  success = false;
1222
1258
  output = "Error: 'done' can only be called as the single tool invocation, not in a batch. Please send 'done' alone.";
@@ -1245,14 +1281,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1245
1281
  success = res.success;
1246
1282
  images = res.images;
1247
1283
  output = res.success ? res.output : (res.error ? (res.output ? `${res.error}\n${res.output}` : res.error) : res.output);
1248
- } catch (err: any) {
1284
+ mutationMayHaveOccurred = res.mutationMayHaveOccurred;
1285
+ } catch (err: unknown) {
1249
1286
  success = false;
1250
- output = err?.message || String(err);
1287
+ output = err instanceof Error ? err.message : String(err);
1288
+ const explicitSignal = err && typeof err === "object" && "mutationMayHaveOccurred" in err && err.mutationMayHaveOccurred === true;
1289
+ const knownMutatingCall =
1290
+ (MUTATING_TOOL_NAMES[tool] === true && (tool !== "lsp_rename" || args?.apply !== false)) ||
1291
+ tool === "bash" ||
1292
+ (tool === "debug" && MUTATING_DEBUG_ACTIONS[String(args?.action ?? "")] === true) ||
1293
+ (tool === "computer" && computerActionMayAffectState(args)) ||
1294
+ ((tool === "monitor" || tool === "job") && String(args?.action ?? "").trim().toLowerCase() === "start");
1295
+ if (explicitSignal || knownMutatingCall) mutationMayHaveOccurred = true;
1251
1296
  }
1252
1297
  }
1253
1298
  }
1254
1299
  }
1255
- return { success, output, images };
1300
+ return { success, output, images, mutationMayHaveOccurred };
1256
1301
  };
1257
1302
 
1258
1303
  const WRITE_TOOLS = new Set(["write", "edit"]);
@@ -1295,7 +1340,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1295
1340
  }
1296
1341
  }
1297
1342
 
1298
- const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[] }[] = Array.from(
1343
+ const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[]; mutationMayHaveOccurred?: boolean }[] = Array.from(
1299
1344
  { length: toolCalls.length },
1300
1345
  () => ({ success: false, output: "", executed: false })
1301
1346
  );
@@ -1398,11 +1443,11 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1398
1443
  if (executedIndices.length > 0) {
1399
1444
  await processAndPushResults(executedIndices);
1400
1445
  }
1401
- return finish({ done: false, steps: step, doneReason: "Cancelled." });
1446
+ } else {
1447
+ const allIndices = toolCalls.map((_, i) => i);
1448
+ await processAndPushResults(allIndices);
1402
1449
  }
1403
1450
 
1404
- const allIndices = toolCalls.map((_, i) => i);
1405
- await processAndPushResults(allIndices);
1406
1451
 
1407
1452
  // Score the budget window per CALL, not per batch: a batch of five failing
1408
1453
  // edits plus one trivial successful read must not look like a progressing
@@ -1411,22 +1456,75 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
1411
1456
  if (results[i].executed) budget.record(callSigs[i], results[i].success, callTargets[i]);
1412
1457
 
1413
1458
  }
1414
- // Only the latest recognized verification result is evidence; a later failure
1415
- // invalidates earlier success. Sequence numbers also reject tests before edits.
1459
+ // Process calls in execution order: a successful verification supersedes
1460
+ // mutation evidence before it, but anything after that verification remains
1461
+ // stale. Verification commands are shell calls, so do not let a wrapper's
1462
+ // mutationMayHaveOccurred flag turn the verifier itself into a later mutation.
1416
1463
  for (let i = 0; i < toolCalls.length; i++) {
1417
1464
  if (!results[i].executed) continue;
1418
1465
  const t = toolCalls[i].tool;
1419
- if ((t === "write" || t === "edit") && results[i].success) {
1466
+ const verificationCall = t === "bash" && isVerificationSignal(String(toolCalls[i].arguments?.command ?? toolCalls[i].arguments?.cmd ?? ""));
1467
+ if (verificationCall) {
1468
+ if (results[i].success) {
1469
+ sawVerification = true;
1470
+ lastVerificationSeq = ++mutVerSeq;
1471
+ } else {
1472
+ sawVerification = false;
1473
+ if (results[i].mutationMayHaveOccurred === true) {
1474
+ sawMutation = true;
1475
+ lastMutationSeq = ++mutVerSeq;
1476
+ }
1477
+ }
1478
+ continue;
1479
+ }
1480
+
1481
+ const output = String(results[i].output ?? "").trim();
1482
+ const noOpMutation = results[i].success && (
1483
+ (t === "ast_edit" && (output.startsWith("0 matches for pattern") || output.startsWith("No TypeScript/JavaScript files matched"))) ||
1484
+ (t === "lsp_rename" && output === "No rename locations found.")
1485
+ );
1486
+ const structuredMutation = results[i].mutationMayHaveOccurred === true && !noOpMutation;
1487
+ const builtInMutation = MUTATING_TOOL_NAMES[t] === true &&
1488
+ (t !== "lsp_rename" || toolCalls[i].arguments?.apply !== false) &&
1489
+ !noOpMutation;
1490
+ if (structuredMutation || builtInMutation) {
1491
+ // Structured mutation evidence from a delegated/control tool is itself
1492
+ // sufficient to invalidate prior verification, even when the wrapper
1493
+ // tool name is not one of the engine's built-in mutators. Successful
1494
+ // built-in mutators remain covered by the same branch.
1495
+ if (results[i].success || results[i].mutationMayHaveOccurred === true) {
1496
+ sawMutation = true;
1497
+ lastMutationSeq = ++mutVerSeq;
1498
+ }
1499
+ } else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
1500
+ sawMutation = true;
1501
+ lastMutationSeq = ++mutVerSeq;
1502
+ } else if (t === "computer" && computerActionMayAffectState(toolCalls[i].arguments)) {
1503
+ // Failed UI actions may have partially changed external state.
1504
+ sawMutation = true;
1505
+ lastMutationSeq = ++mutVerSeq;
1506
+ } else if (t === "monitor" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
1507
+ // Starting a background monitor can mutate the workspace asynchronously;
1508
+ // read-only monitor controls (list/tail/await/cancel) do not invalidate verification.
1509
+ sawMutation = true;
1510
+ lastMutationSeq = ++mutVerSeq;
1511
+ } else if (t === "job" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
1512
+ // Starting a background shell may mutate the workspace asynchronously;
1513
+ // read-only job controls (list/tail/await/cancel) do not invalidate verification.
1420
1514
  sawMutation = true;
1421
1515
  lastMutationSeq = ++mutVerSeq;
1422
1516
  } else if (t === "bash") {
1423
- const cmd = String(toolCalls[i].arguments?.command ?? "");
1424
- if (isVerificationSignal(cmd, results[i].output)) {
1425
- sawVerification = results[i].success;
1426
- lastVerificationSeq = ++mutVerSeq;
1427
- }
1517
+ // Non-verification shell commands may mutate files even when they exit unsuccessfully.
1518
+ sawMutation = true;
1519
+ lastMutationSeq = ++mutVerSeq;
1428
1520
  }
1429
1521
  }
1522
+ await ev.onEvidence?.({
1523
+ sawMutation,
1524
+ sawVerification,
1525
+ verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
1526
+ });
1527
+ if (aborted) return finish({ done: false, steps: step, doneReason: "Cancelled." });
1430
1528
  // F6 (round 4 architect, Low): judge the step by its NON-TRIVIAL calls — a
1431
1529
  // batch of read(ok)+edit(fail) repeated with varying targets previously
1432
1530
  // never tripped MAX_FAILURES because the trivial read reset the streak.
@@ -35,7 +35,13 @@ export function createGoalTool(): ToolHandler {
35
35
  if (!condition) {
36
36
  return { success: false, output: "", error: `goal {action:"set"} requires a non-empty 'condition'.` };
37
37
  }
38
- await writeGoalState({ condition, setAt: Date.now(), verdicts: [] }, cwd);
38
+ const previous = await readGoalState(cwd);
39
+ await writeGoalState({
40
+ condition,
41
+ setAt: Date.now(),
42
+ verdicts: [],
43
+ ...(previous?.pendingMutationVerification ? { pendingMutationVerification: true } : {}),
44
+ }, cwd);
39
45
  return {
40
46
  success: true,
41
47
  output: `Goal set: "${condition}". It will be auto-verified against the conversation each time you call done.`,
@@ -1,3 +1,5 @@
1
+ import { randomUUID } from "node:crypto";
2
+
1
3
  import { callLlm, type Message } from "./loop";
2
4
 
3
5
  export interface GoalVerdict {
@@ -90,26 +92,25 @@ export interface TurnEvidence {
90
92
  sawMutation: boolean;
91
93
  sawVerification: boolean;
92
94
  verificationStale: boolean;
95
+ /** Mutation evidence persisted from an earlier user turn. */
96
+ pendingMutation?: boolean;
93
97
  }
94
98
 
95
99
  /**
96
100
  * Deterministic downgrade gate: an LLM-judged MET verdict is trustworthy only when
97
- * the turn either made no file changes, or made changes AND a fresh (non-stale)
98
- * verification signal (test/build/typecheck/lint) was observed. A turn that mutated
99
- * files with no verification at all, or whose only verification predates the last
100
- * mutation, cannot self-report MET on the transcript alone — that is exactly the
101
- * "gate theater" failure mode (an LLM asserting success without re-checked evidence).
102
- * NOT_MET/IMPOSSIBLE verdicts pass through unchanged: this only ever tightens MET,
103
- * never loosens a verdict the LLM itself already found lacking.
101
+ * no workspace mutation is pending, or a fresh verification signal follows the
102
+ * most relevant mutation. NOT_MET/IMPOSSIBLE pass through unchanged; this only
103
+ * ever tightens MET, never loosens a verdict the LLM itself already found lacking.
104
104
  */
105
105
  export function applyEvidenceGate(verdict: GoalVerdict, evidence: TurnEvidence): GoalVerdict {
106
106
  if (verdict.verdict !== "MET") return verdict;
107
- if (!evidence.sawMutation) return verdict; // nothing changed — the transcript judgment stands
108
- if (evidence.sawVerification && !evidence.verificationStale) return verdict; // fresh verification backs the claim
107
+ const sawMutation = evidence.sawMutation || evidence.pendingMutation === true;
108
+ if (!sawMutation) return verdict;
109
+ if (evidence.sawVerification && !evidence.verificationStale) return verdict;
109
110
 
110
111
  const gap = !evidence.sawVerification
111
- ? "the turn modified files but no test/build/typecheck/lint run was observed"
112
- : "the turn's only verification run predates its last file modification (stale evidence)";
112
+ ? "workspace changes are pending but no test/build/typecheck/lint run was observed"
113
+ : "the only verification run predates the last workspace modification (stale evidence)";
113
114
  return {
114
115
  verdict: "NOT_MET",
115
116
  reason:
@@ -129,8 +130,78 @@ export interface GoalState {
129
130
  verdict: "MET" | "NOT_MET" | "IMPOSSIBLE";
130
131
  gap?: string;
131
132
  }>;
133
+ /** Backward-compatible persisted evidence that a mutation still needs verification. */
134
+ pendingMutationVerification?: boolean;
135
+ }
136
+
137
+ export class GoalStateCorruptError extends Error {
138
+ constructor(message: string) {
139
+ super(message);
140
+ this.name = "GoalStateCorruptError";
141
+ }
142
+ }
143
+
144
+ function isRecord(value: unknown): value is Record<string, unknown> {
145
+ return typeof value === "object" && value !== null && !Array.isArray(value);
146
+ }
147
+ function isGoalVerdict(value: unknown): value is GoalState["verdicts"][number] {
148
+ return (
149
+ isRecord(value) &&
150
+ typeof value.at === "number" &&
151
+ Number.isFinite(value.at) &&
152
+ (value.verdict === "MET" || value.verdict === "NOT_MET" || value.verdict === "IMPOSSIBLE") &&
153
+ (value.gap === undefined || typeof value.gap === "string")
154
+ );
132
155
  }
133
156
 
157
+
158
+ function parseGoalState(data: string, filePath: string): GoalState {
159
+ let value: unknown;
160
+ try {
161
+ value = JSON.parse(data);
162
+ } catch {
163
+ throw new GoalStateCorruptError(`Persisted goal state is malformed JSON: ${filePath}`);
164
+ }
165
+
166
+ if (
167
+ !isRecord(value) ||
168
+ typeof value.condition !== "string" ||
169
+ value.condition.trim().length === 0 ||
170
+ typeof value.setAt !== "number" ||
171
+ !Number.isFinite(value.setAt) ||
172
+ !Array.isArray(value.verdicts) ||
173
+ (value.pendingMutationVerification !== undefined && typeof value.pendingMutationVerification !== "boolean")
174
+ ) {
175
+ throw new GoalStateCorruptError(`Persisted goal state has invalid required fields: ${filePath}`);
176
+ }
177
+
178
+ const verdicts: GoalState["verdicts"] = [];
179
+ for (const verdict of value.verdicts) {
180
+ if (!isGoalVerdict(verdict)) {
181
+ throw new GoalStateCorruptError(`Persisted goal state has an invalid verdict entry: ${filePath}`);
182
+ }
183
+ verdicts.push(verdict);
184
+ }
185
+
186
+ return {
187
+ condition: value.condition,
188
+ setAt: value.setAt,
189
+ verdicts,
190
+ ...(value.pendingMutationVerification === undefined
191
+ ? {}
192
+ : { pendingMutationVerification: value.pendingMutationVerification }),
193
+ };
194
+ }
195
+
196
+ /** Apply this turn's evidence to the persisted pending-mutation flag. */
197
+ export function applyPendingEvidence(state: GoalState, evidence: TurnEvidence): GoalState {
198
+ const pendingMutationVerification = evidence.sawVerification && !evidence.verificationStale
199
+ ? false
200
+ : state.pendingMutationVerification === true || evidence.sawMutation;
201
+ return { ...state, pendingMutationVerification };
202
+ }
203
+
204
+
134
205
  import * as path from "node:path";
135
206
  import * as fs from "node:fs/promises";
136
207
  import { getLocalJeoDir } from "./state";
@@ -141,18 +212,26 @@ export function getGoalPath(cwd: string = process.cwd()): string {
141
212
 
142
213
  export async function readGoalState(cwd: string = process.cwd()): Promise<GoalState | null> {
143
214
  const p = getGoalPath(cwd);
215
+ let data: string;
144
216
  try {
145
- const data = await fs.readFile(p, "utf-8");
146
- return JSON.parse(data) as GoalState;
147
- } catch {
148
- return null;
217
+ data = await fs.readFile(p, "utf-8");
218
+ } catch (error) {
219
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
220
+ throw error;
149
221
  }
222
+ return parseGoalState(data, p);
150
223
  }
151
224
 
152
225
  export async function writeGoalState(state: GoalState, cwd: string = process.cwd()): Promise<void> {
153
226
  const p = getGoalPath(cwd);
154
227
  await fs.mkdir(path.dirname(p), { recursive: true });
155
- await fs.writeFile(p, JSON.stringify(state, null, 2), "utf-8");
228
+ const tempPath = `${p}.${randomUUID()}.tmp`;
229
+ try {
230
+ await fs.writeFile(tempPath, JSON.stringify(state, null, 2), { encoding: "utf-8", flag: "wx" });
231
+ await fs.rename(tempPath, p);
232
+ } finally {
233
+ await fs.unlink(tempPath).catch(() => {});
234
+ }
156
235
  }
157
236
 
158
237
  export async function clearGoalState(cwd: string = process.cwd()): Promise<void> {
@@ -69,19 +69,20 @@ export const GUARD_LIMITS = Object.freeze({
69
69
  });
70
70
 
71
71
  /**
72
- * Commands (or their output) that count as a verification signal: a test, build,
73
- * typecheck, or lint invocation. The done-verification guard treats a turn that mutated
74
- * files without any such signal as "unverified".
72
+ * Recognized standalone verification commands. Matching the full command prevents
73
+ * filenames, prose, and shell output from impersonating verification evidence.
75
74
  */
76
- export const VERIFY_SIGNAL_RE = /\b(test|tests|tsc|typecheck|lint|build|check|spec|pytest|vitest|jest)\b/i;
75
+ export const VERIFY_SIGNAL_RE = /^(?:(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?(?:test|tests|typecheck|lint|build|check|spec|tsc|eslint|vitest|jest)(?::[\w-]+)?|(?:npx|bunx)\s+(?:tsc|eslint|vitest|jest|pytest)|(?:tsc|eslint|vitest|jest|pytest)|(?:python(?:3(?:\.\d+)?)?|uv)\s+(?:-m\s+)?pytest|cargo\s+(?:test|check|build|clippy)|go\s+(?:test|vet|build)|(?:make|just)\s+(?:test|check|lint|build|typecheck))(?:\s+[A-Za-z0-9_./:@+*?,=-]+)*$/i;
76
+ const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--list(?:-tests|Tests)?|-list|--collect-only|--show-?config|--init|--dry-run|--no-run|--passWithNoTests)(?=$|\s|=)/i;
77
+ const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
77
78
 
78
79
  /**
79
- * True when a bash command (or the head of its output) proves the work was verified.
80
- * Output is examined only up to the first 2000 chars — enough to catch a tool runner's
81
- * banner without rescanning a megabyte of logs.
80
+ * True only when the command itself is a recognized verifier invocation. Output
81
+ * is never evidence: arbitrary commands can print words such as "tests passed".
82
82
  */
83
- export function isVerificationSignal(cmd: string, output = ""): boolean {
84
- return VERIFY_SIGNAL_RE.test(cmd) || VERIFY_SIGNAL_RE.test(output.slice(0, 2000));
83
+ export function isVerificationSignal(cmd: string): boolean {
84
+ const command = cmd.trim();
85
+ return !/[;&|<>`$(){}\n\r]/.test(command) && VERIFY_SIGNAL_RE.test(command) && !NONEXECUTING_VERIFY_FLAG_RE.test(command) && !MUTATING_VERIFY_FLAG_RE.test(command);
85
86
  }
86
87
 
87
88
  /**
@@ -75,6 +75,7 @@ export function createLspRenameTool(): ToolHandler {
75
75
 
76
76
  const preview: string[] = [];
77
77
  let totalEdits = 0;
78
+ let mutationMayHaveOccurred = false;
78
79
  for (const [fName, spans] of byFile) {
79
80
  const rel = path.relative(cwd, fName) || fName;
80
81
  let content: string;
@@ -96,7 +97,8 @@ export function createLspRenameTool(): ToolHandler {
96
97
  newText = newText.slice(0, span.start) + newName + newText.slice(span.start + span.length);
97
98
  }
98
99
  const res = await writeTool(rel, newText, cwd);
99
- if (!res.success) return err(`${rel}: ${res.error ?? "write failed"}`);
100
+ if (!res.success) return { ...err(`${rel}: ${res.error ?? "write failed"}`), mutationMayHaveOccurred: res.mutationMayHaveOccurred === true || mutationMayHaveOccurred };
101
+ mutationMayHaveOccurred = true;
100
102
  }
101
103
  }
102
104
 
@@ -598,6 +598,7 @@ export class DiscordDaemon {
598
598
  if (!session) { await emit("Session not found or prefix ambiguous. Use /sessions and a unique ID."); return false; }
599
599
  const body = command[3]!.trim();
600
600
  if (command[1] === "send") return sendPrompt(session, body);
601
+
601
602
  const args = command[1] === "steer" ? /^(\S+)\s+([\s\S]+)$/.exec(body) : /^(\S+)$/.exec(body);
602
603
  if (!args) { await emit(HELP); return false; }
603
604
  if (!session.controlEnabled) { await emit("Discord remote control is disabled in this session.", session.sessionId); return false; }
@@ -606,7 +607,8 @@ export class DiscordDaemon {
606
607
  const delivered = await emit(ok ? "Session acknowledged command." : "Command not acknowledged (unavailable, rejected or timed out).", session.sessionId);
607
608
  return ok && delivered;
608
609
  }
609
- if (text.startsWith("/")) { await emit(HELP); return false; }
610
+ if (text.startsWith("/") || text.startsWith("!")) { await emit(HELP); return false; }
611
+
610
612
  const reference = object(message.message_reference) ? message.message_reference : undefined;
611
613
  const reply = reference && typeof reference.message_id === "string" && (reference.channel_id === undefined || reference.channel_id === message.channel_id)
612
614
  ? this.replies.get(reference.message_id) : undefined;
@@ -7,6 +7,7 @@
7
7
  import * as path from "node:path";
8
8
  import * as os from "node:os";
9
9
  import { jeoEnv } from "../../util/env";
10
+ import { createHash } from "node:crypto";
10
11
 
11
12
  /** Root directory for all local jeo state, resolved at call time (not import
12
13
  * time) so a `JEO_CONFIG_DIR` override or runtime `HOME` change is honored. */
@@ -46,3 +47,10 @@ export function notifyDaemonLockPath(): string {
46
47
  export function notifyDaemonLogPath(): string {
47
48
  return path.join(notifyDir(), "daemon.log");
48
49
  }
50
+
51
+ /** Per-bot/per-chat Telegram update cursor. Hashing the tuple keeps chat IDs
52
+ * (including negative group IDs) out of path syntax and prevents collisions. */
53
+ export function notifyTelegramUpdateOffsetPath(botId: string, chatId: string): string {
54
+ const key = createHash("sha256").update(JSON.stringify([botId, chatId])).digest("hex");
55
+ return path.join(notifyDir(), `telegram-offset-${key}.json`);
56
+ }