jeo-code 0.8.23 → 0.8.25

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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * `eval` tool — Dynamic Workflows: lets the interactive agent write actual JS
3
+ * control flow (loops, conditionals, sequential composition) around subagent
4
+ * dispatch, closing the gap `task`'s `tasks[]` batch cannot: `tasks[]` is ONE
5
+ * declarative, single-stage, parallel-only fan-out — no way to pipe one
6
+ * subagent's output into the next, branch on a result, or run N stages with a
7
+ * barrier between them. `eval` exposes exactly three orchestration primitives
8
+ * (`task`, `parallel`, `pipeline` — the shape this harness's own `eval` tool
9
+ * gives the model, so the mental model transfers) plus `log`; nothing else.
10
+ *
11
+ * Trust model (2026 consensus — see the module-level doc below): this is NOT
12
+ * a security sandbox. `node:vm`/`vm2`-style same-process isolation is not a
13
+ * real security boundary (both have known escapes; a genuine one needs a
14
+ * microVM/container, which contradicts jeo's zero-native-deps philosophy).
15
+ * `eval` runs at the SAME trust tier as the `bash` tool — full process
16
+ * access, gated by the identical deep-interview mutation lock. A subagent's
17
+ * own toolset (`subagentToolset`) never includes `eval` (mirrors `task`'s own
18
+ * exclusion), so nested eval-authored recursion cannot occur.
19
+ *
20
+ * WHY A WORKER (not `node:vm` or a bare `AsyncFunction`, which browser-tool.ts's
21
+ * `{action:"run"}` uses): a bare `AsyncFunction` shares jeo's own event loop —
22
+ * a script with a synchronous bug (`while (true) {}`, an unbounded non-yielding
23
+ * loop) blocks that ONE shared thread forever, and `Promise.race([fn(), timeoutPromise])`
24
+ * can NEVER preempt it: the timeout's own callback needs the same blocked
25
+ * loop to fire (verified empirically — see this file's own test coverage).
26
+ * A `Worker` runs on a genuinely separate OS thread; `worker.terminate()`
27
+ * preempts it unconditionally, exactly like `bashTool`'s SIGTERM/SIGKILL
28
+ * escalation on a spawned OS process (spawnTextWithTimeout in tools.ts) —
29
+ * the established hard-timeout pattern this codebase already uses elsewhere,
30
+ * just via a Worker instead of a child process (lighter, same-runtime, and
31
+ * `task`/`parallel`/`pipeline` need no native/OS capability a Worker lacks).
32
+ * The worker has NO live config/credential/network access of its own — every
33
+ * `task()` call it makes crosses back to the main thread over `postMessage` as
34
+ * an RPC request, so config/credentials/`runSubagentOnce` never leave the main
35
+ * thread and every dispatch stays subject to the main thread's own limits
36
+ * (MAX_EVAL_TASK_CALLS' total-call cap, an acquireFanoutSlot/releaseFanoutSlot
37
+ * semaphore capping CONCURRENT dispatches at MAX_FANOUT regardless of whether
38
+ * the script used parallel()/pipeline() or fired N raw task() calls directly,
39
+ * excludedCredentialScopes, the abort signal).
40
+ */
41
+ import { assertBashAllowed, type ToolResult } from "./tools";
42
+ import type { ToolHandler } from "./engine";
43
+ import {
44
+ runSubagentOnce,
45
+ MAX_FANOUT,
46
+ type SubagentTaskConfig,
47
+ type SubagentRunResult,
48
+ type TaskSubEvent,
49
+ } from "./task-tool";
50
+ import { getSubagentRole, defaultSubagentRole, subagentRoleIds } from "./subagents";
51
+ import { loadProjectContext, type ProjectContextFile } from "./context-files";
52
+
53
+ export interface EvalToolOptions {
54
+ config: SubagentTaskConfig;
55
+ signal?: AbortSignal;
56
+ onEvent?: (ev: TaskSubEvent) => void;
57
+ /** Mid-turn steering drain — forwarded to every `task()` dispatch the script
58
+ * makes, same contract as TaskToolOptions.steer. */
59
+ steer?: () => string[];
60
+ }
61
+
62
+ /** Structured result `task()` resolves to inside eval-authored code — a
63
+ * deliberately NARROWER view than the full `SubagentRunResult` (drops the
64
+ * internal contract/mutation-audit bookkeeping fields that belong to jeo's
65
+ * own callers, e.g. `team.ts`'s role gate) since orchestration script logic
66
+ * only needs to branch on "did it work" and "what did it say". */
67
+ export interface EvalTaskResult {
68
+ success: boolean;
69
+ output: string;
70
+ error?: string;
71
+ }
72
+
73
+ function toEvalResult(r: SubagentRunResult): EvalTaskResult {
74
+ return r.error ? { success: r.success, output: r.output, error: r.error } : { success: r.success, output: r.output };
75
+ }
76
+
77
+ /** Hard ceiling on how many `task()` dispatches ONE eval call may make.
78
+ * Enforced ONLY on the main thread's RPC handler (the sole path any dispatch
79
+ * can take — the worker has no other way to reach `runSubagentOnce`), so a
80
+ * script cannot bypass it via `parallel()`/`pipeline()`/a bare loop no matter
81
+ * how it shapes the calls. Generous (workflows genuinely chaining many small
82
+ * steps are the whole point of this tool) but not unbounded. */
83
+ const MAX_EVAL_TASK_CALLS = 40;
84
+
85
+ /** Hard cap on a single `parallel()`/`pipeline()` call's item-array length
86
+ * (queue size, not concurrency — both always run at most MAX_FANOUT at once
87
+ * regardless of how many items are queued behind that). Mirrors
88
+ * MAX_SERIAL_EXECUTOR's philosophy in task-tool.ts: an eval script is
89
+ * self-documenting code (unlike a one-shot `tasks[]` JSON batch), so no
90
+ * justification-string gate is required, but a hard ceiling still exists so
91
+ * a buggy/malicious loop cannot queue an unbounded batch. */
92
+ const MAX_PARALLEL_QUEUE = 20;
93
+
94
+ /** Wall-clock budget for one `eval` call — mirrors `bashTool`'s own default
95
+ * timeout philosophy (a runaway script must not block the turn forever).
96
+ * Overridable per-call via `args.timeoutMs` (capped at the same ceiling
97
+ * `bashTool` allows) for a script that's genuinely running many sequential
98
+ * subagent stages. Enforced by `worker.terminate()` — a REAL preemptive
99
+ * kill, not a same-thread race (see module doc). */
100
+ const DEFAULT_EVAL_TIMEOUT_MS = 300_000; // 5 minutes
101
+ const MAX_EVAL_TIMEOUT_MS = 1_800_000; // 30 minutes — same outer ceiling as JEO_CALL_TIMEOUT_MS's default
102
+
103
+ export function evalToolProtocolLine(config?: Pick<import("./state").Config, "subagents">): string {
104
+ return (
105
+ `eval {code, timeoutMs?} — Dynamic Workflows: write an async JS function BODY (return value or throw) ` +
106
+ `composing subagent dispatch with REAL control flow (loops/conditionals/sequencing) instead of one static tool call. ` +
107
+ `In scope: task(role, taskText, context?) -> {success, output, error?} runs ONE subagent to completion ` +
108
+ `(role: ${subagentRoleIds(config).join("|")}, same roles as the 'task' tool); ` +
109
+ `parallel(thunks) -> results[] runs an array of () => Promise thunks through a bounded pool (max ${MAX_FANOUT} concurrent, input order kept); ` +
110
+ `pipeline(items, ...stages) -> results[] maps items through one-arg async stages left-to-right with a barrier between stages, each stage itself bounded at ${MAX_FANOUT} concurrent ` +
111
+ `(stage 1 gets the item, later stages get the previous stage's result); log(message) prints a progress line. ` +
112
+ `Runs in an isolated worker with a hard wall-clock timeout (default ${Math.round(DEFAULT_EVAL_TIMEOUT_MS / 1000)}s, override with {timeoutMs} up to ${Math.round(MAX_EVAL_TIMEOUT_MS / 1000)}s). ` +
113
+ `Use this ONLY when the work genuinely needs sequential composition or branching across multiple subagent calls that 'task's tasks[] (single-stage, parallel-only) cannot express — ` +
114
+ `prefer a plain 'task' call for anything that fits in one dispatch. TRUST NOTE: eval runs with the SAME full process access as 'bash' (no sandbox) — gated by the identical interview mutation lock.`
115
+ );
116
+ }
117
+
118
+ // ── Worker bootstrap (runs in the isolated thread) ──────────────────────────
119
+ // A fixed, non-user-authored script: the ONLY untrusted input crossing into
120
+ // the worker is the `code` string itself, delivered via postMessage (never
121
+ // string-interpolated into this bootstrap — avoids any quoting/escaping
122
+ // hazard). `task`/`parallel`/`pipeline`/`log` are defined HERE, in the
123
+ // worker's own scope, so `parallel`'s thunks and `pipeline`'s stage functions
124
+ // (arbitrary closures the script defines) never need to cross the postMessage
125
+ // boundary — only `task()`'s plain-data request/response does.
126
+ const WORKER_BOOTSTRAP = `
127
+ const MAX_FANOUT = ${MAX_FANOUT};
128
+ const MAX_PARALLEL_QUEUE = ${MAX_PARALLEL_QUEUE};
129
+ let nextReqId = 0;
130
+ const pending = new Map();
131
+
132
+ self.onmessage = async (ev) => {
133
+ const msg = ev.data;
134
+ if (msg.type === "task_response") {
135
+ const resolver = pending.get(msg.id);
136
+ if (!resolver) return;
137
+ pending.delete(msg.id);
138
+ if (msg.ok) resolver.resolve(msg.result);
139
+ else resolver.reject(new Error(msg.error));
140
+ return;
141
+ }
142
+ if (msg.type !== "run") return;
143
+
144
+ const task = (role, taskText, context) => {
145
+ const id = nextReqId++;
146
+ const { promise, resolve, reject } = Promise.withResolvers();
147
+ pending.set(id, { resolve, reject });
148
+ self.postMessage({ type: "task_request", id, role, taskText, context });
149
+ return promise;
150
+ };
151
+
152
+ const boundedMap = async (items, fn) => {
153
+ if (!Array.isArray(items)) throw new Error("items must be an array.");
154
+ if (items.length === 0) return [];
155
+ if (items.length > MAX_PARALLEL_QUEUE) {
156
+ throw new Error(\`item array of \${items.length} exceeds the cap of \${MAX_PARALLEL_QUEUE} — split into multiple calls.\`);
157
+ }
158
+ const results = new Array(items.length);
159
+ const limit = Math.min(items.length, MAX_FANOUT);
160
+ let next = 0;
161
+ const worker = async () => {
162
+ while (true) {
163
+ const i = next++;
164
+ if (i >= items.length) return;
165
+ results[i] = await fn(items[i], i);
166
+ }
167
+ };
168
+ await Promise.all(Array.from({ length: limit }, () => worker()));
169
+ return results;
170
+ };
171
+
172
+ const parallel = (thunks) => {
173
+ if (!Array.isArray(thunks) || thunks.some(t => typeof t !== "function")) {
174
+ throw new Error("parallel(thunks): every element must be a () => Promise function.");
175
+ }
176
+ return boundedMap(thunks, (t) => t());
177
+ };
178
+
179
+ const pipeline = async (items, ...stages) => {
180
+ if (!Array.isArray(items)) throw new Error("pipeline(items, ...stages): items must be an array.");
181
+ if (stages.length === 0) return items;
182
+ let current = items;
183
+ for (const stage of stages) {
184
+ if (typeof stage !== "function") throw new Error("pipeline(): every stage must be a function.");
185
+ current = await boundedMap(current, (v) => stage(v));
186
+ }
187
+ return current;
188
+ };
189
+
190
+ const log = (message) => {
191
+ self.postMessage({ type: "log", message: typeof message === "string" ? message : JSON.stringify(message) });
192
+ };
193
+
194
+ try {
195
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
196
+ const fn = new AsyncFunction("task", "parallel", "pipeline", "log", msg.code);
197
+ const result = await fn(task, parallel, pipeline, log);
198
+ self.postMessage({ type: "done", ok: true, result });
199
+ } catch (e) {
200
+ self.postMessage({ type: "done", ok: false, error: (e && e.message) ? e.message : String(e) });
201
+ }
202
+ };
203
+ `;
204
+
205
+ /** One RPC round-trip's shape, as posted by the worker. */
206
+ interface TaskRequestMsg {
207
+ type: "task_request";
208
+ id: number;
209
+ role: unknown;
210
+ taskText: unknown;
211
+ context: unknown;
212
+ }
213
+ interface LogMsg {
214
+ type: "log";
215
+ message: string;
216
+ }
217
+ interface DoneMsg {
218
+ type: "done";
219
+ ok: boolean;
220
+ result?: unknown;
221
+ error?: string;
222
+ }
223
+ type WorkerMsg = TaskRequestMsg | LogMsg | DoneMsg;
224
+
225
+ export function createEvalTool(opts: EvalToolOptions): ToolHandler {
226
+ return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
227
+ const code = typeof args.code === "string" ? args.code : "";
228
+ if (!code.trim()) {
229
+ return { success: false, output: "", error: `eval requires a non-empty 'code' body (an async function BODY — the last expression or an explicit 'return' becomes the result).` };
230
+ }
231
+ // Same gate bashTool itself opens with — eval carries the identical
232
+ // full-process trust level, so it must never bypass the interview lock a
233
+ // plain bash call would be stopped by.
234
+ try {
235
+ await assertBashAllowed(cwd);
236
+ } catch (e: any) {
237
+ return { success: false, output: "", error: e.message ?? String(e) };
238
+ }
239
+
240
+ const timeoutRaw = Number(args.timeoutMs);
241
+ const timeoutMs = Number.isFinite(timeoutRaw) && timeoutRaw > 0
242
+ ? Math.min(timeoutRaw, MAX_EVAL_TIMEOUT_MS)
243
+ : DEFAULT_EVAL_TIMEOUT_MS;
244
+
245
+ const logs: string[] = [];
246
+ let taskCalls = 0;
247
+ // Batch-scoped, shared across every task() dispatch this script makes
248
+ // (sequential or via parallel/pipeline) — mirrors createTaskTool's
249
+ // fan-out batchExcludedScopes: the first dispatch to hit an exhausted
250
+ // OAuth-subscription window saves every later dispatch the same wasted round.
251
+ const excludedCredentialScopes = new Set<string>();
252
+ // Loaded once per eval call, shared by every task() dispatch — mirrors
253
+ // createTaskTool's fan-out batchContext (avoids re-scanning AGENTS.md per
254
+ // subagent when a script dispatches many in sequence/parallel).
255
+ let projectContext: ProjectContextFile[] | undefined;
256
+ // Concurrency chokepoint (distinct from MAX_EVAL_TASK_CALLS' total-count
257
+ // cap above): every task() dispatch — whether made sequentially, via
258
+ // parallel()/pipeline()'s own worker-pool bound, or via N raw concurrent
259
+ // task() calls a script fires directly (bypassing parallel/pipeline
260
+ // entirely, e.g. Promise.all(items.map(i => task(...)))) — passes through
261
+ // this ONE main-thread RPC handler (task_request below), so gating
262
+ // concurrency HERE, not just inside the worker's own boundedMap, is what
263
+ // actually makes "at most MAX_FANOUT concurrent" unbypassable regardless
264
+ // of how the script shapes its calls.
265
+ let inFlight = 0;
266
+ const fanoutQueue: Array<{ entry: () => void; remove: () => void }> = [];
267
+ // `abortSignal` here is `rpcSignal` (settle-aware — see below), NOT
268
+ // `combinedSignal`: a QUEUED (not yet dispatched) request must stop
269
+ // waiting for a slot the instant the tool settles, even though nothing
270
+ // was ever sent to runSubagentOnce for it to cancel.
271
+ const acquireFanoutSlot = (abortSignal: AbortSignal): Promise<void> => {
272
+ if (abortSignal.aborted) return Promise.reject(new Error("eval settled before this task() dispatch reached the front of the queue."));
273
+ if (inFlight < MAX_FANOUT) {
274
+ inFlight++;
275
+ return Promise.resolve();
276
+ }
277
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
278
+ const entry = () => { inFlight++; resolve(); };
279
+ const queued = { entry, remove: () => {} };
280
+ const onQueueAbort = () => {
281
+ const idx = fanoutQueue.indexOf(queued);
282
+ if (idx !== -1) fanoutQueue.splice(idx, 1); // still waiting — drop it, never dispatched
283
+ reject(new Error("eval settled while this task() dispatch was still queued."));
284
+ };
285
+ abortSignal.addEventListener("abort", onQueueAbort);
286
+ queued.remove = () => abortSignal.removeEventListener("abort", onQueueAbort);
287
+ fanoutQueue.push(queued);
288
+ return promise.finally(queued.remove);
289
+ };
290
+ const releaseFanoutSlot = (): void => {
291
+ inFlight--;
292
+ const next = fanoutQueue.shift();
293
+ if (next) next.entry();
294
+ };
295
+
296
+ const blob = new Blob([WORKER_BOOTSTRAP], { type: "application/javascript" });
297
+ const url = URL.createObjectURL(blob);
298
+ const worker = new Worker(url);
299
+
300
+ // Combined signal: EITHER the caller's own abort (Ctrl-C / turn cancel)
301
+ // OR this call's own timeout terminates the worker AND cancels whatever
302
+ // task() dispatch is currently in flight on the main thread — mirrors
303
+ // model-manager.ts's composeAbort (AbortSignal.any is native on the
304
+ // Bun version this CLI enforces; no polyfill needed).
305
+ const timeoutController = new AbortController();
306
+ const timer = setTimeout(() => timeoutController.abort(), timeoutMs);
307
+ const combinedSignal = opts.signal
308
+ ? AbortSignal.any([opts.signal, timeoutController.signal])
309
+ : timeoutController.signal;
310
+
311
+ // Settle-aware signal (v0.8.24 fix): a script that fires `task()` WITHOUT
312
+ // awaiting it (`task("executor", "x"); return "done";`) can have the
313
+ // worker post `done` while that dispatch is still in flight (mid
314
+ // `runSubagentOnce`) OR still queued behind MAX_FANOUT other dispatches.
315
+ // Without this, the tool returns "success" to the model while a REAL
316
+ // subagent keeps running invisibly in the background — burning LLM
317
+ // calls/tokens/mutations whose result is silently discarded, and (for a
318
+ // queued one) potentially never even starting the wait it's blocked on.
319
+ // `rpcSignal` fires on EVERY settlement path (done/error/timeout/abort),
320
+ // strictly BEFORE the tool returns, and is threaded into BOTH
321
+ // acquireFanoutSlot (cancels a QUEUED wait) and runSubagentOnce's own
322
+ // `signal` (cancels an IN-FLIGHT dispatch) — a fire-and-forget task()
323
+ // gets a bounded cancellation instead of an unbounded background tail.
324
+ const rpcSettleController = new AbortController();
325
+ const rpcSignal = AbortSignal.any([combinedSignal, rpcSettleController.signal]);
326
+ const settleRpc = () => rpcSettleController.abort();
327
+
328
+ const { promise: donePromise, resolve: resolveDone, reject: rejectDone } = Promise.withResolvers<unknown>();
329
+ let settled = false;
330
+
331
+ const onAbort = () => {
332
+ if (settled) return;
333
+ settled = true;
334
+ settleRpc();
335
+ worker.terminate();
336
+ rejectDone(new Error(
337
+ opts.signal?.aborted
338
+ ? "eval cancelled."
339
+ : `eval exceeded its ${timeoutMs < 10_000 ? (timeoutMs / 1000).toFixed(1) : Math.round(timeoutMs / 1000)}s timeout (raise via {timeoutMs} up to ${Math.round(MAX_EVAL_TIMEOUT_MS / 1000)}s for a long-running workflow) — the isolated worker was terminated.`
340
+ ));
341
+ };
342
+ combinedSignal.addEventListener("abort", onAbort);
343
+ if (combinedSignal.aborted) onAbort();
344
+
345
+ // A response racing a just-terminated worker (e.g. an in-flight
346
+ // runSubagentOnce that was cancelled by settleRpc but still reaches its
347
+ // own catch block a tick later) must never throw and crash the handler —
348
+ // the worker is already gone; the response has nowhere to go.
349
+ const safePostMessage = (msg: Record<string, unknown>): void => {
350
+ try {
351
+ worker.postMessage(msg);
352
+ } catch {
353
+ // worker already terminated — nothing to deliver to.
354
+ }
355
+ };
356
+
357
+ worker.onmessage = async (ev: MessageEvent<WorkerMsg>) => {
358
+ const msg = ev.data;
359
+ if (settled) return;
360
+ if (msg.type === "log") {
361
+ logs.push(msg.message);
362
+ opts.onEvent?.({ role: "executor", kind: "step", detail: msg.message });
363
+ return;
364
+ }
365
+ if (msg.type === "done") {
366
+ settled = true;
367
+ settleRpc(); // cancel any fire-and-forget task() still in flight/queued
368
+ if (msg.ok) resolveDone(msg.result);
369
+ else rejectDone(new Error(msg.error));
370
+ return;
371
+ }
372
+ if (msg.type === "task_request") {
373
+ const { id, role: roleArg, taskText, context } = msg;
374
+ try {
375
+ const trimmedTask = typeof taskText === "string" ? taskText.trim() : "";
376
+ if (!trimmedTask) throw new Error("task(role, taskText, context?): taskText must be a non-empty string.");
377
+ const role = typeof roleArg === "string" && roleArg
378
+ ? getSubagentRole(roleArg, opts.config)
379
+ : defaultSubagentRole();
380
+ if (!role) throw new Error(`Unknown subagent role '${String(roleArg)}'. Valid roles: ${subagentRoleIds(opts.config).join(", ")}.`);
381
+ if (taskCalls >= MAX_EVAL_TASK_CALLS) {
382
+ throw new Error(`eval script exceeded the per-call task() dispatch cap (${MAX_EVAL_TASK_CALLS}) — split the workflow across multiple eval calls.`);
383
+ }
384
+ taskCalls++;
385
+ await acquireFanoutSlot(rpcSignal);
386
+ try {
387
+ if (projectContext === undefined) projectContext = await loadProjectContext(cwd);
388
+ const ctxText = typeof context === "string" && context.trim() ? `\n\nContext:\n${context.trim()}` : "";
389
+ const result = await runSubagentOnce(role, trimmedTask, ctxText, cwd, {
390
+ config: opts.config,
391
+ signal: rpcSignal,
392
+ onEvent: opts.onEvent,
393
+ steer: opts.steer,
394
+ projectContext,
395
+ excludedCredentialScopes,
396
+ });
397
+ safePostMessage({ type: "task_response", id, ok: true, result: toEvalResult(result) });
398
+ } finally {
399
+ releaseFanoutSlot();
400
+ }
401
+ } catch (e: any) {
402
+ safePostMessage({ type: "task_response", id, ok: false, error: e?.message ?? String(e) });
403
+ }
404
+ }
405
+ };
406
+ worker.onerror = (ev: ErrorEvent) => {
407
+ if (settled) return;
408
+ settled = true;
409
+ settleRpc();
410
+ rejectDone(new Error(`eval worker crashed: ${ev.message ?? "unknown error"}`));
411
+ };
412
+
413
+ worker.postMessage({ type: "run", code });
414
+
415
+ try {
416
+ const result = await donePromise;
417
+ const resultText = result === undefined
418
+ ? ""
419
+ : `\n\nReturn value:\n${typeof result === "string" ? result : JSON.stringify(result, null, 2)}`;
420
+ const combined = `${logs.join("\n")}${resultText}`.trim();
421
+ return { success: true, output: combined || "(eval completed with no output and no return value)" };
422
+ } catch (e: any) {
423
+ const combined = logs.length ? `${logs.join("\n")}\n\n` : "";
424
+ return { success: false, output: "", error: `${combined}eval failed: ${e?.message ?? String(e)}` };
425
+ } finally {
426
+ settleRpc(); // backstop — every settlement path above already calls this, idempotent
427
+ clearTimeout(timer);
428
+ combinedSignal.removeEventListener("abort", onAbort);
429
+ worker.terminate();
430
+ URL.revokeObjectURL(url);
431
+ }
432
+ };
433
+ }
@@ -18,6 +18,7 @@ import { jeoEnv } from "../util/env";
18
18
  import { parseConcept, serializeConcept, slugify, isReservedFile, conceptId } from "./memory-okf";
19
19
  import { tryExtractJsonObject } from "./json";
20
20
  import { buildConceptGraph, expandByGraph, lintConceptGraph, type ConceptGraph, type GraphLintReport } from "./memory-graph";
21
+ import { ensureJeoGitignore } from "./state";
21
22
 
22
23
  /** On-disk document cap — the distill prompt instructs the model to stay under it. */
23
24
  export const MEMORY_MAX_CHARS = 6_000;
@@ -217,6 +218,24 @@ export interface Concept {
217
218
  relPath: string;
218
219
  }
219
220
 
221
+ /** Pure mechanical staleness check (2026 industry-pattern research: confidence
222
+ * must be EARNED via a verification event and staleness must be ACTIVELY
223
+ * tracked, not read off a passive write-timestamp). A concept is stale when
224
+ * it has never been verified (`last_verified` absent/unparseable) or its last
225
+ * verification is older than `staleDays` (default 30). Pure/no I/O so it is
226
+ * trivially unit-testable and wireable into a future `lintConceptGraph` pass
227
+ * or a `jeo memory verify` command without touching this module again. */
228
+ export function isConceptStale(
229
+ concept: Pick<Concept, "confidence"> & { last_verified?: string },
230
+ opts: { staleDays?: number } = {},
231
+ ): boolean {
232
+ if (!concept.last_verified) return true;
233
+ const verifiedAtMs = new Date(concept.last_verified).getTime();
234
+ if (Number.isNaN(verifiedAtMs)) return true;
235
+ const staleDays = opts.staleDays ?? 30;
236
+ return Date.now() - verifiedAtMs > staleDays * 24 * 60 * 60 * 1000;
237
+ }
238
+
220
239
  /** Per-file parse cache keyed by absolute path → (mtimeMs+size signature, parsed
221
240
  * Concept or null for a frontmatter-less/unparseable file). loadConcepts walks
222
241
  * `.jeo/memory/` and read+parses every concept file; team/ralph/autopilot call
@@ -849,6 +868,7 @@ async function cleanupStalePendingFiles(dir: string): Promise<void> {
849
868
  async function upsertConceptFile(
850
869
  bundleDir: string,
851
870
  c: { type: string; title: string; description?: string; body?: string; tags?: string[]; confidence?: string; links?: string[]; pinned?: boolean },
871
+ opts: { verified?: boolean } = {},
852
872
  ): Promise<string> {
853
873
  const dir = DIR_BY_TYPE[c.type] ?? "facts";
854
874
  await fs.mkdir(path.join(bundleDir, dir), { recursive: true });
@@ -879,8 +899,8 @@ async function upsertConceptFile(
879
899
  tags: c.tags ?? [],
880
900
  timestamp: new Date().toISOString(),
881
901
  confidence: c.confidence ?? "high",
882
- last_verified: new Date().toISOString().split("T")[0],
883
902
  links: c.links ?? [],
903
+ ...(opts.verified ? { last_verified: new Date().toISOString().split("T")[0] } : {}),
884
904
  ...(c.pinned ? { pinned: true } : {}),
885
905
  };
886
906
  const tmpPath = `${fullPath}.tmp-${process.pid}`;
@@ -892,7 +912,7 @@ async function upsertConceptFile(
892
912
  export async function distillSessionMemory(
893
913
  history: Message[],
894
914
  cwd: string,
895
- opts: { model?: string; timeoutMs?: number } = {},
915
+ opts: { model?: string; timeoutMs?: number; sessionVerified?: boolean } = {},
896
916
  ): Promise<DistillResult> {
897
917
  if (jeoEnv("NO_MEMORY") === "1") return { updated: false, skipped: "disabled (JEO_NO_MEMORY=1)" };
898
918
  const body = history.filter(m => m.role !== "system");
@@ -983,6 +1003,7 @@ export async function distillSessionMemory(
983
1003
 
984
1004
  if (parsedJson && Array.isArray(parsedJson.concepts)) {
985
1005
  await fs.mkdir(bundleDir, { recursive: true });
1006
+ await ensureJeoGitignore(cwd);
986
1007
  const updatedConcepts: { title: string; type: string }[] = [];
987
1008
 
988
1009
  for (const raw of parsedJson.concepts) {
@@ -1008,7 +1029,7 @@ export async function distillSessionMemory(
1008
1029
  tags: Array.isArray(concept.tags) ? concept.tags.filter((t): t is string => typeof t === "string") : [],
1009
1030
  confidence: typeof concept.confidence === "string" ? concept.confidence : "high",
1010
1031
  links: Array.isArray(concept.links) ? concept.links.filter((l): l is string => typeof l === "string") : [],
1011
- });
1032
+ }, { verified: opts.sessionVerified === true });
1012
1033
  updatedConcepts.push({ title, type });
1013
1034
  } catch {
1014
1035
  // Skip just this concept; keep distilling the rest of the batch.
package/src/agent/plan.ts CHANGED
@@ -53,6 +53,65 @@ export const PlanSchema = z.object({
53
53
  }
54
54
  prevGroup = group;
55
55
  }
56
+
57
+ // Maker -> verifier ORDERING (not mere presence): a plan containing any
58
+ // mutating work must have a DEDICATED architect/critic step AFTER the last
59
+ // such mutation, or `jeo team` never runs an independent, evidence-grounded
60
+ // gate over what was actually changed (team.ts's parseRoleGateVerdict is
61
+ // fail-closed and genuinely reads real files — but only fires for a step
62
+ // that HAS an architect/critic role in the right position; nothing before
63
+ // this rule required the plan to include one at all, or forbade a verifier
64
+ // sitting BEFORE the mutations it's supposed to check). Roles read-only by
65
+ // the SUBAGENT_ROLES registry (planner/architect/critic) never mutate;
66
+ // architect/critic ALSO carry a verdict contract team.ts enforces — planner
67
+ // does not, so it can't discharge this gate. `executor` (or an unset/unknown
68
+ // role, which resolves to executor's default) mutates. Grouped
69
+ // (`parallel_group`) steps are evaluated as ONE atomic unit: concurrent
70
+ // steps cannot verify each other (they run in isolated worktrees with no
71
+ // visibility into siblings' STILL-IN-FLIGHT changes), so a critic/architect
72
+ // inside the SAME group as a mutating sibling does not clear the gate —
73
+ // only a later, separate unit does.
74
+ // Local literal, not imported from subagents.ts — `plan.ts` stays a pure,
75
+ // dependency-free schema module (mirrors subagents.ts's own DEFAULT_ROLE_ID).
76
+ const DEFAULT_STEP_ROLE = "executor";
77
+ const READONLY_ROLES: Record<string, true> = { planner: true, architect: true, critic: true };
78
+ const VERIFIER_ROLES: Record<string, true> = { architect: true, critic: true };
79
+ type Unit = { steps: typeof plan.steps; label: string };
80
+ const units: Unit[] = [];
81
+ for (const step of plan.steps) {
82
+ const group = step.parallel_group?.trim() || undefined;
83
+ const last = units[units.length - 1];
84
+ if (group && last && last.label === group) {
85
+ last.steps.push(step);
86
+ } else {
87
+ units.push({ steps: [step], label: group ?? `#solo:${step.name}` });
88
+ }
89
+ }
90
+ let pendingUnverifiedMutation: string | undefined;
91
+ for (const unit of units) {
92
+ // .toLowerCase() mirrors subagents.ts's normalizeRoleId (dispatch is
93
+ // case-insensitive) — without it, a plan ending in e.g. `role: ARCHITECT`
94
+ // would resolve correctly at runtime but be WRONGLY rejected here as an
95
+ // unverified mutation (READONLY_ROLES["ARCHITECT"] is undefined, not true).
96
+ const roleIds = unit.steps.map(s => (s.role?.trim().toLowerCase() || DEFAULT_STEP_ROLE));
97
+ const unitMutates = roleIds.some(r => READONLY_ROLES[r] !== true);
98
+ const unitVerifies = roleIds.some(r => VERIFIER_ROLES[r] === true);
99
+ if (unitMutates) {
100
+ pendingUnverifiedMutation = unit.steps[unit.steps.length - 1]!.name;
101
+ } else if (unitVerifies) {
102
+ pendingUnverifiedMutation = undefined;
103
+ }
104
+ }
105
+ if (pendingUnverifiedMutation) {
106
+ ctx.addIssue({
107
+ code: z.ZodIssueCode.custom,
108
+ message:
109
+ `plan ends with an unverified mutation (last mutating step: "${pendingUnverifiedMutation}") — add an ` +
110
+ `architect or critic step AFTER it (never inside the same parallel_group, which runs concurrently and ` +
111
+ `cannot verify a still-in-flight sibling) so team.ts's evidence-grounded role gate actually runs over the ` +
112
+ `real changes before the plan is considered complete.`,
113
+ });
114
+ }
56
115
  });
57
116
 
58
117
  /**
@@ -22,7 +22,7 @@ import * as path from "node:path";
22
22
  import { SKILLS, getSkill, bundledSkillFileContent } from "../skills/catalog";
23
23
  import { callLlm } from "./loop";
24
24
  import { resolveVerifierModel } from "./prompt-router";
25
- import { readGlobalConfig } from "./state";
25
+ import { readGlobalConfig, ensureJeoGitignore } from "./state";
26
26
  import { tryExtractJsonObject } from "./json";
27
27
 
28
28
  export interface SkillLesson {
@@ -154,6 +154,7 @@ export async function appendSkillLesson(cwd: string, lesson: SkillLesson): Promi
154
154
 
155
155
  const finalContent = lines.join("\n").replace(/\n{3,}$/, "\n\n");
156
156
  await fs.mkdir(path.dirname(filePath), { recursive: true });
157
+ await ensureJeoGitignore(cwd);
157
158
  const tmpPath = `${filePath}.tmp-${process.pid}`;
158
159
  await fs.writeFile(tmpPath, finalContent, "utf-8");
159
160
  await fs.rename(tmpPath, filePath);
@@ -541,6 +541,32 @@ export function getLocalJeoDir(cwd: string = process.cwd()): string {
541
541
  return path.join(cwd, ".jeo");
542
542
  }
543
543
 
544
+ /** Ensure `.jeo/` self-ignores in git — memory/skill-lesson writes under `.jeo/`
545
+ * are agent-local working state, not source; without this a CI routine (`jeo
546
+ * routine init`, running `jeo -p` then committing via create-pull-request)
547
+ * ships `.jeo/memory/*`/`.jeo/skills/*` churn into every generated PR. Writes
548
+ * a bare `*` pattern ONCE on first `.jeo/` creation — idempotent (checked via
549
+ * a stat, never overwrites a file that already exists, so a user's own
550
+ * customized `.jeo/.gitignore` is never clobbered). Best-effort: any I/O
551
+ * failure is swallowed, never thrown (this is a hygiene nicety, not a
552
+ * correctness requirement — a missing .gitignore degrades gracefully to "no
553
+ * ignore", not a broken feature). */
554
+ export async function ensureJeoGitignore(cwd: string = process.cwd()): Promise<void> {
555
+ const gitignorePath = path.join(getLocalJeoDir(cwd), ".gitignore");
556
+ try {
557
+ await fs.access(gitignorePath);
558
+ return; // already exists — never overwrite (user customization respected)
559
+ } catch {
560
+ // does not exist — fall through to create
561
+ }
562
+ try {
563
+ await fs.mkdir(getLocalJeoDir(cwd), { recursive: true });
564
+ await fs.writeFile(gitignorePath, "*\n", "utf-8");
565
+ } catch {
566
+ // best-effort — a failed write here must never break the caller's real work
567
+ }
568
+ }
569
+
544
570
  // mtime+size-validated cache for the small per-skill workflow-state JSON. readWorkflowState
545
571
  // runs repeatedly (the mutation guard reads before every mutating tool), so re-reading +
546
572
  // re-parsing each call is wasteful. CROSS-PROCESS-SAFE: a write by another process bumps