ennodia 0.2.0 → 0.3.0

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 (51) hide show
  1. package/README.md +76 -119
  2. package/docs/evidence/building-ennodia.md +66 -0
  3. package/docs/evidence/docs-drift.md +73 -0
  4. package/docs/evidence/index.md +18 -0
  5. package/docs/evidence/measurement.md +40 -0
  6. package/docs/evidence/skill-trial.md +68 -0
  7. package/docs/getting-started.md +55 -32
  8. package/docs/guides/agent-skills.md +22 -8
  9. package/docs/guides/budgets-and-limits.md +1 -1
  10. package/docs/guides/pragmatic-mode.md +122 -0
  11. package/docs/guides/recipes.md +56 -0
  12. package/docs/guides/troubleshooting.md +48 -0
  13. package/docs/guides/understand-results.md +41 -0
  14. package/docs/index.md +27 -90
  15. package/docs/install.md +49 -16
  16. package/docs/reference/controlled-english.md +2 -3
  17. package/docs/reference/ennodia-io.md +27 -4
  18. package/docs/reference/mcp-tools.md +167 -10
  19. package/docs/reference/supported-harnesses.md +40 -1
  20. package/docs/roadmap.md +25 -0
  21. package/examples/docs-drift/README.md +8 -0
  22. package/examples/docs-drift/archive/migration.md +4 -0
  23. package/examples/docs-drift/check.ts +7 -0
  24. package/examples/docs-drift/config.ts +8 -0
  25. package/examples/docs-drift/demo.ts +54 -0
  26. package/examples/docs-drift/recorded-run.json +16 -0
  27. package/examples/skill-trial/README.md +8 -0
  28. package/examples/skill-trial/check.ts +15 -0
  29. package/examples/skill-trial/contract.md +7 -0
  30. package/examples/skill-trial/recorded-trial.json +42 -0
  31. package/examples/skill-trial/retry.ts +4 -0
  32. package/package.json +5 -2
  33. package/skills/pragmatic/SKILL.md +40 -0
  34. package/src/advisor.ts +5 -0
  35. package/src/budget.ts +16 -10
  36. package/src/cli.ts +3 -0
  37. package/src/compare.ts +18 -12
  38. package/src/compositional.ts +22 -4
  39. package/src/core.ts +77 -47
  40. package/src/diagnosis.ts +44 -7
  41. package/src/harnesses.ts +286 -33
  42. package/src/history.ts +4 -1
  43. package/src/index.ts +3 -0
  44. package/src/plan-advice.ts +5 -2
  45. package/src/planner.ts +24 -1
  46. package/src/pragmatic.ts +52 -0
  47. package/src/process.ts +18 -0
  48. package/src/runs.ts +85 -10
  49. package/src/server.ts +47 -17
  50. package/src/tasks.ts +191 -25
  51. package/src/version.ts +1 -1
package/src/diagnosis.ts CHANGED
@@ -42,23 +42,29 @@ export function diagnoseTasks(tasks: TaskView[]): TaskDiagnosis | undefined {
42
42
  }
43
43
 
