jeo-code 0.11.2 → 0.11.3

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/src/autopilot.ts CHANGED
@@ -180,6 +180,7 @@ export function foldBest(goal: Goal, best: number | undefined, score: number): n
180
180
  /**
181
181
  * Single source of truth for the ratchet keep/revert decision. Shared by step,
182
182
  * loop, and status so they can never diverge.
183
+ * - every goal: a failed eval always reverts, regardless of its printed score.
183
184
  * - gate goal: keep iff the eval passed (score is irrelevant).
184
185
  * - min/max goal: a non-measurable (NaN) score can never prove improvement, so
185
186
  * it is always reverted; otherwise keep iff it improves on the best so far.
@@ -190,7 +191,8 @@ export function decideStep(
190
191
  passed: boolean,
191
192
  best: number | undefined,
192
193
  ): "keep" | "revert" {
193
- if (goal === "gate") return passed ? "keep" : "revert";
194
+ if (!passed) return "revert";
195
+ if (goal === "gate") return "keep";
194
196
  if (Number.isNaN(score)) return "revert";
195
197
  return isImprovement(goal, score, best) ? "keep" : "revert";
196
198
  }
@@ -255,8 +257,11 @@ function cmdStep(flags: Record<string, string>): void {
255
257
  if (decision === "revert" && flags["on-revert"]) {
256
258
  try {
257
259
  execSync(flags["on-revert"], { stdio: "inherit", shell: getShell() });
258
- } catch {
259
- console.error("jeo autopilot: --on-revert hook failed (decision still logged)");
260
+ } catch (error) {
261
+ appendLog({ type: "stop", reason: "rollback_failed", change, score, passed, prevBest: best ?? null, output, error: String(error) });
262
+ console.error(`jeo autopilot: stop — --on-revert hook failed: ${String(error)}`);
263
+ process.exitCode = 1;
264
+ return;
260
265
  }
261
266
  }
262
267
 
@@ -295,6 +300,7 @@ function cmdLoop(flags: Record<string, string>): void {
295
300
  if (!runnerOk) {
296
301
  appendLog({ type: "stop", reason: "runner_failed", iteration: i });
297
302
  console.log(`jeo autopilot: stop — runner failed at iteration ${i}`);
303
+ process.exitCode = 1;
298
304
  return;
299
305
  }
300
306
 
@@ -304,8 +310,11 @@ function cmdLoop(flags: Record<string, string>): void {
304
310
  if (decision === "revert" && flags["on-revert"]) {
305
311
  try {
306
312
  execSync(flags["on-revert"], { stdio: "inherit", shell: getShell() });
307
- } catch {
308
- /* logged below regardless */
313
+ } catch (error) {
314
+ appendLog({ type: "stop", reason: "rollback_failed", iteration: i, change: `loop#${i}`, score, passed, prevBest: best ?? null, output, error: String(error) });
315
+ console.error(`jeo autopilot: stop — --on-revert hook failed at iteration ${i}: ${String(error)}`);
316
+ process.exitCode = 1;
317
+ return;
309
318
  }
310
319
  }
311
320
  appendLog({ type: "step", iteration: i, change: `loop#${i}`, score, passed, decision, prevBest: best ?? null, output });
package/src/cli/runner.ts CHANGED
@@ -112,8 +112,8 @@ export const COMMANDS: readonly CommandSpec[] = [
112
112
  },
