jeo-code 0.1.0 → 0.4.4

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.
Files changed (177) hide show
  1. package/README.ja.md +160 -0
  2. package/README.ko.md +160 -0
  3. package/README.md +115 -297
  4. package/README.zh.md +160 -0
  5. package/package.json +11 -6
  6. package/scripts/install.sh +28 -28
  7. package/scripts/uninstall.sh +17 -15
  8. package/src/AGENTS.md +50 -0
  9. package/src/agent/AGENTS.md +49 -0
  10. package/src/agent/bash-fixups.ts +103 -0
  11. package/src/agent/compaction.ts +410 -19
  12. package/src/agent/config-schema.ts +119 -5
  13. package/src/agent/context-files.ts +314 -17
  14. package/src/agent/dev/AGENTS.md +36 -0
  15. package/src/agent/dev/advanced-analyzer.ts +12 -0
  16. package/src/agent/dev/evolution-bridge.ts +82 -0
  17. package/src/agent/dev/evolution-logger.ts +41 -0
  18. package/src/agent/dev/self-analysis.ts +64 -0
  19. package/src/agent/dev/self-improve.ts +24 -0
  20. package/src/agent/dev/spec-automation.ts +49 -0
  21. package/src/agent/engine.ts +804 -54
  22. package/src/agent/hooks.ts +273 -0
  23. package/src/agent/loop.ts +21 -1
  24. package/src/agent/memory.ts +201 -0
  25. package/src/agent/model-recency.ts +32 -0
  26. package/src/agent/output-minimizer.ts +108 -0
  27. package/src/agent/output-util.ts +64 -0
  28. package/src/agent/plan.ts +187 -0
  29. package/src/agent/seed.ts +52 -0
  30. package/src/agent/session.ts +235 -21
  31. package/src/agent/state.ts +286 -39
  32. package/src/agent/step-budget.ts +232 -0
  33. package/src/agent/subagents.ts +223 -26
  34. package/src/agent/task-tool.ts +272 -0
  35. package/src/agent/todo-tool.ts +87 -0
  36. package/src/agent/tokenizer.ts +117 -0
  37. package/src/agent/tool-registry.ts +54 -0
  38. package/src/agent/tools.ts +562 -103
  39. package/src/agent/web-search.ts +538 -0
  40. package/src/ai/AGENTS.md +44 -0
  41. package/src/ai/index.ts +1 -0
  42. package/src/ai/model-catalog-compat.ts +3 -1
  43. package/src/ai/model-catalog.ts +74 -9
  44. package/src/ai/model-discovery.ts +215 -17
  45. package/src/ai/model-manager.ts +346 -32
  46. package/src/ai/model-picker.ts +1 -1
  47. package/src/ai/model-registry.ts +4 -2
  48. package/src/ai/pricing.ts +84 -0
  49. package/src/ai/provider-registry.ts +23 -0
  50. package/src/ai/provider-status.ts +60 -16
  51. package/src/ai/providers/AGENTS.md +42 -0
  52. package/src/ai/providers/anthropic.ts +250 -31
  53. package/src/ai/providers/antigravity.ts +219 -0
  54. package/src/ai/providers/errors.ts +15 -1
  55. package/src/ai/providers/gemini.ts +196 -13
  56. package/src/ai/providers/ollama.ts +37 -7
  57. package/src/ai/providers/openai-responses.ts +173 -0
  58. package/src/ai/providers/openai.ts +64 -12
  59. package/src/ai/sse.ts +4 -1
  60. package/src/ai/types.ts +18 -1
  61. package/src/auth/AGENTS.md +41 -0
  62. package/src/auth/callback-server.ts +6 -1
  63. package/src/auth/flows/AGENTS.md +32 -0
  64. package/src/auth/flows/antigravity.ts +151 -0
  65. package/src/auth/flows/google-project.ts +190 -0
  66. package/src/auth/flows/google.ts +39 -18
  67. package/src/auth/flows/index.ts +15 -5
  68. package/src/auth/flows/openai.ts +2 -2
  69. package/src/auth/oauth.ts +8 -0
  70. package/src/auth/refresh.ts +44 -27
  71. package/src/auth/storage.ts +149 -26
  72. package/src/auth/types.ts +1 -1
  73. package/src/autopilot.ts +362 -0
  74. package/src/bun-imports.d.ts +4 -0
  75. package/src/cli/AGENTS.md +39 -0
  76. package/src/cli/runner.ts +148 -14
  77. package/src/cli.ts +13 -4
  78. package/src/commands/AGENTS.md +40 -0
  79. package/src/commands/approve.ts +62 -3
  80. package/src/commands/auth.ts +167 -25
  81. package/src/commands/chat.ts +37 -8
  82. package/src/commands/deep-interview.ts +633 -175
  83. package/src/commands/doctor.ts +84 -37
  84. package/src/commands/evolve-core.ts +18 -0
  85. package/src/commands/evolve.ts +2 -1
  86. package/src/commands/export.ts +176 -0
  87. package/src/commands/gjc.ts +52 -0
  88. package/src/commands/launch.ts +3549 -240
  89. package/src/commands/mcp.ts +3 -3
  90. package/src/commands/ooo-seed.ts +19 -0
  91. package/src/commands/ralplan.ts +253 -35
  92. package/src/commands/resume.ts +1 -1
  93. package/src/commands/session.ts +183 -0
  94. package/src/commands/setup-helpers.ts +10 -3
  95. package/src/commands/setup.ts +57 -16
  96. package/src/commands/skills.ts +78 -18
  97. package/src/commands/state.ts +198 -0
  98. package/src/commands/status.ts +84 -0
  99. package/src/commands/team.ts +340 -212
  100. package/src/commands/ultragoal.ts +122 -61
  101. package/src/commands/update.ts +244 -0
  102. package/src/ledger.ts +270 -0
  103. package/src/mcp/AGENTS.md +38 -0
  104. package/src/mcp/server.ts +115 -14
  105. package/src/mcp/tools.ts +42 -22
  106. package/src/md-modules.d.ts +4 -0
  107. package/src/prompts/AGENTS.md +41 -0
  108. package/src/prompts/agents/AGENTS.md +35 -0
  109. package/src/prompts/agents/architect.md +35 -0
  110. package/src/prompts/agents/critic.md +37 -0
  111. package/src/prompts/agents/executor.md +36 -0
  112. package/src/prompts/agents/planner.md +37 -0
  113. package/src/prompts/skills/AGENTS.md +36 -0
  114. package/src/prompts/skills/deep-dive/AGENTS.md +31 -0
  115. package/src/prompts/skills/deep-dive/SKILL.md +13 -0
  116. package/src/prompts/skills/deep-interview/AGENTS.md +31 -0
  117. package/src/prompts/skills/deep-interview/SKILL.md +12 -0
  118. package/src/prompts/skills/gjc/AGENTS.md +31 -0
  119. package/src/prompts/skills/gjc/SKILL.md +15 -0
  120. package/src/prompts/skills/ralplan/AGENTS.md +31 -0
  121. package/src/prompts/skills/ralplan/SKILL.md +11 -0
  122. package/src/prompts/skills/team/AGENTS.md +31 -0
  123. package/src/prompts/skills/team/SKILL.md +11 -0
  124. package/src/prompts/skills/ultragoal/AGENTS.md +31 -0
  125. package/src/prompts/skills/ultragoal/SKILL.md +11 -0
  126. package/src/skills/AGENTS.md +38 -0
  127. package/src/skills/catalog.ts +565 -31
  128. package/src/tui/AGENTS.md +43 -0
  129. package/src/tui/app.ts +1181 -92
  130. package/src/tui/components/AGENTS.md +42 -0
  131. package/src/tui/components/ascii-art.ts +257 -15
  132. package/src/tui/components/autocomplete.ts +98 -16
  133. package/src/tui/components/autopilot-status.ts +65 -0
  134. package/src/tui/components/category-index.ts +49 -0
  135. package/src/tui/components/code-view.ts +54 -11
  136. package/src/tui/components/color.ts +171 -2
  137. package/src/tui/components/config-panel.ts +82 -15
  138. package/src/tui/components/duration.ts +38 -0
  139. package/src/tui/components/evolution.ts +3 -3
  140. package/src/tui/components/footer.ts +91 -42
  141. package/src/tui/components/forge.ts +426 -31
  142. package/src/tui/components/hints.ts +54 -0
  143. package/src/tui/components/hud.ts +73 -0
  144. package/src/tui/components/index.ts +4 -0
  145. package/src/tui/components/input-box.ts +150 -0
  146. package/src/tui/components/layout.ts +11 -3
  147. package/src/tui/components/live-model-picker.ts +108 -0
  148. package/src/tui/components/markdown-table.ts +140 -0
  149. package/src/tui/components/markdown-text.ts +97 -0
  150. package/src/tui/components/meter.ts +4 -1
  151. package/src/tui/components/model-picker.ts +3 -2
  152. package/src/tui/components/provider-picker.ts +3 -2
  153. package/src/tui/components/section.ts +70 -0
  154. package/src/tui/components/select-list.ts +40 -10
  155. package/src/tui/components/skill-picker.ts +25 -0
  156. package/src/tui/components/slash.ts +244 -21
  157. package/src/tui/components/status.ts +272 -11
  158. package/src/tui/components/step-timeline.ts +218 -0
  159. package/src/tui/components/stream.ts +26 -9
  160. package/src/tui/components/themes.ts +212 -6
  161. package/src/tui/components/todo-card.ts +47 -0
  162. package/src/tui/components/tool-list.ts +58 -12
  163. package/src/tui/components/transcript.ts +120 -0
  164. package/src/tui/components/update-box.ts +31 -0
  165. package/src/tui/components/welcome.ts +162 -0
  166. package/src/tui/components/width.ts +163 -0
  167. package/src/tui/monitoring/AGENTS.md +31 -0
  168. package/src/tui/monitoring/hud-view.ts +55 -0
  169. package/src/tui/renderer.ts +112 -3
  170. package/src/tui/terminal.ts +40 -33
  171. package/src/util/AGENTS.md +39 -0
  172. package/src/util/clipboard-image.ts +118 -0
  173. package/src/util/env.ts +12 -0
  174. package/src/util/provider-error.ts +78 -0
  175. package/src/util/retry.ts +91 -6
  176. package/src/util/update-check.ts +64 -0
  177. package/src/commands/models.ts +0 -104
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
2
2
  import * as path from "node:path";
