ennodia 0.1.1 → 0.2.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 (46) hide show
  1. package/CONTRIBUTING.md +60 -36
  2. package/README.md +74 -58
  3. package/bench/README.md +7 -5
  4. package/bench/bug-recall/run.ts +12 -4
  5. package/bin/ennodia +23 -2
  6. package/docs/assets/agents/mcp.svg +1 -1
  7. package/docs/assets/orchestration-pipeline.svg +4 -3
  8. package/docs/comparisons/agent-frameworks.md +15 -10
  9. package/docs/comparisons/autogen.md +20 -15
  10. package/docs/comparisons/chathub.md +15 -14
  11. package/docs/comparisons/index.md +30 -22
  12. package/docs/comparisons/langgraph.md +19 -12
  13. package/docs/comparisons/mixture-of-agents.md +20 -17
  14. package/docs/comparisons/model-merging.md +7 -5
  15. package/docs/comparisons/openrouter.md +30 -22
  16. package/docs/concepts/compositional-audits.md +16 -13
  17. package/docs/concepts/data-governance.md +31 -15
  18. package/docs/concepts/how-ennodia-works.md +48 -22
  19. package/docs/concepts/interfaces-and-core.md +19 -6
  20. package/docs/concepts/second-opinions.md +20 -20
  21. package/docs/getting-started.md +55 -27
  22. package/docs/guides/agent-skills.md +16 -13
  23. package/docs/guides/budgets-and-limits.md +30 -20
  24. package/docs/guides/running-better-audits.md +50 -44
  25. package/docs/index.md +60 -37
  26. package/docs/install.md +41 -32
  27. package/docs/reference/benchmarks.md +6 -4
  28. package/docs/reference/controlled-english.md +130 -0
  29. package/docs/reference/ennodia-io.md +28 -22
  30. package/docs/reference/mcp-tools.md +244 -87
  31. package/docs/reference/supported-harnesses.md +9 -9
  32. package/package.json +7 -5
  33. package/src/advisor.ts +685 -0
  34. package/src/budget.ts +25 -6
  35. package/src/cli.ts +1 -1
  36. package/src/compare.ts +248 -52
  37. package/src/compositional.ts +18 -0
  38. package/src/core.ts +547 -16
  39. package/src/harnesses.ts +33 -2
  40. package/src/index.ts +38 -0
  41. package/src/plan-advice.ts +601 -0
  42. package/src/runs.ts +74 -9
  43. package/src/server.ts +312 -46
  44. package/src/skills.ts +131 -11
  45. package/src/tasks.ts +192 -22
  46. package/src/version.ts +1 -1
package/src/skills.ts CHANGED
@@ -426,6 +426,7 @@ function legacySkillDirectory(path: string, scope: "project" | "user"): SkillDir
426
426
  async function loadAvailableSkills(cwd?: string): Promise<LoadedSkillDiscovery> {
427
427
  const directories = getSkillDirectories(cwd);
428
428
  const skillsMap = new Map<string, Skill>();
429
+ const ambiguousHarnessIds = new Map<string, Set<string>>();
429
430
  const warnings: string[] = [];
430
431
 
431
432
  for (const directory of directories) {
@@ -439,10 +440,42 @@ async function loadAvailableSkills(cwd?: string): Promise<LoadedSkillDiscovery>
439
440
  );
440
441
  const withInstallation = addInstallation(skill, directory);
441
442
  const existing = skillsMap.get(withInstallation.id);
442
- skillsMap.set(
443
- withInstallation.id,
444
- existing ? mergeSkills(existing, withInstallation) : withInstallation,
445
- );
443
+ if (!existing) {
444
+ skillsMap.set(withInstallation.id, withInstallation);
445
+ continue;
446
+ }
447
+
448
+ if (existing.hash !== withInstallation.hash) {
449
+ const ambiguous = ambiguousHarnessIds.get(withInstallation.id) ??
450
+ new Set<string>();
451
+ for (const harnessId of withInstallation.harnessIds) {
452
+ if (existing.harnessIds.includes(harnessId)) {
453
+ ambiguous.add(harnessId);
454
+ }
455
+ }
456
+ ambiguousHarnessIds.set(withInstallation.id, ambiguous);
457
+ skillsMap.set(withInstallation.id, {
458
+ ...existing,
459
+ harnessIds: existing.harnessIds.filter((harnessId) =>
460
+ !ambiguous.has(harnessId)
461
+ ),
462
+ });
463
+ warnings.push(
464
+ `${filePath}: Conflicting skill content for ${existing.id}: ${existing.path} and ${withInstallation.path}. Ennodia kept the higher-priority installation and removed ambiguous harness support.`,
465
+ );
466
+ continue;
467
+ }
468
+
469
+ const merged = mergeSkills(existing, withInstallation);
470
+ const ambiguous = ambiguousHarnessIds.get(withInstallation.id);
471
+ skillsMap.set(withInstallation.id, ambiguous
472
+ ? {
473
+ ...merged,
474
+ harnessIds: merged.harnessIds.filter((harnessId) =>
475
+ !ambiguous.has(harnessId)
476
+ ),
477
+ }
478
+ : merged);
446
479
  } catch (error) {
447
480
  warnings.push(`${filePath}: ${errorMessage(error)}`);
448
481
  }
