jeo-code 0.1.0 → 0.4.5
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.ja.md +160 -0
- package/README.ko.md +160 -0
- package/README.md +115 -297
- package/README.zh.md +160 -0
- package/package.json +11 -6
- package/scripts/install.sh +28 -28
- package/scripts/uninstall.sh +17 -15
- package/src/AGENTS.md +50 -0
- package/src/agent/AGENTS.md +49 -0
- package/src/agent/bash-fixups.ts +103 -0
- package/src/agent/compaction.ts +410 -19
- package/src/agent/config-schema.ts +119 -5
- package/src/agent/context-files.ts +314 -17
- package/src/agent/dev/AGENTS.md +36 -0
- package/src/agent/dev/advanced-analyzer.ts +12 -0
- package/src/agent/dev/evolution-bridge.ts +82 -0
- package/src/agent/dev/evolution-logger.ts +41 -0
- package/src/agent/dev/self-analysis.ts +64 -0
- package/src/agent/dev/self-improve.ts +24 -0
- package/src/agent/dev/spec-automation.ts +49 -0
- package/src/agent/engine.ts +808 -54
- package/src/agent/hooks.ts +273 -0
- package/src/agent/loop.ts +21 -1
- package/src/agent/memory.ts +201 -0
- package/src/agent/model-recency.ts +32 -0
- package/src/agent/output-minimizer.ts +108 -0
- package/src/agent/output-util.ts +64 -0
- package/src/agent/plan.ts +187 -0
- package/src/agent/seed.ts +52 -0
- package/src/agent/session.ts +235 -21
- package/src/agent/state.ts +286 -39
- package/src/agent/step-budget.ts +232 -0
- package/src/agent/subagents.ts +223 -26
- package/src/agent/task-tool.ts +272 -0
- package/src/agent/todo-tool.ts +87 -0
- package/src/agent/tokenizer.ts +117 -0
- package/src/agent/tool-registry.ts +54 -0
- package/src/agent/tools.ts +624 -103
- package/src/agent/web-search.ts +538 -0
- package/src/ai/AGENTS.md +44 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/model-catalog-compat.ts +3 -1
- package/src/ai/model-catalog.ts +74 -9
- package/src/ai/model-discovery.ts +215 -17
- package/src/ai/model-manager.ts +346 -32
- package/src/ai/model-picker.ts +1 -1
- package/src/ai/model-registry.ts +4 -2
- package/src/ai/pricing.ts +84 -0
- package/src/ai/provider-registry.ts +23 -0
- package/src/ai/provider-status.ts +60 -16
- package/src/ai/providers/AGENTS.md +42 -0
- package/src/ai/providers/anthropic.ts +250 -31
- package/src/ai/providers/antigravity.ts +219 -0
- package/src/ai/providers/errors.ts +15 -1
- package/src/ai/providers/gemini.ts +196 -13
- package/src/ai/providers/ollama.ts +37 -7
- package/src/ai/providers/openai-responses.ts +173 -0
- package/src/ai/providers/openai.ts +64 -12
- package/src/ai/sse.ts +4 -1
- package/src/ai/types.ts +18 -1
- package/src/auth/AGENTS.md +41 -0
- package/src/auth/callback-server.ts +6 -1
- package/src/auth/flows/AGENTS.md +32 -0
- package/src/auth/flows/antigravity.ts +151 -0
- package/src/auth/flows/google-project.ts +190 -0
- package/src/auth/flows/google.ts +39 -18
- package/src/auth/flows/index.ts +15 -5
- package/src/auth/flows/openai.ts +2 -2
- package/src/auth/oauth.ts +8 -0
- package/src/auth/refresh.ts +44 -27
- package/src/auth/storage.ts +149 -26
- package/src/auth/types.ts +1 -1
- package/src/autopilot.ts +362 -0
- package/src/bun-imports.d.ts +4 -0
- package/src/cli/AGENTS.md +39 -0
- package/src/cli/runner.ts +148 -14
- package/src/cli.ts +13 -4
- package/src/commands/AGENTS.md +40 -0
- package/src/commands/approve.ts +62 -3
- package/src/commands/auth.ts +167 -25
- package/src/commands/chat.ts +37 -8
- package/src/commands/deep-interview.ts +633 -175
- package/src/commands/doctor.ts +84 -37
- package/src/commands/evolve-core.ts +18 -0
- package/src/commands/evolve.ts +2 -1
- package/src/commands/export.ts +176 -0
- package/src/commands/gjc.ts +52 -0
- package/src/commands/launch.ts +3549 -240
- package/src/commands/mcp.ts +3 -3
- package/src/commands/ooo-seed.ts +19 -0
- package/src/commands/ralplan.ts +253 -35
- package/src/commands/resume.ts +1 -1
- package/src/commands/session.ts +183 -0
- package/src/commands/setup-helpers.ts +10 -3
- package/src/commands/setup.ts +57 -16
- package/src/commands/skills.ts +78 -18
- package/src/commands/state.ts +198 -0
- package/src/commands/status.ts +84 -0
- package/src/commands/team.ts +340 -212
- package/src/commands/ultragoal.ts +122 -61
- package/src/commands/update.ts +244 -0
- package/src/ledger.ts +270 -0
- package/src/mcp/AGENTS.md +38 -0
- package/src/mcp/server.ts +115 -14
- package/src/mcp/tools.ts +42 -22
- package/src/md-modules.d.ts +4 -0
- package/src/prompts/AGENTS.md +41 -0
- package/src/prompts/agents/AGENTS.md +35 -0
- package/src/prompts/agents/architect.md +35 -0
- package/src/prompts/agents/critic.md +37 -0
- package/src/prompts/agents/executor.md +36 -0
- package/src/prompts/agents/planner.md +37 -0
- package/src/prompts/skills/AGENTS.md +36 -0
- package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
- package/src/prompts/skills/deep-dive/SKILL.md +13 -0
- package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
- package/src/prompts/skills/deep-interview/SKILL.md +12 -0
- package/src/prompts/skills/gjc/AGENTS.md +31 -0
- package/src/prompts/skills/gjc/SKILL.md +15 -0
- package/src/prompts/skills/ralplan/AGENTS.md +31 -0
- package/src/prompts/skills/ralplan/SKILL.md +11 -0
- package/src/prompts/skills/team/AGENTS.md +31 -0
- package/src/prompts/skills/team/SKILL.md +11 -0
- package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
- package/src/prompts/skills/ultragoal/SKILL.md +11 -0
- package/src/skills/AGENTS.md +38 -0
- package/src/skills/catalog.ts +565 -31
- package/src/tui/AGENTS.md +43 -0
- package/src/tui/app.ts +1181 -92
- package/src/tui/components/AGENTS.md +42 -0
- package/src/tui/components/ascii-art.ts +257 -15
- package/src/tui/components/autocomplete.ts +98 -16
- package/src/tui/components/autopilot-status.ts +65 -0
- package/src/tui/components/category-index.ts +49 -0
- package/src/tui/components/code-view.ts +54 -11
- package/src/tui/components/color.ts +171 -2
- package/src/tui/components/config-panel.ts +82 -15
- package/src/tui/components/duration.ts +38 -0
- package/src/tui/components/evolution.ts +3 -3
- package/src/tui/components/footer.ts +91 -42
- package/src/tui/components/forge.ts +426 -31
- package/src/tui/components/hints.ts +54 -0
- package/src/tui/components/hud.ts +73 -0
- package/src/tui/components/index.ts +4 -0
- package/src/tui/components/input-box.ts +150 -0
- package/src/tui/components/layout.ts +11 -3
- package/src/tui/components/live-model-picker.ts +108 -0
- package/src/tui/components/markdown-table.ts +140 -0
- package/src/tui/components/markdown-text.ts +97 -0
- package/src/tui/components/meter.ts +4 -1
- package/src/tui/components/model-picker.ts +3 -2
- package/src/tui/components/provider-picker.ts +3 -2
- package/src/tui/components/section.ts +70 -0
- package/src/tui/components/select-list.ts +40 -10
- package/src/tui/components/skill-picker.ts +25 -0
- package/src/tui/components/slash.ts +244 -21
- package/src/tui/components/status.ts +272 -11
- package/src/tui/components/step-timeline.ts +218 -0
- package/src/tui/components/stream.ts +26 -9
- package/src/tui/components/themes.ts +212 -6
- package/src/tui/components/todo-card.ts +47 -0
- package/src/tui/components/tool-list.ts +58 -12
- package/src/tui/components/transcript.ts +120 -0
- package/src/tui/components/update-box.ts +31 -0
- package/src/tui/components/welcome.ts +162 -0
- package/src/tui/components/width.ts +163 -0
- package/src/tui/monitoring/AGENTS.md +31 -0
- package/src/tui/monitoring/hud-view.ts +55 -0
- package/src/tui/renderer.ts +112 -3
- package/src/tui/terminal.ts +40 -33
- package/src/util/AGENTS.md +39 -0
- package/src/util/clipboard-image.ts +118 -0
- package/src/util/env.ts +12 -0
- package/src/util/provider-error.ts +78 -0
- package/src/util/retry.ts +91 -6
- package/src/util/update-check.ts +64 -0
- package/src/commands/models.ts +0 -104
package/src/auth/storage.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import { readGlobalConfig, readRawGlobalConfig, saveConfigPatch, type StoredOAuth } from "../agent/state";
|
|
5
|
+
import { jeoEnv } from "../util/env";
|
|
2
6
|
|
|
3
|
-
|
|
7
|
+
|
|
8
|
+
export type AuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
|
|
4
9
|
|
|
5
10
|
export type Credential =
|
|
6
|
-
| { kind: "oauth"; provider: AuthProvider; token: string }
|
|
11
|
+
| { kind: "oauth"; provider: AuthProvider; token: string; projectId?: string }
|
|
7
12
|
| { kind: "api_key"; provider: AuthProvider; token: string }
|
|
8
13
|
| { kind: "none"; provider: AuthProvider };
|
|
9
14
|
|
|
@@ -17,17 +22,123 @@ export interface AuthSnapshot {
|
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
const inFlightRefresh = new Map<AuthProvider, Promise<any>>();
|
|
25
|
+
function getLockPath(provider: AuthProvider): string {
|
|
26
|
+
const dir = jeoEnv("CONFIG_DIR") || path.join(os.homedir(), ".jeo");
|
|
27
|
+
return path.join(dir, `oauth-${provider}.lock`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function acquireLock(provider: AuthProvider, timeoutMs = 5000): Promise<void> {
|
|
31
|
+
const lockPath = getLockPath(provider);
|
|
32
|
+
const dir = path.dirname(lockPath);
|
|
33
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
|
|
35
|
+
// `timeoutMs` is the STALENESS threshold for a dead holder's lock file; the
|
|
36
|
+
// acquisition wait itself is bounded at 2× that. The previous unbounded 50ms
|
|
37
|
+
// retry loop spun forever when the stale lock could not be unlinked (or was
|
|
38
|
+
// recreated under churn by concurrent sessions) — a mid-turn OAuth refresh
|
|
39
|
+
// then froze the whole agent turn with no diagnostic.
|
|
40
|
+
const deadline = Date.now() + Math.max(timeoutMs * 2, 1_000);
|
|
41
|
+
while (true) {
|
|
42
|
+
try {
|
|
43
|
+
const handle = await fs.open(lockPath, "wx");
|
|
44
|
+
const info = JSON.stringify({ pid: process.pid, createdAt: Date.now() });
|
|
45
|
+
await handle.writeFile(info, "utf-8");
|
|
46
|
+
await handle.close();
|
|
47
|
+
return;
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
if (err.code !== "EEXIST") {
|
|
50
|
+
throw err;
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const content = await fs.readFile(lockPath, "utf-8");
|
|
54
|
+
const info = JSON.parse(content);
|
|
55
|
+
if (typeof info.createdAt === "number" && info.createdAt + timeoutMs < Date.now()) {
|
|
56
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
try {
|
|
60
|
+
const stat = await fs.stat(lockPath);
|
|
61
|
+
if (stat.mtimeMs + timeoutMs < Date.now()) {
|
|
62
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
63
|
+
}
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (Date.now() >= deadline) {
|
|
68
|
+
// Deadline reached: the holder is dead or wedged. Steal once — the lock
|
|
69
|
+
// guards a short config read-modify-write, so waiting longer only hangs
|
|
70
|
+
// the caller (and with it the live turn that triggered the refresh).
|
|
71
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
72
|
+
const handle = await fs.open(lockPath, "wx").catch(() => null);
|
|
73
|
+
if (handle) {
|
|
74
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now(), stolen: true }), "utf-8");
|
|
75
|
+
await handle.close();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`OAuth lock for ${provider} could not be acquired within ${Math.max(timeoutMs * 2, 1_000)}ms (${lockPath})`);
|
|
79
|
+
}
|
|
80
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function releaseLock(provider: AuthProvider): Promise<void> {
|
|
85
|
+
const lockPath = getLockPath(provider);
|
|
86
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
|
|
20
89
|
|
|
21
90
|
function accessOf(stored: string | StoredOAuth | undefined): string | undefined {
|
|
22
91
|
if (!stored) return undefined;
|
|
23
92
|
return typeof stored === "string" ? stored : stored.access;
|
|
24
93
|
}
|
|
25
94
|
|
|
26
|
-
/**
|
|
95
|
+
/** Raw credential resolver: returns refreshable OAuth first; execution/status may override with an API key when both exist. */
|
|
27
96
|
export async function resolveCredential(provider: AuthProvider): Promise<Credential> {
|
|
28
97
|
const cfg = await readGlobalConfig();
|
|
29
|
-
|
|
98
|
+
let stored = cfg.oauth?.[provider];
|
|
99
|
+
|
|
100
|
+
// Auto-import gemini-cli credentials (~/.gemini/oauth_creds.json) when jeo has no
|
|
101
|
+
// gemini OAuth of its own — the out-of-the-box antigravity/gemini unlock. Hermetic
|
|
102
|
+
// under tests/custom config sandboxes: if JEO_CONFIG_DIR is explicitly set, only an
|
|
103
|
+
// explicit JEO_GEMINI_CREDS_PATH opts in, so tests never read the developer's real
|
|
104
|
+
// credentials or refresh real tokens.
|
|
105
|
+
const credsOverride = jeoEnv("GEMINI_CREDS_PATH");
|
|
106
|
+
const configDirOverridden = !!jeoEnv("CONFIG_DIR");
|
|
107
|
+
if (!stored && provider === "gemini" && (credsOverride || !configDirOverridden)) {
|
|
108
|
+
try {
|
|
109
|
+
const credsPath = jeoEnv("GEMINI_CREDS_PATH") || path.join(os.homedir(), ".gemini", "oauth_creds.json");
|
|
110
|
+
const content = await fs.readFile(credsPath, "utf-8");
|
|
111
|
+
const creds = JSON.parse(content);
|
|
112
|
+
if (creds && creds.access_token) {
|
|
113
|
+
let email: string | undefined;
|
|
114
|
+
if (creds.id_token) {
|
|
115
|
+
const parts = creds.id_token.split(".");
|
|
116
|
+
if (parts.length >= 2) {
|
|
117
|
+
try {
|
|
118
|
+
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
119
|
+
const payload = JSON.parse(atob(base64));
|
|
120
|
+
email = payload.email;
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
let expires = typeof creds.expiry_date === "number" ? creds.expiry_date : (typeof creds.expiry_date === "string" ? parseInt(creds.expiry_date, 10) : undefined);
|
|
125
|
+
if (isNaN(expires as number)) expires = undefined;
|
|
30
126
|
|
|
127
|
+
const imported: StoredOAuth = {
|
|
128
|
+
access: creds.access_token,
|
|
129
|
+
refresh: creds.refresh_token,
|
|
130
|
+
expires,
|
|
131
|
+
email,
|
|
132
|
+
};
|
|
133
|
+
await setOauthCredential("gemini", imported);
|
|
134
|
+
// stderr, NOT stdout: --json consumers (doctor, models) parse stdout.
|
|
135
|
+
console.error(`[NOTICE] Transparently imported Gemini OAuth credentials from ~/.gemini/oauth_creds.json`);
|
|
136
|
+
stored = imported;
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// never break existing resolution
|
|
140
|
+
}
|
|
141
|
+
}
|
|
31
142
|
if (stored) {
|
|
32
143
|
// Auto-refresh refreshable credentials that are past their expiry.
|
|
33
144
|
if (typeof stored !== "string" && stored.refresh && stored.expires && stored.expires <= Date.now()) {
|
|
@@ -39,9 +150,10 @@ export async function resolveCredential(provider: AuthProvider): Promise<Credent
|
|
|
39
150
|
return refreshOAuthToken(provider);
|
|
40
151
|
})();
|
|
41
152
|
inFlightRefresh.set(provider, refreshPromise);
|
|
42
|
-
|
|
153
|
+
// Cleanup must not create its own unobserved rejection if the refresh rejects.
|
|
154
|
+
void refreshPromise.finally(() => {
|
|
43
155
|
inFlightRefresh.delete(provider);
|
|
44
|
-
});
|
|
156
|
+
}).catch(() => {});
|
|
45
157
|
}
|
|
46
158
|
const result = await refreshPromise;
|
|
47
159
|
if (result.refreshed && result.credential.kind === "oauth") {
|
|
@@ -52,7 +164,14 @@ export async function resolveCredential(provider: AuthProvider): Promise<Credent
|
|
|
52
164
|
}
|
|
53
165
|
}
|
|
54
166
|
const token = accessOf(stored);
|
|
55
|
-
if (token)
|
|
167
|
+
if (token) {
|
|
168
|
+
return {
|
|
169
|
+
kind: "oauth",
|
|
170
|
+
provider,
|
|
171
|
+
token,
|
|
172
|
+
projectId: typeof stored === "object" ? stored.projectId : undefined,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
56
175
|
}
|
|
57
176
|
|
|
58
177
|
const apiKey = cfg.providers[provider];
|
|
@@ -81,33 +200,37 @@ export async function getStoredOAuth(provider: AuthProvider): Promise<StoredOAut
|
|
|
81
200
|
|
|
82
201
|
/** Persist a plain bearer token (legacy / manual paste — no refresh metadata). */
|
|
83
202
|
export async function setOauthToken(provider: AuthProvider, token: string): Promise<void> {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
203
|
+
// Persist onto the RAW on-disk config (not env-overlaid) so a short-lived
|
|
204
|
+
// *_OAUTH_TOKEN env / OLLAMA_HOST / role tier is never baked into config.json.
|
|
205
|
+
await saveConfigPatch(raw => ({ oauth: { ...(raw.oauth ?? {}), [provider]: token } }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Persist a full OAuth credential set (access + refresh + expiry). */
|
|
209
|
+
export async function setOauthCredentialNoLock(provider: AuthProvider, cred: StoredOAuth): Promise<void> {
|
|
210
|
+
await saveConfigPatch(raw => ({ oauth: { ...(raw.oauth ?? {}), [provider]: cred } }));
|
|
89
211
|
}
|
|
90
212
|
|
|
91
213
|
/** Persist a full OAuth credential set (access + refresh + expiry). */
|
|
92
214
|
export async function setOauthCredential(provider: AuthProvider, cred: StoredOAuth): Promise<void> {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
215
|
+
await acquireLock(provider);
|
|
216
|
+
try {
|
|
217
|
+
await setOauthCredentialNoLock(provider, cred);
|
|
218
|
+
} finally {
|
|
219
|
+
await releaseLock(provider);
|
|
220
|
+
}
|
|
98
221
|
}
|
|
99
222
|
|
|
100
223
|
export async function clearOauthToken(provider: AuthProvider): Promise<boolean> {
|
|
101
|
-
const
|
|
102
|
-
if (!
|
|
103
|
-
|
|
104
|
-
|
|
224
|
+
const raw = await readRawGlobalConfig();
|
|
225
|
+
if (!raw.oauth?.[provider]) return false;
|
|
226
|
+
await saveConfigPatch(r => {
|
|
227
|
+
const oauth = { ...(r.oauth ?? {}) };
|
|
228
|
+
delete oauth[provider];
|
|
229
|
+
return { oauth };
|
|
230
|
+
});
|
|
105
231
|
return true;
|
|
106
232
|
}
|
|
107
233
|
|
|
108
234
|
export async function setApiKey(provider: AuthProvider, key: string): Promise<void> {
|
|
109
|
-
|
|
110
|
-
const next: Config = JSON.parse(JSON.stringify(cfg));
|
|
111
|
-
next.providers[provider] = key;
|
|
112
|
-
await saveGlobalConfig(next);
|
|
235
|
+
await saveConfigPatch(raw => ({ providers: { ...(raw.providers ?? {}), [provider]: key } }));
|
|
113
236
|
}
|
package/src/auth/types.ts
CHANGED
package/src/autopilot.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jeo autopilot — autonomous build loop hardened with autoresearch ratcheting.
|
|
3
|
+
*
|
|
4
|
+
* Fuses two skills:
|
|
5
|
+
* - /skill:autopilot : end-to-end plan -> implement -> verify -> stop loop
|
|
6
|
+
* - /skill:autoresearch : frozen evaluator, one change per step, keep-if-improved /
|
|
7
|
+
* revert-otherwise by score, append-only log, baseline-first,
|
|
8
|
+
* convergence/stop discipline.
|
|
9
|
+
*
|
|
10
|
+
* The engine owns the RATCHET BRAIN (decision + evidence ledger), not destructive
|
|
11
|
+
* git ops. Mutation ("make one change") is supplied by the operator/agent via a
|
|
12
|
+
* runner command; reverts run an operator-supplied --on-revert hook.
|
|
13
|
+
*
|
|
14
|
+
* State (per cwd):
|
|
15
|
+
* .jeo/autopilot/session.json frozen contract (immutable for the session)
|
|
16
|
+
* .jeo/autopilot/log.jsonl append-only attempt log (baseline, steps, stops)
|
|
17
|
+
*
|
|
18
|
+
* No external dependencies (Node stdlib only). Runs under Bun or Node.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as fs from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { execSync } from "node:child_process";
|
|
24
|
+
import { renderAutopilotStatusPanel, type AutopilotStatusPanelData } from "./tui/components/autopilot-status";
|
|
25
|
+
|
|
26
|
+
const AP_DIR = path.join(".jeo", "autopilot");
|
|
27
|
+
const SESSION = path.join(AP_DIR, "session.json");
|
|
28
|
+
const LOG = path.join(AP_DIR, "log.jsonl");
|
|
29
|
+
function getShell(): string | undefined {
|
|
30
|
+
if (process.platform === "win32") return undefined;
|
|
31
|
+
return process.env.SHELL || "/bin/bash";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Goal = "min" | "max" | "gate";
|
|
35
|
+
|
|
36
|
+
interface Session {
|
|
37
|
+
task: string;
|
|
38
|
+
evalCmd: string;
|
|
39
|
+
goal: Goal;
|
|
40
|
+
timeoutSec: number;
|
|
41
|
+
patience: number;
|
|
42
|
+
createdAt: string;
|
|
43
|
+
frozen: true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface LogEvent {
|
|
47
|
+
ts: string;
|
|
48
|
+
type: "baseline" | "step" | "stop";
|
|
49
|
+
[k: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type LogEventInput = {
|
|
53
|
+
type: LogEvent["type"];
|
|
54
|
+
[k: string]: unknown;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function die(msg: string): never {
|
|
58
|
+
console.error(`jeo autopilot: ${msg}`);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseArgs(argv: string[]): { positionals: string[]; flags: Record<string, string> } {
|
|
63
|
+
const positionals: string[] = [];
|
|
64
|
+
const flags: Record<string, string> = {};
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const a = argv[i];
|
|
67
|
+
if (a.startsWith("--")) {
|
|
68
|
+
const key = a.slice(2);
|
|
69
|
+
const next = argv[i + 1];
|
|
70
|
+
if (next === undefined || next.startsWith("--")) flags[key] = "true";
|
|
71
|
+
else {
|
|
72
|
+
flags[key] = next;
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
} else positionals.push(a);
|
|
76
|
+
}
|
|
77
|
+
return { positionals, flags };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function loadSession(): Session {
|
|
81
|
+
if (!fs.existsSync(SESSION)) die("no session — run: jeo autopilot init --task <t> --eval <cmd>");
|
|
82
|
+
return JSON.parse(fs.readFileSync(SESSION, "utf8")) as Session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function appendLog(ev: LogEventInput): LogEvent {
|
|
86
|
+
const full: LogEvent = { ts: new Date().toISOString(), ...ev };
|
|
87
|
+
fs.appendFileSync(LOG, JSON.stringify(full) + "\n");
|
|
88
|
+
return full;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readLog(): LogEvent[] {
|
|
92
|
+
if (!fs.existsSync(LOG)) return [];
|
|
93
|
+
return fs
|
|
94
|
+
.readFileSync(LOG, "utf8")
|
|
95
|
+
.split("\n")
|
|
96
|
+
.filter((l) => l.trim())
|
|
97
|
+
.map((l) => JSON.parse(l) as LogEvent);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Run the frozen eval. Returns { score, passed }. score is NaN when no score: line. */
|
|
101
|
+
function runEval(s: Session): { score: number; passed: boolean; output: string } {
|
|
102
|
+
let output = "";
|
|
103
|
+
let passed = true;
|
|
104
|
+
try {
|
|
105
|
+
output = execSync(s.evalCmd, {
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
timeout: s.timeoutSec * 1000,
|
|
108
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
109
|
+
shell: getShell(),
|
|
110
|
+
});
|
|
111
|
+
} catch (e: unknown) {
|
|
112
|
+
passed = false;
|
|
113
|
+
const err = e as { stdout?: string; stderr?: string };
|
|
114
|
+
output = (err.stdout ?? "") + (err.stderr ?? "");
|
|
115
|
+
}
|
|
116
|
+
const matches = [...output.matchAll(/score:\s*(-?\d+(?:\.\d+)?)/gi)];
|
|
117
|
+
const score = matches.length ? Number(matches[matches.length - 1][1]) : NaN;
|
|
118
|
+
return { score, passed, output: output.trim() };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Best kept score so far, folding baseline + kept steps. undefined if none. */
|
|
122
|
+
function currentBest(s: Session): number | undefined {
|
|
123
|
+
let best: number | undefined;
|
|
124
|
+
for (const ev of readLog()) {
|
|
125
|
+
if (ev.type === "baseline" || (ev.type === "step" && ev.decision === "keep")) {
|
|
126
|
+
const sc = ev.score as number;
|
|
127
|
+
if (typeof sc === "number" && !Number.isNaN(sc)) {
|
|
128
|
+
if (best === undefined) best = sc;
|
|
129
|
+
else if (s.goal === "min") best = Math.min(best, sc);
|
|
130
|
+
else if (s.goal === "max") best = Math.max(best, sc);
|
|
131
|
+
else best = sc;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return best;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isImprovement(goal: Goal, score: number, best: number | undefined): boolean {
|
|
139
|
+
if (best === undefined) return true;
|
|
140
|
+
if (goal === "min") return score < best;
|
|
141
|
+
if (goal === "max") return score > best;
|
|
142
|
+
return true; // gate handled via passed, not score
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function hasBaseline(): boolean {
|
|
146
|
+
return readLog().some((e) => e.type === "baseline");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── commands ──────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
function cmdInit(flags: Record<string, string>): void {
|
|
152
|
+
if (fs.existsSync(SESSION) && flags.force !== "true") {
|
|
153
|
+
die(`session already frozen at ${SESSION} (use --force to overwrite)`);
|
|
154
|
+
}
|
|
155
|
+
if (!flags.task) die("init requires --task");
|
|
156
|
+
if (!flags.eval) die("init requires --eval <command that prints 'score: N' or exits 0/1>");
|
|
157
|
+
const goal = (flags.goal ?? "min") as Goal;
|
|
158
|
+
if (!["min", "max", "gate"].includes(goal)) die("--goal must be min|max|gate");
|
|
159
|
+
fs.mkdirSync(AP_DIR, { recursive: true });
|
|
160
|
+
const session: Session = {
|
|
161
|
+
task: flags.task,
|
|
162
|
+
evalCmd: flags.eval,
|
|
163
|
+
goal,
|
|
164
|
+
timeoutSec: flags.timeout ? Number(flags.timeout) : 300,
|
|
165
|
+
patience: flags.patience ? Number(flags.patience) : 3,
|
|
166
|
+
createdAt: new Date().toISOString(),
|
|
167
|
+
frozen: true,
|
|
168
|
+
};
|
|
169
|
+
fs.writeFileSync(SESSION, JSON.stringify(session, null, 2) + "\n");
|
|
170
|
+
// fresh log on (re)init
|
|
171
|
+
fs.writeFileSync(LOG, "");
|
|
172
|
+
console.log(`jeo autopilot: session frozen → ${SESSION}`);
|
|
173
|
+
console.log(` task=${session.task}`);
|
|
174
|
+
console.log(` eval=${session.evalCmd} goal=${session.goal} timeout=${session.timeoutSec}s patience=${session.patience}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function cmdBaseline(): void {
|
|
178
|
+
const s = loadSession();
|
|
179
|
+
if (hasBaseline()) die("baseline already recorded (re-init to reset)");
|
|
180
|
+
const { score, passed, output } = runEval(s);
|
|
181
|
+
appendLog({ type: "baseline", score, passed, output });
|
|
182
|
+
console.log(`jeo autopilot: baseline score=${fmt(score)} passed=${passed}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function cmdStep(flags: Record<string, string>): void {
|
|
186
|
+
const s = loadSession();
|
|
187
|
+
if (s.goal !== "gate" && !hasBaseline()) die("record a baseline first: jeo autopilot baseline");
|
|
188
|
+
const change = flags.change ?? "(unspecified change)";
|
|
189
|
+
const best = currentBest(s);
|
|
190
|
+
const { score, passed, output } = runEval(s);
|
|
191
|
+
|
|
192
|
+
let decision: "keep" | "revert";
|
|
193
|
+
if (s.goal === "gate") {
|
|
194
|
+
decision = passed ? "keep" : "revert";
|
|
195
|
+
} else if (Number.isNaN(score)) {
|
|
196
|
+
decision = "revert"; // no measurable score => cannot prove improvement
|
|
197
|
+
} else {
|
|
198
|
+
decision = isImprovement(s.goal, score, best) ? "keep" : "revert";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (decision === "revert" && flags["on-revert"]) {
|
|
202
|
+
try {
|
|
203
|
+
execSync(flags["on-revert"], { stdio: "inherit", shell: getShell() });
|
|
204
|
+
} catch {
|
|
205
|
+
console.error("jeo autopilot: --on-revert hook failed (decision still logged)");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
appendLog({ type: "step", change, score, passed, decision, prevBest: best ?? null, output });
|
|
210
|
+
const cmp =
|
|
211
|
+
s.goal === "gate"
|
|
212
|
+
? passed ? "pass" : "fail"
|
|
213
|
+
: `${fmt(score)} vs best ${fmt(best)}`;
|
|
214
|
+
console.log(`jeo autopilot: step ${decision.toUpperCase()} (${cmp}) — ${change}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function cmdLoop(flags: Record<string, string>): void {
|
|
218
|
+
const s = loadSession();
|
|
219
|
+
const runner = flags.runner;
|
|
220
|
+
if (!runner) die("loop requires --runner <command that makes ONE change>");
|
|
221
|
+
const max = flags.max ? Number(flags.max) : 10;
|
|
222
|
+
if (s.goal !== "gate" && !hasBaseline()) {
|
|
223
|
+
const { score, passed, output } = runEval(s);
|
|
224
|
+
appendLog({ type: "baseline", score, passed, output });
|
|
225
|
+
console.log(`jeo autopilot: auto-baseline score=${fmt(score)}`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let sinceImprove = 0;
|
|
229
|
+
for (let i = 1; i <= max; i++) {
|
|
230
|
+
// mutate: runner makes exactly one change
|
|
231
|
+
let runnerOk = true;
|
|
232
|
+
try {
|
|
233
|
+
execSync(runner, { stdio: "inherit", timeout: s.timeoutSec * 1000, shell: getShell() });
|
|
234
|
+
} catch {
|
|
235
|
+
runnerOk = false;
|
|
236
|
+
}
|
|
237
|
+
if (!runnerOk) {
|
|
238
|
+
appendLog({ type: "stop", reason: "runner_failed", iteration: i });
|
|
239
|
+
console.log(`jeo autopilot: stop — runner failed at iteration ${i}`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const best = currentBest(s);
|
|
244
|
+
const { score, passed, output } = runEval(s);
|
|
245
|
+
let decision: "keep" | "revert";
|
|
246
|
+
if (s.goal === "gate") decision = passed ? "keep" : "revert";
|
|
247
|
+
else if (Number.isNaN(score)) decision = "revert";
|
|
248
|
+
else decision = isImprovement(s.goal, score, best) ? "keep" : "revert";
|
|
249
|
+
|
|
250
|
+
if (decision === "revert" && flags["on-revert"]) {
|
|
251
|
+
try {
|
|
252
|
+
execSync(flags["on-revert"], { stdio: "inherit", shell: getShell() });
|
|
253
|
+
} catch {
|
|
254
|
+
/* logged below regardless */
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
appendLog({ type: "step", iteration: i, change: `loop#${i}`, score, passed, decision, prevBest: best ?? null, output });
|
|
258
|
+
const improved = decision === "keep" && (s.goal === "gate" || !Number.isNaN(score));
|
|
259
|
+
sinceImprove = improved && (best === undefined || s.goal === "gate" || isImprovement(s.goal, score, best)) ? 0 : sinceImprove + 1;
|
|
260
|
+
console.log(`jeo autopilot: loop ${i}/${max} ${decision.toUpperCase()} score=${fmt(score)} (sinceImprove=${sinceImprove})`);
|
|
261
|
+
|
|
262
|
+
if (s.goal !== "gate" && sinceImprove >= s.patience) {
|
|
263
|
+
appendLog({ type: "stop", reason: "converged", iteration: i, patience: s.patience });
|
|
264
|
+
console.log(`jeo autopilot: stop — converged (no improvement in ${s.patience} steps)`);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
appendLog({ type: "stop", reason: "max_iterations", iteration: max });
|
|
269
|
+
console.log(`jeo autopilot: stop — reached max ${max} iterations`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function cmdStatus(flags: Record<string, string>): void {
|
|
273
|
+
const s = loadSession();
|
|
274
|
+
const log = readLog();
|
|
275
|
+
const steps = log.filter((e) => e.type === "step");
|
|
276
|
+
const kept = steps.filter((e) => e.decision === "keep").length;
|
|
277
|
+
const reverted = steps.filter((e) => e.decision === "revert").length;
|
|
278
|
+
const baseline = log.find((e) => e.type === "baseline");
|
|
279
|
+
const best = currentBest(s);
|
|
280
|
+
const stop = [...log].reverse().find((e) => e.type === "stop");
|
|
281
|
+
|
|
282
|
+
// convergence: steps since last keep-with-improvement
|
|
283
|
+
let sinceImprove = 0;
|
|
284
|
+
for (const e of steps) {
|
|
285
|
+
if (e.decision === "keep") sinceImprove = 0;
|
|
286
|
+
else sinceImprove++;
|
|
287
|
+
}
|
|
288
|
+
const converged = s.goal !== "gate" && sinceImprove >= s.patience;
|
|
289
|
+
|
|
290
|
+
let recommendation: string;
|
|
291
|
+
if (stop) recommendation = `stopped: ${stop.reason as string}`;
|
|
292
|
+
else if (converged) recommendation = "converged — stop or change strategy";
|
|
293
|
+
else recommendation = "continue";
|
|
294
|
+
|
|
295
|
+
const out: AutopilotStatusPanelData = {
|
|
296
|
+
task: s.task,
|
|
297
|
+
goal: s.goal,
|
|
298
|
+
eval: s.evalCmd,
|
|
299
|
+
baseline: fmt(baseline ? (baseline.score as number) : null),
|
|
300
|
+
best: fmt(best ?? null),
|
|
301
|
+
attempts: steps.length,
|
|
302
|
+
kept,
|
|
303
|
+
reverted,
|
|
304
|
+
sinceImprove,
|
|
305
|
+
converged,
|
|
306
|
+
recommendation,
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
if (flags.json === "true") {
|
|
310
|
+
console.log(JSON.stringify({
|
|
311
|
+
...out,
|
|
312
|
+
baseline: baseline ? (baseline.score as number) : null,
|
|
313
|
+
best: best ?? null,
|
|
314
|
+
}, null, 2));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
console.log(renderAutopilotStatusPanel(out, {
|
|
318
|
+
cols: process.stdout.columns || 88,
|
|
319
|
+
color: !!process.stdout.isTTY,
|
|
320
|
+
unicode: true,
|
|
321
|
+
}).join("\n"));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function fmt(n: number | null | undefined): string {
|
|
325
|
+
if (n === null || n === undefined || Number.isNaN(n)) return "—";
|
|
326
|
+
return String(n);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function help(): void {
|
|
330
|
+
console.log(
|
|
331
|
+
[
|
|
332
|
+
"jeo autopilot — autonomous build loop with autoresearch ratcheting",
|
|
333
|
+
"",
|
|
334
|
+
" init --task <t> --eval <cmd> [--goal min|max|gate] [--timeout S] [--patience N]",
|
|
335
|
+
" baseline",
|
|
336
|
+
" step --change <desc> [--on-revert <cmd>]",
|
|
337
|
+
" loop --runner <cmd> [--max N] [--on-revert <cmd>]",
|
|
338
|
+
" status [--json]",
|
|
339
|
+
"",
|
|
340
|
+
"eval contract: command prints 'score: <number>' (min/max goals) or exits 0/1 (gate goal).",
|
|
341
|
+
].join("\n"),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function runAutopilot(argv: string[]): void {
|
|
346
|
+
const [cmd, ...rest] = argv;
|
|
347
|
+
const { flags } = parseArgs(rest);
|
|
348
|
+
switch (cmd) {
|
|
349
|
+
case "init": cmdInit(flags); break;
|
|
350
|
+
case "baseline": cmdBaseline(); break;
|
|
351
|
+
case "step": cmdStep(flags); break;
|
|
352
|
+
case "loop": cmdLoop(flags); break;
|
|
353
|
+
case "status": cmdStatus(flags); break;
|
|
354
|
+
case undefined:
|
|
355
|
+
case "help":
|
|
356
|
+
case "--help": help(); break;
|
|
357
|
+
default: die(`unknown subcommand: ${cmd} (try: jeo autopilot help)`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// allow running this module directly: bun src/autopilot.ts <args>
|
|
362
|
+
if (import.meta.main) runAutopilot(process.argv.slice(2));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!-- Parent: ../AGENTS.md -->
|
|
2
|
+
<!-- Generated: 2026-06-11 | Updated: 2026-06-11 -->
|
|
3
|
+
|
|
4
|
+
# cli
|
|
5
|
+
|
|
6
|
+
## Purpose
|
|
7
|
+
Command-line interface routing, argument parsing, and initialization logic. Defines the shape of the `jeo` binary interface.
|
|
8
|
+
|
|
9
|
+
## Key Files
|
|
10
|
+
| File | Description |
|
|
11
|
+
|------|-------------|
|
|
12
|
+
| `parser.ts` | Argument parsing and flag validation |
|
|
13
|
+
| `router.ts` | Dispatches CLI commands to their respective implementations |
|
|
14
|
+
|
|
15
|
+
## Subdirectories
|
|
16
|
+
*(None)*
|
|
17
|
+
|
|
18
|
+
## For AI Agents
|
|
19
|
+
|
|
20
|
+
### Working In This Directory
|
|
21
|
+
- Keep parsing logic declarative.
|
|
22
|
+
- Ensure all flags have clear help text and defaults.
|
|
23
|
+
- Delegate actual command execution to `src/commands/`.
|
|
24
|
+
|
|
25
|
+
### Testing Requirements
|
|
26
|
+
- Test CLI arg parsing with various flag combinations.
|
|
27
|
+
|
|
28
|
+
### Common Patterns
|
|
29
|
+
- Early exit for `--help` and `--version`.
|
|
30
|
+
|
|
31
|
+
## Dependencies
|
|
32
|
+
|
|
33
|
+
### Internal
|
|
34
|
+
- Routes to `src/commands/`.
|
|
35
|
+
|
|
36
|
+
### External
|
|
37
|
+
- Standard CLI arg parsing libraries or custom minimal parsers.
|
|
38
|
+
|
|
39
|
+
<!-- MANUAL: -->
|