3
3
  import * as os from "node:os";
4
4
  import { parseConfig } from "./config-schema";
5
+ import { jeoEnv } from "../util/env";
5
6
 
6
7
  /** Persisted OAuth credential set (access + refresh + expiry) for a provider. */
7
8
  export interface StoredOAuth {
@@ -14,30 +15,45 @@ export interface StoredOAuth {
14
15
  projectId?: string;
15
16
  }
16
17
 
18
+ export interface HookConfig {
19
+ enabled?: boolean;
20
+ hooks?: Array<{
21
+ event: "pre-tool" | "post-turn" | "post-implementation";
22
+ match?: { tool?: string };
23
+ run: string;
24
+ timeoutMs?: number;
25
+ }>;
26
+ }
27
+
17
28
  export interface Config {
18
29
  providers: {
19
30
  anthropic?: string;
20
31
  openai?: string;
21
32
  gemini?: string;
33
+ antigravity?: string;
22
34
  };
23
35
  /**
24
- * OAuth credentials (take precedence over API keys for the same provider).
25
- * A bare string is a legacy/manually-pasted bearer with no refresh metadata;
26
- * a {@link StoredOAuth} object carries refresh token + expiry for auto-refresh.
36
+ * OAuth credentials. `resolveCredential()` returns these before API keys so refresh
37
+ * metadata is not lost, but provider execution/status applies the GJC parity rule:
38
+ * an API key is broader and wins whenever both key + OAuth exist.
27
39
  */
28
40
  oauth?: {
29
41
  anthropic?: string | StoredOAuth;
30
42
  openai?: string | StoredOAuth;
31
43
  gemini?: string | StoredOAuth;
44
+ antigravity?: string | StoredOAuth;
32
45
  };
33
46
  /** Base URL for the local Ollama server (keyless). */
34
47
  ollamaBaseUrl?: string;
35
48
  /** Base URL override for OpenAI-compatible providers (LM Studio, vLLM, llama-cpp-server, ...). */
36
49
  openaiBaseUrl?: string;
37
50
  defaultModel: string;
51
+ theme?: string;
38
52
  thinkingLevel?: "minimal" | "low" | "medium" | "high" | "xhigh";
39
53
  /** Friendly model aliases, e.g. { fast: "ollama/qwen2.5:0.5b" }. Override built-ins. */
40
54
  modelAliases?: { [alias: string]: string };
55
+ /** Most-recently-selected models, newest first (MRU; head == defaultModel). */
56
+ recentModels?: string[];
41
57
  /**
42
58
  * Provider retry budgets (gjc parity). `requestMaxRetries` is the number of
43
59
  * retries (excluding the initial request) for a provider request; `maxDelayMs`
@@ -49,18 +65,57 @@ export interface Config {
49
65
  streamMaxRetries?: number;
50
66
  maxRetries?: number;
51
67
  maxDelayMs?: number;
68
+ /** Retries (excluding the initial request) specifically for 429 rate limits. */
69
+ rateLimitRetries?: number;
70
+ /** Minimum backoff (ms) for a 429 when the server sends no Retry-After. */
71
+ rateLimitMinDelayMs?: number;
72
+ /** HTTP statuses to treat as NON-retryable even when defaultRetryable would
73
+ * retry them (e.g. pin 503 to fail fast instead of riding the backoff ladder). */
74
+ failFastStatuses?: number[];
75
+ /** Case-insensitive substrings; an error whose message matches any of these
76
+ * fails fast (non-retryable) even when the chosen predicate would retry it. */
77
+ failFastPatterns?: string[];
52
78
  };
53
79
  /**
54
- * Per-subagent-role overrides (gjc role-agent parity). Keyed by role id
55
- * (executor / planner / architect / critic); each may pin a model and/or a
56
- * tool-loop step budget.
80
+ * Per-subagent-role overrides (gjc role-agent parity), keyed by role id.
81
+ * Bundled ids (executor / planner / architect / critic) take model / maxSteps /
82
+ * thinking pins. A NON-bundled id that declares `title`, `description`, or
83
+ * `prompt` becomes a config-defined CUSTOM ROLE (system-driven registry —
84
+ * see rolesFromConfig). Custom roles are read-only unless `readOnly: false`.
57
85
  */
58
- subagents?: { [roleId: string]: { model?: string; maxSteps?: number } };
86
+ subagents?: {
87
+ [roleId: string]: {
88
+ model?: string;
89
+ maxSteps?: number;
90
+ thinking?: "minimal" | "low" | "medium" | "high" | "xhigh";
91
+ title?: string;
92
+ description?: string;
93
+ prompt?: string;
94
+ readOnly?: boolean;
95
+ };
96
+ };
59
97
  /**
60
98
  * Model role tiers (gjc `--smol`/`--slow`/`--plan` parity). Each falls back to
61
- * `defaultModel`. Env `JOC_SMOL_MODEL`/`JOC_SLOW_MODEL`/`JOC_PLAN_MODEL` fill gaps.
99
+ * `defaultModel`. Env `JEO_SMOL_MODEL`/`JEO_SLOW_MODEL`/`JEO_PLAN_MODEL` fill gaps.
62
100
  */
63
101
  roles?: { smol?: string; slow?: string; plan?: string };
102
+ hooks?: HookConfig;
103
+ }
104
+
105
+ export interface WorkflowTopologyComponent {
106
+ id: string;
107
+ name: string;
108
+ description: string;
109
+ status: "active" | "deferred";
110
+ evidence?: string[];
111
+ }
112
+
113
+ export interface WorkflowTopologyState {
114
+ status: "pending" | "confirmed" | "legacy_missing";
115
+ confirmed_at?: string | null;
116
+ components: WorkflowTopologyComponent[];
117
+ deferrals?: Array<{ component_id: string; reason: string; confirmed_at: string }>;
118
+ last_targeted_component_id?: string | null;
64
119
  }
