ennodia 0.2.0 → 0.3.0-rc.2

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 +75 -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 +48 -25
  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 +85 -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 +61 -8
  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 +23 -0
  34. package/src/advisor.ts +4 -0
  35. package/src/budget.ts +12 -8
  36. package/src/cli.ts +3 -0
  37. package/src/compare.ts +13 -12
  38. package/src/compositional.ts +10 -4
  39. package/src/core.ts +53 -47
  40. package/src/diagnosis.ts +17 -3
  41. package/src/harnesses.ts +88 -29
  42. package/src/history.ts +4 -1
  43. package/src/index.ts +1 -0
  44. package/src/plan-advice.ts +5 -2
  45. package/src/planner.ts +24 -1
  46. package/src/pragmatic.ts +59 -0
  47. package/src/process.ts +18 -0
  48. package/src/runs.ts +19 -1
  49. package/src/server.ts +17 -13
  50. package/src/tasks.ts +106 -18
  51. package/src/version.ts +1 -1
@@ -5,7 +5,7 @@ import {
5
5
  type BudgetCheck,
6
6
  type BudgetLimits,
7
7
  } from "./budget";
8
- import type { HarnessDiscovery } from "./harnesses";
8
+ import type { HarnessAdapter, HarnessDiscovery } from "./harnesses";
9
9
  import type { RoutePlan } from "./planner";
10
10
  import type { RouteCategory } from "./priority";
11
11
  import type { TaskView } from "./tasks";
@@ -216,6 +216,7 @@ export function estimateCompositionalBudget(
216
216
  includeCompareEstimate: boolean,
217
217
  maxOutputChars: number | undefined,
218
218
  budget: BudgetLimits | undefined,
219
+ findAdapter?: (id: string) => HarnessAdapter | undefined,
219
220
  ): BudgetCheck {
220
221
  return checkBudgetLimits(
221
222
  estimateTaskBatchBudget({
@@ -225,7 +226,7 @@ export function estimateCompositionalBudget(
225
226
  })),
226
227
  comparePlanned: includeCompareEstimate,
227
228
  maxOutputChars,
228
- }),
229
+ }, findAdapter),
229
230
  budget,
230
231
  );
231
232
  }
@@ -248,7 +249,7 @@ export function summarizeCompositionalTasks(input: {
248
249
  !knownIds.has(taskId)
249
250
  );
250
251
  const readyTaskIds = tasks
251
- .filter((task) => task.status === "succeeded" && task.stdoutChars > 0)
252
+ .filter((task) => task.status === "succeeded" && hasTaskEvidence(task))
252
253
  .map((task) => task.id);
253
254
  const runningTaskIds = tasks
254
255
  .filter((task) => task.status === "running")
@@ -260,7 +261,7 @@ export function summarizeCompositionalTasks(input: {
260
261
  .filter((task) => task.status === "cancelled")
261
262
  .map((task) => task.id);
262
263
  const emptySucceededTaskIds = tasks
263
- .filter((task) => task.status === "succeeded" && task.stdoutChars === 0)
264
+ .filter((task) => task.status === "succeeded" && !hasTaskEvidence(task))
264
265
  .map((task) => task.id);
265
266
  const compareReady = readyTaskIds.length >=
266
267
  (input.minSuccessfulTasksForCompare ?? 2);
@@ -329,3 +330,8 @@ function compositionalPrompt(
329
330
 
330
331
  return lines.join("\n");
331
332
  }
333
+
334
+ function hasTaskEvidence(task: TaskView): boolean {
335
+ return task.hasOutput ?? Boolean(task.finalMessage?.trim() ||
336
+ task.finalMessageChars || task.stdoutChars || task.stderrChars);
337
+ }
package/src/core.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { preparePragmaticRun, type PragmaticOptions } from "./pragmatic";
1
2
  import { createHash } from "node:crypto";
2
3
  import type {
3
4
  AdvisorExecutionPlan,
@@ -109,6 +110,8 @@ export type EnnodiaCoreOptions = {
109
110
  export type EnnodiaCoreShutdownOptions = TaskManagerShutdownOptions;
110
111
 
111
112
  export type RunEstimateInput = {
113
+ pragmatic?: PragmaticOptions;
114
+ model?: string;
112
115
  prompt: string;
113
116
  category?: RouteCategory;
114
117
  harnessId?: string;
@@ -360,6 +363,7 @@ export class EnnodiaCore {
360
363
  advisorModel: input.advisorModel,
361
364
  timeoutMs: input.timeoutMs,
362
365
  budget: input.budget,
366
+ findHarnessAdapter: this.findHarnessAdapter,
363
367
  });
364
368
  }
365
369
 
@@ -428,7 +432,7 @@ export class EnnodiaCore {
428
432
  harnessId: slice.harnessId,
429
433
  })),
430
434
  comparePlanned: false,
431
- }),
435
+ }, this.findHarnessAdapter),
432
436
  input.budget,