@@ -589,6 +622,12 @@ function addInstallation(skill: Skill, directory: SkillDirectory): Skill {
589
622
  }
590
623
 
591
624
  function mergeSkills(primary: Skill, secondary: Skill): Skill {
625
+ if (primary.hash !== secondary.hash) {
626
+ throw new Error(
627
+ `Conflicting skill content for ${primary.id}: ${primary.path} and ${secondary.path}. Ennodia will keep the higher-priority installation without claiming support from the conflicting installation.`,
628
+ );
629
+ }
630
+
592
631
  return {
593
632
  ...primary,
594
633
  harnessIds: [...new Set([...primary.harnessIds, ...secondary.harnessIds])].sort(),
@@ -721,18 +760,97 @@ function splitFrontmatter(content: string): {
721
760
 
722
761
  const metadata: Record<string, string> = {};
723
762
  const frontmatter = normalized.slice(4, end).trim();
724
- for (const line of frontmatter.split("\n")) {
725
- const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(line.trim());
763
+ const lines = frontmatter.split("\n");
764
+
765
+ for (let i = 0; i < lines.length; i++) {
766
+ const match = /^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/.exec(lines[i].trim());
726
767
  if (!match) {
727
768
  continue;
728
769
  }
729
770
 
730
- metadata[match[1]] = unquote(match[2].trim());
771
+ const key = match[1];
772
+ const rest = match[2].trim();
773
+ const blockScalar = /^([|>])([+-]?)$/.exec(rest);
774
+
775
+ if (blockScalar) {
776
+ const style = blockScalar[1] as "|" | ">";
777
+ const chomp = blockScalar[2];
778
+ const consumed = readYamlBlockScalar(lines, i + 1);
779
+ metadata[key] = foldYamlBlockScalar(consumed.value, style, chomp);
780
+ i = consumed.nextIndex - 1;
781
+ continue;
782
+ }
783
+
784
+ metadata[key] = unquote(rest);
731
785
  }
732
786
 
733
787
  return { metadata, body: normalized.slice(end + 5) };
734
788
  }
735
789
 
790
+ function readYamlBlockScalar(
791
+ lines: string[],
792
+ startIndex: number,
793
+ ): { value: string[]; nextIndex: number } {
794
+ const value: string[] = [];
795
+ let indent: number | null = null;
796
+ let i = startIndex;
797
+
798
+ for (; i < lines.length; i++) {
799
+ const rawLine = lines[i];
800
+ if (rawLine.trim() === "") {
801
+ value.push("");
802
+ continue;
803
+ }
804
+
805
+ const lineIndent = rawLine.match(/^ */)?.[0].length ?? 0;
806
+ if (indent === null) {
807
+ if (lineIndent === 0) {
808
+ break;
809
+ }
810
+ indent = lineIndent;
811
+ } else if (lineIndent < indent) {
812
+ break;
813
+ }
814
+
815
+ value.push(rawLine.slice(indent ?? 0));
816
+ }
817
+
818
+ return { value, nextIndex: i };
819
+ }
820
+
821
+ function foldYamlBlockScalar(
822
+ lines: string[],
823
+ style: "|" | ">",
824
+ chomp: string,
825
+ ): string {
826
+ const trimmed = [...lines];
827
+ while (trimmed.length && trimmed[trimmed.length - 1] === "" && chomp !== "+") {
828
+ trimmed.pop();
829
+ }
830
+
831
+ if (style === "|") {
832
+ return trimmed.join("\n");
833
+ }
834
+
835
+ const paragraphs: string[] = [];
836
+ let current: string[] = [];
837
+ for (const line of trimmed) {
838
+ if (line === "") {
839
+ if (current.length) {
840
+ paragraphs.push(current.join(" "));
841
+ current = [];
842
+ }
843
+ continue;
844
+ }
845
+ current.push(line);
846
+ }
847
+ if (current.length) {
848
+ paragraphs.push(current.join(" "));
849
+ }
850
+
851
+ return paragraphs.join("\n").trim();
852
+ }
853
+
736
854
  function stringField(
737
855
  record: Record<string, unknown>,
738
856
  key: string,
@@ -805,10 +923,12 @@ function firstParagraph(content: string): string | undefined {
805
923
  }
806
924
 
807
925
  function skillSupportsHarness(skill: Skill, harnessId: string): boolean {
808
- return (
809
- skill.harnessIds.includes(harnessId) ||
810
- skill.harnessIds.includes("legacy")
811
- );
926
+ if (skill.native) {
927
+ return skill.harnessIds.includes(harnessId);
928
+ }
929
+
930
+ return skill.harnessIds.includes(harnessId) ||
931
+ skill.harnessIds.includes("legacy");
812
932
  }
813
933
 
814
934
  function unquote(value: string): string {
package/src/tasks.ts CHANGED
@@ -1,10 +1,14 @@
1
- import { existsSync } from "node:fs";
2
- import { basename } from "node:path";
1
+ import { cpSync, existsSync, lstatSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, join } from "node:path";
4
+ import { tmpdir } from "node:os";
3
5
  import { randomUUID } from "node:crypto";
4
6
  import type {
7
+ CommandSpec,
5
8
  HarnessAdapter,
6
9
  HarnessDiscovery,
7
10
  HarnessRunInput,
11
+ HarnessUsage,
8
12
  } from "./harnesses";
9
13
  import { preview, tailItems } from "./internal";
10
14
  import {
@@ -49,6 +53,16 @@ export type TaskView = {
49
53
  stderr: string;
50
54
  events: TaskEvent[];
51
55
  appliedSkills?: AppliedSkillInfo[];
56
+ /** Clean final message, distinct from raw stdout, when the adapter supports
57
+ * writing one out-of-band (e.g. Codex's --output-last-message). Prefer this
58
+ * over stdout for cross-harness comparisons: some adapters' stdout is a
59
+ * full session transcript, others' is just the final reply. */
60
+ finalMessage?: string;
61
+ /** Best-effort usage metrics parsed from the adapter's own output. */
62
+ usage?: HarnessUsage;
63
+ /** Set when this task ran against an isolated copy of the requested cwd
64
+ * (see HarnessRunInput.isolateCwd), naming the original directory. */
65
+ isolatedFrom?: string;
52
66
  };
53
67
 
54
68
  export type TaskViewOptions = {
@@ -91,6 +105,11 @@ type InternalTask = Omit<
91
105
  settled?: Promise<void>;
92
106
  streamReaders: Set<ReadableStreamDefaultReader<Uint8Array>>;
93
107
  timeout?: Timer;
108
+ finalMessagePath?: string;
109
+ /** Exact temporary directory allocated by TaskManager. Keep this separate
110
+ * from cwd because an adapter can override the command working directory. */
111
+ isolatedCwd?: string;
112
+ extractUsage?: HarnessAdapter["extractUsage"];
94
113
  };
95
114
 
96
115
  export type StartTaskResult = {
@@ -101,6 +120,7 @@ export type TaskManagerOptions = {
101
120
  drainTimeoutMs?: number;
102
121
  maxTasks?: number;
103
122
  spawn?: TaskSpawn;
123
+ removeIsolatedCwd?: (path: string) => void;
104
124
  };
105
125
 
106
126
  export type TaskManagerShutdownOptions = {
@@ -120,6 +140,7 @@ export class TaskManager {
120
140
  private readonly drainTimeoutMs: number;
121
141
  private readonly maxTasks: number;
122
142
  private readonly spawn: TaskSpawn;
143
+ private readonly removeIsolatedCwd: (path: string) => void;
123
144
  private shuttingDown = false;
124
145
  private shutdownPromise?: Promise<void>;
125
146
 
@@ -127,6 +148,9 @@ export class TaskManager {
127
148
  this.drainTimeoutMs = options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS;
128
149
  this.maxTasks = Math.max(1, options.maxTasks ?? DEFAULT_MAX_TASKS);
129
150
  this.spawn = options.spawn ?? ((input) => Bun.spawn(input));
151
+ this.removeIsolatedCwd = options.removeIsolatedCwd ?? ((path) => {
152
+ rmSync(path, { recursive: true, force: true });
153
+ });
130
154
  }
131
155
 
132
156
  start(
@@ -142,18 +166,61 @@ export class TaskManager {
142
166
  throw new Error(`${adapter.name} is not runnable through Ennodia yet.`);
143
167
  }
144
168
 
169
+ const sourceCwd = input.cwd ?? process.cwd();
170
+ if (!existsSync(sourceCwd)) {
171
+ throw new Error(`Working directory does not exist: ${sourceCwd}`);
172
+ }
173
+
174
+ let isolatedCwd: string | undefined;
175
+ let isolatedFrom: string | undefined;
176
+ let resolvedInputCwd = input.cwd;
177
+ if (input.isolateCwd) {
178
+ isolatedCwd = isolateDirectory(sourceCwd);
179
+ resolvedInputCwd = isolatedCwd;
180
+ isolatedFrom = sourceCwd;
181
+ }
182
+
183
+ // Always allocate a scratch path; adapters that support writing a clean
184
+ // final message (e.g. Codex's --output-last-message) opt in by
185
+ // referencing input.finalMessagePath in buildCommand. Adapters that
186
+ // don't support it simply never create the file, and it's cleaned up
187
+ // as a no-op.
188
+ const finalMessagePath = join(tmpdir(), `ennodia-final-${randomUUID()}.txt`);
189
+
145
190
  const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
146
191
  const augmentedPrompt = input.skills && input.skills.length > 0
147
192
  ? augmentPrompt(input.prompt, input.skills, adapter.id)
148
193
  : input.prompt;
149
- const commandSpec = adapter.buildCommand(discovery.commandPath, {
150
- ...input,
151
- prompt: augmentedPrompt,
152
- });
153
- const cwd = commandSpec.cwd ?? process.cwd();
194
+ let commandSpec: CommandSpec;
195
+ let cwd: string;
196
+ let child: TaskProcess;
197
+ try {
198
+ commandSpec = adapter.buildCommand(discovery.commandPath, {
199
+ ...input,
200
+ cwd: resolvedInputCwd,
201
+ prompt: augmentedPrompt,
202
+ finalMessagePath,
203
+ });
204
+ cwd = commandSpec.cwd ?? process.cwd();
154
205
 
155
- if (!existsSync(cwd)) {
156
- throw new Error(`Working directory does not exist: ${cwd}`);
206
+ if (!existsSync(cwd)) {
207
+ throw new Error(`Working directory does not exist: ${cwd}`);
208
+ }
209
+
210
+ child = this.spawn({
211
+ cmd: [commandSpec.command, ...commandSpec.args],
212
+ cwd,
213
+ env: { ...process.env, ...commandSpec.env },
214
+ stdin: commandSpec.stdin === undefined ? "ignore" : "pipe",
215
+ stdout: "pipe",
216
+ stderr: "pipe",
217
+ });
218
+ } catch (error) {
219
+ cleanupFailedStartArtifacts(
220
+ finalMessagePath,
221
+ isolatedCwd,
222
+ );
223
+ throw error;
157
224
  }
158
225
 
159
226
  const now = Date.now();
@@ -163,6 +230,7 @@ export class TaskManager {
163
230
  harnessName: adapter.name,
164
231
  status: "running",
165
232
  cwd,
233
+ isolatedFrom,
166
234
  command: [
167
235
  basename(commandSpec.command),
168
236
  ...commandSpec.args.map((arg) => arg === augmentedPrompt ? "<prompt>" : arg),
@@ -180,24 +248,20 @@ export class TaskManager {
180
248
  events: [],
181
249
  streamReaders: new Set(),
182
250
  appliedSkills: input.skills?.map(toAppliedSkillInfo),
251
+ finalMessagePath,
252
+ isolatedCwd,
253
+ extractUsage: adapter.extractUsage,
183
254
  };
184
255
 
256
+ task.process = child;
257
+ task.pid = child.pid;
258
+
259
+ // Publish a task only after the process exists. A synchronous spawn
260
+ // failure must not leave a phantom running task that cannot settle.
185
261
  this.tasks.set(task.id, task);
186
262
  this.pruneTasks(task.id);
187
263
  this.pushEvent(task, { type: "started", message: "Task started." });
188
264
 
189
- const child = this.spawn({
190
- cmd: [commandSpec.command, ...commandSpec.args],
191
- cwd,
192
- env: { ...process.env, ...commandSpec.env },
193
- stdin: commandSpec.stdin === undefined ? "ignore" : "pipe",
194
- stdout: "pipe",
195
- stderr: "pipe",
196
- });
197
-
198
- task.process = child;
199
- task.pid = child.pid;
200
-
201
265
  task.streamsDone = Promise.all([
202
266
  this.pipeStreamSafely(task, "stdout", child.stdout),
203
267
  this.pipeStreamSafely(task, "stderr", child.stderr),
@@ -325,6 +389,7 @@ export class TaskManager {
325
389
 
326
390
  for (const task of this.tasks.values()) {
327
391
  this.clearTimers(task);
392
+ this.cleanupIsolatedCwd(task);
328
393
  }
329
394
  }
330
395
 
@@ -366,12 +431,70 @@ export class TaskManager {
366
431
  message: error instanceof Error ? error.message : String(error),
367
432
  });
368
433
  } finally {
434
+ await this.collectFinalMessage(task);
435
+ this.collectUsage(task);
436
+ this.cleanupIsolatedCwd(task);
369
437
  this.clearTimers(task);
370
438
  this.touch(task);
371
439
  this.pruneTasks(task.id);
372
440
  }
373
441
  }
374
442
 
443
+ private async collectFinalMessage(task: InternalTask): Promise<void> {
444
+ if (!task.finalMessagePath) {
445
+ return;
446
+ }
447
+
448
+ try {
449
+ if (existsSync(task.finalMessagePath)) {
450
+ const content = (await readFile(task.finalMessagePath, "utf8")).trim();
451
+ if (content) {
452
+ task.finalMessage = content;
453
+ }
454
+ rmSync(task.finalMessagePath, { force: true });
455
+ }
456
+ } catch (error) {
457
+ this.pushEvent(task, {
458
+ type: "error",
459
+ message: `Failed to read final message file: ${
460
+ error instanceof Error ? error.message : String(error)
461
+ }`,
462
+ });
463
+ }
464
+ }
465
+
466
+ private collectUsage(task: InternalTask): void {
467
+ if (!task.extractUsage) {
468
+ return;
469
+ }
470
+
471
+ try {
472
+ task.usage = task.extractUsage(task.stdout, task.stderr);
473
+ } catch {
474
+ task.usage = undefined;
475
+ }
476
+ }
477
+
478
+ private cleanupIsolatedCwd(task: InternalTask): boolean {
479
+ if (!task.isolatedCwd) {
480
+ return true;
481
+ }
482
+
483
+ try {
484
+ this.removeIsolatedCwd(task.isolatedCwd);
485
+ task.isolatedCwd = undefined;
486
+ return true;
487
+ } catch (error) {
488
+ this.pushEvent(task, {
489
+ type: "error",
490
+ message: `Failed to remove isolated working directory: ${
491
+ error instanceof Error ? error.message : String(error)
492
+ }`,
493
+ });
494
+ return false;
495
+ }
496
+ }
497
+
375
498
  private pruneTasks(protectedTaskId?: string): void {
376
499
  const overflow = this.tasks.size - this.maxTasks;
377
500
  if (overflow <= 0) {
@@ -382,8 +505,16 @@ export class TaskManager {
382
505
  .filter((task) => task.id !== protectedTaskId && task.status !== "running")
383
506
  .sort((a, b) => a.createdAtMs - b.createdAtMs);
384
507
 
385
- for (const task of removable.slice(0, overflow)) {
508
+ let removed = 0;
509
+ for (const task of removable) {
510
+ if (removed >= overflow) {
511
+ break;
512
+ }
513
+ if (!this.cleanupIsolatedCwd(task)) {
514
+ continue;
515
+ }
386
516
  this.tasks.delete(task.id);
517
+ removed += 1;
387
518
  }
388
519
  }
389
520
 
@@ -569,6 +700,9 @@ export class TaskManager {
569
700
  stderr: includeOutput ? tail(task.stderr, maxOutputChars) : "",
570
701
  events: includeEvents ? tailItems(task.events, maxEvents) : [],
571
702
  appliedSkills: task.appliedSkills,
703
+ finalMessage: includeOutput ? task.finalMessage : undefined,
704
+ usage: task.usage,
705
+ isolatedFrom: task.isolatedFrom,
572
706
  };
573
707
  }
574
708
 
@@ -627,3 +761,39 @@ function trimEventMessage(message: string): string {
627
761
 
628
762
  return `${message.slice(0, MAX_EVENT_MESSAGE_CHARS)}...`;
629
763
  }
764
+
765
+ const ISOLATION_EXCLUDED_DIRS = new Set(["node_modules", ".git"]);
766
+
767
+ function isolateDirectory(source: string): string {
768
+ const target = mkdtempSync(join(tmpdir(), "ennodia-cwd-"));
769
+ try {
770
+ cpSync(source, target, {
771
+ recursive: true,
772
+ filter: (candidate) => {
773
+ if (ISOLATION_EXCLUDED_DIRS.has(basename(candidate))) {
774
+ return false;
775
+ }
776
+ if (lstatSync(candidate).isSymbolicLink()) {
777
+ throw new Error(
778
+ `Cannot isolate working directory containing symbolic link: ${candidate}`,
779
+ );
780
+ }
781
+ return true;
782
+ },
783
+ });
784
+ return target;
785
+ } catch (error) {
786
+ rmSync(target, { recursive: true, force: true });
787
+ throw error;
788
+ }
789
+ }
790
+
791
+ function cleanupFailedStartArtifacts(
792
+ finalMessagePath: string,
793
+ isolatedCwd?: string,
794
+ ): void {
795
+ rmSync(finalMessagePath, { force: true });
796
+ if (isolatedCwd) {
797
+ rmSync(isolatedCwd, { recursive: true, force: true });
798
+ }
799
+ }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const ENNODIA_VERSION = "0.1.1";
1
+ export const ENNODIA_VERSION = "0.2.0";