65
120
 
66
121
  export interface WorkflowState {
@@ -72,20 +127,45 @@ export interface WorkflowState {
72
127
  initial_idea?: string;
73
128
  current_ambiguity?: number;
74
129
  threshold?: number;
130
+ threshold_source?: string;
131
+ type?: "greenfield" | "brownfield";
75
132
  seed_path?: string;
133
+ topology?: WorkflowTopologyState;
134
+ codebase_context?: string;
135
+ language?: string;
76
136
  plan_path?: string;
77
137
  completed_tasks?: string[];
78
138
  pending_tasks?: string[];
139
+ /** team: the task the previous run failed on — surfaces a partial-edits
140
+ * warning on resume (round-8). Cleared when the run resumes past it. */
141
+ failed_task?: string;
142
+ /** ralplan: persisted consensus-critic verdict ("okay" | "iterate" | "reject" |
143
+ * "unverified") — `jeo approve` requires "okay" (round-11 real consensus). */
144
+ consensus?: string;
145
+ /** ralplan: the critic's justification (bounded excerpt) for auditability. */
146
+ consensus_detail?: string;
79
147
  approved?: boolean;
148
+ /** ultragoal terminal outcome. */
149
+ status?: string;
150
+ passed?: number;
151
+ total?: number;
152
+ /** ultragoal: whether the global verification suite was green (round-7 honest
153
+ * contract — criteria are recorded, not individually claimed as passed). */
154
+ suite_green?: boolean;
80
155
  }
81
156
 
157
+ /** The built-in default model when neither disk config nor JEO_DEFAULT_MODEL provides one.
158
+ * Shared by envDefaultConfig (runtime) and readRawGlobalConfig (persistence base) so a
159
+ * fresh-install saveConfigPatch never bakes a DIFFERENT default than the runtime resolves. */
160
+ const DEFAULT_MODEL = "claude-sonnet-4-5";
161
+
82
162
  /**
83
163
  * Resolve the global config directory at call time (not import time) so that a
84
- * `JOC_CONFIG_DIR` override or a runtime `HOME` change is always honored.
85
- * `JOC_CONFIG_DIR` takes precedence; otherwise `~/.joc`.
164
+ * `JEO_CONFIG_DIR` override or a runtime `HOME` change is always honored.
165
+ * `JEO_CONFIG_DIR` takes precedence; otherwise `~/.jeo`.
86
166
  */
87
167
  function globalConfigDir(): string {
88
- return process.env.JOC_CONFIG_DIR || path.join(os.homedir(), ".joc");
168
+ return jeoEnv("CONFIG_DIR") || path.join(os.homedir(), ".jeo");
89
169
  }
90
170
  function globalConfigPath(): string {
91
171
  return path.join(globalConfigDir(), "config.json");
@@ -99,20 +179,31 @@ function envOAuth(): NonNullable<Config["oauth"]> {
99
179
  };
100
180
  }
101
181
 
102
- /** Merge env-provided OAuth tokens / Ollama base over a config (env fills gaps only). */
182
+ /** Merge env-provided credentials / base URLs over a config (env fills gaps only;
183
+ * on-disk values always win). Previously the providers API-key map was NOT overlaid
184
+ * when a config file existed, so a provider whose key lived only in the environment
185
+ * (e.g. GEMINI_API_KEY) resolved to "no credential" — breaking provider/model
186
+ * selection (including per-role subagent overrides) despite the key being present. */
103
187
  function withEnvOverlay(cfg: Config): Config {
104
188
  const envTok = envOAuth();
105
189
  const oauth = { ...envTok, ...(cfg.oauth ?? {}) };
190
+ // Disk wins when it is a real key; env fills missing/blank gaps. A hand-edited
191
+ // empty string should not mask a valid environment credential.
192
+ const providers: Config["providers"] = { ...(cfg.providers ?? {}) };
193
+ if (!providers.anthropic && process.env.ANTHROPIC_API_KEY) providers.anthropic = process.env.ANTHROPIC_API_KEY;
194
+ if (!providers.openai && process.env.OPENAI_API_KEY) providers.openai = process.env.OPENAI_API_KEY;
195
+ if (!providers.gemini && process.env.GEMINI_API_KEY) providers.gemini = process.env.GEMINI_API_KEY;
106
196
  return {
107
197
  ...cfg,
198
+ providers,
108
199
  oauth,
109
- defaultModel: process.env.JOC_DEFAULT_MODEL || cfg.defaultModel,
200
+ defaultModel: jeoEnv("DEFAULT_MODEL") || cfg.defaultModel,
110
201
  ollamaBaseUrl: cfg.ollamaBaseUrl || process.env.OLLAMA_HOST || "http://localhost:11434",
111
202
  openaiBaseUrl: cfg.openaiBaseUrl || process.env.OPENAI_BASE_URL,
112
203
  roles: {
113
- smol: cfg.roles?.smol || process.env.JOC_SMOL_MODEL,
114
- slow: cfg.roles?.slow || process.env.JOC_SLOW_MODEL,
115
- plan: cfg.roles?.plan || process.env.JOC_PLAN_MODEL,
204
+ smol: cfg.roles?.smol || jeoEnv("SMOL_MODEL"),
205
+ slow: cfg.roles?.slow || jeoEnv("SLOW_MODEL"),
206
+ plan: cfg.roles?.plan || jeoEnv("PLAN_MODEL"),
116
207
  },
117
208
  };
118
209
  }
@@ -124,50 +215,122 @@ function envDefaultConfig(): Config {
124
215
  openai: process.env.OPENAI_API_KEY,
125
216
  gemini: process.env.GEMINI_API_KEY,
126
217
  },
127
- defaultModel: process.env.JOC_DEFAULT_MODEL || "claude-3-5-sonnet",
218
+ defaultModel: jeoEnv("DEFAULT_MODEL") || DEFAULT_MODEL,
128
219
  thinkingLevel: "medium",
129
220
  };