433
437
  );
434
438
  assertBudgetWithinLimits(budget);
@@ -550,6 +554,7 @@ export class EnnodiaCore {
550
554
  }
551
555
 
552
556
  async estimateRun(input: RunEstimateInput): Promise<RunEstimate> {
557
+ input = preparePragmaticRun(input);
553
558
  const harnesses = await this.discoverHarnesses({ refresh: input.refresh });
554
559
  const plan = this.planRoute(input.prompt, harnesses, {
555
560
  category: input.category,
@@ -575,7 +580,7 @@ export class EnnodiaCore {
575
580
  selectedHarnessIds.length,
576
581
  ),
577
582
  maxOutputChars: input.maxOutputChars,
578
- }),
583
+ }, this.findHarnessAdapter),
579
584
  input.budget,
580
585
  ),
581
586
  };
@@ -602,7 +607,7 @@ export class EnnodiaCore {
602
607
  prompt: input.prompt,
603
608
  selectedHarnessIds,
604
609
  comparePlanned: false,
605
- }),
610
+ }, this.findHarnessAdapter),
606
611
  input.budget,
607
612
  );
608
613
  assertBudgetWithinLimits(budget);
@@ -612,25 +617,24 @@ export class EnnodiaCore {
612
617
  input.skillIds,
613
618
  input.cwd,
614
619
  );
615
- const tasks = selectedHarnessIds.map((harnessId) => {
616
- const { adapter, discovery } = this.requireRunnableHarness(
617
- harnessId,
618
- harnesses,
619
- );
620
-
621
- return this.taskManager.start(adapter, discovery, {
622
- prompt: input.prompt,
623
- cwd: input.cwd,
624
- isolateCwd: input.isolateCwd,
625
- model: input.model,
626
- timeoutMs: input.timeoutMs,
627
- skills,
628
- }).task;
629
- });
630
- const unrequestedSkillsPresent = await this.findUnrequestedSkills(
631
- input.cwd,
632
- skills,
633
- );
620
+ const unrequestedSkillsPresent = await this.findUnrequestedSkills(input.cwd, skills);
621
+ const tasks: TaskView[] = [];
622
+ try {
623
+ for (const harnessId of selectedHarnessIds) {
624
+ const { adapter, discovery } = this.requireRunnableHarness(harnessId, harnesses);
625
+ tasks.push(this.taskManager.start(adapter, discovery, {
626
+ prompt: input.prompt,
627
+ cwd: input.cwd,
628
+ isolateCwd: input.isolateCwd,
629
+ model: input.model,
630
+ timeoutMs: input.timeoutMs,
631
+ skills,
632
+ }).task);
633
+ }
634
+ } catch (error) {
635
+ for (const task of tasks) this.taskManager.cancel(task.id);
636
+ throw error;
637
+ }
634
638
 
635
639
  return { plan, tasks, budget, unrequestedSkillsPresent };
636
640
  }
@@ -649,6 +653,7 @@ export class EnnodiaCore {
649
653
  input.includeCompareEstimate ?? true,
650
654
  input.maxOutputChars,
651
655
  input.budget,
656
+ this.findHarnessAdapter,
652
657
  ),
