@kybernesis/create 0.14.7 → 0.14.9

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.md CHANGED
@@ -85,7 +85,7 @@ Run inside an agent project. Checks, with pass/warn/fail per line:
85
85
 
86
86
  Exit code 1 on any failure — usable as a CI preflight.
87
87
 
88
- ## `kyb upgrade [--skip-eval] [--yes]`
88
+ ## `kyb upgrade [--host[=<ssh-target>]] [--skip-eval] [--yes]`
89
89
 
90
90
  Compares installed `@kybernesis/*` versions against npm, installs what's
91
91
  behind, typechecks, then **runs the eval suite as the gate** — prints
@@ -100,6 +100,23 @@ runs fail closed unless `--yes` (or `-y`) is supplied. The warning is still prin
100
100
  are migrated to `kyb-eval`; custom scripts are left untouched with a manual fix.
101
101
  The wrapper counts corruption diagnostics beside the eval result and turns a
102
102
  nominally green eve exit into a failure when condemned durable state was seen.
103
+ It also identifies failures from `judge.autoevals.*` assertions as judge
104
+ reachability or HTTP failures rather than implying that the agent model failed.
105
+
106
+ Immediately before a local gate, upgrade classifies the authored and compiled
107
+ model route the same way as `kyb doctor`, then checks host-only dependencies. A
108
+ Claude subscription agent must have its loopback proxy answering, and an
109
+ exe-backed agent or judge must be able to reach `EXE_LLM_URL` (or the internal
110
+ exe model catalog). If those routes are unavailable, the eval command is not
111
+ run and upgrade exits with status 2, prints that the gate can only run on the
112
+ host, and gives the exact ssh command. It does not print "Do NOT deploy" because
113
+ no eval exercised the code.
114
+
115
+ Use `kyb upgrade --host` to run the gate over ssh after the upgrade. The target
116
+ uses the same defaults as `kyb deploy`: `EVE_SSH_HOST`, then
117
+ `$EXE_VM_NAME.exe.xyz`; `--host=<ssh-target>` overrides them. The remote project
118
+ path is `~/<package-name>`, and the remote eval output streams directly to the
119
+ terminal. `--skip-eval` remains an explicit bypass and performs no preflight.
103
120
 
104
121
  On self-hosted agents, upgrade also reconciles package-owned host artifacts. It
105
122
  refreshes a stale `/etc/cron.daily/kyb-docker-prune` and an existing generated
package/dist/cli.js CHANGED
@@ -11,6 +11,8 @@
11
11
  * kyb deploy put this repo on its host and restart it, with proof
12
12
  * kyb upgrade bump @kybernesis/* to latest, gated on the eval suite
13
13
  * --skip-eval skip the eval gate (not for production changes)
14
+ * --host[=<target>] run the eval gate over ssh on the agent host
15
+ * --allow-stale proceed even when this kyb is behind npm
14
16
  * --yes, -y confirm an eve upgrade noninteractively
15
17
  */
16
18
  import { existsSync, readFileSync } from "node:fs";
@@ -52,6 +54,12 @@ function flag(rest, key) {
52
54
  const hit = rest.find((a) => a.startsWith(`--${key}=`));
53
55
  return hit ? hit.slice(key.length + 3) : undefined;
54
56
  }