44
44
  function likelyCause(tasks: TaskView[]): string {
45
+ if (tasks.every(hasQuotaError)) {
46
+ return "Provider usage capacity was exhausted. This attempt does not establish solution quality or a cost saving.";
47
+ }
48
+ if (tasks.every(hasAuthenticationError)) {
49
+ return "Agent authentication failed. This attempt did not establish how the selected model would handle the task.";
50
+ }
45
51
  if (tasks.every((task) => task.timedOut)) {
46
52
  if (tasks.some(outputChars) && tasks.some(hasRecentOutput)) {
47
- return "Task needed more time; at least one provider was still producing output before the deadline.";
53
+ return "The task produced output near its deadline but did not return a completed result.";
48
54
  }
49
55
 
50
56
  if (tasks.some(outputChars)) {
51
- return "Provider timeout after partial progress.";
57
+ return "Ennodia stopped the task at its execution deadline after capturing output; the value of that output is not yet assessed.";
52
58
  }
53
59
 
54
- return "Provider timeout before useful output; this may be slow startup, a model hang, or provider configuration.";
60
+ return "Ennodia stopped the task at its execution deadline without captured output. This does not establish whether the model was investigating, blocked, or unable to solve the task.";
55
61
  }
56
62
 
57
63
  if (tasks.some((task) => task.drainTimedOut)) {
58
64
  return "Output drain timed out; captured output may be incomplete.";
59
65
  }
60
66
 
61
- if (tasks.some((task) => task.exitCode !== undefined && task.exitCode !== 0)) {
67
+ if (tasks.some((task) => !task.timedOut && !task.cancelRequested && task.exitCode !== undefined && task.exitCode !== null && task.exitCode !== 0)) {
62
68
  return "Provider command error or bad configuration.";
63
69
  }
64
70
 
@@ -75,17 +81,27 @@ function suggestions(
75
81
  ): string[] {
76
82
  const result = new Set<string>();
77
83
 
84
+ if (tasks.some(hasQuotaError)) {
85
+ result.add("Preserve partial findings. Check capacity through the provider's supported interface before retrying; do not repeat calls against the exhausted account.");
86
+ }
87
+
88
+ if (tasks.some(hasAuthenticationError)) {
89
+ result.add("Sign in through the affected agent's supported CLI, then retry the same task.");
90
+ }
91
+
78
92
  if (tasks.some((task) => task.timedOut)) {
79
- result.add("Retry the timed-out provider with a longer timeoutMs.");
93
+ result.add("Review captured findings and partial changes before retrying. Check the assigned scope, deadline, tool access, and permissions; continue useful work with an adequate budget instead of repeating the investigation.");
80
94
  }
81
95
 
82
96
  if (hasPartialOutput) {
83
97
  result.add("Inspect partial output and events with ennodia_get_task.");
84
98
  }
85
99
 
86
- if (tasks.some((task) => task.exitCode !== undefined && task.exitCode !== 0)) {
100
+ if (tasks.some((task) => !task.timedOut && !task.cancelRequested && task.exitCode !== undefined && task.exitCode !== null && task.exitCode !== 0)) {
87
101
  result.add("Inspect stderr and task events with ennodia_get_task.");
88
- result.add("Retry with a different harness if the provider command looks broken.");
102
+ if (!tasks.every(task => hasAuthenticationError(task) || hasQuotaError(task))) {
103
+ result.add("Check the requested model and command configuration before retrying.");
104
+ }
89
105
  }
90
106
 
91
107
  if (tasks.some((task) => task.drainTimedOut)) {
@@ -99,6 +115,27 @@ function suggestions(
99
115
  return [...result];
100
116
  }
101
117
 
118
+ /** Only native error envelopes count; quoted tool output is not a quota signal. */
119
+ export function hasCodexQuotaError(stdout: string): boolean {
120
+ for (const line of stdout.split("\n")) {
121
+ let event;
122
+ try { event = JSON.parse(line); } catch { continue; }
123
+ const message = event?.type === "error" ? event.message
124
+ : event?.type === "turn.failed" ? event.error?.message : undefined;
125
+ if (typeof message === "string" && /^You've hit your usage limit\b/i.test(message)) return true;
126
+ }
127
+ return false;
128
+ }
129
+
130
+ function hasQuotaError(task: TaskView): boolean {
131
+ return task.harnessId === "codex" && (hasCodexQuotaError(task.stdout) || hasCodexQuotaError(task.stderr));
132
+ }
133
+
134
+ function hasAuthenticationError(task: TaskView): boolean {
135
+ return !task.timedOut && /^(?:Error: )?(?:Failed to authenticate:|OAuth session expired|Authentication failed:|Not logged in\b)/im
136
+ .test(`${task.stdout}\n${task.stderr}`);
137
+ }
138
+
102
139
  function summaryLine(task: TaskView): string {
103
140
  const status = task.timedOut ? "timed out" : task.status;
104
141
  const facts = [
package/src/harnesses.ts CHANGED
@@ -1,12 +1,35 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { signalOwnedProcess } from "./process";
2
3
  import { type Skill } from "./skills";
3
4
 
4
5
  export type HarnessKind = "cli" | "app";
5
6
 
7
+ export const DEFAULT_TASK_TIMEOUT_MS = 5 * 60 * 1000;
8
+
9
+ export const REASONING_EFFORTS = [
10
+ "low",
11
+ "medium",
12
+ "high",
13
+ "xhigh",
14
+ "max",
15
+ "ultra",
16
+ ] as const;
17
+
18
+ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
19
+
6
20
  export type HarnessRunInput = {
7
21
  prompt: string;
8
22
  cwd?: string;
9
23
  model?: string;
24
+ reasoningEffort?: ReasoningEffort;
25
+ nativeSubagents?: "disabled";
26
+ nativeSandbox?: "read-only" | "workspace-write";
27
+ /** Request native session persistence when the selected adapter supports it. */
28
+ persistSession?: boolean;
29
+ /** Manager-resolved native session ID to resume for a continuation. */
30
+ nativeSessionId?: string;
31
+ /** TaskManager-owned task ID for continuation; adapters do not consume it. */
32
+ continueTaskId?: string;
10
33
  timeoutMs?: number;
11
34
  skills?: Skill[];
12
35
  /** Run the task against an ephemeral isolated copy of cwd instead of cwd
@@ -24,6 +47,12 @@ export type HarnessUsage = {
24
47
  /** Best-effort, adapter-reported token count. Not guaranteed available or
25
48
  * billing-accurate; parsed from each CLI's own text output where possible. */
26
49
  tokensUsed?: number;
50
+ /** Native input-token count when the adapter reports a structured breakdown. */
51
+ inputTokens?: number;
52
+ /** Native cached-input count; this is a subset of inputTokens. */
53
+ cachedInputTokens?: number;
54
+ /** Native output-token count when the adapter reports a structured breakdown. */
55
+ outputTokens?: number;
27
56
  };
28
57
 
29
58
  export type CommandSpec = {
@@ -43,11 +72,46 @@ export type HarnessAdapter = {
43
72
  versionArgs?: string[];
44
73
  capabilities: string[];
45
74
  notes?: string[];
75
+ /** Input preparation guidance, not a guarantee of model or tool access. */
76
+ inputGuidance?: string[];
77
+ /** Whether the adapter can receive an explicit reasoning effort setting. */
78
+ supportsReasoningEffort?: boolean;
79
+ supportsNativeSubagentControl?: boolean;
80
+ supportsNativeSandbox?: boolean;
81
+ /** Whether the adapter supports manager-owned native session continuation. */
82
+ supportsSessionContinuation?: boolean;
46
83
  buildCommand?: (commandPath: string, input: HarnessRunInput) => CommandSpec;
47
84
  /** Best-effort usage extraction from a finished task's captured output. */
48
85
  extractUsage?: (stdout: string, stderr: string) => HarnessUsage | undefined;
86
+ /** Extract a native session ID from captured stdout as it arrives. */
87
+ extractSessionId?: (stdout: string) => string | undefined;
88
+ /** Some public CLIs report a semantic failure with exit code zero. */
89
+ failureReason?: (stdout: string, stderr: string) => string | undefined;
49
90
  };
50
91
 
92
+ export function assertNativeSandboxSupported(adapter: HarnessAdapter, setting?: "read-only" | "workspace-write"): void {
93
+ if (setting === undefined) return;
94
+ if (setting !== "read-only" && setting !== "workspace-write") throw new Error("Unsupported nativeSandbox setting.");
95
+ if (!adapter.supportsNativeSandbox) throw new Error(`${adapter.name} (${adapter.id}) does not support nativeSandbox control.`);
96
+ }
97
+
98
+ export function assertNativeSubagentsSupported(adapter: HarnessAdapter, setting?: "disabled"): void {
99
+ if (setting !== undefined && !adapter.supportsNativeSubagentControl) {
100
+ throw new Error(`${adapter.name} (${adapter.id}) does not support nativeSubagents control.`);
101
+ }
102
+ }
103
+
104
+ export function assertReasoningEffortSupported(
105
+ adapter: Pick<HarnessAdapter, "id" | "name" | "supportsReasoningEffort">,
106
+ reasoningEffort?: ReasoningEffort,
107
+ ): void {
108
+ if (reasoningEffort !== undefined && !adapter.supportsReasoningEffort) {
109
+ throw new Error(
110
+ `${adapter.name} (${adapter.id}) does not support explicit reasoningEffort.`,
111
+ );
112
+ }
113
+ }
114
+
51
115
  export type HarnessDiscovery = {
52
116
  id: string;
53
117
  name: string;
@@ -59,6 +123,7 @@ export type HarnessDiscovery = {
59
123
  version?: string;
60
124
  capabilities: string[];
61
125
  notes: string[];
126
+ inputGuidance?: string[];
62
127
  };
63
128
 
64
129
  export type DiscoverHarnessesOptions = {
@@ -75,12 +140,36 @@ type CaptureResult = {
75
140
 
76
141
  const DEFAULT_VERSION_TIMEOUT_MS = 2_500;
77
142
  const DEFAULT_DISCOVERY_CACHE_MS = 30_000;
143
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
78
144
 
79
145
  let cachedDiscovery:
80
146
  | { createdAtMs: number; harnesses: HarnessDiscovery[] }
81
147
  | undefined;
82
148
  let inFlightDiscovery: Promise<HarnessDiscovery[]> | undefined;
83
149
 
150
+ export const MULTIMODAL_INPUT_GUIDANCE = [
151
+ "Ennodia passes text prompts and local file paths, not media attachments. Native input depends on the selected harness, model, file tools, permissions, and format. Missing guidance means unverified access.",
152
+ "When native listening or viewing is required, first load one small sample with native file tools. Report the file, inspected range, tool, and content-specific evidence, or the exact blocking error. A transcript, DSP score, or extracted frame is not equivalent evidence.",
153
+ "For media comparisons, use matching excerpts and an original reference. Separate cleanup quality from voice or image fidelity. Report conversions and limitations; a successful process exit does not establish successful media inspection.",
154
+ ];
155
+
156
+ export function hasMultimodalInput(prompt: string): boolean {
157
+ return /\b(audio|video|image|multimodal|listen|listening|sound|speech|voice|recordings?|footage)\b|\.(mp3|wav|flac|m4a|aac|opus|ogg|ogv|mp4|mov|webm|png|jpe?g|webp)\b/i.test(prompt);
158
+ }
159
+
160
+ /** Keep preparation instructions out of unrelated work and internal text judges. */
161
+ export function withInputGuidance(
162
+ prompt: string,
163
+ adapter?: Pick<HarnessAdapter, "inputGuidance">,
164
+ ): string {
165
+ if (!hasMultimodalInput(prompt) || /^ENNODIA_(?:COMPARE|PLAN_ADVISOR)/.test(prompt)) {
166
+ return prompt;
167
+ }
168
+ return `${prompt}\n\nEnnodia media input guidance (applies when native media inspection is required):\n${
169
+ (adapter?.inputGuidance ?? MULTIMODAL_INPUT_GUIDANCE).map((note) => `- ${note}`).join("\n")
170
+ }`;
171
+ }
172
+
84
173
  export const harnessAdapters: HarnessAdapter[] = [
85
174
  {
86
175
  id: "claude-code",
@@ -102,7 +191,7 @@ export const harnessAdapters: HarnessAdapter[] = [
102
191
  args.push("--model", input.model);
103
192
  }
104
193
 
105
- args.push(input.prompt);
194
+ args.push("--", input.prompt);
106
195
  return { command: commandPath, args, cwd: input.cwd };
107
196
  },
108
197
  },
@@ -114,17 +203,26 @@ export const harnessAdapters: HarnessAdapter[] = [
114
203
  versionArgs: ["--version"],
115
204
  capabilities: ["reasoning", "code", "agents", "mcp", "non-interactive-cli"],
116
205
  notes: ["Defaults to read-only sandboxing for Ennodia-launched tasks."],
206
+ supportsReasoningEffort: true,
207
+ supportsSessionContinuation: true,
208
+ supportsNativeSubagentControl: true,
209
+ supportsNativeSandbox: true,
117
210
  buildCommand: (commandPath, input) => {
211
+ const resuming = Boolean(input.nativeSessionId);
212
+ const persistent = Boolean(input.persistSession || resuming);
118
213
  const args = [
119
214
  "exec",
120
215
  "--color",
121
216
  "never",
122
217
  "--sandbox",
123
- "read-only",
218
+ input.nativeSandbox ?? "read-only",
124
219
  "--skip-git-repo-check",
125
- "--ephemeral",
126
220
  ];
127
221
 
222
+ if (!persistent) {
223
+ args.push("--ephemeral");
224
+ }
225
+
128
226
  if (input.cwd) {
129
227
  args.push("-C", input.cwd);
130
228
  }
@@ -133,15 +231,36 @@ export const harnessAdapters: HarnessAdapter[] = [
133
231
  args.push("--model", input.model);
134
232
  }
135
233
 
234
+ if (input.reasoningEffort) {
235
+ args.push("-c", `model_reasoning_effort="${input.reasoningEffort}"`);
236
+ }
237
+
238
+ if (input.nativeSubagents === "disabled") args.push("--disable", "multi_agent", "--disable", "multi_agent_v2");
239
+
240
+ if (resuming) {
241
+ args.push("resume", input.nativeSessionId!, "--json");
242
+ } else if (persistent) {
243
+ args.push("--json");
244
+ }
245
+
136
246
  if (input.finalMessagePath) {
137
247
  args.push("-o", input.finalMessagePath);
138
248
  }
139
249
 
140
- args.push(input.prompt);
250
+ args.push("--", input.prompt);
141
251
  return { command: commandPath, args, cwd: input.cwd };
142
252
  },
143
- extractUsage: (stdout) => {
144
- const match = /tokens used\s*\n\s*([\d,]+)/i.exec(stdout);
253
+ extractSessionId: extractCodexSessionId,
254
+ extractUsage: (stdout, stderr) => {
255
+ const jsonUsage = extractCodexJsonUsage(stdout);
256
+ if (jsonUsage) {
257
+ return jsonUsage;
258
+ }
259
+
260
+ if (/"type"\s*:\s*"turn\.(?:started|completed|failed)"/.test(stdout)) return undefined;
261
+
262
+ const summary = /(?:^|\n)tokens used[ \t]*\r?\n[ \t]*([\d,]+)\s*$/i;
263
+ const match = summary.exec(stderr) ?? summary.exec(stdout);
145
264
  if (!match) {
146
265
  return undefined;
147
266
  }
@@ -169,7 +288,7 @@ export const harnessAdapters: HarnessAdapter[] = [
169
288
  args.push("--model", input.model);
170
289
  }
171
290
 
172
- args.push(input.prompt);
291
+ args.push("--", input.prompt);
173
292
  return { command: commandPath, args, cwd: input.cwd };
174
293
  },
175
294
  },
@@ -201,7 +320,7 @@ export const harnessAdapters: HarnessAdapter[] = [
201
320
  args.push("--model", input.model);
202
321
  }
203
322
 
204
- args.push(input.prompt);
323
+ args.push("--", input.prompt);
205
324
  return { command: commandPath, args, cwd: input.cwd };
206
325
  },
207
326
  },
@@ -256,7 +375,7 @@ export const harnessAdapters: HarnessAdapter[] = [
256
375
  args.push("--model", input.model);
257
376
  }
258
377
 
259
- args.push(input.prompt);
378
+ args.push("--", input.prompt);
260
379
  return { command: commandPath, args, cwd: input.cwd };
261
380
  },
262
381
  },
@@ -281,7 +400,7 @@ export const harnessAdapters: HarnessAdapter[] = [
281
400
  args.push("--model", input.model);
282
401
  }
283
402
 
284
- args.push(input.prompt);
403
+ args.push("--", input.prompt);
285
404
  return { command: commandPath, args, cwd: input.cwd };
286
405
  },
287
406
  },
@@ -296,11 +415,22 @@ export const harnessAdapters: HarnessAdapter[] = [
296
415
  "/Applications/Antigravity IDE.app",
297
416
  ],
298
417
  capabilities: ["ide", "browser-automation", "agents", "non-interactive-cli"],
418
+ inputGuidance: [
419
+ ...MULTIMODAL_INPUT_GUIDANCE,
420
+ "Antigravity headless input is text-only, including stream-json content blocks. Put local paths in the prompt and use the worker's native view_file tool when available; do not send Gemini API attachment blocks to agy. https://antigravity.google/docs/cli/headless/",
421
+ "Observed 2026-09-10 with agy 1.2.0 and gemini-3.8-flash-medium: an eight-second MP3 loaded through view_file and returned spoken words absent from the prompt. Start with a short MP3 probe for this route, then compare matched samples after confirming native access.",
422
+ "In that same case, FLAC was rejected as unsupported MIME audio/x-flac; four WAV samples timed out without output. These are scoped observations, not a universal FLAC/WAV support rule. CLI release notes list broader audio support: https://antigravity.google/changelog",
423
+ "Use native file tools first. Headless command permission denials cannot be approved interactively; report the blocked operation and keep normal permission settings. Do not keep guessing formats or repeat an unchanged failed request.",
424
+ ],
299
425
  notes: [
300
426
  "Runs through the supported `agy` CLI surface.",
301
427
  "Defaults to Antigravity sandbox mode for Ennodia-launched tasks.",
302
428
  "Passes the prompt as the value of Antigravity's non-interactive `--print` option.",
303
429
  ],
430
+ failureReason: (stdout, stderr) => !stdout.trim() &&
431
+ /^jetski: no output produced\b/m.test(stderr)
432
+ ? "Antigravity returned no answer. Check the required tool access in its normal permission settings."
433
+ : undefined,
304
434
  buildCommand: (commandPath, input) => {
305
435
  const args = [
306
436
  "--sandbox",
@@ -323,6 +453,110 @@ export const harnessAdapters: HarnessAdapter[] = [
323
453
  },
324
454
  ];
325
455
 
456
+ function extractCodexSessionId(stdout: string): string | undefined {
457
+ for (const line of stdout.split(/\r?\n/)) {
458
+ const event = parseJsonObject(line);
459
+ if (event?.type !== "thread.started") {
460
+ continue;
461
+ }
462
+
463
+ const threadId = event.thread_id;
464
+ if (typeof threadId === "string" && UUID_PATTERN.test(threadId)) {
465
+ return threadId;
466
+ }
467
+ }
468
+
469
+ return undefined;
470
+ }
471
+
472
+ function extractCodexJsonUsage(stdout: string): HarnessUsage | undefined {
473
+ let inputTokens = 0;
474
+ let outputTokens = 0;
475
+ let cachedInputTokens = 0;
476
+ let cachedInputKnown = true;
477
+ let found = false;
478
+ let malformed = false;
479
+ let pendingTurn = false;
480
+
481
+ for (const line of stdout.split(/\r?\n/)) {
482
+ const event = parseJsonObject(line);
483
+ if (event?.type === "turn.started" || event?.type === "turn.failed") pendingTurn = true;
484
+ if (event?.type !== "turn.completed") {
485
+ if (
486
+ !event &&
487
+ /^\s*\{/.test(line) &&
488
+ /"type"\s*:\s*"turn\.completed"/.test(line)
489
+ ) {
490
+ malformed = true;
491
+ break;
492
+ }
493
+ continue;
494
+ }
495
+
496
+ if (!isRecord(event.usage)) {
497
+ malformed = true;
498
+ break;
499
+ }
500
+
501
+ const input = event.usage.input_tokens;
502
+ const output = event.usage.output_tokens;
503
+ if (!isTokenCount(input) || !isTokenCount(output)) {
504
+ malformed = true;
505
+ break;
506
+ }
507
+
508
+ found = true;
509
+ pendingTurn = false;
510
+ inputTokens += input;
511
+ outputTokens += output;
512
+ const cached = event.usage.cached_input_tokens;
513
+ if (cached === undefined) {
514
+ cachedInputKnown = false;
515
+ } else if (!isTokenCount(cached) || cached > input) {
516
+ malformed = true;
517
+ break;
518
+ } else {
519
+ cachedInputTokens += cached;
520
+ }
521
+ }
522
+
523
+ if (
524
+ malformed ||
525
+ pendingTurn ||
526
+ !found ||
527
+ !isTokenCount(inputTokens) ||
528
+ !isTokenCount(outputTokens) ||
529
+ !isTokenCount(inputTokens + outputTokens) ||
530
+ (cachedInputKnown && !isTokenCount(cachedInputTokens))
531
+ ) {
532
+ return undefined;
533
+ }
534
+
535
+ return {
536
+ tokensUsed: inputTokens + outputTokens,
537
+ inputTokens,
538
+ ...(cachedInputKnown ? { cachedInputTokens } : {}),
539
+ outputTokens,
540
+ };
541
+ }
542
+
543
+ function parseJsonObject(line: string): Record<string, unknown> | undefined {
544
+ try {
545
+ const value: unknown = JSON.parse(line);
546
+ return isRecord(value) ? value : undefined;
547
+ } catch {
548
+ return undefined;
549
+ }
550
+ }
551
+
552
+ function isRecord(value: unknown): value is Record<string, unknown> {
553
+ return typeof value === "object" && value !== null;
554
+ }
555
+
556
+ function isTokenCount(value: unknown): value is number {
557
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
558
+ }
559
+
326
560
  export async function discoverHarnesses(
327
561
  options: DiscoverHarnessesOptions = {},
328
562
  ): Promise<HarnessDiscovery[]> {
@@ -367,7 +601,15 @@ async function discoverHarness(
367
601
  .find((match): match is string => Boolean(match));
368
602
 
369
603
  const appPath = adapter.appPaths?.find((path) => existsSync(path));
370
- const version = commandPath ? await readVersion(commandPath, adapter) : undefined;
604
+ let version: string | undefined;
605
+ const notes = [...(adapter.notes ?? [])];
606
+ if (commandPath) {
607
+ try {
608
+ version = await readVersion(commandPath, adapter);
609
+ } catch (error) {
610
+ notes.push(`Version check failed: ${error instanceof Error ? error.message : String(error)}`);
611
+ }
612
+ }
371
613
  const available = Boolean(commandPath || appPath);
372
614
  const runnable = Boolean(commandPath && adapter.buildCommand);
373
615
 
@@ -381,7 +623,8 @@ async function discoverHarness(
381
623
  appPath,
382
624
  version,
383
625
  capabilities: adapter.capabilities,
384
- notes: adapter.notes ?? [],
626
+ notes,
627
+ ...(adapter.inputGuidance ? { inputGuidance: [...adapter.inputGuidance] } : {}),
385
628
  };
386
629
  }
387
630
 
@@ -396,6 +639,8 @@ async function readVersion(
396
639
  const result = await capture(commandPath, adapter.versionArgs, {
397
640
  timeoutMs: DEFAULT_VERSION_TIMEOUT_MS,
398
641
  });
642
+ if (result.timedOut) throw new Error("Version probe timed out.");
643
+ if (result.exitCode !== 0) throw new Error(`Version probe exited with code ${result.exitCode}.`);
399
644
 
400
645
  const text = `${result.stdout}\n${result.stderr}`.trim();
401
646
  return text.split(/\r?\n/).find((line) => line.trim())?.trim();
@@ -406,41 +651,49 @@ async function capture(
406
651
  args: string[],
407
652
  options: { timeoutMs: number },
408
653
  ): Promise<CaptureResult> {
409
- const process = Bun.spawn({
654
+ const child = Bun.spawn({
410
655
  cmd: [command, ...args],
411
656
  stdout: "pipe",
412
657
  stderr: "pipe",
658
+ detached: process.platform !== "win32",
413
659
  });
414
660
 
415
661
  let timedOut = false;
416
- const timeout = setTimeout(() => {
417
- timedOut = true;
418
- process.kill();
419
- }, options.timeoutMs);
662
+ let timeout: Timer | undefined;
663
+ const readers = [child.stdout.getReader(), child.stderr.getReader()];
664
+ const output = ["", ""];
665
+ const drained = Promise.all(readers.map(async (reader, index) => {
666
+ const decoder = new TextDecoder();
667
+ try {
668
+ for (;;) {
669
+ const { done, value } = await reader.read();
670
+ if (done) break;
671
+ output[index] = (output[index] + decoder.decode(value, { stream: true })).slice(0, 8_000);
672
+ }
673
+ output[index] += decoder.decode();
674
+ } catch { /* cancellation closes a stalled pipe */ }
675
+ finally { reader.releaseLock(); }
676
+ }));
420
677
 
421
678
  try {
422
- const [stdout, stderr, exitCode] = await Promise.all([
423
- streamToText(process.stdout),
424
- streamToText(process.stderr),
425
- process.exited,
679
+ const exitCode = await Promise.race([
680
+ Promise.all([drained, child.exited]).then(([, code]) => code),
681
+ new Promise<null>((resolve) => {
682
+ timeout = setTimeout(() => {
683
+ timedOut = true;
684
+ signalOwnedProcess(child, "SIGKILL");
685
+ for (const reader of readers) void reader.cancel().catch(() => undefined);
686
+ resolve(null);
687
+ }, options.timeoutMs);
688
+ }),
426
689
  ]);
427
-
428
- return { stdout, stderr, exitCode, timedOut };
690
+ return { stdout: output[0], stderr: output[1], exitCode, timedOut };
429
691
  } finally {
430
692
  clearTimeout(timeout);
693
+ if (!timedOut) signalOwnedProcess(child, "SIGKILL");
431
694
  }
432
695
  }
433
696
 
434
- async function streamToText(
435
- stream: ReadableStream<Uint8Array> | null,
436
- ): Promise<string> {
437
- if (!stream) {
438
- return "";
439
- }
440
-
441
- return await new Response(stream).text();
442
- }
443
-
444
697
  function toGoDuration(timeoutMs = 5 * 60 * 1000): string {
445
698
  const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
446
699
  return `${seconds}s`;
package/src/history.ts CHANGED
@@ -60,10 +60,13 @@ export class FileHistorySink implements HistorySink {
60
60
 
61
61
  async recordRun(snapshot: RunHistorySnapshot): Promise<void> {
62
62
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
63
+ // Frame each append with a leading newline so a previous truncated record
64
+ // cannot swallow the next receipt. Keep the delimiter and record in one
65
+ // append; never read/truncate the shared file to repair it.
63
66
  // Append-only hot path: an interrupted write can lose at most this
64
67
  // snapshot, and concurrent writers (MCP and IO processes share the
65
68
  // default directory) cannot clobber each other's lines.
66
- await appendFile(this.path, `${serialize(snapshot)}\n`, { mode: 0o600 });
69
+ await appendFile(this.path, `\n${serialize(snapshot)}\n`, { mode: 0o600 });
67
70
  await this.compactIfNeeded();
68
71
  }
69
72
 
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { pragmaticSchema, type PragmaticOptions } from "./pragmatic";
1
2
  export {
2
3
  createEnnodiaServer,
3
4
  shutdownEnnodia,
@@ -99,12 +100,14 @@ export {
99
100
  discoverHarnesses,
100
101
  findHarnessAdapter,
101
102
  harnessAdapters,
103
+ REASONING_EFFORTS,
102
104
  type CommandSpec,
103
105
  type DiscoverHarnessesOptions,
104
106
  type HarnessAdapter,
105
107
  type HarnessDiscovery,
106
108
  type HarnessKind,
107
109
  type HarnessRunInput,
110
+ type ReasoningEffort,
108
111
  } from "./harnesses";
109
112
  export { planRoute, renderPlanMermaid, type RoutePlan } from "./planner";
110
113
  export {
@@ -97,6 +97,7 @@ export type PlanAdviceManagerStartInput = {
97
97
  advisorModel?: string;
98
98
  timeoutMs?: number;
99
99
  budget?: BudgetLimits;
100
+ findHarnessAdapter?: (id: string) => HarnessAdapter | undefined;
100
101
  };
101
102
 
102
103
  export type PlanAdviceRevalidationContext = {
@@ -117,6 +118,7 @@ type InternalPlanAdvice = {
117
118
  advisorModel?: string;
118
119
  advisorTimeoutMs: number;
119
120
  budgetLimits?: BudgetLimits;
121
+ findHarnessAdapter?: PlanAdviceManagerStartInput["findHarnessAdapter"];
120
122
  advisorBudget: BudgetCheck;
121
123
  executionBudget?: BudgetCheck;
122
124
  proposal?: AdvisorPlanProposal;
@@ -167,7 +169,7 @@ export class PlanAdviceManager {
167
169
  harnessId: input.advisor.adapter.id,
168
170
  }],
169
171
  comparePlanned: false,
170
- }),
172
+ }, () => input.advisor.adapter),
171
173
  input.budget,
172
174
  );
173
175
  assertBudgetWithinLimits(advisorBudget);
@@ -208,6 +210,7 @@ export class PlanAdviceManager {
208
210
  advisorTimeoutMs: advisorTask.timeoutMs ??
209
211
  input.timeoutMs ?? DEFAULT_ADVISOR_TIMEOUT_MS,
210
212
  budgetLimits: input.budget ? { ...input.budget } : undefined,
213
+ findHarnessAdapter: input.findHarnessAdapter,
211
214
  advisorBudget,
212
215
  issues: [],
213
216
  createdAtMs: now,
@@ -386,7 +389,7 @@ export class PlanAdviceManager {
386
389
  harnessId: slice.harnessId,
387
390
  })),
388
391
  comparePlanned: false,
389
- }),
392
+ }, record.findHarnessAdapter),
390
393
  record.budgetLimits,
391
394
  );
392
395
  record.executionBudget = executionBudget;