653
658
  };
654
659
  }
@@ -663,34 +668,34 @@ export class EnnodiaCore {
663
668
  input.includeCompareEstimate ?? true,
664
669
  input.maxOutputChars,
665
670
  input.budget,
671
+ this.findHarnessAdapter,
666
672
  );
667
673
  assertBudgetWithinLimits(budget);
668
674
 
669
- const tasks = resolvedSlices.map((slice, index) => {
670
- const { adapter, discovery } = this.requireRunnableHarness(
671
- slice.harnessId,
672
- harnesses,
673
- );
674
-
675
- return {
676
- sliceId: slice.id,
677
- sliceTitle: slice.title,
678
- harnessId: slice.harnessId,
679
- routeCategory: slice.plan.category,
680
- task: this.taskManager.start(adapter, discovery, {
681
- prompt: slice.prompt,
682
- cwd: input.cwd,
683
- isolateCwd: input.isolateCwd,
684
- model: slice.model,
685
- timeoutMs: input.timeoutMs,
686
- skills: sliceSkills[index] ?? [],
687
- }).task,
688
- };
689
- });
690
- const unrequestedSkillsPresent = await this.findUnrequestedSkills(
691
- input.cwd,
692
- requestedSkills,
693
- );
675
+ const unrequestedSkillsPresent = await this.findUnrequestedSkills(input.cwd, requestedSkills);
676
+ const tasks: CompositionalStart["tasks"] = [];
677
+ try {
678
+ for (const [index, slice] of resolvedSlices.entries()) {
679
+ const { adapter, discovery } = this.requireRunnableHarness(slice.harnessId, harnesses);
680
+ tasks.push({
681
+ sliceId: slice.id,
682
+ sliceTitle: slice.title,
683
+ harnessId: slice.harnessId,
684
+ routeCategory: slice.plan.category,
685
+ task: this.taskManager.start(adapter, discovery, {
686
+ prompt: slice.prompt,
687
+ cwd: input.cwd,
688
+ isolateCwd: input.isolateCwd,
689
+ model: slice.model,
690
+ timeoutMs: input.timeoutMs,
691
+ skills: sliceSkills[index] ?? [],
692
+ }).task,
693
+ });
694
+ }
695
+ } catch (error) {
696
+ for (const started of tasks) this.taskManager.cancel(started.task.id);
697
+ throw error;
698
+ }
694
699
 