113
113
  {
114
114
  name: "notify",
115
- summary: "Configure/inspect remote subagent notifications over Telegram (gjc notify parity).",
116
- usage: "notify [setup [--token <t> --chat-id <id>]|status]",
115
+ summary: "Configure/inspect Telegram, Discord, and Slack notification channels.",
116
+ usage: "notify [setup|status|health|test] [--provider telegram|discord|slack] [--token-env NAME] [--app-token-env NAME] [--chat-id ID|--channel-id ID] [--allowed-user-ids IDS]",
117
117
  loader: async () => {
118
118
  const m = await import("../commands/notify");
119
119
  return args => m.runNotifyCommand(args);
@@ -121,7 +121,7 @@ export const COMMANDS: readonly CommandSpec[] = [
121
121
  },
122
122
  {
123
123
  name: "daemon",
124
- summary: "Manage the background Telegram notification/subagent-control daemon.",
124
+ summary: "Manage the shared Telegram/Discord/Slack notification daemon.",
125
125
  usage: "daemon [status|start|stop|reload]",
126
126
  loader: async () => {
127
127
  const m = await import("../commands/daemon");
@@ -197,7 +197,7 @@ export const COMMANDS: readonly CommandSpec[] = [
197
197
  },
198
198
  {
199
199
  name: "notify-daemon-run",
200
- summary: "(internal) Foreground worker for the Telegram notification daemon, spawned by 'jeo daemon start'.",
200
+ summary: "(internal) Foreground notification worker, spawned by 'jeo daemon start'.",
201
201
  usage: "notify-daemon-run",
202
202
  loader: async () => {
203
203
  const m = await import("../agent/notify/telegram-daemon");
@@ -67,11 +67,6 @@ export async function approvePlan(planPathInput: string, cwd: string): Promise<A
67
67
  };
68
68
  }
69
69
 
70
- // Idempotency: check if already approved
71
- if (ralplanState.approved) {
72
- return { ok: true, message: `[SUCCESS] Plan is already approved.` };
73
- }
74
-
75
70
  // Round-10 #4 (architect ref 8-Round10Planning): approval is a GATE, not a
76
71
  // rubber stamp — validate the plan against the exact contract `jeo team`
77
72
  // enforces, so a schema-invalid/unknown-role plan is refused HERE instead of
@@ -118,17 +113,27 @@ export async function approvePlan(planPathInput: string, cwd: string): Promise<A
118
113
  };
119
114
  }
120
115
 
121
- // Round-13: verify the plan's hash matches the consensus hash to prevent silent edits
122
- if (ralplanState.consensus_hash) {
123
- const currentHash = createHash("sha256").update(planContent).digest("hex");
124
- if (currentHash !== ralplanState.consensus_hash) {
125
- return {
126
- ok: false,
127
- message:
128
- `[ERROR] Refusing to approve: the plan file has been modified since the consensus critic reviewed it.\n` +
129
- ` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve again.`,
130
- };
131
- }
116
+ // Approval and idempotent re-approval both require the exact reviewed bytes.
117
+ if (!ralplanState.consensus_hash) {
118
+ return {
119
+ ok: false,
120
+ message:
121
+ `[ERROR] Refusing to approve: the plan lacks a consensus review hash.\n` +
122
+ ` Re-run 'jeo ralplan' so the consensus critic can review the plan, then approve again.`,
123
+ };
124
+ }
125
+ const currentHash = createHash("sha256").update(planContent).digest("hex");
126
+ if (currentHash !== ralplanState.consensus_hash) {
127
+ return {
128
+ ok: false,
129
+ message:
130
+ `[ERROR] Refusing to approve: the plan file has been modified since the consensus critic reviewed it.\n` +
131
+ ` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve again.`,
132
+ };
133
+ }
134
+
135
+ if (ralplanState.approved) {
136
+ return { ok: true, message: `[SUCCESS] Plan is already approved.` };
132
137
  }
133
138
 
134
139
  // Update ralplan-state.json to approved: true
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `jeo daemon status|start|stop|reload` — manage the background Telegram
2
+ * `jeo daemon status|start|stop|reload` — manage the shared Telegram/Discord/Slack
3
3
  * notification/subagent-control daemon (gjc `gjc daemon` parity, scoped to
4
4
  * jeo's one daemon kind). See `src/agent/notify/daemon-control.ts`.
5
5
  */
@@ -10,8 +10,10 @@ async function printStatus(): Promise<void> {
10
10
  if (!status.configured) {
11
11
  console.log("notifications not configured — run 'jeo notify setup' first.");
12
12
  }
13
- if (status.running) {
14
- console.log(`running (pid ${status.pid}, started ${new Date(status.startedAt!).toISOString()})`);
13
+ if (status.pairing) {
14
+ console.log(`pairing owns polling (pid ${status.pid}); daemon not running`);
15
+ } else if (status.running) {
16
+ console.log(`process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}, started ${new Date(status.startedAt!).toISOString()}); platform connectivity not checked`);
15
17
  // Say so when the PID-reuse guard could not actually run. On a host with no `/proc`
16
18
  // and no usable `ps` (distroless images, seccomp-hardened sandboxes) "running" is an
17
19
  // existence-only guess: the pid is alive, but it may belong to an unrelated process
@@ -54,12 +54,12 @@ function providerDefaultModel(p: ProviderName): string {
54
54
  return openaiCompatDef(p)?.defaultModel ?? STATIC_PROVIDER_DEFAULT[p] ?? "";
55
55
  }
56
56
 
57
- // Pick-list entries for ONE provider, with static fallbacks so the list is
58
- // never empty. Pure function — mirrors launch.ts's exported `providerPickEntries`
57
+ // Pick-list entries for ONE provider. Antigravity requires live availability;
58
+ // other providers retain static fallbacks. Mirrors launch.ts's `providerPickEntries`
59
59
  // (duplicated here rather than imported to avoid a launch.ts <-> launch/*.ts cycle).
60
60
  function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
61
61
  const fromLive = flattenModels(live.filter(r => r.provider === want));
62
- if (fromLive.length) return fromLive;
62
+ if (fromLive.length || want === "antigravity") return fromLive;
63
63
  const catalog = catalogByProvider(want);
64
64
  if (catalog.length) {
65
65
  return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
@@ -253,6 +253,11 @@ export async function runAgentsSlash(input: string, ctx: AgentsSlashCtx): Promis
253
253
  const forProvider = providerPickEntries(live, want);
254
254
  const liveForProvider = live.some(r => r.ok && r.provider === want && r.models.length > 0);
255
255
  const explicit = tokens[3];
256
+ if (want === "antigravity" && !forProvider.length && (!explicit || explicit.startsWith("#"))) {
257
+ const reason = live.find(r => r.provider === want)?.error ?? "no account-available models returned";
258
+ console.log(`Cannot list ${want} models: ${reason}. Retry discovery or specify an explicit model id.`);
259
+ return result();
260
+ }
256
261
  let chosenModel: string;
257
262
  if (explicit && forProvider.length) {
258
263
  const sel = resolveSelection(forProvider, explicit);
@@ -335,22 +335,22 @@ function providerDefaultModel(p: ProviderName): string {
335
335
  }
336
336
 
337
337
  /**
338
- * Pick-list entries for ONE provider. OpenAI availability comes only from live
339
- * results; other providers retain their offline catalog fallbacks.
338
+ * Pick-list entries for ONE provider. OpenAI and Antigravity availability comes
339
+ * only from live results; other providers retain their offline catalog fallbacks.
340
340
  *
341
341
  * Live discovery yields ids only for a logged-in, reachable provider, and
342
342
  * `catalogOr` backfills the static catalog for OAuth sources and for API-key
343
343
  * providers whose models-list endpoint is absent (HTTP 404, e.g. Tencent MaaS).
344
344
  * A not-yet-configured provider still has an empty live list, so prefer live ids;
345
345
  * else the provider's capability catalog; else its single known default model (all
346
- * 24 OpenAI-compat providers carry one) so the user always sees at least one id.
346
+ * 24 OpenAI-compat providers carry one). Antigravity never uses these fallbacks.
347
347
  */
348
348
  export function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
349
349
  const fromLive = flattenModels(live.filter(r => r.provider === want));
350
350
  if (fromLive.length) return fromLive;
351
- // An OpenAI empty/error response is authoritative for this picker. Offering
352
- // the static capability catalog here would fabricate account availability.
353
- if (want === "openai") return [];
351
+ // An OpenAI or Antigravity empty/error response is authoritative for this picker.
352
+ // Offering the static capability catalog here would fabricate account availability.
353
+ if (want === "openai" || want === "antigravity") return [];
354
354
  const catalog = catalogByProvider(want);
355
355
  if (catalog.length) {
356
356
  return catalog.map((m, i) => ({ index: i + 1, provider: want, model: qualifyModelId(m.providerModel, want) }));
@@ -1519,15 +1519,6 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1519
1519
 
1520
1520
  const goalState = await readGoalState(cwd);
1521
1521
  if (goalState && goalState.condition) {
1522
- const reBlockCount = goalState.verdicts.filter(v => v.verdict === "NOT_MET" || v.verdict === "IMPOSSIBLE").length;
1523
- const MAX_RE_BLOCKS = 2;
1524
-
1525
- if (reBlockCount >= MAX_RE_BLOCKS) {
1526
- if (tui) tui.events().onNotice?.(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
1527
- else console.log(`[Goal Verifier] Re-block cap of ${MAX_RE_BLOCKS} reached. Auto-allowing done.`);
1528
- return null;
1529
- }
1530
-
1531
1522
  if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
1532
1523
  const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
1533
1524
  // Deterministic downgrade: a fresh MET from the transcript-only LLM
@@ -4197,35 +4188,36 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4197
4188
  // `rl`/`promptInput`'s `pendingRemoteInject`, and `pendingStdinLines`, all
4198
4189
  // established by now. See the Tier 2 contract's "Session-side routing".
4199
4190
  if (sessionNotifyEndpoint) {
4200
- sessionNotifyEndpoint.onUserMessage = (msg): void => {
4201
- void (async () => {
4202
- // Resolve any attached photo/document paths (already downloaded to
4203
- // local temp files by the daemon) into real ImageAttachments via the
4204
- // SAME path `attachImagePaths` uses for local drag-drop — one image
4205
- // ingestion mechanism, not a parallel one. Bare paths never contain
4206
- // whitespace here (daemon-generated temp names), so simple whitespace
4207
- // joining is a safe token boundary for the path-token scanner.
4208
- const withPaths = msg.imagePaths?.length ? `${msg.text} ${msg.imagePaths.join(" ")}`.trim() : msg.text;
4209
- const attached = await attachImagePaths(withPaths, pendingImages.length + 1);
4210
- const cleanedText = attached.images.length ? attached.text : msg.text;
4211
- if (attached.images.length) pendingImages.push(...attached.images);
4212
-
4213
- if (interactiveTurnActive && currentTurnSteer) {
4214
- // Busy: steer the running turn exactly like a local mid-turn Enter.
4215
- currentTurnSteer.push(cleanedText);
4216
- currentTurnSteer.flushCard(cleanedText);
4217
- return;
4218
- }
4219
- // Idle: never hijack text the user is actively typing locally — only
4220
- // abort+inject when the readline buffer is confirmed empty (verified
4221
- // empirically: aborting mid-partial-type corrupts the buffer).
4222
- const rli = rl as unknown as { line?: string };
4223
- if (rli.line === "" && pendingRemoteInject?.(cleanedText)) return;
4224
- // No question pending, or the user is mid-type: queue for the NEXT
4225
- // prompt cycle instead (same queue `pendingMidTurnCommands`/paste-merge
4226
- // already feed — consumed at the top of the next `promptInput` call).
4227
- pendingStdinLines.push(cleanedText);
4228
- })();
4191
+ sessionNotifyEndpoint.onUserMessage = async (msg): Promise<void | boolean> => {
4192
+ // Resolve any attached photo/document paths (already downloaded to
4193
+ // local temp files by the daemon) into real ImageAttachments via the
4194
+ // SAME path `attachImagePaths` uses for local drag-drop — one image
4195
+ // ingestion mechanism, not a parallel one. Bare paths never contain
4196
+ // whitespace here (daemon-generated temp names), so simple whitespace
4197
+ // joining is a safe token boundary for the path-token scanner.
4198
+ const withPaths = msg.imagePaths?.length ? `${msg.text} ${msg.imagePaths.join(" ")}`.trim() : msg.text;
4199
+ const attached = await attachImagePaths(withPaths, pendingImages.length + 1);
4200
+ const cleanedText = attached.images.length ? attached.text : msg.text;
4201
+ // Attachment normalization must not expose a remote slash command that
4202
+ // bypasses the local approval prompt or changes local execution policy.
4203
+ if (cleanedText.trimStart().startsWith("/")) return false;
4204
+ if (attached.images.length) pendingImages.push(...attached.images);
4205
+
4206
+ if (interactiveTurnActive && currentTurnSteer) {
4207
+ // Busy: steer the running turn exactly like a local mid-turn Enter.
4208
+ currentTurnSteer.push(cleanedText);
4209
+ currentTurnSteer.flushCard(cleanedText);
4210
+ return;
4211
+ }
4212
+ // Idle: never hijack text the user is actively typing locally — only
4213
+ // abort+inject when the readline buffer is confirmed empty (verified
4214
+ // empirically: aborting mid-partial-type corrupts the buffer).
4215
+ const rli = rl as unknown as { line?: string };
4216
+ if (rli.line === "" && pendingRemoteInject?.(cleanedText)) return;
4217
+ // No question pending, or the user is mid-type: queue for the NEXT
4218
+ // prompt cycle instead (same queue `pendingMidTurnCommands`/paste-merge
4219
+ // already feed — consumed at the top of the next `promptInput` call).
4220
+ pendingStdinLines.push(cleanedText);
4229
4221
  };
4230
4222
  sessionNotifyEndpoint.onConfigCommand = (cmd): void => {
4231
4223
  if (cmd.verbosity !== undefined) notifyVerbosity = cmd.verbosity;