130
221
  }
131
222
 
132
- export async function readGlobalConfig(): Promise<Config> {
133
- let data: string;
223
+ /**
224
+ * Parsed-config read cache keyed by path and validated by stat (mtimeMs + size).
225
+ * `readGlobalConfig`/`readRawGlobalConfig` run on EVERY model call (resolveCall,
226
+ * credential resolution, per-turn config reads), so an uncached read paid a disk
227
+ * read + JSON.parse + zod validation per agent-loop step. The cache is bounded
228
+ * (≤8 paths — one per JEO_CONFIG_DIR seen) and invalidated by `saveGlobalConfig`;
229
+ * any external write changes mtime/size, so a stale entry is never served. Reads
230
+ * return a structuredClone so callers can never poison the cached object.
231
+ */
232
+ type ParsedFile =
233
+ | { kind: "ok"; config: Config }
234
+ | { kind: "missing" }
235
+ | { kind: "bad-json"; warned: boolean }
236
+ | { kind: "invalid"; message: string; warned: boolean };
237
+ const configReadCache = new Map<string, { mtimeMs: number; size: number; parsed: ParsedFile }>();
238
+ const CONFIG_CACHE_CAP = 8;
239
+
240
+ /** Drop all cached config reads (tests / explicit invalidation). */
241
+ export function clearConfigReadCache(): void {
242
+ configReadCache.clear();
243
+ }
244
+
245
+ async function readParsedConfigFile(): Promise<ParsedFile> {
246
+ const p = globalConfigPath();
247
+ let st: { mtimeMs: number; size: number };
134
248
  try {
135
- data = await fs.readFile(globalConfigPath(), "utf-8");
249
+ st = await fs.stat(p);
136
250
  } catch {
137
- return withEnvOverlay(envDefaultConfig());
251
+ configReadCache.delete(p);
252
+ return { kind: "missing" };
138
253
  }
139
-
140
- let raw: unknown;
254
+ const hit = configReadCache.get(p);
255
+ if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.parsed;
256
+ let parsed: ParsedFile;
141
257
  try {
142
- raw = JSON.parse(data);
258
+ const raw = JSON.parse(await fs.readFile(p, "utf-8"));
259
+ const result = parseConfig(raw);
260
+ parsed = result.ok
261
+ ? { kind: "ok", config: result.config as Config }
262
+ : { kind: "invalid", message: result.message, warned: false };
143
263
  } catch {
144
- process.stderr.write(`[joc] ${globalConfigPath()} is not valid JSON; using environment defaults.\n`);
145
- return withEnvOverlay(envDefaultConfig());
264
+ parsed = { kind: "bad-json", warned: false };
265
+ }
266
+ if (configReadCache.size >= CONFIG_CACHE_CAP && !configReadCache.has(p)) {
267
+ const oldest = configReadCache.keys().next().value;
268
+ if (oldest !== undefined) configReadCache.delete(oldest);
146
269
  }
270
+ configReadCache.set(p, { mtimeMs: st.mtimeMs, size: st.size, parsed });
271
+ return parsed;
272
+ }
147
273
 
148
- const parsed = parseConfig(raw);
149
- if (!parsed.ok) {
150
- process.stderr.write(`[joc] ${globalConfigPath()} is invalid (${parsed.message}); using environment defaults.\n`);
274
+ export async function readGlobalConfig(): Promise<Config> {
275
+ const parsed = await readParsedConfigFile();
276
+ if (parsed.kind === "bad-json" || parsed.kind === "invalid") {
277
+ // Warn once per file VERSION (mtime/size change resets the entry), not per read.
278
+ if (!parsed.warned) {
279
+ parsed.warned = true;
280
+ const detail = parsed.kind === "invalid" ? ` is invalid (${parsed.message})` : " is not valid JSON";
281
+ process.stderr.write(`[jeo] ${globalConfigPath()}${detail}; using environment defaults.\n`);
282
+ }
151
283
  return withEnvOverlay(envDefaultConfig());
152
284
  }
153
- return withEnvOverlay(parsed.config as Config);
285
+ if (parsed.kind === "missing") return withEnvOverlay(envDefaultConfig());
286
+ return withEnvOverlay(structuredClone(parsed.config));
154
287
  }
155
288
 
156
289
  export async function saveGlobalConfig(config: Config): Promise<void> {
157
- await fs.mkdir(globalConfigDir(), { recursive: true, mode: 0o700 });
158
- await fs.writeFile(globalConfigPath(), JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 });
159
- await fs.chmod(globalConfigPath(), 0o600).catch(() => {}); // ensure mode even if file pre-existed
290
+ const dir = globalConfigDir();
291
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
292
+ const target = globalConfigPath();
293
+ const tmpPath = `${target}.${Math.random().toString(36).slice(2)}.tmp`;
294
+ try {
295
+ await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), { encoding: "utf-8", mode: 0o600 });
296
+ await fs.chmod(tmpPath, 0o600).catch(() => {});
297
+ await fs.rename(tmpPath, target);
298
+ configReadCache.delete(target); // the next read re-parses the fresh file
299
+ } catch (err) {
300
+ await fs.unlink(tmpPath).catch(() => {});
301
+ throw err;
302
+ }
160
303
  }