695
700
  return {
696
701
  tasks,
@@ -902,6 +907,7 @@ export class EnnodiaCore {
902
907
  name: discovery.name,
903
908
  runnable: true,
904
909
  capabilities: [...new Set(discovery.capabilities)].sort(),
910
+ ...(discovery.inputGuidance ? { inputGuidance: [...discovery.inputGuidance] } : {}),
905
911
  allowedModelIds: [
906
912
  ...new Set(input.allowedModels?.[discovery.id] ?? []),
907
913
  ].sort(),
package/src/diagnosis.ts CHANGED
@@ -42,9 +42,12 @@ export function diagnoseTasks(tasks: TaskView[]): TaskDiagnosis | undefined {
42
42
  }
43
43
 
44
44
  function likelyCause(tasks: TaskView[]): string {
45
+ if (tasks.every(hasAuthenticationError)) {
46
+ return "Agent authentication failed. This attempt did not establish how the selected model would handle the task.";
47
+ }
45
48
  if (tasks.every((task) => task.timedOut)) {
46
49
  if (tasks.some(outputChars) && tasks.some(hasRecentOutput)) {
47
- return "Task needed more time; at least one provider was still producing output before the deadline.";
50
+ return "The task produced output near its deadline but did not return a completed result.";
48
51
  }
49
52
 
50
53
  if (tasks.some(outputChars)) {
@@ -75,8 +78,12 @@ function suggestions(
75
78
  ): string[] {
76
79
  const result = new Set<string>();
77
80
 
81
+ if (tasks.some(hasAuthenticationError)) {
82
+ result.add("Sign in through the affected agent's supported CLI, then retry the same task.");
83
+ }
84
+
78
85
  if (tasks.some((task) => task.timedOut)) {
79
- result.add("Retry the timed-out provider with a longer timeoutMs.");
86
+ result.add("Inspect the attempt before retrying. Narrow the task, adjust its execution settings, or allow more time if justified.");
80
87
  }
81
88
 
82
89
  if (hasPartialOutput) {
@@ -85,7 +92,9 @@ function suggestions(
85
92
 
86
93
  if (tasks.some((task) => task.exitCode !== undefined && task.exitCode !== 0)) {
87
94
  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.");
95
+ if (!tasks.every(hasAuthenticationError)) {
96
+ result.add("Check the requested model and command configuration before retrying.");
97
+ }
89
98
  }
90
99
 
91
100
  if (tasks.some((task) => task.drainTimedOut)) {
@@ -99,6 +108,11 @@ function suggestions(
99
108
  return [...result];
100
109
  }
101
110
 
111
+ function hasAuthenticationError(task: TaskView): boolean {
112
+ return !task.timedOut && /^(?:Error: )?(?:Failed to authenticate:|OAuth session expired|Authentication failed:|Not logged in\b)/im
113
+ .test(`${task.stdout}\n${task.stderr}`);
114
+ }
115
+
102
116
  function summaryLine(task: TaskView): string {
103
117
  const status = task.timedOut ? "timed out" : task.status;
104
118
  const facts = [
package/src/harnesses.ts CHANGED
@@ -1,4 +1,5 @@
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";
@@ -43,9 +44,13 @@ export type HarnessAdapter = {
43
44
  versionArgs?: string[];
44
45
  capabilities: string[];
45
46
  notes?: string[];
47
+ /** Input preparation guidance, not a guarantee of model or tool access. */
48
+ inputGuidance?: string[];
46
49
  buildCommand?: (commandPath: string, input: HarnessRunInput) => CommandSpec;
47
50
  /** Best-effort usage extraction from a finished task's captured output. */
48
51
  extractUsage?: (stdout: string, stderr: string) => HarnessUsage | undefined;
52
+ /** Some public CLIs report a semantic failure with exit code zero. */
53
+ failureReason?: (stdout: string, stderr: string) => string | undefined;
49
54
  };
50
55
 
51
56
  export type HarnessDiscovery = {
@@ -59,6 +64,7 @@ export type HarnessDiscovery = {
59
64
  version?: string;
60
65
  capabilities: string[];
61
66
  notes: string[];
67
+ inputGuidance?: string[];
62
68
  };
63
69
 
64
70
  export type DiscoverHarnessesOptions = {
@@ -81,6 +87,29 @@ let cachedDiscovery:
81
87
  | undefined;
82
88
  let inFlightDiscovery: Promise<HarnessDiscovery[]> | undefined;
83
89
 
90
+ export const MULTIMODAL_INPUT_GUIDANCE = [
91
+ "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.",
92
+ "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.",
93
+ "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.",
94
+ ];
95
+
96
+ export function hasMultimodalInput(prompt: string): boolean {
97
+ 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);
98
+ }
99
+
100
+ /** Keep preparation instructions out of unrelated work and internal text judges. */
101
+ export function withInputGuidance(
102
+ prompt: string,
103
+ adapter?: Pick<HarnessAdapter, "inputGuidance">,
104
+ ): string {
105
+ if (!hasMultimodalInput(prompt) || /^ENNODIA_(?:COMPARE|PLAN_ADVISOR)/.test(prompt)) {
106
+ return prompt;
107
+ }
108
+ return `${prompt}\n\nEnnodia media input guidance (applies when native media inspection is required):\n${
109
+ (adapter?.inputGuidance ?? MULTIMODAL_INPUT_GUIDANCE).map((note) => `- ${note}`).join("\n")
110
+ }`;
111
+ }
112
+
84
113
  export const harnessAdapters: HarnessAdapter[] = [
85
114
  {
86
115
  id: "claude-code",
@@ -102,7 +131,7 @@ export const harnessAdapters: HarnessAdapter[] = [
102
131
  args.push("--model", input.model);
103
132
  }
104
133
 
105
- args.push(input.prompt);
134
+ args.push("--", input.prompt);
106
135
  return { command: commandPath, args, cwd: input.cwd };
107
136
  },
108
137
  },
@@ -137,7 +166,7 @@ export const harnessAdapters: HarnessAdapter[] = [
137
166
  args.push("-o", input.finalMessagePath);
138
167
  }
139
168
 
140
- args.push(input.prompt);
169
+ args.push("--", input.prompt);
141
170
  return { command: commandPath, args, cwd: input.cwd };
142
171
  },
143
172
  extractUsage: (stdout) => {
@@ -169,7 +198,7 @@ export const harnessAdapters: HarnessAdapter[] = [
169
198
  args.push("--model", input.model);
170
199
  }
171
200
 
172
- args.push(input.prompt);
201
+ args.push("--", input.prompt);
173
202
  return { command: commandPath, args, cwd: input.cwd };
174
203
  },
175
204
  },
@@ -201,7 +230,7 @@ export const harnessAdapters: HarnessAdapter[] = [
201
230
  args.push("--model", input.model);
202
231
  }
203
232
 
204
- args.push(input.prompt);
233
+ args.push("--", input.prompt);
205
234
  return { command: commandPath, args, cwd: input.cwd };
206
235
  },
207
236
  },
@@ -256,7 +285,7 @@ export const harnessAdapters: HarnessAdapter[] = [
256
285
  args.push("--model", input.model);
257
286
  }
258
287
 
259
- args.push(input.prompt);
288
+ args.push("--", input.prompt);
260
289
  return { command: commandPath, args, cwd: input.cwd };
261
290
  },
262
291
  },
@@ -281,7 +310,7 @@ export const harnessAdapters: HarnessAdapter[] = [
281
310
  args.push("--model", input.model);
282
311
  }
283
312
 
284
- args.push(input.prompt);
313
+ args.push("--", input.prompt);
285
314
  return { command: commandPath, args, cwd: input.cwd };
286
315
  },
287
316
  },
@@ -296,11 +325,22 @@ export const harnessAdapters: HarnessAdapter[] = [
296
325
  "/Applications/Antigravity IDE.app",
297
326
  ],
298
327
  capabilities: ["ide", "browser-automation", "agents", "non-interactive-cli"],
328
+ inputGuidance: [
329
+ ...MULTIMODAL_INPUT_GUIDANCE,
330
+ "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/",
331
+ "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.",
332
+ "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",
333
+ "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.",
334
+ ],
299
335
  notes: [
300
336
  "Runs through the supported `agy` CLI surface.",
301
337
  "Defaults to Antigravity sandbox mode for Ennodia-launched tasks.",
302
338
  "Passes the prompt as the value of Antigravity's non-interactive `--print` option.",
303
339
  ],
340
+ failureReason: (stdout, stderr) => !stdout.trim() &&
341
+ /^jetski: no output produced\b/m.test(stderr)
342
+ ? "Antigravity returned no answer. Check the required tool access in its normal permission settings."
343
+ : undefined,
304
344
  buildCommand: (commandPath, input) => {
305
345
  const args = [
306
346
  "--sandbox",
@@ -367,7 +407,15 @@ async function discoverHarness(
367
407
  .find((match): match is string => Boolean(match));
368
408
 
369
409
  const appPath = adapter.appPaths?.find((path) => existsSync(path));
370
- const version = commandPath ? await readVersion(commandPath, adapter) : undefined;
410
+ let version: string | undefined;
411
+ const notes = [...(adapter.notes ?? [])];
412
+ if (commandPath) {
413
+ try {
414
+ version = await readVersion(commandPath, adapter);
415
+ } catch (error) {
416
+ notes.push(`Version check failed: ${error instanceof Error ? error.message : String(error)}`);
417
+ }
418
+ }
371
419
  const available = Boolean(commandPath || appPath);
372
420
  const runnable = Boolean(commandPath && adapter.buildCommand);
373
421
 
@@ -381,7 +429,8 @@ async function discoverHarness(
381
429
  appPath,
382
430
  version,
383
431
  capabilities: adapter.capabilities,
384
- notes: adapter.notes ?? [],
432
+ notes,
433
+ ...(adapter.inputGuidance ? { inputGuidance: [...adapter.inputGuidance] } : {}),
385
434
  };
386
435
  }
387
436
 
@@ -396,6 +445,8 @@ async function readVersion(
396
445
  const result = await capture(commandPath, adapter.versionArgs, {
397
446
  timeoutMs: DEFAULT_VERSION_TIMEOUT_MS,
398
447
  });
448
+ if (result.timedOut) throw new Error("Version probe timed out.");
449
+ if (result.exitCode !== 0) throw new Error(`Version probe exited with code ${result.exitCode}.`);
399
450
 
400
451
  const text = `${result.stdout}\n${result.stderr}`.trim();
401
452
  return text.split(/\r?\n/).find((line) => line.trim())?.trim();
@@ -406,41 +457,49 @@ async function capture(
406
457
  args: string[],
407
458
  options: { timeoutMs: number },
408
459
  ): Promise<CaptureResult> {
409
- const process = Bun.spawn({
460
+ const child = Bun.spawn({
410
461
  cmd: [command, ...args],
411
462
  stdout: "pipe",
412
463
  stderr: "pipe",
464
+ detached: process.platform !== "win32",
413
465
  });
414
466
 
415
467
  let timedOut = false;
416
- const timeout = setTimeout(() => {
417
- timedOut = true;
418
- process.kill();
419
- }, options.timeoutMs);
468
+ let timeout: Timer | undefined;
469
+ const readers = [child.stdout.getReader(), child.stderr.getReader()];
470
+ const output = ["", ""];
471
+ const drained = Promise.all(readers.map(async (reader, index) => {
472
+ const decoder = new TextDecoder();
473
+ try {
474
+ for (;;) {
475
+ const { done, value } = await reader.read();
476
+ if (done) break;
477
+ output[index] = (output[index] + decoder.decode(value, { stream: true })).slice(0, 8_000);
478
+ }
479
+ output[index] += decoder.decode();
480
+ } catch { /* cancellation closes a stalled pipe */ }
481
+ finally { reader.releaseLock(); }
482
+ }));
420
483
 
421
484
  try {
422
- const [stdout, stderr, exitCode] = await Promise.all([
423
- streamToText(process.stdout),
424
- streamToText(process.stderr),
425
- process.exited,
485
+ const exitCode = await Promise.race([
486
+ Promise.all([drained, child.exited]).then(([, code]) => code),
487
+ new Promise<null>((resolve) => {
488
+ timeout = setTimeout(() => {
489
+ timedOut = true;
490
+ signalOwnedProcess(child, "SIGKILL");
491
+ for (const reader of readers) void reader.cancel().catch(() => undefined);
492
+ resolve(null);
493
+ }, options.timeoutMs);
494
+ }),
426
495
  ]);
427
-
428
- return { stdout, stderr, exitCode, timedOut };
496
+ return { stdout: output[0], stderr: output[1], exitCode, timedOut };
429
497
  } finally {
430
498
  clearTimeout(timeout);
499
+ if (!timedOut) signalOwnedProcess(child, "SIGKILL");
431
500
  }
432
501
  }
433
502
 
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
503
  function toGoDuration(timeoutMs = 5 * 60 * 1000): string {
445
504
  const seconds = Math.max(1, Math.ceil(timeoutMs / 1000));
446
505
  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,
@@ -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;
package/src/planner.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { HarnessDiscovery } from "./harnesses";
2
+ import { hasMultimodalInput, MULTIMODAL_INPUT_GUIDANCE } from "./harnesses";
2
3
  import {
3
4
  CATEGORY_HARNESS_PRIORITIES,
4
5
  type RouteCategory,
@@ -11,6 +12,7 @@ export type RoutePlan = {
11
12
  selected?: string;
12
13
  parallelSuggested: boolean;
13
14
  compareSuggested: boolean;
15
+ inputGuidance?: string[];
14
16
  };
15
17
 
16
18
  export type PlanRouteOptions = {
@@ -26,6 +28,10 @@ export function planRoute(
26
28
  const runnable = harnesses.filter((harness) => harness.runnable);
27
29
  const runnableIds = new Set(runnable.map((harness) => harness.id));
28
30
  const reasons: string[] = [];
31
+ const audioInspectionSignals = /\bnative audio\b|\baudio listening\b|\blistening comparison\b|\blisten to\b[^.!?\n]{0,80}\b(audio|recordings?|files?|samples?)\b|\b(compare|assess|judge)\b[^.!?\n]{0,60}\b(audio|recordings?|voice|sound)\b/.test(lower);
32
+ const requestedAction = lower.replace(/^\s*(?:please\s+)?(?:(?:can|could|would|will)\s+you\s+|i\s+(?:want|need)\s+you\s+to\s+)?(?:please\s+)?/, "");
33
+ const softwareMaintenance = /^(fix|implement|refactor|debug)\b|^write\b[^.!?\n]{0,40}\b(script|function|tests?)\b|^review (?:the )?(code|implementation)\b/.test(requestedAction);
34
+ const nativeAudioProbe = !options.category && audioInspectionSignals && !softwareMaintenance;
29
35
 
30
36
  let category: RouteCategory = "general";
31
37
  const strongBrowserSignals =
@@ -48,6 +54,9 @@ export function planRoute(
48
54
  if (options.category) {
49
55
  category = options.category;
50
56
  reasons.push("Caller-provided category.");
57
+ } else if (nativeAudioProbe) {
58
+ category = "general";
59
+ reasons.push("The task needs native audio inspection. Start with one capability probe; compare after native access is confirmed.");
51
60
  } else if (browserSignals) {
52
61
  category = "browser";
53
62
  reasons.push("The prompt mentions a website, UI, or rendered page.");
@@ -70,11 +79,16 @@ export function planRoute(
70
79
  (id) => !CATEGORY_HARNESS_PRIORITIES[category].includes(id),
71
80
  ),
72
81
  ];
82
+ if (nativeAudioProbe && runnableIds.has("antigravity")) {
83
+ candidates.splice(candidates.indexOf("antigravity"), 1);
84
+ candidates.unshift("antigravity");
85
+ reasons.push("Antigravity has an observed native-audio probe path. This is a probe candidate, not verified model access.");
86
+ }
73
87
 
74
88
  const complexSignals = /\b(audit|assess|compare|critique|evaluate|inspect|judge|multiple|parallel|review|several|tradeoff|best)\b/.test(
75
89
  lower,
76
90
  );
77
- const parallelSuggested = candidates.length > 1 && complexSignals;
91
+ const parallelSuggested = !nativeAudioProbe && candidates.length > 1 && complexSignals;
78
92
  const compareSuggested = parallelSuggested;
79
93
 
80
94
  return {
@@ -84,6 +98,15 @@ export function planRoute(
84
98
  selected: candidates[0],
85
99
  parallelSuggested,
86
100
  compareSuggested,
101
+ ...(hasMultimodalInput(prompt) ? {
102
+ inputGuidance: [
103
+ ...MULTIMODAL_INPUT_GUIDANCE,
104
+ "Keyword routing does not verify native media access. Inspect the chosen harness inputGuidance and select a harness/model explicitly for the initial probe.",
105
+ ...runnable.flatMap((harness) => (harness.inputGuidance ?? [])
106
+ .filter((note) => !MULTIMODAL_INPUT_GUIDANCE.includes(note))
107
+ .map((note) => `${harness.id}: ${note}`)),
108
+ ],
109
+ } : {}),
87
110
  };
88
111
  }
89
112