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/core.ts CHANGED
@@ -1,8 +1,14 @@
1
+ import { createHash } from "node:crypto";
2
+ import type {
3
+ AdvisorExecutionPlan,
4
+ AdvisorInventorySnapshot,
5
+ } from "./advisor";
1
6
  import {
2
7
  assertBudgetWithinLimits,
3
8
  checkBudgetLimits,
4
9
  estimateCompareBudget,
5
10
  estimateRunBudget,
11
+ estimateTaskBatchBudget,
6
12
  type BudgetCheck,
7
13
  type BudgetLimits,
8
14
  } from "./budget";
@@ -62,6 +68,7 @@ import {
62
68
  } from "./runs";
63
69
  import {
64
70
  assertSkillsSupportHarnesses,
71
+ discoverSkills,
65
72
  discoverSkillsWithWarnings,
66
73
  installBundledSkills,
67
74
  loadRunnableSkillsByIds,
@@ -77,6 +84,12 @@ import {
77
84
  type TaskView,
78
85
  type TaskViewOptions,
79
86
  } from "./tasks";
87
+ import {
88
+ PlanAdviceManager,
89
+ type PlanAdviceManagerOptions,
90
+ type PlanAdviceView,
91
+ type PlanAdviceViewOptions,
92
+ } from "./plan-advice";
80
93
 
81
94
  export type EnnodiaCoreOptions = {
82
95
  discoverHarnesses?: DiscoverHarnesses;
@@ -85,9 +98,11 @@ export type EnnodiaCoreOptions = {
85
98
  taskManager?: TaskManager;
86
99
  compareManager?: CompareManager;
87
100
  runManager?: RunManager;
101
+ planAdviceManager?: PlanAdviceManager;
88
102
  taskManagerOptions?: TaskManagerOptions;
89
103
  compareManagerOptions?: CompareManagerOptions;
90
104
  runManagerOptions?: RunManagerOptions;
105
+ planAdviceManagerOptions?: PlanAdviceManagerOptions;
91
106
  historySink?: HistorySink;
92
107
  };
93
108
 
@@ -116,6 +131,7 @@ export type TaskBatchStartInput = {
116
131
  harnessId?: string;
117
132
  mode?: "single" | "parallel";
118
133
  cwd?: string;
134
+ isolateCwd?: boolean;
119
135
  model?: string;
120
136
  timeoutMs?: number;
121
137
  refresh?: boolean;
@@ -127,12 +143,17 @@ export type TaskBatchStart = {
127
143
  plan: RoutePlan;
128
144
  tasks: TaskView[];
129
145
  budget: BudgetCheck;
146
+ /** Skills discoverable in cwd's native skill directories but not part of
147
+ * skillIds. A harness may self-select these on its own initiative even
148
+ * though this run didn't request them - see skillIds isolation caveat. */
149
+ unrequestedSkillsPresent: string[];
130
150
  };
131
151
 
132
152
  export type CompositionalEstimateInput = {
133
153
  prompt: string;
134
154
  slices: CompositionalSliceInput[];
135
155
  cwd?: string;
156
+ isolateCwd?: boolean;
136
157
  refresh?: boolean;
137
158
  skillIds?: string[];
138
159
  includeCompareEstimate?: boolean;
@@ -162,6 +183,7 @@ export type CompositionalStart = {
162
183
  tasks: CompositionalTaskStart[];
163
184
  budget: BudgetCheck;
164
185
  compareNext: CompositionalCompareNext;
186
+ unrequestedSkillsPresent: string[];
165
187
  };
166
188
 
167
189
  export type CompositionalStatusInput = {
@@ -184,10 +206,70 @@ export type RunStartWithSkillIdsInput = RunStartInput & {
184
206
  skillIds?: string[];
185
207
  };
186
208
 
209
+ export type PlanAdviceStartInput = {
210
+ prompt: string;
211
+ cwd?: string;
212
+ refresh?: boolean;
213
+ advisorHarnessId?: string;
214
+ advisorModel?: string;
215
+ /** Worker harnesses the Advisor may select. Defaults to every runnable
216
+ * harness with a public Ennodia adapter. */
217
+ allowedHarnessIds?: string[];
218
+ /** Exact caller-approved model IDs per worker harness. Omitted harnesses may
219
+ * still be selected, but only with their configured harness default. */
220
+ allowedModels?: Record<string, string[]>;
221
+ maxSlices?: number;
222
+ maxSkillsPerSlice?: number;
223
+ maxTotalSkillAssignments?: number;
224
+ timeoutMs?: number;
225
+ budget?: BudgetLimits;
226
+ };
227
+
228
+ export type AdvisedPlanStartInput = {
229
+ adviceId: string;
230
+ expectedPlanDigest: string;
231
+ cwd?: string;
232
+ isolateCwd?: boolean;
233
+ timeoutMs?: number;
234
+ budget?: BudgetLimits;
235
+ };
236
+
237
+ export type AdvisedPlanTaskStart = {
238
+ sliceId: string;
239
+ sliceTitle?: string;
240
+ harnessId: string;
241
+ model?: string;
242
+ skillIds: string[];
243
+ task: TaskView;
244
+ };
245
+
246
+ export type AdvisedPlanStart = {
247
+ adviceId: string;
248
+ planDigest: string;
249
+ inventorySnapshotId: string;
250
+ plan: AdvisorExecutionPlan;
251
+ tasks: AdvisedPlanTaskStart[];
252
+ budget: BudgetCheck;
253
+ execution: {
254
+ cwd?: string;
255
+ isolateCwd: boolean;
256
+ timeoutMs?: number;
257
+ };
258
+ unrequestedSkillsPresent: string[];
259
+ };
260
+
261
+ type AdvisorInventoryRuntime = {
262
+ inventory: AdvisorInventorySnapshot;
263
+ harnesses: ResolvedHarness[];
264
+ skills: Skill[];
265
+ projectNativeSkillIds: string[];
266
+ };
267
+
187
268
  export class EnnodiaCore {
188
269
  readonly taskManager: TaskManager;
189
270
  readonly compareManager: CompareManager;
190
271
  readonly runManager: RunManager;
272
+ readonly planAdviceManager: PlanAdviceManager;
191
273
  readonly discoverHarnesses: DiscoverHarnesses;
192
274
  readonly findHarnessAdapter: FindHarnessAdapter;
193
275
  readonly planRoute: PlanRoute;
@@ -204,6 +286,11 @@ export class EnnodiaCore {
204
286
  noopHistorySink;
205
287
  this.taskManager = options.taskManager ??
206
288
  new TaskManager(options.taskManagerOptions);
289
+ this.planAdviceManager = options.planAdviceManager ??
290
+ new PlanAdviceManager(
291
+ this.taskManager,
292
+ options.planAdviceManagerOptions,
293
+ );
207
294
  this.compareManager = options.compareManager ??
208
295
  new CompareManager(
209
296
  this.taskManager,
@@ -248,6 +335,198 @@ export class EnnodiaCore {
248
335
  return this.planRoute(prompt, harnesses, { category: options.category });
249
336
  }
250
337
 
338
+ async startPlanAdvice(
339
+ input: PlanAdviceStartInput,
340
+ ): Promise<PlanAdviceView> {
341
+ const { inventory } = await this.buildAdvisorInventory({
342
+ cwd: input.cwd,
343
+ refresh: input.refresh,
344
+ allowedHarnessIds: input.allowedHarnessIds,
345
+ allowedModels: input.allowedModels,
346
+ maxSlices: Math.min(
347
+ input.maxSlices ?? 8,
348
+ input.budget?.maxChildTasks ?? Number.POSITIVE_INFINITY,
349
+ ),
350
+ maxSkillsPerSlice: input.maxSkillsPerSlice ?? 8,
351
+ maxTotalSkillAssignments: input.maxTotalSkillAssignments ?? 32,
352
+ });
353
+ const advisor = await this.resolveRunnableHarness(input.advisorHarnessId);
354
+
355
+ return this.planAdviceManager.start({
356
+ prompt: input.prompt,
357
+ inventory,
358
+ inventoryCwd: input.cwd,
359
+ advisor,
360
+ advisorModel: input.advisorModel,
361
+ timeoutMs: input.timeoutMs,
362
+ budget: input.budget,
363
+ });
364
+ }
365
+
366
+ listPlanAdvice(
367
+ options: PlanAdviceViewOptions = {},
368
+ ): PlanAdviceView[] {
369
+ return this.planAdviceManager.listViews(options);
370
+ }
371
+
372
+ getPlanAdvice(
373
+ id: string,
374
+ options: PlanAdviceViewOptions = {},
375
+ ): PlanAdviceView | undefined {
376
+ return this.planAdviceManager.get(id, options);
377
+ }
378
+
379
+ waitForPlanAdvice(
380
+ id: string,
381
+ timeoutMs?: number,
382
+ options: PlanAdviceViewOptions = {},
383
+ ): Promise<PlanAdviceView | undefined> {
384
+ return this.planAdviceManager.waitForTerminal(id, timeoutMs, options);
385
+ }
386
+
387
+ cancelPlanAdvice(id: string): PlanAdviceView {
388
+ return this.planAdviceManager.cancel(id);
389
+ }
390
+
391
+ async startAdvisedPlan(
392
+ input: AdvisedPlanStartInput,
393
+ ): Promise<AdvisedPlanStart> {
394
+ const context = this.planAdviceManager.getRevalidationContext(
395
+ input.adviceId,
396
+ );
397
+ const executionCwd = input.cwd ?? context.inventoryCwd;
398
+ const currentRuntime = await this.buildAdvisorInventory({
399
+ cwd: executionCwd,
400
+ refresh: true,
401
+ allowedHarnessIds: context.inventory.harnesses.map((harness) =>
402
+ harness.id
403
+ ),
404
+ allowedModels: Object.fromEntries(
405
+ context.inventory.harnesses.map((harness) => [
406
+ harness.id,
407
+ harness.allowedModelIds,
408
+ ]),
409
+ ),
410
+ ...context.inventory.limits,
411
+ });
412
+ let budget!: BudgetCheck;
413
+ let resolved!: Array<{
414
+ slice: AdvisorExecutionPlan["slices"][number];
415
+ harness: ResolvedHarness;
416
+ skills: Skill[];
417
+ }>;
418
+ let requestedSkillIds: string[] = [];
419
+ const plan = this.planAdviceManager.authorizeExecution(
420
+ input.adviceId,
421
+ input.expectedPlanDigest,
422
+ currentRuntime.inventory,
423
+ (authorizedPlan) => {
424
+ budget = checkBudgetLimits(
425
+ estimateTaskBatchBudget({
426
+ tasks: authorizedPlan.slices.map((slice) => ({
427
+ prompt: slice.prompt,
428
+ harnessId: slice.harnessId,
429
+ })),
430
+ comparePlanned: false,
431
+ }),
432
+ input.budget,
433
+ );
434
+ assertBudgetWithinLimits(budget);
435
+
436
+ // Resolve and validate every harness/skill before launching the first
437
+ // task. This preserves the zero-launch guarantee for deterministic
438
+ // validation, inventory drift, digest, and budget failures.
439
+ requestedSkillIds = [
440
+ ...new Set(authorizedPlan.slices.flatMap((slice) => slice.skillIds)),
441
+ ];
442
+ const runtimeSkillsById = new Map(
443
+ currentRuntime.skills.map((skill) => [skill.id, skill]),
444
+ );
445
+ const requestedSkills = requestedSkillIds.map((skillId) => {
446
+ const skill = runtimeSkillsById.get(skillId);
447
+ if (!skill) {
448
+ throw new Error(
449
+ `Skill not found in authorized inventory: ${skillId}`,
450
+ );
451
+ }
452
+ return skill;
453
+ });
454
+ const skillsById = new Map(
455
+ requestedSkills.map((skill) => [skill.id, skill]),
456
+ );
457
+ const harnessesById = new Map(
458
+ currentRuntime.harnesses.map((harness) => [
459
+ harness.discovery.id,
460
+ harness,
461
+ ]),
462
+ );
463
+ resolved = authorizedPlan.slices.map((slice) => {
464
+ const harness = harnessesById.get(slice.harnessId);
465
+ if (!harness) {
466
+ throw new Error(
467
+ `Harness not found in authorized inventory: ${slice.harnessId}`,
468
+ );
469
+ }
470
+ const skills = slice.skillIds.map((skillId) => {
471
+ const skill = skillsById.get(skillId);
472
+ if (!skill) {
473
+ throw new Error(`Skill not found after loading: ${skillId}`);
474
+ }
475
+ return skill;
476
+ });
477
+ if (skills.length > 0) {
478
+ assertSkillsSupportHarnesses(skills, [slice.harnessId]);
479
+ }
480
+ return { slice, harness, skills };
481
+ });
482
+ },
483
+ );
484
+
485
+ const requestedIds = new Set(requestedSkillIds);
486
+ const unrequestedSkillsPresent = currentRuntime.projectNativeSkillIds
487
+ .filter((skillId) => !requestedIds.has(skillId));
488
+
489
+ const tasks: AdvisedPlanTaskStart[] = [];
490
+ try {
491
+ for (const { slice, harness, skills } of resolved) {
492
+ tasks.push({
493
+ sliceId: slice.id,
494
+ sliceTitle: slice.title,
495
+ harnessId: slice.harnessId,
496
+ model: slice.model,
497
+ skillIds: [...slice.skillIds],
498
+ task: this.taskManager.start(harness.adapter, harness.discovery, {
499
+ prompt: slice.prompt,
500
+ cwd: executionCwd,
501
+ isolateCwd: input.isolateCwd,
502
+ model: slice.model,
503
+ timeoutMs: input.timeoutMs,
504
+ skills,
505
+ }).task,
506
+ });
507
+ }
508
+ } catch (error) {
509
+ for (const started of tasks) {
510
+ this.taskManager.cancel(started.task.id);
511
+ }
512
+ throw error;
513
+ }
514
+ return {
515
+ adviceId: input.adviceId,
516
+ planDigest: input.expectedPlanDigest,
517
+ inventorySnapshotId: currentRuntime.inventory.snapshotId,
518
+ plan,
519
+ tasks,
520
+ budget,
521
+ execution: {
522
+ cwd: executionCwd,
523
+ isolateCwd: input.isolateCwd ?? false,
524
+ timeoutMs: input.timeoutMs,
525
+ },
526
+ unrequestedSkillsPresent,
527
+ };
528
+ }
529
+
251
530
  async resolveRunnableHarness(harnessId?: string): Promise<ResolvedHarness> {
252
531
  const harnesses = await this.discoverHarnesses();
253
532
  const preferredIds = harnessId
@@ -342,13 +621,18 @@ export class EnnodiaCore {
342
621
  return this.taskManager.start(adapter, discovery, {
343
622
  prompt: input.prompt,
344
623
  cwd: input.cwd,
624
+ isolateCwd: input.isolateCwd,
345
625
  model: input.model,
346
626
  timeoutMs: input.timeoutMs,
347
627
  skills,
348
628
  }).task;
349
629
  });
630
+ const unrequestedSkillsPresent = await this.findUnrequestedSkills(
631
+ input.cwd,
632
+ skills,
633
+ );
350
634
 
351
- return { plan, tasks, budget };
635
+ return { plan, tasks, budget, unrequestedSkillsPresent };
352
636
  }
353
637
 
354
638
  async estimateCompositional(
@@ -372,7 +656,7 @@ export class EnnodiaCore {
372
656
  async startCompositional(
373
657
  input: CompositionalStartInput,
374
658
  ): Promise<CompositionalStart> {
375
- const { harnesses, resolvedSlices, skills } = await this
659
+ const { harnesses, resolvedSlices, sliceSkills, requestedSkills } = await this
376
660
  .resolveCompositional(input);
377
661
  const budget = estimateCompositionalBudget(
378
662
  resolvedSlices,
@@ -382,7 +666,7 @@ export class EnnodiaCore {
382
666
  );
383
667
  assertBudgetWithinLimits(budget);
384
668
 
385
- const tasks = resolvedSlices.map((slice) => {
669
+ const tasks = resolvedSlices.map((slice, index) => {
386
670
  const { adapter, discovery } = this.requireRunnableHarness(
387
671
  slice.harnessId,
388
672
  harnesses,
@@ -396,12 +680,17 @@ export class EnnodiaCore {
396
680
  task: this.taskManager.start(adapter, discovery, {
397
681
  prompt: slice.prompt,
398
682
  cwd: input.cwd,
683
+ isolateCwd: input.isolateCwd,
399
684
  model: slice.model,
400
685
  timeoutMs: input.timeoutMs,
401
- skills,
686
+ skills: sliceSkills[index] ?? [],
402
687
  }).task,
403
688
  };
404
689
  });
690
+ const unrequestedSkillsPresent = await this.findUnrequestedSkills(
691
+ input.cwd,
692
+ requestedSkills,
693
+ );
405
694
 
406
695
  return {
407
696
  tasks,
@@ -411,6 +700,7 @@ export class EnnodiaCore {
411
700
  taskIds: tasks.map((item) => item.task.id),
412
701
  maxOutputChars: input.maxOutputChars,
413
702
  },
703
+ unrequestedSkillsPresent,
414
704
  };
415
705
  }
416
706
 
@@ -445,7 +735,13 @@ export class EnnodiaCore {
445
735
  ? await loadRunnableSkillsByIds(skillIds, runInput.cwd)
446
736
  : undefined);
447
737
 
448
- return this.runManager.start({ ...runInput, skills });
738
+ const view = await this.runManager.start({ ...runInput, skills });
739
+ const unrequestedSkillsPresent = await this.findUnrequestedSkills(
740
+ runInput.cwd,
741
+ skills ?? [],
742
+ );
743
+
744
+ return { ...view, unrequestedSkillsPresent };
449
745
  }
450
746
 
451
747
  listRuns(options: RunViewOptions = {}): RunView[] {
@@ -475,12 +771,31 @@ export class EnnodiaCore {
475
771
  }
476
772
 
477
773
  async startCompare(input: CompareStartWithBudgetInput): Promise<CompareStart> {
478
- const { budget: budgetLimits, ...compareInput } = input;
774
+ const {
775
+ budget: budgetLimits,
776
+ advisorHarnessId: preferredAdvisorHarnessId,
777
+ advisorModel: preferredAdvisorModel,
778
+ synthesizerHarnessId,
779
+ synthesizerModel,
780
+ ...compareInput
781
+ } = input;
782
+ const advisorHarnessId = resolvePreferredAlias(
783
+ "advisorHarnessId",
784
+ preferredAdvisorHarnessId,
785
+ "synthesizerHarnessId",
786
+ synthesizerHarnessId,
787
+ );
788
+ const advisorModel = resolvePreferredAlias(
789
+ "advisorModel",
790
+ preferredAdvisorModel,
791
+ "synthesizerModel",
792
+ synthesizerModel,
793
+ );
479
794
  const judgeHarness = await this.resolveRunnableHarness(
480
795
  compareInput.judgeHarnessId,
481
796
  );
482
- const synthesizerHarness = compareInput.synthesizerHarnessId
483
- ? await this.resolveRunnableHarness(compareInput.synthesizerHarnessId)
797
+ const advisorHarness = advisorHarnessId
798
+ ? await this.resolveRunnableHarness(advisorHarnessId)
484
799
  : judgeHarness;
485
800
  const budget = checkBudgetLimits(
486
801
  estimateCompareBudget({
@@ -491,7 +806,7 @@ export class EnnodiaCore {
491
806
  0,
492
807
  ),
493
808
  judgeHarnessId: judgeHarness.adapter.id,
494
- synthesizerHarnessId: synthesizerHarness.adapter.id,
809
+ advisorHarnessId: advisorHarness.adapter.id,
495
810
  maxOutputChars: compareInput.maxOutputChars,
496
811
  }),
497
812
  budgetLimits,
@@ -501,7 +816,8 @@ export class EnnodiaCore {
501
816
  const compare = await this.compareManager.start({
502
817
  ...compareInput,
503
818
  judgeHarnessId: judgeHarness.adapter.id,
504
- synthesizerHarnessId: synthesizerHarness.adapter.id,
819
+ advisorHarnessId: advisorHarness.adapter.id,
820
+ advisorModel,
505
821
  });
506
822
 
507
823
  return { ...compare, budget };
@@ -534,14 +850,140 @@ export class EnnodiaCore {
534
850
  async shutdown(options: EnnodiaCoreShutdownOptions = {}): Promise<void> {
535
851
  await this.runManager.shutdown(options);
536
852
  await this.compareManager.shutdown(options);
853
+ await this.planAdviceManager.shutdown(options);
537
854
  await this.taskManager.shutdown(options);
538
855
  }
539
856
 
857
+ private async buildAdvisorInventory(input: {
858
+ cwd?: string;
859
+ refresh?: boolean;
860
+ allowedHarnessIds?: string[];
861
+ allowedModels?: Record<string, string[]>;
862
+ maxSlices: number;
863
+ maxSkillsPerSlice: number;
864
+ maxTotalSkillAssignments: number;
865
+ }): Promise<AdvisorInventoryRuntime> {
866
+ const discoveredHarnesses = await this.discoverHarnesses({
867
+ refresh: input.refresh,
868
+ });
869
+ const requestedHarnessIds = input.allowedHarnessIds
870
+ ? [...new Set(input.allowedHarnessIds)]
871
+ : discoveredHarnesses
872
+ .filter((harness) =>
873
+ harness.runnable && Boolean(this.findHarnessAdapter(harness.id)?.buildCommand)
874
+ )
875
+ .map((harness) => harness.id);
876
+
877
+ if (requestedHarnessIds.length === 0) {
878
+ throw new Error("Plan Advisor needs at least one runnable worker harness.");
879
+ }
880
+ this.assertHarnessesRunnable(requestedHarnessIds, discoveredHarnesses);
881
+
882
+ const outsideInventory = Object.keys(input.allowedModels ?? {}).filter(
883
+ (harnessId) => !requestedHarnessIds.includes(harnessId),
884
+ );
885
+ if (outsideInventory.length > 0) {
886
+ throw new Error(
887
+ `Model allowlist references a harness outside allowedHarnessIds: ${
888
+ outsideInventory.join(", ")
889
+ }`,
890
+ );
891
+ }
892
+
893
+ const selectedHarnesses = requestedHarnessIds
894
+ .map((harnessId) =>
895
+ this.requireRunnableHarness(harnessId, discoveredHarnesses)
896
+ )
897
+ .sort((left, right) =>
898
+ left.discovery.id.localeCompare(right.discovery.id)
899
+ );
900
+ const inventoryHarnesses = selectedHarnesses.map(({ discovery }) => ({
901
+ id: discovery.id,
902
+ name: discovery.name,
903
+ runnable: true,
904
+ capabilities: [...new Set(discovery.capabilities)].sort(),
905
+ allowedModelIds: [
906
+ ...new Set(input.allowedModels?.[discovery.id] ?? []),
907
+ ].sort(),
908
+ }));
909
+ const harnessIds = new Set(inventoryHarnesses.map((harness) => harness.id));
910
+ const discoveredSkills = await discoverSkills(input.cwd);
911
+ const inventorySkills = discoveredSkills
912
+ .filter((skill) => skill.native)
913
+ .map((skill) => {
914
+ // A same-content legacy installation can merge into a native skill.
915
+ // It must not turn that skill into a wildcard: only native
916
+ // installations can make a skill available to a harness here.
917
+ const supportedHarnessIds = skill.installations
918
+ .filter((installation) => installation.native)
919
+ .flatMap((installation) => installation.harnessIds)
920
+ .filter((harnessId) =>
921
+ harnessIds.has(harnessId) && skill.harnessIds.includes(harnessId)
922
+ );
923
+ return {
924
+ skill,
925
+ supportedHarnessIds: [...new Set(supportedHarnessIds)].sort(),
926
+ };
927
+ })
928
+ .filter((entry) => entry.supportedHarnessIds.length > 0)
929
+ .sort((left, right) => left.skill.id.localeCompare(right.skill.id));
930
+ const limits = {
931
+ maxSlices: input.maxSlices,
932
+ maxSkillsPerSlice: input.maxSkillsPerSlice,
933
+ maxTotalSkillAssignments: input.maxTotalSkillAssignments,
934
+ };
935
+ const snapshotBody = {
936
+ schemaVersion: 1 as const,
937
+ harnesses: inventoryHarnesses,
938
+ skills: inventorySkills.map(({ skill, supportedHarnessIds }) => ({
939
+ id: skill.id,
940
+ name: skill.name,
941
+ description: skill.description,
942
+ harnessIds: supportedHarnessIds,
943
+ })),
944
+ limits,
945
+ };
946
+ const fingerprintMaterial = {
947
+ ...snapshotBody,
948
+ harnessRuntime: selectedHarnesses.map(({ discovery }) => ({
949
+ id: discovery.id,
950
+ commandPath: discovery.commandPath,
951
+ version: discovery.version,
952
+ })),
953
+ skillRuntime: inventorySkills.map(({ skill, supportedHarnessIds }) => ({
954
+ id: skill.id,
955
+ version: skill.version,
956
+ hash: skill.hash,
957
+ native: skill.native,
958
+ harnessIds: supportedHarnessIds,
959
+ })),
960
+ };
961
+
962
+ return {
963
+ inventory: {
964
+ ...snapshotBody,
965
+ snapshotId: `sha256:${
966
+ createHash("sha256").update(stableJson(fingerprintMaterial)).digest("hex")
967
+ }`,
968
+ },
969
+ harnesses: selectedHarnesses,
970
+ skills: inventorySkills.map(({ skill }) => skill),
971
+ projectNativeSkillIds: discoveredSkills
972
+ .filter((skill) =>
973
+ skill.native && skill.installations.some((installation) =>
974
+ installation.scope === "project" && installation.native
975
+ )
976
+ )
977
+ .map((skill) => skill.id),
978
+ };
979
+ }
980
+
540
981
  private async resolveCompositional(input: CompositionalEstimateInput): Promise<{
541
982
  harnesses: HarnessDiscovery[];
542
983
  resolvedSlices: ResolvedCompositionalSlice[];
543
984
  selectedHarnessIds: string[];
544
- skills: Skill[];
985
+ sliceSkills: Skill[][];
986
+ requestedSkills: Skill[];
545
987
  }> {
546
988
  assertUniqueSliceIds(input.slices);
547
989
  const harnesses = await this.discoverHarnesses({ refresh: input.refresh });
@@ -550,16 +992,42 @@ export class EnnodiaCore {
550
992
  input.slices,
551
993
  harnesses,
552
994
  this.planRoute,
995
+ input.skillIds,
553
996
  );
554
997
  const selectedHarnessIds = resolvedSlices.map((slice) => slice.harnessId);
555
998
  this.assertHarnessesRunnable(selectedHarnessIds, harnesses);
556
- const skills = await this.loadSkillsFor(
557
- selectedHarnessIds,
558
- input.skillIds,
559
- input.cwd,
999
+ const requestedSkillIds = [
1000
+ ...new Set(resolvedSlices.flatMap((slice) => slice.skillIds)),
1001
+ ];
1002
+ const requestedSkills = requestedSkillIds.length
1003
+ ? await loadRunnableSkillsByIds(requestedSkillIds, input.cwd)
1004
+ : [];
1005
+ const skillsById = new Map(
1006
+ requestedSkills.map((skill) => [skill.id, skill]),
560
1007
  );
1008
+ const sliceSkills = resolvedSlices.map((slice) => {
1009
+ const skills = slice.skillIds.map((skillId) => {
1010
+ const skill = skillsById.get(skillId);
1011
+ if (!skill) {
1012
+ throw new Error(`Skill not found after loading: ${skillId}`);
1013
+ }
1014
+ return skill;
1015
+ });
1016
+
1017
+ if (skills.length > 0) {
1018
+ assertSkillsSupportHarnesses(skills, [slice.harnessId]);
1019
+ }
561
1020
 
562
- return { harnesses, resolvedSlices, selectedHarnessIds, skills };
1021
+ return skills;
1022
+ });
1023
+
1024
+ return {
1025
+ harnesses,
1026
+ resolvedSlices,
1027
+ selectedHarnessIds,
1028
+ sliceSkills,
1029
+ requestedSkills,
1030
+ };
563
1031
  }
564
1032
 
565
1033
  private async loadSkillsFor(
@@ -578,6 +1046,35 @@ export class EnnodiaCore {
578
1046
  return skills;
579
1047
  }
580
1048
 
1049
+ /**
1050
+ * Skills discoverable in cwd's native skill directories that were not part
1051
+ * of this run's requested skills. A harness's own skill mechanism can pick
1052
+ * these up on its own initiative (observed: Codex reading and applying an
1053
+ * unrequested skill file purely because it existed on disk), independent
1054
+ * of what Ennodia told it to use via skillIds. This does not prevent that;
1055
+ * it only makes the exposure visible to the caller.
1056
+ */
1057
+ private async findUnrequestedSkills(
1058
+ cwd: string | undefined,
1059
+ requestedSkills: Skill[],
1060
+ ): Promise<string[]> {
1061
+ if (!cwd) {
1062
+ return [];
1063
+ }
1064
+
1065
+ const requestedIds = new Set(requestedSkills.map((skill) => skill.id));
1066
+ const discovery = await discoverSkillsWithWarnings(cwd);
1067
+
1068
+ return discovery.skills
1069
+ .filter((skill) =>
1070
+ skill.installations.some((installation) =>
1071
+ installation.scope === "project" && installation.native
1072
+ )
1073
+ )
1074
+ .map((skill) => skill.id)
1075
+ .filter((id) => !requestedIds.has(id));
1076
+ }
1077
+
581
1078
  private requireRunnableHarness(
582
1079
  harnessId: string,
583
1080
  harnesses: HarnessDiscovery[],
@@ -616,3 +1113,37 @@ export function createDefaultEnnodiaCore(
616
1113
  createDefaultHistorySink(),
617
1114
  });
618
1115
  }
1116
+
1117
+ function resolvePreferredAlias(
1118
+ preferredName: string,
1119
+ preferredValue: string | undefined,
1120
+ legacyName: string,
1121
+ legacyValue: string | undefined,
1122
+ ): string | undefined {
1123
+ if (
1124
+ preferredValue !== undefined &&
1125
+ legacyValue !== undefined &&
1126
+ preferredValue !== legacyValue
1127
+ ) {
1128
+ throw new Error(
1129
+ `Conflicting Compare fields: ${preferredName} and deprecated ${legacyName}.`,
1130
+ );
1131
+ }
1132
+ return preferredValue ?? legacyValue;
1133
+ }
1134
+
1135
+ function stableJson(value: unknown): string {
1136
+ if (Array.isArray(value)) {
1137
+ return `[${value.map(stableJson).join(",")}]`;
1138
+ }
1139
+ if (value && typeof value === "object") {
1140
+ return `{${
1141
+ Object.entries(value as Record<string, unknown>)
1142
+ .filter(([, entry]) => entry !== undefined)
1143
+ .sort(([left], [right]) => left.localeCompare(right))
1144
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
1145
+ .join(",")
1146
+ }}`;
1147
+ }
1148
+ return JSON.stringify(value);
1149
+ }