161
304
 
162
- export function getLocalJocDir(cwd: string = process.cwd()): string {
163
- return path.join(cwd, ".joc");
305
+ /** Read the on-disk config WITHOUT the env overlay. Used as the base for
306
+ * persistence so env-only values (OAuth bearer tokens, JEO_DEFAULT_MODEL,
307
+ * JEO_*_MODEL role tiers, OLLAMA_HOST/OPENAI_BASE_URL) are never baked into
308
+ * ~/.jeo/config.json by an unrelated `/agents`/`/roles`/`/model save`. */
309
+ export async function readRawGlobalConfig(): Promise<Config> {
310
+ const clean: Config = { providers: {}, defaultModel: DEFAULT_MODEL, thinkingLevel: "medium" };
311
+ const parsed = await readParsedConfigFile();
312
+ return parsed.kind === "ok" ? structuredClone(parsed.config) : clean;
313
+ }
314
+
315
+ /** Merge a patch onto the RAW on-disk config and persist. The `build` callback
316
+ * receives the raw config so partial updates (subagents/roles maps) are derived
317
+ * from on-disk state, never from the env-overlaid runtime config. */
318
+ export async function saveConfigPatch(build: (raw: Config) => Partial<Config>): Promise<Config> {
319
+ const raw = await readRawGlobalConfig();
320
+ const next = { ...raw, ...build(raw) };
321
+ await saveGlobalConfig(next);
322
+ return next;
323
+ }
324
+
325
+ export function getLocalJeoDir(cwd: string = process.cwd()): string {
326
+ return path.join(cwd, ".jeo");
164
327
  }
165
328
 
166
329
  export async function readWorkflowState(
167
330
  skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
168
331
  cwd: string = process.cwd()
169
332
  ): Promise<WorkflowState | null> {
170
- const statePath = path.join(getLocalJocDir(cwd), "state", `${skill}-state.json`);
333
+ const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
171
334
  try {
172
335
  const data = await fs.readFile(statePath, "utf-8");
173
336
  return JSON.parse(data) as WorkflowState;
@@ -176,15 +339,49 @@ export async function readWorkflowState(
176
339
  }
177
340
  }
178
341
 
342
+ /**
343
+ * Like {@link readWorkflowState} but distinguishes a missing file (→ null) from a
344
+ * corrupt/invalid one (→ throws). Security-sensitive callers (the MutationGuard)
345
+ * use this to fail CLOSED: a corrupt lock state must not be treated as "no lock".
346
+ */
347
+ export async function readWorkflowStateStrict(
348
+ skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
349
+ cwd: string = process.cwd()
350
+ ): Promise<WorkflowState | null> {
351
+ const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
352
+ let data: string;
353
+ try {
354
+ data = await fs.readFile(statePath, "utf-8");
355
+ } catch (err) {
356
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
357
+ throw err;
358
+ }
359
+ try {
360
+ return JSON.parse(data) as WorkflowState;
361
+ } catch {
362
+ throw new Error(`workflow state ${statePath} is corrupt (invalid JSON)`);
363
+ }
364
+ }
365
+
179
366
  export async function writeWorkflowState(
180
367
  skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
181
368
  state: WorkflowState,
182
369
  cwd: string = process.cwd()
183
370
  ): Promise<string> {
184
- const stateDir = path.join(getLocalJocDir(cwd), "state");
371
+ const stateDir = path.join(getLocalJeoDir(cwd), "state");
185
372
  await fs.mkdir(stateDir, { recursive: true });
186
373
  const statePath = path.join(stateDir, `${skill}-state.json`);
187
- await fs.writeFile(statePath, JSON.stringify(state, null, 2), "utf-8");
374
+ // Atomic temp+rename (zeroclaw crash-durability): workflow state is rewritten
375
+ // repeatedly mid-workflow, and the mutation guard fails CLOSED on corrupt JSON —
376
+ // a torn write would otherwise wedge the agent into a permanent mutation block.
377
+ const tmpPath = `${statePath}.${Math.random().toString(36).slice(2)}.tmp`;
378
+ try {
379
+ await fs.writeFile(tmpPath, JSON.stringify(state, null, 2), "utf-8");
380
+ await fs.rename(tmpPath, statePath);
381
+ } catch (err) {
382
+ await fs.unlink(tmpPath).catch(() => {});
383
+ throw err;
384
+ }
188
385
  return statePath;
189
386
  }
190
387
 
@@ -192,8 +389,58 @@ export async function clearWorkflowState(
192
389
  skill: "deep-interview" | "ralplan" | "team" | "ultragoal",
193
390
  cwd: string = process.cwd()
194
391
  ): Promise<void> {
195
- const statePath = path.join(getLocalJocDir(cwd), "state", `${skill}-state.json`);
392
+ const statePath = path.join(getLocalJeoDir(cwd), "state", `${skill}-state.json`);
196
393
  try {
197
394
  await fs.unlink(statePath);
198
395
  } catch {}
199
396
  }
397
+
398
+ /**
399
+ * Cross-process run lock for a workflow engine (round-8, architect ref
400
+ * 7-Round7Workflow): two concurrent `jeo team` processes would read-modify-write
401
+ * team-state.json last-writer-wins — tasks executed twice, completions lost.
402
+ * O_EXCL lockfile carrying the holder's pid; a DEAD holder's stale lock is taken
403
+ * over once, a LIVE holder refuses with an actionable error. Returns a release fn.
404
+ */
405
+ export async function acquireWorkflowRunLock(
406
+ skill: "team" | "ultragoal",
407
+ cwd: string = process.cwd(),
408
+ ): Promise<() => Promise<void>> {
409
+ const stateDir = path.join(getLocalJeoDir(cwd), "state");
410
+ await fs.mkdir(stateDir, { recursive: true });
411
+ const lockPath = path.join(stateDir, `${skill}.lock`);
412
+ const payload = JSON.stringify({ pid: process.pid, at: Date.now() });
413
+ for (let attempt = 0; attempt < 2; attempt++) {
414
+ try {
415
+ await fs.writeFile(lockPath, payload, { flag: "wx" });
416
+ return async () => {
417
+ await fs.unlink(lockPath).catch(() => {});
418
+ };
419
+ } catch {
420
+ let holder: { pid?: number } = {};
421
+ try {
422
+ holder = JSON.parse(await fs.readFile(lockPath, "utf-8")) as { pid?: number };
423
+ } catch { /* unreadable/torn lock → treat as stale */ }
424
+ const alive = typeof holder.pid === "number" && holder.pid > 0 && (() => {
425
+ try {
426
+ process.kill(holder.pid!, 0);
427
+ return true;
428
+ } catch {
429
+ return false;
430
+ }
431
+ })();
432
+ if (alive) {
433
+ throw new Error(
434
+ `another 'jeo ${skill}' run (pid ${holder.pid}) holds ${lockPath} — wait for it to finish, or delete the lock file if that process is gone.`,
435
+ );
436
+ }
437
+ await fs.unlink(lockPath).catch(() => {}); // stale (dead/unreadable) → take over once
438
+ }
439
+ }
440
+ throw new Error(`could not acquire ${lockPath} even after stale-lock takeover.`);
441
+ }
442
+
443
+ /** Returns true if the agent is running in development mode (enables self-improvement). */
444
+ export function isDevMode(): boolean {
445
+ return jeoEnv("DEV_MODE") === "1" || process.env.NODE_ENV === "development";
446
+ }