57
+ function optionalValueFlag(rest, key) {
58
+ if (rest.includes(`--${key}`))
59
+ return true;
60
+ const value = flag(rest, key);
61
+ return value === undefined ? undefined : value;
62
+ }
55
63
  function initOptions(rest) {
56
64
  const subs = flag(rest, 'subagents');
57
65
  return {
@@ -75,7 +83,7 @@ const COMMANDS = {
75
83
  credential: "Write the agent credential onto a host (--local to stay here).",
76
84
  register: "Register this agent with the control plane (--name, --url).",
77
85
  deploy: "Deploy this agent to its host (--no-env to leave the env file alone).",
78
- upgrade: "Bring @kybernesis packages and eve to certified versions (--skip-eval, --yes).",
86
+ upgrade: "Bring @kybernesis packages and eve to certified versions (--host, --skip-eval, --allow-stale, --yes).",
79
87
  tui: "Talk to your agents in the terminal.",
80
88
  version: "Print the version of this tool.",
81
89
  };
@@ -101,6 +109,14 @@ function usage(command) {
101
109
 
102
110
  ${dim("Factory env: KYB_MODEL_REACH=claude-sub (the CLI flag takes precedence).")}
103
111
  ${dim("Claude subscription reach uses the loopback OAuth proxy; it does not read EXE_MODEL.")}\n`);
112
+ }
113
+ else if (command === "upgrade") {
114
+ console.log(` ${bold("kyb upgrade")} [options]
115
+ --host[=<target>] ${dim("run the eval gate over ssh; defaults like kyb deploy")}
116
+ --skip-eval ${dim("skip the eval gate")}
117
+ --allow-stale ${dim("continue even when this kyb is behind the published version")}
118
+ --yes, -y ${dim("confirm an eve version change noninteractively")}
119
+ `);
104
120
  }
105
121
  return;
106
122
  }
@@ -161,6 +177,8 @@ switch (command) {
161
177
  break;
162
178
  case "upgrade":
163
179
  await upgrade({
180
+ allowStale: rest.includes("--allow-stale"),
181
+ host: optionalValueFlag(rest, "host"),
164
182
  skipEval: rest.includes("--skip-eval"),
165
183
  yes: rest.includes("--yes") || rest.includes("-y"),
166
184
  });
@@ -228,7 +246,9 @@ ${dim(" npm i -g @kybernesis/create@latest")}
228
246
  --host=<target> ${dim("ssh target; defaults to $EXE_VM_NAME.exe.xyz")}
229
247
  --no-env ${dim("do not send .env.local (host manages its own secrets)")}
230
248
  ${bold("kyb upgrade")} bump @kybernesis/* packages, gated on evals
249
+ --host[=<target>] ${dim("run the eval gate over ssh; defaults to EVE_SSH_HOST or EXE_VM_NAME")}
231
250
  --skip-eval ${dim("skip the eval gate")}
251
+ --allow-stale ${dim("continue even when this kyb is behind the published version")}
232
252
  --yes, -y ${dim("confirm an eve version change noninteractively")}
233
253
 
234
254
  Registry: https://registry.kybernesis.ai · Docs: the FDE playbook
package/dist/deploy.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ /** The ssh target: an explicit flag, then EVE_SSH_HOST, then the exe VM name. */
2
+ export declare function sshTarget(dir: string, explicit?: string): string | null;
3
+ export declare function remoteProjectPath(dir: string): string;
1
4
  export declare function deploy(options: {
2
5
  host?: string;
3
6
  dir?: string;
package/dist/deploy.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
+ import { TEMPLATE_CONTAINER_PROBE, assessRestart, parseTemplateContainers } from "./sandbox-orphans.js";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { upsertEnv } from "./envfile.js";
@@ -33,7 +34,7 @@ function hostOf(dir) {
33
34
  return deps["@kybernesis/exe"] ? "exe" : "vercel";
34
35
  }
35
36
  /** The ssh target: an explicit flag, then EVE_SSH_HOST, then the exe VM name. */
36
- function sshTarget(dir, explicit) {
37
+ export function sshTarget(dir, explicit) {
37
38
  const e = env(dir);
38
39
  if (explicit)
39
40
  return explicit;
@@ -96,6 +97,10 @@ async function ensureModel(dir, target) {
96
97
  upsertEnv(dir, { EXE_MODEL: chosen });
97
98
  console.log(green(` ✓ EXE_MODEL="${chosen}" written to .env.local\n`));
98
99
  }
100
+ export function remoteProjectPath(dir) {
101
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
102
+ return `~/${pkg.name ?? "agent"}`;
103
+ }
99
104
  export async function deploy(options) {
100
105
  const dir = options.dir ?? process.cwd();
101
106
  if (!existsSync(join(dir, "agent"))) {
@@ -117,8 +122,7 @@ export async function deploy(options) {
117
122
  process.exitCode = 1;
118
123
  return;
119
124
  }
120
- const name = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name ?? "agent";
121
- const remote = `~/${name}`;
125
+ const remote = remoteProjectPath(dir);
122
126
  console.log(dim(` host: ${target} path: ${remote}\n`));
123
127
  await ensureModel(dir, target);
124
128
  const run = (cmd, args) => {
@@ -239,6 +243,14 @@ export async function deploy(options) {
239
243
  ' SCRIPT="scripts/eve-server.sh"',
240
244
  "fi",
241
245
  'if [ -z "$SCRIPT" ]; then echo "FAILED: no restart script on the host"; exit 1; fi',
246
+ // An orphaned sandbox template container (a start killed mid-build; only
247
+ // `sleep` inside) makes every eve start hang forever. The packaged start
248
+ // script clears it too, but a host still running its own older copy
249
+ // would not, so the deploy does it first and says so (KYB-531).
250
+ "for c in $(command -v docker >/dev/null 2>&1 && docker ps --filter name=eve-sbx-tpl- --format '{{.Names}}' 2>/dev/null); do",
251
+ " live=$(docker top \"$c\" 2>/dev/null | tail -n +2 | awk '{print $8}' | grep -vc '^sleep$')",
252
+ ' if [ "${live:-0}" = "0" ]; then echo "removing orphaned sandbox template container $c (only sleep inside; it would block every start)"; docker rm -f "$c" >/dev/null 2>&1 || true; fi',
253
+ "done",
242
254
  'echo "using $SCRIPT"',
243
255
  'setsid nohup bash "$SCRIPT" > /tmp/kyb-deploy.log 2>&1 < /dev/null &',
244
256
  "sleep 2",
@@ -266,23 +278,45 @@ export async function deploy(options) {
266
278
  let last = "";
267
279
  let seen = "";
268
280
  let lastChange = Date.now();
281
+ let lastBuildingNote = 0;
282
+ let stuck = null;
269
283
  while (Date.now() - startedAt < CEILING_MS) {
270
- const out = execFileSync("ssh", [target, `tail -40 /tmp/kyb-deploy.log 2>/dev/null || true`], {
271
- encoding: "utf8",
272
- });
273
- last = out;
274
- if (out !== seen) {
275
- for (const line of out.split("\n")) {
276
- if (line && !seen.includes(line) && /npm|install|build|SOURCE IS NEWER|waiting|pid=|health:|FAILED/i.test(line)) {
284
+ // Two witnesses: the log, and the sandbox template containers. A cold
285
+ // template build is silent in the log for longer than the quiet limit,
286
+ // and judging it on the log alone is how a fine build got killed (KYB-531).
287
+ const out = execFileSync("ssh", [target, `tail -40 /tmp/kyb-deploy.log 2>/dev/null || true; echo __TEMPLATES__; ${TEMPLATE_CONTAINER_PROBE}`], { encoding: "utf8" });
288
+ const [logPart, probePart = ""] = out.split("__TEMPLATES__");
289
+ last = logPart;
290
+ if (logPart !== seen) {
291
+ for (const line of logPart.split("\n")) {
292
+ if (line && !seen.includes(line) && /npm|install|build|SOURCE IS NEWER|waiting|pid=|health:|FAILED|template|removing/i.test(line)) {
277
293
  console.log(dim(` ${line.trim().slice(0, 120)}`));
278
294
  }
279
295
  }
280
- seen = out;
296
+ seen = logPart;
281
297
  lastChange = Date.now();
282
298
  }
283
- if (/health:|FAILED/.test(out))
299
+ const verdict = assessRestart({
300
+ log: logPart,
301
+ templates: parseTemplateContainers(probePart),
302
+ quietMs: Date.now() - lastChange,
303
+ quietLimitMs: QUIET_LIMIT_MS,
304
+ });
305
+ if (verdict.state === "healthy" || verdict.state === "failed")
284
306
  break;
285
- if (Date.now() - lastChange > QUIET_LIMIT_MS) {
307
+ if (verdict.state === "building") {
308
+ lastChange = Date.now();
309
+ if (Date.now() - lastBuildingNote > 60_000) {
310
+ console.log(dim(` still building the sandbox template (${Math.round((Date.now() - startedAt) / 60_000)} min): ${verdict.detail}`));
311
+ lastBuildingNote = Date.now();
312
+ }
313
+ }
314
+ else if (verdict.state === "orphaned") {
315
+ stuck = verdict.detail ?? null;
316
+ console.log(yellow(`\n ! an orphaned sandbox template container is blocking the start: ${stuck}`));
317
+ break;
318
+ }
319
+ else if (verdict.state === "quiet") {
286
320
  console.log(yellow(`\n ! nothing new on the host for ${Math.round(QUIET_LIMIT_MS / 60_000)} minutes — giving up on the wait`));
287
321
  break;
288
322
  }
@@ -296,8 +330,11 @@ export async function deploy(options) {
296
330
  .join("\n"));
297
331
  console.log(healthy
298
332
  ? green("\n ✓ deployed and serving the current build")
299
- : yellow("\n ! the restart did not report health. It may still be building — this stops watching, it does not stop the host.\n" +
300
- ` Watch it: ssh ${target} 'tail -f /tmp/kyb-deploy.log'`));
333
+ : stuck
334
+ ? yellow(`\n ! the restart cannot finish while that container exists. Remove it on the host, then run kyb deploy again.\n` +
335
+ ` ssh ${target} '${stuck.replace(/^.*remove it: /, "").replace(/\).*$/, "")}'`)
336
+ : yellow("\n ! the restart did not report health. It may still be building — this stops watching, it does not stop the host.\n" +
337
+ ` Watch it: ssh ${target} 'tail -f /tmp/kyb-deploy.log'`));
301
338
  if (!healthy)
302
339
  process.exitCode = 1;
303
340
  }
package/dist/doctor.d.ts CHANGED
@@ -6,6 +6,8 @@ export interface Check {
6
6
  label: string;
7
7
  detail?: string;
8
8
  }
9
+ /** Report the opt-in GitHub capability only when its mount is present. */
10
+ export declare function githubToolsDoctorCheck(mountExists: boolean, env: Record<string, string | undefined>): Check | null;
9
11
  /** The shape @kybernesis/exe's inspector returns; declared here so create keeps zero runtime deps. */
10
12
  export interface DockerTemplateInspection {
11
13
  status: "skipped" | "present" | "failed";
package/dist/doctor.js CHANGED
@@ -1,15 +1,27 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { TEMPLATE_CONTAINER_PROBE, parseTemplateContainers, templateContainerDoctorChecks } from "./sandbox-orphans.js";
2
3
  import { join } from "node:path";
3
4
  import { pathToFileURL } from "node:url";
4
5
  import { bold, capture, dim, green, parseEnv, red, yellow } from "./util.js";
5
6
  import { diagnoseManageRestart, findMatchingAgentServiceUnit } from "./systemd.js";
6
7
  import { inspectEveAgent } from "./agent-limits.js";
7
8
  import { classifyModelReach } from "./model-reach.js";
9
+ import { checkSelfVersion } from "./self-version.js";
8
10
  const MARK = {
9
11
  pass: green("✓"),
10
12
  warn: yellow("!"),
11
13
  fail: red("✗"),
12
14
  };
15
+ /** Report the opt-in GitHub capability only when its mount is present. */
16
+ export function githubToolsDoctorCheck(mountExists, env) {
17
+ if (!mountExists || env.GITHUB_TOKEN)
18
+ return null;
19
+ return {
20
+ verdict: "warn",
21
+ label: "GitHub tools off (no GITHUB_TOKEN)",
22
+ detail: "set GITHUB_TOKEN in .env.local or the deployment environment, then rebuild and restart",
23
+ };
24
+ }
13
25
  export function dockerTemplateDoctorChecks(result) {
14
26
  if (result.status === "skipped")
15
27
  return [];
@@ -39,6 +51,8 @@ export function modelReachDoctorCheck(authoredSource, compiledRouting) {
39
51
  return { verdict: "pass", label: "Model reach: claude-sub (Claude subscription)" };
40
52
  case "exe":
41
53
  return { verdict: "pass", label: "Model reach: exe (host LLM integration)" };
54
+ case "grok-sub":
55
+ return { verdict: "pass", label: "Model reach: grok-sub (direct xAI subscription credential)" };
42
56
  case "gateway":
43
57
  return { verdict: "pass", label: "Model reach: gateway" };
44
58
  case "direct-provider":
@@ -98,6 +112,10 @@ export async function doctor() {
98
112
  const installedAgentService = findMatchingAgentServiceUnit(cwd);
99
113
  const checks = [];
100
114
  const add = (verdict, label, detail) => checks.push({ verdict, label, detail });
115
+ const selfVersion = checkSelfVersion();
116
+ if (selfVersion.kind === "stale") {
117
+ add("warn", selfVersion.message, selfVersion.fix);
118
+ }
101
119
  // ── project shape ──────────────────────────────────────────────────────
102
120
  if (!existsSync(join(cwd, "agent"))) {
103
121
  console.error(red("Not an eve agent project (no agent/ directory). Run inside the agent repo."));
@@ -137,6 +155,9 @@ export async function doctor() {
137
155
  };
138
156
  if (!existsSync(envPath))
139
157
  add("warn", ".env.local missing", "copy .env.example and fill it");
158
+ const githubCheck = githubToolsDoctorCheck(existsSync(join(cwd, "agent/extensions/github.ts")), env);
159
+ if (githubCheck)
160
+ add(githubCheck.verdict, githubCheck.label, githubCheck.detail);
140
161
  // ── arcana keys: validate every key↔workspace pair read-only ───────────
141
162
  const arcanaPairs = [];
142
163
  if (env.ARCANA_API_KEY && env.ARCANA_COMPANY_WORKSPACE)
@@ -377,6 +398,11 @@ export async function doctor() {
377
398
  * eventually produces looks like anything except a full disk.
378
399
  */
379
400
  if (capture("sh", ["-c", "command -v docker >/dev/null && echo yes"])?.trim() === "yes") {
401
+ // A template container left behind by a killed start blocks every
402
+ // later start forever; a live one means "do not restart yet" (KYB-531).
403
+ for (const check of templateContainerDoctorChecks(parseTemplateContainers(capture("sh", ["-c", TEMPLATE_CONTAINER_PROBE])))) {
404
+ checks.push(check);
405
+ }
380
406
  const job = capture("sh", ["-c", "test -x /etc/cron.daily/kyb-docker-prune && echo yes"])?.trim();
381
407
  const percent = Number(capture("sh", ["-c", "df / | awk 'NR==2{print $5}' | tr -d '%'"])?.trim() ?? 0);
382
408
  if (job !== "yes") {
package/dist/init.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import { type ChannelKind, type HostKind } from "./templates.js";
2
+ /**
3
+ * Replace Eve's freshly installed registry mount with the Kybernesis-managed
4
+ * token-aware form. A failed registry add must not create a capability file.
5
+ */
6
+ export declare function finalizeGithubToolsRegistryMount(cwd: string, registryAddSucceeded: boolean): boolean;
2
7
  export interface InitOptions {
3
8
  engineer?: boolean;
4
9
  /**
package/dist/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { DEFAULT_ISSUER, EVE_VERSION, REGISTRY_URL, ask, bold, closePrompts, dim, green, run, slug, red, yellow, } from "./util.js";
4
- import { CHANNEL_KINDS, channelPlan, engineerPlan, envExample, evalFileTs, exeEvalConfigTs, evalScript, hostAgentTs, hostSteps, identityMd, rootArcanaTs, subagentAgentTs, subagentArcanaTs, subagentInstructionsMd, } from "./templates.js";
4
+ import { CHANNEL_KINDS, channelPlan, engineerPlan, envExample, evalFileTs, exeEvalConfigTs, evalScript, hostAgentTs, hostSteps, githubToolsMountTs, identityMd, rootArcanaTs, subagentAgentTs, subagentArcanaTs, subagentInstructionsMd, } from "./templates.js";
5
5
  import { suiteDir } from "./skills.js";
6
6
  import { configureArcana } from "./arcana.js";
7
7
  import { upsertEnv } from "./envfile.js";
@@ -19,6 +19,19 @@ const CORE_ITEMS = ["enterprise", "arcana", "evals"];
19
19
  // self-hosted agent it cannot get an OIDC token and the agent fails to boot.
20
20
  const ENGINEER_ITEMS_ALL = ["extension/agent-browser", "extension/github-tools"];
21
21
  const ENGINEER_ITEMS_VERCEL = ["connection/vercel"];
22
+ /**
23
+ * Replace Eve's freshly installed registry mount with the Kybernesis-managed
24
+ * token-aware form. A failed registry add must not create a capability file.
25
+ */
26
+ export function finalizeGithubToolsRegistryMount(cwd, registryAddSucceeded) {
27
+ if (!registryAddSucceeded)
28
+ return false;
29
+ const path = join(cwd, "agent/extensions/github.ts");
30
+ if (!existsSync(path))
31
+ return false;
32
+ writeFileSync(path, githubToolsMountTs());
33
+ return true;
34
+ }
22
35
  export async function init(rawName, options = {}) {
23
36
  const engineer = options.engineer === true;
24
37
  const studio = options.studio === true;
@@ -165,6 +178,8 @@ export async function init(rawName, options = {}) {
165
178
  const engItems = [...ENGINEER_ITEMS_ALL, ...(host === "vercel" ? ENGINEER_ITEMS_VERCEL : [])];
166
179
  for (const item of engItems) {
167
180
  const ok = run("npx", ["eve", "add", item, "--overwrite"], { cwd: dir, allowFail: true });
181
+ if (item === "extension/github-tools")
182
+ finalizeGithubToolsRegistryMount(dir, ok);
168
183
  if (!ok)
169
184
  console.log(yellow(` ! ${item} did not install cleanly — re-run: npx eve add ${item}`));
170
185
  }
@@ -19,6 +19,8 @@ export type DoctorModelReach = {
19
19
  kind: "claude-sub";
20
20
  } | {
21
21
  kind: "exe";
22
+ } | {
23
+ kind: "grok-sub";
22
24
  } | {
23
25
  kind: "gateway";
24
26
  } | {
@@ -35,12 +35,16 @@ export function classifyModelReach(authoredSource, compiledRouting) {
35
35
  if (authoredSource !== null) {
36
36
  const claudeSubscription = /\bclaudeSubscription\s*\(/.test(authoredSource);
37
37
  const exeModel = /\bexeModel\s*\(/.test(authoredSource);
38
- if (claudeSubscription && !exeModel)
38
+ const grokSubscription = /\bgrokSubscription\s*\(/.test(authoredSource);
39
+ const authored = [claudeSubscription, exeModel, grokSubscription].filter(Boolean).length;
40
+ if (authored === 1 && claudeSubscription)
39
41
  return { kind: "claude-sub" };
40
- if (exeModel && !claudeSubscription)
42
+ if (authored === 1 && exeModel)
41
43
  return { kind: "exe" };
42
- if (claudeSubscription && exeModel) {
43
- return { kind: "unresolved", reason: "root agent authors both claudeSubscription() and exeModel()" };
44
+ if (authored === 1 && grokSubscription)
45
+ return { kind: "grok-sub" };
46
+ if (authored > 1) {
47
+ return { kind: "unresolved", reason: "root agent authors more than one model reach helper" };
44
48
  }
45
49
  }
46
50
  if (compiledRouting.kind === "gateway")
@@ -0,0 +1,56 @@
1
+ import type { Check } from "./doctor.js";
2
+ /**
3
+ * Docker sandbox template containers: the ones still building, and the ones
4
+ * left behind.
5
+ *
6
+ * eve builds each Docker sandbox template inside an `eve-sbx-tpl-*` container
7
+ * on first start. A cold build (apt-get, pnpm, playwright with its browsers)
8
+ * runs for minutes and prints nothing to the deploy log while it does. Two
9
+ * things went wrong with that on one deployment (KYB-531):
10
+ *
11
+ * - the host start script judged the start on a 45-second clock and printed
12
+ * "FAILED to start" while the template was building, so the operator killed
13
+ * the start;
14
+ * - the killed start left the container behind with `sleep 2147483647` as its
15
+ * only process, and while it existed every later `eve start` blocked forever
16
+ * at "initializing N sandbox templates" with nothing in any log to say why.
17
+ *
18
+ * Everything here reads one probe: each template container's name and how
19
+ * many processes inside it are not `sleep`. Building means at least one; an
20
+ * orphan means none.
21
+ */
22
+ export interface TemplateContainer {
23
+ name: string;
24
+ /** Processes inside the container other than `sleep`. */
25
+ liveProcesses: number;
26
+ }
27
+ /**
28
+ * Shell that prints one `name count` line per `eve-sbx-tpl-*` container, or
29
+ * nothing when Docker is absent. The same probe serves doctor (locally), the
30
+ * deploy (over ssh), and the host start script.
31
+ */
32
+ export declare const TEMPLATE_CONTAINER_PROBE: string;
33
+ export declare function parseTemplateContainers(output: string | null | undefined): TemplateContainer[];
34
+ export declare function orphanedTemplates(containers: readonly TemplateContainer[]): TemplateContainer[];
35
+ export declare function buildingTemplates(containers: readonly TemplateContainer[]): TemplateContainer[];
36
+ /** The exact command that clears an orphan; printed as the remedy and run by the start script. */
37
+ export declare function removeOrphanCommand(name: string): string;
38
+ export declare function templateContainerDoctorChecks(containers: readonly TemplateContainer[]): Check[];
39
+ export type RestartState = "healthy" | "failed" | "building" | "orphaned" | "quiet" | "waiting";
40
+ /**
41
+ * What a deploy should conclude from the host right now.
42
+ *
43
+ * The log alone was the whole verdict before, and a template build is silent
44
+ * in it. The template probe is the second witness: while something is building,
45
+ * silence is progress and the wait goes on; an orphan with nothing building
46
+ * and no server is a stuck host with a known remedy, not a slow one.
47
+ */
48
+ export declare function assessRestart(input: {
49
+ log: string;
50
+ templates: readonly TemplateContainer[];
51
+ quietMs: number;
52
+ quietLimitMs: number;
53
+ }): {
54
+ state: RestartState;
55
+ detail?: string;
56
+ };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Shell that prints one `name count` line per `eve-sbx-tpl-*` container, or
3
+ * nothing when Docker is absent. The same probe serves doctor (locally), the
4
+ * deploy (over ssh), and the host start script.
5
+ */
6
+ export const TEMPLATE_CONTAINER_PROBE = [
7
+ "command -v docker >/dev/null 2>&1 || exit 0",
8
+ "for c in $(docker ps --filter name=eve-sbx-tpl- --format '{{.Names}}' 2>/dev/null); do",
9
+ " live=$(docker top \"$c\" 2>/dev/null | tail -n +2 | awk '{print $8}' | grep -vc '^sleep$')",
10
+ ' echo "$c ${live:-0}"',
11
+ "done",
12
+ ].join("\n");
13
+ export function parseTemplateContainers(output) {
14
+ return (output ?? "")
15
+ .split("\n")
16
+ .map((line) => line.trim())
17
+ .filter((line) => line.startsWith("eve-sbx-tpl-"))
18
+ .map((line) => {
19
+ const [name, count] = line.split(/\s+/);
20
+ return { name, liveProcesses: Number(count) || 0 };
21
+ });
22
+ }
23
+ export function orphanedTemplates(containers) {
24
+ return containers.filter((c) => c.liveProcesses === 0);
25
+ }
26
+ export function buildingTemplates(containers) {
27
+ return containers.filter((c) => c.liveProcesses > 0);
28
+ }
29
+ /** The exact command that clears an orphan; printed as the remedy and run by the start script. */
30
+ export function removeOrphanCommand(name) {
31
+ return `docker rm -f ${name}`;
32
+ }
33
+ export function templateContainerDoctorChecks(containers) {
34
+ const orphans = orphanedTemplates(containers);
35
+ const building = buildingTemplates(containers);
36
+ const checks = orphans.map((c) => ({
37
+ verdict: "fail",
38
+ label: `orphaned sandbox template container ${c.name}: every eve start will hang until it is removed`,
39
+ detail: `its only process is sleep; a start was killed mid-build. Remove it: ${removeOrphanCommand(c.name)}`,
40
+ }));
41
+ if (building.length) {
42
+ checks.push({
43
+ verdict: "warn",
44
+ label: `sandbox template building: ${building.map((c) => c.name).join(", ")}`,
45
+ detail: "a cold build takes minutes and the server binds its port only after it finishes; do not restart",
46
+ });
47
+ }
48
+ return checks;
49
+ }
50
+ /**
51
+ * What a deploy should conclude from the host right now.
52
+ *
53
+ * The log alone was the whole verdict before, and a template build is silent
54
+ * in it. The template probe is the second witness: while something is building,
55
+ * silence is progress and the wait goes on; an orphan with nothing building
56
+ * and no server is a stuck host with a known remedy, not a slow one.
57
+ */
58
+ export function assessRestart(input) {
59
+ if (/health:\s*200/.test(input.log))
60
+ return { state: "healthy" };
61
+ if (/FAILED/.test(input.log))
62
+ return { state: "failed" };
63
+ const building = buildingTemplates(input.templates);
64
+ if (building.length) {
65
+ return { state: "building", detail: building.map((c) => c.name).join(", ") };
66
+ }
67
+ const orphans = orphanedTemplates(input.templates);
68
+ if (orphans.length) {
69
+ return {
70
+ state: "orphaned",
71
+ detail: orphans.map((c) => `${c.name} (remove it: ${removeOrphanCommand(c.name)})`).join("; "),
72
+ };
73
+ }
74
+ if (input.quietMs > input.quietLimitMs)
75
+ return { state: "quiet" };
76
+ return { state: "waiting" };
77
+ }
@@ -0,0 +1,24 @@
1
+ export declare const STALE_KYB_FIX = "npm install -g @kybernesis/create@latest";
2
+ export type SelfVersionResult = {
3
+ kind: "stale";
4
+ installed: string;
5
+ latest: string;
6
+ message: string;
7
+ fix: typeof STALE_KYB_FIX;
8
+ } | {
9
+ kind: "current";
10
+ installed: string;
11
+ latest: string;
12
+ } | {
13
+ kind: "ahead";
14
+ installed: string;
15
+ latest: string;
16
+ } | {
17
+ kind: "unknown";
18
+ installed?: string;
19
+ latest?: string;
20
+ };
21
+ /** Compare the three numeric version components exactly as upgrade historically has. */
22
+ export declare function versionLt(a: string, b: string): boolean;
23
+ /** Look up this CLI's published version. Unknown lookup state is deliberately non-blocking. */
24
+ export declare function checkSelfVersion(): SelfVersionResult;
@@ -0,0 +1,56 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ export const STALE_KYB_FIX = "npm install -g @kybernesis/create@latest";
4
+ function usableVersion(value) {
5
+ return Boolean(value && /^\d+\.\d+\.\d+$/.test(value));
6
+ }
7
+ /** Compare the three numeric version components exactly as upgrade historically has. */
8
+ export function versionLt(a, b) {
9
+ const pa = a.split(".").map(Number);
10
+ const pb = b.split(".").map(Number);
11
+ for (let i = 0; i < 3; i++) {
12
+ if ((pa[i] ?? 0) < (pb[i] ?? 0))
13
+ return true;
14
+ if ((pa[i] ?? 0) > (pb[i] ?? 0))
15
+ return false;
16
+ }
17
+ return false;
18
+ }
19
+ /** Look up this CLI's published version. Unknown lookup state is deliberately non-blocking. */
20
+ export function checkSelfVersion() {
21
+ let installed;
22
+ try {
23
+ installed = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
24
+ }
25
+ catch {
26
+ return { kind: "unknown" };
27
+ }
28
+ let latest;
29
+ try {
30
+ const result = spawnSync("npm", ["view", "@kybernesis/create", "version"], {
31
+ encoding: "utf8",
32
+ env: process.env,
33
+ });
34
+ if (result.status === 0)
35
+ latest = result.stdout.trim();
36
+ }
37
+ catch {
38
+ return { kind: "unknown", installed };
39
+ }
40
+ if (!usableVersion(installed) || !usableVersion(latest)) {
41
+ return { kind: "unknown", ...(installed ? { installed } : {}), ...(latest ? { latest } : {}) };
42
+ }
43
+ if (installed === latest)
44
+ return { kind: "current", installed, latest };
45
+ if (versionLt(installed, latest)) {
46
+ return {
47
+ kind: "stale",
48
+ installed,
49
+ latest,
50
+ message: `kyb ${installed} is behind ${latest}. The certified eve version is ` +
51
+ "compiled into this tool, so an old kyb reports an old pin as current.",
52
+ fix: STALE_KYB_FIX,
53
+ };
54
+ }
55
+ return { kind: "ahead", installed, latest };
56
+ }
@@ -1,5 +1,17 @@
1
1
  /** File templates written by `kyb init`. Kept in sync with the proven Kyber patterns. */
2
2
  import type { ModelScaffoldConfig } from "./model-reach.js";
3
+ /** Exact output of Eve's legacy extension/github-tools registry item. */
4
+ export declare const LEGACY_GITHUB_TOOLS_MOUNT = "import githubTools from \"@github-tools/eve-extension\";\n\nexport default githubTools({});\n";
5
+ /**
6
+ * The managed GitHub extension mount written by init and recognized by upgrade.
7
+ *
8
+ * Eve discovers configured extensions from the direct imported-factory default
9
+ * call, so keep that syntax. An empty include list prevents the dynamic resolver
10
+ * from building any descriptors without a credential, while the lazy provider
11
+ * avoids eager missing-token validation. After GITHUB_TOKEN is added, a restart
12
+ * evaluates the module again and preserves the registry's original `{}` behavior.
13
+ */
14
+ export declare function githubToolsMountTs(): string;
3
15
  export declare const DEPT_DESCRIPTIONS: Record<string, string>;
4
16
  export declare function deptDescription(dept: string): string;
5
17
  export declare function identityMd(displayName: string, depts: string[]): string;
package/dist/templates.js CHANGED
@@ -1,4 +1,35 @@
1
1
  /** File templates written by `kyb init`. Kept in sync with the proven Kyber patterns. */
2
+ /** Exact output of Eve's legacy extension/github-tools registry item. */
3
+ export const LEGACY_GITHUB_TOOLS_MOUNT = `import githubTools from "@github-tools/eve-extension";
4
+
5
+ export default githubTools({});
6
+ `;
7
+ /**
8
+ * The managed GitHub extension mount written by init and recognized by upgrade.
9
+ *
10
+ * Eve discovers configured extensions from the direct imported-factory default
11
+ * call, so keep that syntax. An empty include list prevents the dynamic resolver
12
+ * from building any descriptors without a credential, while the lazy provider
13
+ * avoids eager missing-token validation. After GITHUB_TOKEN is added, a restart
14
+ * evaluates the module again and preserves the registry's original `{}` behavior.
15
+ */
16
+ export function githubToolsMountTs() {
17
+ return `import githubTools from "@github-tools/eve-extension";
18
+
19
+ function githubToolsOptions(hasToken = Boolean(process.env.GITHUB_TOKEN)) {
20
+ return hasToken
21
+ ? {}
22
+ : {
23
+ include: [],
24
+ token: async () => {
25
+ throw new Error("GITHUB_TOKEN is not set.");
26
+ },
27
+ };
28
+ }
29
+
30
+ export default githubTools(githubToolsOptions());
31
+ `;
32
+ }
2
33
  export const DEPT_DESCRIPTIONS = {
3
34
  finance: "Finance specialist: financials, budgets, spend, revenue, invoices, runway, and financial reporting. Keeps the finance team's own memory workspace. Delegate any finance-shaped task, question, or reporting request here.",
4
35
  marketing: "Marketing specialist: campaigns, content, positioning, brand, launches, social, and ads. Keeps the marketing team's own memory workspace. Delegate any marketing-shaped task or question here.",
package/dist/upgrade.d.ts CHANGED
@@ -1,7 +1,25 @@
1
1
  import { type AgentInputLimit } from "./agent-limits.js";
2
2
  export declare function agentInputLimitUpgradeMessage(limit: AgentInputLimit): string;
3
+ export type GithubToolsMountRepair = {
4
+ kind: "missing" | "current" | "updated" | "customized";
5
+ } | {
6
+ kind: "error";
7
+ message: string;
8
+ };
9
+ /** Upgrade only the exact registry scaffold; authored GitHub policy is not ours. */
10
+ export declare function repairGithubToolsMount(cwd: string): GithubToolsMountRepair;
3
11
  export interface UpgradeOptions {
12
+ allowStale?: boolean;
13
+ host?: true | string;
4
14
  skipEval?: boolean;
5
15
  yes?: boolean;
6
16
  }
17
+ type EvalGatePreflight = {
18
+ kind: "ready";
19
+ } | {
20
+ kind: "unavailable";
21
+ reasons: string[];
22
+ };
23
+ export declare function preflightEvalGate(cwd: string): Promise<EvalGatePreflight>;
7
24
  export declare function upgrade(options?: UpgradeOptions): Promise<void>;
25
+ export {};
package/dist/upgrade.js CHANGED
@@ -1,6 +1,7 @@
1
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
1
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
4
5
  import { upsertEnv } from "./envfile.js";
5
6
  import { reconcileHostArtifact } from "./host-artifacts.js";
6
7
  import { findMatchingAgentServiceUnit, repairManageRestart } from "./systemd.js";
@@ -8,43 +9,11 @@ import { repairTerminalSandboxCleanupHooks } from "./sandbox-cleanup.js";
8
9
  import { repairRemovedDefaultTools as repairRemovedDefaultTools_ } from "./removed-default-tools.js";
9
10
  import { EVE_VERSION, bold, capture, dim, green, parseEnv, red, run, yellow } from "./util.js";
10
11
  import { inspectEveAgent } from "./agent-limits.js";
12
+ import { remoteProjectPath, sshTarget } from "./deploy.js";
13
+ import { classifyModelReach } from "./model-reach.js";
14
+ import { checkSelfVersion, versionLt } from "./self-version.js";
15
+ import { LEGACY_GITHUB_TOOLS_MOUNT, githubToolsMountTs } from "./templates.js";
11
16
  import { confirmEveUpgrade, inspectBuzzSessions, inspectDurableRuns, reconcileEvalScript, } from "./upgrade-sessions.js";
12
- function versionLt(a, b) {
13
- const pa = a.split(".").map(Number);
14
- const pb = b.split(".").map(Number);
15
- for (let i = 0; i < 3; i++) {
16
- if ((pa[i] ?? 0) < (pb[i] ?? 0))
17
- return true;
18
- if ((pa[i] ?? 0) > (pb[i] ?? 0))
19
- return false;
20
- }
21
- return false;
22
- }
23
- /**
24
- * Warn when this CLI is itself out of date.
25
- *
26
- * @remarks
27
- * The certified eve version is a constant compiled INTO this tool, so an old
28
- * kyb reports an old pin as though it were current — and does it with total
29
- * confidence, in the one command whose entire job is telling you what current
30
- * means. That failure runs the wrong way round: it tells a healthy agent it is
31
- * ahead of certified and in "unsupported territory", which invites someone to
32
- * downgrade a fleet that was fine.
33
- *
34
- * Checked here rather than at install because this is the command where being
35
- * stale changes the answer.
36
- */
37
- function warnIfStale() {
38
- const installed = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
39
- const latest = capture("npm", ["view", "@kybernesis/create", "version"])?.trim();
40
- if (!latest || latest === installed)
41
- return;
42
- if (!versionLt(installed, latest))
43
- return;
44
- console.log(` ${yellow("!")} kyb ${installed} is behind ${latest}. The certified eve version is ` +
45
- `compiled into this tool, so an old kyb reports an old pin as current.`);
46
- console.log(` ${dim("npm install -g @kybernesis/create@latest")}\n`);
47
- }
48
17
  export function agentInputLimitUpgradeMessage(limit) {
49
18
  switch (limit.kind) {
50
19
  case "explicit-numeric":
@@ -223,6 +192,48 @@ function repairSandboxCleanupHooks(cwd, deps) {
223
192
  console.log(` ${green("+")} installed terminal sandbox cleanup for ${added.length} agent scope(s) ` +
224
193
  `${dim("(completed and failed sessions only)")}\n`);
225
194
  }
195
+ /** Upgrade only the exact registry scaffold; authored GitHub policy is not ours. */
196
+ export function repairGithubToolsMount(cwd) {
197
+ const path = join(cwd, "agent/extensions/github.ts");
198
+ if (!existsSync(path))
199
+ return { kind: "missing" };
200
+ let current;
201
+ try {
202
+ current = readFileSync(path, "utf8");
203
+ }
204
+ catch (error) {
205
+ return { kind: "error", message: `could not read agent/extensions/github.ts: ${error.message}` };
206
+ }
207
+ const desired = githubToolsMountTs();
208
+ if (current === desired)
209
+ return { kind: "current" };
210
+ if (current !== LEGACY_GITHUB_TOOLS_MOUNT)
211
+ return { kind: "customized" };
212
+ const temp = `${path}.kyb-${process.pid}`;
213
+ try {
214
+ writeFileSync(temp, desired);
215
+ renameSync(temp, path);
216
+ return { kind: "updated" };
217
+ }
218
+ catch (error) {
219
+ rmSync(temp, { force: true });
220
+ return { kind: "error", message: `could not update agent/extensions/github.ts: ${error.message}` };
221
+ }
222
+ }
223
+ function reportGithubToolsMountRepair(cwd) {
224
+ const result = repairGithubToolsMount(cwd);
225
+ if (result.kind === "updated") {
226
+ console.log(` ${green("+")} updated agent/extensions/github.ts ` +
227
+ `${dim("(GitHub tools stay off until GITHUB_TOKEN is set)")}`);
228
+ }
229
+ else if (result.kind === "customized") {
230
+ console.log(` ${yellow("!")} agent/extensions/github.ts is customized, so it was left unchanged.\n` +
231
+ ` To adopt the quiet no-token behavior, compare it with a new \`kyb init --engineer\` scaffold.`);
232
+ }
233
+ else if (result.kind === "error") {
234
+ console.log(` ${yellow("!")} ${result.message}; leaving the file unchanged.`);
235
+ }
236
+ }
226
237
  /**
227
238
  * Keep package-owned host files synchronized with the package that installed them.
228
239
  *
@@ -237,6 +248,33 @@ function repairHostArtifacts(cwd, deps) {
237
248
  const pruneSource = join(scripts, "docker-prune.sh");
238
249
  const pruneTarget = "/etc/cron.daily/kyb-docker-prune";
239
250
  const hasDocker = capture("sh", ["-c", "command -v docker >/dev/null && echo yes"])?.trim() === "yes";
251
+ // The host start script is package-owned too. It carried a 45-second
252
+ // verdict clock that called a cold sandbox template build a failure, and no
253
+ // sweep for the orphaned template container that a killed start leaves
254
+ // behind (KYB-531). An agent still running the copy `kyb init` made keeps
255
+ // both faults until something refreshes it; this does, on every upgrade.
256
+ const serverSource = join(scripts, "eve-server.sh");
257
+ const serverTarget = join(cwd, "scripts/eve-server.sh");
258
+ if (existsSync(serverSource) && existsSync(serverTarget)) {
259
+ reconcileHostArtifact({
260
+ targetPath: serverTarget,
261
+ desiredContent: readFileSync(serverSource),
262
+ expectedMode: 0o755,
263
+ installIfMissing: false,
264
+ owner: "@kybernesis/exe/scripts/eve-server.sh",
265
+ update: () => {
266
+ try {
267
+ copyFileSync(serverSource, serverTarget);
268
+ chmodSync(serverTarget, 0o755);
269
+ return true;
270
+ }
271
+ catch {
272
+ return false;
273
+ }
274
+ },
275
+ manualCommand: `cp ${shellQuote(serverSource)} scripts/eve-server.sh && chmod +x scripts/eve-server.sh`,
276
+ });
277
+ }
240
278
  if (existsSync(pruneSource)) {
241
279
  const pruneResult = reconcileHostArtifact({
242
280
  targetPath: pruneTarget,
@@ -330,6 +368,141 @@ function repairEvalCommand(cwd) {
330
368
  ` Manual remediation: make this command invoke kyb-eval instead of eve eval, preserving its setup and arguments.\n` +
331
369
  ` Current scripts.eval: ${dim(JSON.stringify(current))}`);
332
370
  }
371
+ const EVAL_GATE_UNAVAILABLE_EXIT = 2;
372
+ const DEFAULT_EXE_MODELS_URL = "https://llm.int.exe.xyz/models.json";
373
+ function evalGateEnvironment(cwd) {
374
+ const envPath = join(cwd, ".env.local");
375
+ return {
376
+ ...(existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {}),
377
+ ...process.env,
378
+ };
379
+ }
380
+ async function routeAnswers(url) {
381
+ try {
382
+ await fetch(url, { signal: AbortSignal.timeout(6000) });
383
+ return true;
384
+ }
385
+ catch {
386
+ return false;
387
+ }
388
+ }
389
+ async function claudeSubscriptionProxyReady(cwd) {
390
+ const module = join(cwd, "node_modules/@kybernesis/exe/dist/claude.js");
391
+ if (!existsSync(module))
392
+ return false;
393
+ try {
394
+ const loaded = (await import(pathToFileURL(module).href));
395
+ if (typeof loaded.claudeProxyReady !== "function")
396
+ return false;
397
+ return (await loaded.claudeProxyReady()).ok;
398
+ }
399
+ catch {
400
+ return false;
401
+ }
402
+ }
403
+ /** Resolve an exe-backed judge from the provider the authored config actually calls. */
404
+ function exeJudgeUrl(cwd, env) {
405
+ const paths = [join(cwd, "evals/evals.config.ts"), join(cwd, "evals.config.ts")];
406
+ const path = paths.find((candidate) => existsSync(candidate));
407
+ if (!path)
408
+ return null;
409
+ const source = readFileSync(path, "utf8");
410
+ const judgeProvider = /judge\s*:\s*\{[\s\S]*?model\s*:\s*([A-Za-z_$][\w$]*)\s*\(/.exec(source)?.[1];
411
+ if (!judgeProvider)
412
+ return null;
413
+ const escaped = judgeProvider.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
414
+ const providerBody = new RegExp(`(?:const|let)\\s+${escaped}\\s*=\\s*create[A-Za-z]+\\s*\\(\\s*\\{([\\s\\S]*?)\\}\\s*\\)`).exec(source)?.[1];
415
+ if (!providerBody)
416
+ return /EXE_LLM_URL|llm\.int\.exe\.xyz/.test(source) ? undefined : null;
417
+ if (/EXE_LLM_URL/.test(providerBody))
418
+ return env.EXE_LLM_URL || DEFAULT_EXE_MODELS_URL;
419
+ const baseUrl = /baseURL\s*:\s*["']([^"']+)["']/.exec(providerBody)?.[1];
420
+ if (baseUrl?.includes("llm.int.exe.xyz"))
421
+ return baseUrl;
422
+ return null;
423
+ }
424
+ export async function preflightEvalGate(cwd) {
425
+ const env = evalGateEnvironment(cwd);
426
+ const agentPath = join(cwd, "agent/agent.ts");
427
+ const authoredSource = existsSync(agentPath) ? readFileSync(agentPath, "utf8") : null;
428
+ const inspection = inspectEveAgent(cwd, env);
429
+ const reach = classifyModelReach(authoredSource, inspection?.modelRouting ?? {
430
+ kind: "unresolved",
431
+ reason: "eve info failed before compiled model routing could be inspected",
432
+ });
433
+ const reasons = [];
434
+ switch (reach.kind) {
435
+ case "claude-sub":
436
+ if (!(await claudeSubscriptionProxyReady(cwd)))
437
+ reasons.push("the agent model proxy is unreachable");
438
+ break;
439
+ case "exe":
440
+ if (!(await routeAnswers(env.EXE_LLM_URL || DEFAULT_EXE_MODELS_URL)))
441
+ reasons.push("the agent model route is unreachable");
442
+ break;
443
+ case "unresolved":
444
+ reasons.push(`the agent model route could not be resolved (${reach.reason})`);
445
+ break;
446
+ case "grok-sub":
447
+ case "gateway":
448
+ case "direct-provider":
449
+ break;
450
+ }
451
+ const judgeUrl = exeJudgeUrl(cwd, env);
452
+ if (judgeUrl === undefined)
453
+ reasons.push("the eve judge route could not be resolved");
454
+ else if (judgeUrl && !(await routeAnswers(judgeUrl)))
455
+ reasons.push("the eve judge is unreachable");
456
+ return reasons.length === 0 ? { kind: "ready" } : { kind: "unavailable", reasons };
457
+ }
458
+ function hostEvalCommand(cwd, target) {
459
+ const host = target ?? "<host>";
460
+ return `ssh ${host} ${shellQuote(`cd ${remoteProjectPath(cwd)} && npm run eval`)}`;
461
+ }
462
+ function reportEvalGateUnavailable(cwd, target, reasons) {
463
+ console.log(`\n${yellow("The eval gate for this agent can only run on its host (the model proxy and the eve judge are only reachable there).")}`);
464
+ for (const reason of reasons)
465
+ console.log(` ${dim(reason)}`);
466
+ console.log(`Run it there: ${bold(hostEvalCommand(cwd, target ?? undefined))}\n`);
467
+ process.exitCode = EVAL_GATE_UNAVAILABLE_EXIT;
468
+ }
469
+ async function runUpgradeEvalGate(cwd, host) {
470
+ if (host !== undefined) {
471
+ const target = sshTarget(cwd, typeof host === "string" && host.length > 0 ? host : undefined);
472
+ if (!target) {
473
+ reportEvalGateUnavailable(cwd, null, ["no ssh target is configured"]);
474
+ return;
475
+ }
476
+ console.log(bold(`\nRunning the eval gate on ${target} (${remoteProjectPath(cwd)}) — deploy only on green …\n`));
477
+ const result = spawnSync("ssh", [target, `cd ${remoteProjectPath(cwd)} && npm run eval`], {
478
+ cwd,
479
+ env: process.env,
480
+ stdio: "inherit",
481
+ });
482
+ if (result.status === 0) {
483
+ console.log(`\n${green("✓ Upgrade green.")} Deploy with: ${bold("npx eve deploy")}\n`);
484
+ }
485
+ else {
486
+ console.log(`\n${red("✗ Evals failed after upgrade.")} Do NOT deploy — inspect .eve/evals/ artifacts on the host.\n`);
487
+ process.exitCode = 1;
488
+ }
489
+ return;
490
+ }
491
+ const preflight = await preflightEvalGate(cwd);
492
+ if (preflight.kind === "unavailable") {
493
+ reportEvalGateUnavailable(cwd, sshTarget(cwd), preflight.reasons);
494
+ return;
495
+ }
496
+ console.log(bold("\nRunning the eval gate (npm run eval) — deploy only on green …\n"));
497
+ const ok = run("npm", ["run", "eval"], { cwd, allowFail: true });
498
+ if (ok) {
499
+ console.log(`\n${green("✓ Upgrade green.")} Deploy with: ${bold("npx eve deploy")}\n`);
500
+ }
501
+ else {
502
+ console.log(`\n${red("✗ Evals failed after upgrade.")} Do NOT deploy — inspect .eve/evals/ artifacts.\n`);
503
+ process.exitCode = 1;
504
+ }
505
+ }
333
506
  function upgradeEnvironment(cwd) {
334
507
  const envPath = join(cwd, ".env.local");
335
508
  const env = {
@@ -480,6 +653,16 @@ function startStoppedBridge(cwd, unit) {
480
653
  return false;
481
654
  }
482
655
  export async function upgrade(options = {}) {
656
+ console.log(bold("\nkyb upgrade — checking @kybernesis/* and eve against npm\n"));
657
+ const selfVersion = checkSelfVersion();
658
+ if (selfVersion.kind === "stale") {
659
+ console.log(` ${yellow("!")} ${selfVersion.message}`);
660
+ console.log(` ${dim(selfVersion.fix)}\n`);
661
+ if (!options.allowStale) {
662
+ process.exitCode = 1;
663
+ return;
664
+ }
665
+ }
483
666
  const cwd = process.cwd();
484
667
  const packagePath = join(cwd, "package.json");
485
668
  const pkg = JSON.parse(readFileSync(packagePath, "utf8"));
@@ -487,8 +670,6 @@ export async function upgrade(options = {}) {
487
670
  ...(pkg.dependencies ?? {}),
488
671
  ...(pkg.devDependencies ?? {}),
489
672
  };
490
- console.log(bold("\nkyb upgrade — checking @kybernesis/* and eve against npm\n"));
491
- warnIfStale();
492
673
  reportAgentInputLimit(cwd);
493
674
  const entries = dependencyEntries(pkg);
494
675
  const installedVersions = new Map();
@@ -570,6 +751,7 @@ export async function upgrade(options = {}) {
570
751
  repairBuzzSetup(cwd, deps);
571
752
  repairRemovedDefaultTools(cwd);
572
753
  repairSandboxCleanupHooks(cwd, deps);
754
+ reportGithubToolsMountRepair(cwd);
573
755
  repairHostArtifacts(cwd, deps);
574
756
  repairEvalCommand(cwd);
575
757
  repairManageRestart(cwd, deps);
@@ -627,6 +809,7 @@ export async function upgrade(options = {}) {
627
809
  repairBuzzSetup(cwd, deps);
628
810
  repairRemovedDefaultTools(cwd);
629
811
  repairSandboxCleanupHooks(cwd, deps);
812
+ reportGithubToolsMountRepair(cwd);
630
813
  repairHostArtifacts(cwd, deps);
631
814
  repairEvalCommand(cwd);
632
815
  repairManageRestart(cwd, deps);
@@ -639,13 +822,5 @@ export async function upgrade(options = {}) {
639
822
  console.log(yellow("\nEval gate SKIPPED (--skip-eval). Run `npm run eval` before deploying.\n"));
640
823
  return;
641
824
  }
642
- console.log(bold("\nRunning the eval gate (npm run eval) — deploy only on green …\n"));
643
- const ok = run("npm", ["run", "eval"], { cwd, allowFail: true });
644
- if (ok) {
645
- console.log(`\n${green("✓ Upgrade green.")} Deploy with: ${bold("npx eve deploy")}\n`);
646
- }
647
- else {
648
- console.log(`\n${red("✗ Evals failed after upgrade.")} Do NOT deploy — inspect .eve/evals/ artifacts.\n`);
649
- process.exit(1);
650
- }
825
+ await runUpgradeEvalGate(cwd, options.host);
651
826
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kybernesis/create",
3
- "version": "0.14.7",
3
+ "version": "0.14.9",
4
4
  "description": "The Kybernesis agent scaffolder and FDE toolkit: one command to a governed, remembering, multiplayer, self-testing eve agent — plus doctor and upgrade.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",