ennodia 0.4.0 → 0.5.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.
package/src/core.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import { preparePragmaticRun, type PragmaticOptions } from "./pragmatic";
2
+ import { inspectInstallation, type InstallationReport } from "./installation";
3
+ import { HarnessReadinessManager, HarnessReadinessError, harnessAttention, readinessBlocked, type HarnessReadinessOptions, type HarnessCheckInput, type HarnessAttention, type PreflightReport } from "./readiness";
2
4
  import { createHash } from "node:crypto";
3
- import type {
4
- AdvisorExecutionPlan,
5
- AdvisorInventorySnapshot,
5
+ import {
6
+ MAX_ADVISOR_SKILL_DESCRIPTION_CHARS,
7
+ type AdvisorExecutionPlan,
8
+ type AdvisorInventorySnapshot,
6
9
  } from "./advisor";
7
10
  import {
8
11
  assertBudgetWithinLimits,
@@ -44,6 +47,7 @@ import {
44
47
  } from "./harnesses";
45
48
  import {
46
49
  createDefaultHistorySink,
50
+ FileHistorySink,
47
51
  noopHistorySink,
48
52
  type HistorySink,
49
53
  type RunHistoryListOptions,
@@ -95,6 +99,7 @@ import {
95
99
  } from "./plan-advice";
96
100
 
97
101
  export type EnnodiaCoreOptions = {
102
+ readinessOptions?: HarnessReadinessOptions;
98
103
  discoverHarnesses?: DiscoverHarnesses;
99
104
  findHarnessAdapter?: FindHarnessAdapter;
100
105
  planRoute?: PlanRoute;
@@ -111,6 +116,10 @@ export type EnnodiaCoreOptions = {
111
116
 
112
117
  export type EnnodiaCoreShutdownOptions = TaskManagerShutdownOptions;
113
118
 
119
+ function remainingPreflightBudget(limits: BudgetLimits | undefined, check: BudgetCheck): BudgetLimits | undefined {
120
+ return limits?.maxEstimatedInputTokens !== undefined ? { ...limits, maxEstimatedInputTokens: limits.maxEstimatedInputTokens - check.estimate.estimatedTotalInputTokens } : limits;
121
+ }
122
+
114
123
  export type RunEstimateInput = {
115
124
  pragmatic?: PragmaticOptions;
116
125
  model?: string;
@@ -148,6 +157,8 @@ export type TaskBatchStartInput = {
148
157
  };
149
158
 
150
159
  export type TaskBatchStart = {
160
+ preflight?: PreflightReport;
161
+ actionRequired?: HarnessAttention[];
151
162
  plan: RoutePlan;
152
163
  tasks: TaskView[];
153
164
  budget: BudgetCheck;
@@ -189,6 +200,7 @@ export type CompositionalTaskStart = {
189
200
  };
190
201
 
191
202
  export type CompositionalStart = {
203
+ preflight?: PreflightReport;
192
204
  tasks: CompositionalTaskStart[];
193
205
  budget: BudgetCheck;
194
206
  compareNext: CompositionalCompareNext;
@@ -208,6 +220,7 @@ export type CompareStartWithBudgetInput = CompareStartInput & {
208
220
  };
209
221
 
210
222
  export type CompareStart = CompareView & {
223
+ preflight?: PreflightReport;
211
224
  budget: BudgetCheck;
212
225
  };
213
226
 
@@ -253,6 +266,7 @@ export type AdvisedPlanTaskStart = {
253
266
  };
254
267
 
255
268
  export type AdvisedPlanStart = {
269
+ preflight?: PreflightReport;
256
270
  adviceId: string;
257
271
  planDigest: string;
258
272
  inventorySnapshotId: string;
@@ -275,18 +289,19 @@ type AdvisorInventoryRuntime = {
275
289
  };
276
290
 
277
291
  export class EnnodiaCore {
292
+ private installation?: InstallationReport;
293
+ readonly readinessManager?: HarnessReadinessManager;
278
294
  readonly taskManager: TaskManager;
279
295
  readonly compareManager: CompareManager;
280
296
  readonly runManager: RunManager;
281
297
  readonly planAdviceManager: PlanAdviceManager;
282
298
  readonly discoverHarnesses: DiscoverHarnesses;
299
+ private readonly discoverInstalledHarnesses: DiscoverHarnesses;
283
300
  readonly findHarnessAdapter: FindHarnessAdapter;
284
301
  readonly planRoute: PlanRoute;
285
302
  readonly historySink: HistorySink;
286
303
 
287
304
  constructor(options: EnnodiaCoreOptions = {}) {
288
- this.discoverHarnesses = options.discoverHarnesses ??
289
- defaultDiscoverHarnesses;
290
305
  this.findHarnessAdapter = options.findHarnessAdapter ??
291
306
  defaultFindHarnessAdapter;
292
307
  this.planRoute = options.planRoute ?? defaultPlanRoute;
@@ -295,6 +310,27 @@ export class EnnodiaCore {
295
310
  noopHistorySink;
296
311
  this.taskManager = options.taskManager ??
297
312
  new TaskManager(options.taskManagerOptions);
313
+ // Injected inventories keep their existing contract. Production inventories
314
+ // and explicitly configured test managers receive native readiness checks.
315
+ if (!options.discoverHarnesses || options.readinessOptions) {
316
+ this.readinessManager = new HarnessReadinessManager({ ...options.readinessOptions,
317
+ isBusy: options.readinessOptions?.isBusy ?? (() => this.taskManager.listViews({ includeOutput: false, includeEvents: false }).some(t => t.status === "running")),
318
+ });
319
+ }
320
+ const discover = options.discoverHarnesses ?? defaultDiscoverHarnesses;
321
+ this.discoverInstalledHarnesses = discover;
322
+ this.discoverHarnesses = async (settings) => {
323
+ const harnesses = await discover(settings);
324
+ if (!this.readinessManager) return harnesses;
325
+ return Promise.all(harnesses.map(async h => ({ ...h, readiness: await this.readinessManager!.check(h, this.findHarnessAdapter(h.id), { refresh: settings?.refresh }) })));
326
+ };
327
+ this.taskManager.onSettled((task) => {
328
+ if (!this.readinessManager) return;
329
+ void discover().then(harnesses => {
330
+ const h = harnesses.find(h => h.id === task.harnessId);
331
+ if (h) this.readinessManager!.observe(h, { model: task.model, cwd: task.isolatedFrom ?? task.cwd }, task);
332
+ }).catch(() => {});
333
+ });
298
334
  this.planAdviceManager = options.planAdviceManager ??
299
335
  new PlanAdviceManager(
300
336
  this.taskManager,
@@ -303,7 +339,7 @@ export class EnnodiaCore {
303
339
  this.compareManager = options.compareManager ??
304
340
  new CompareManager(
305
341
  this.taskManager,
306
- (harnessId) => this.resolveRunnableHarness(harnessId),
342
+ (harnessId, model, cwd) => this.resolveRunnableHarness(harnessId, model, cwd, true),
307
343
  options.compareManagerOptions,
308
344
  );
309
345
  this.runManager = options.runManager ??
@@ -313,6 +349,17 @@ export class EnnodiaCore {
313
349
  discoverHarnesses: this.discoverHarnesses,
314
350
  findHarnessAdapter: this.findHarnessAdapter,
315
351
  planRoute: this.planRoute,
352
+ getInstallation: () => this.getInstallation(),
353
+ describeReadiness: this.readinessManager ? (h, state, model) => this.readinessManager!.describe(h, state, model) : undefined,
354
+ prepareHarnesses: async (harnesses, ids, input) => {
355
+ const routes = ids.map(harnessId => ({ harnessId, model: input.model }));
356
+ if (shouldPlanCompare(input.compare ?? "auto", this.planRoute(input.prompt, harnesses).compareSuggested, ids.length)) {
357
+ const judge = await this.resolveRunnableHarness(input.judgeHarnessId);
358
+ const advisor = input.advisorHarnessId || input.synthesizerHarnessId ? await this.resolveRunnableHarness(input.advisorHarnessId ?? input.synthesizerHarnessId) : judge;
359
+ routes.push({ harnessId: judge.adapter.id, model: input.judgeModel }, { harnessId: advisor.adapter.id, model: input.advisorModel ?? input.synthesizerModel });
360
+ }
361
+ return this.prepareHarnesses(harnesses, routes, input.cwd, input.budget);
362
+ },
316
363
  }, { ...options.runManagerOptions, historySink: this.historySink });
317
364
  }
318
365
 
@@ -322,6 +369,82 @@ export class EnnodiaCore {
322
369
  return this.discoverHarnesses(options);
323
370
  }
324
371
 
372
+ getInstallation(refresh = false): InstallationReport {
373
+ if (refresh || !this.installation || Date.now() - Date.parse(this.installation.checkedAt) >= 30_000) {
374
+ this.installation = inspectInstallation({
375
+ history: { enabled: this.historySink !== noopHistorySink, directory: this.historySink instanceof FileHistorySink ? this.historySink.storageDirectory : undefined },
376
+ autoUpgrade: this.readinessManager?.autoUpgrade ?? false,
377
+ upgradeStateDirectory: this.readinessManager?.stateDirectory,
378
+ });
379
+ }
380
+ return this.installation;
381
+ }
382
+
383
+ async checkHarnesses(input: HarnessCheckInput = {}): Promise<{ installation: InstallationReport; harnesses: HarnessDiscovery[]; actionRequired: HarnessAttention[] }> {
384
+ let harnesses = await this.discoverInstalledHarnesses({ refresh: input.refresh });
385
+ if (input.harnessIds) {
386
+ for (const id of input.harnessIds) if (!harnesses.some(h => h.id === id)) throw new Error(`Unknown harness: ${id}`);
387
+ harnesses = harnesses.filter(h => input.harnessIds!.includes(h.id));
388
+ }
389
+ if (input.upgrade && this.readinessManager) {
390
+ for (const h of harnesses) await this.readinessManager.upgrade(h, true);
391
+ const fresh = await this.discoverInstalledHarnesses({ refresh: true });
392
+ harnesses = fresh.filter(h => harnesses.some(selected => selected.id === h.id));
393
+ }
394
+ if (this.readinessManager) {
395
+ if (input.refresh) for (const h of harnesses) this.readinessManager.invalidate(h.id);
396
+ harnesses = await Promise.all(harnesses.map(async h => ({ ...h, readiness: await this.readinessManager!.check(h, this.findHarnessAdapter(h.id), input) })));
397
+ }
398
+ return { installation: this.getInstallation(input.refresh ?? true), harnesses, actionRequired: harnessAttention(harnesses) };
399
+ }
400
+
401
+ private async prepareHarnesses(harnesses: HarnessDiscovery[], routes: { harnessId: string; model?: string }[], cwd?: string, budget?: BudgetLimits, allowUpgrade = true): Promise<PreflightReport> {
402
+ const report: PreflightReport = { checks: [], modelProbes: 0, estimatedInputTokens: 0, usage: "unknown" };
403
+ if (!this.readinessManager) {
404
+ const blocked = harnessAttention(harnesses.filter(h => routes.some(r => r.harnessId === h.id)));
405
+ if (blocked.length) throw new HarnessReadinessError(blocked);
406
+ return report;
407
+ }
408
+ const knownBlocked = harnessAttention(harnesses.filter(h => routes.some(r => r.harnessId === h.id && (!h.readiness?.model || h.readiness.model === r.model))));
409
+ if (knownBlocked.length) throw new HarnessReadinessError(knownBlocked);
410
+ if (allowUpgrade && this.readinessManager.autoUpgrade) {
411
+ for (const id of new Set(routes.map(r => r.harnessId))) {
412
+ const h = harnesses.find(h => h.id === id);
413
+ if (h) await this.readinessManager.upgrade(h);
414
+ }
415
+ const refreshed = await this.discoverInstalledHarnesses({ refresh: true });
416
+ for (const h of harnesses) { const fresh = refreshed.find(f => f.id === h.id); if (fresh) Object.assign(h, fresh); }
417
+ }
418
+ const unique = [...new Map(routes.map(r => [JSON.stringify(r), r])).values()];
419
+ // Check every native login before any model probe or reviewer launch.
420
+ const checked = await Promise.all(unique.map(async route => {
421
+ const h = harnesses.find(h => h.id === route.harnessId);
422
+ if (!h) throw new Error(`Unknown harness: ${route.harnessId}`);
423
+ return { h, route, readiness: await this.readinessManager!.check(h, this.findHarnessAdapter(h.id), { model: route.model, cwd }) };
424
+ }));
425
+ const initialBlocked = checked.filter(c => readinessBlocked(c.readiness)).map(c => ({ harnessId: c.h.id, harnessName: c.h.name, readiness: c.readiness }));
426
+ if (initialBlocked.length) throw new HarnessReadinessError(initialBlocked);
427
+ for (const { h, readiness } of checked) {
428
+ if (readiness.state !== "ready") {
429
+ // Probes are real provider calls. Reserve them before starting, and
430
+ // expose their estimate separately rather than implying free checks.
431
+ report.modelProbes++; report.estimatedInputTokens += 256;
432
+ }
433
+ report.checks.push({ harnessId: h.id, harnessName: h.name, readiness });
434
+ if (budget?.maxChildTasks !== undefined && routes.length + report.modelProbes > budget.maxChildTasks) throw new Error("Budget limit exceeded: readiness probes also count as child calls. Verify the routes separately or increase maxChildTasks.");
435
+ if (budget?.maxEstimatedInputTokens !== undefined && report.estimatedInputTokens > budget.maxEstimatedInputTokens) throw new Error("Budget limit exceeded: readiness probe input reserve.");
436
+ }
437
+ const verified = await Promise.all(report.checks.map(async c => ({ ...c, readiness: await this.readinessManager!.check(harnesses.find(h => h.id === c.harnessId)!, this.findHarnessAdapter(c.harnessId), { model: c.readiness.model, cwd, verify: true }) })));
438
+ report.checks = verified;
439
+ const blocked = verified.filter(c => readinessBlocked(c.readiness));
440
+ if (blocked.length) throw new HarnessReadinessError(blocked);
441
+ for (const check of verified) {
442
+ const h = harnesses.find(h => h.id === check.harnessId);
443
+ if (h) h.readiness = check.readiness;
444
+ }
445
+ return report;
446
+ }
447
+
325
448
  findAdapter(id: string): HarnessAdapter | undefined {
326
449
  return this.findHarnessAdapter(id);
327
450
  }
@@ -346,7 +469,7 @@ export class EnnodiaCore {
346
469
 
347
470
  async startPlanAdvice(
348
471
  input: PlanAdviceStartInput,
349
- ): Promise<PlanAdviceView> {
472
+ ): Promise<PlanAdviceView & { preflight?: PreflightReport }> {
350
473
  const { inventory } = await this.buildAdvisorInventory({
351
474
  cwd: input.cwd,
352
475
  refresh: input.refresh,
@@ -361,7 +484,7 @@ export class EnnodiaCore {
361
484
  });
362
485
  const advisor = await this.resolveRunnableHarness(input.advisorHarnessId);
363
486
 
364
- return this.planAdviceManager.start({
487
+ const advisorInput = {
365
488
  prompt: input.prompt,
366
489
  inventory,
367
490
  inventoryCwd: input.cwd,
@@ -370,7 +493,11 @@ export class EnnodiaCore {
370
493
  timeoutMs: input.timeoutMs,
371
494
  budget: input.budget,
372
495
  findHarnessAdapter: this.findHarnessAdapter,
373
- });
496
+ };
497
+ const advisorBudget = this.planAdviceManager.estimateBudget(advisorInput);
498
+ const preflight = await this.prepareHarnesses([advisor.discovery], [{ harnessId: advisor.adapter.id, model: input.advisorModel }], undefined, remainingPreflightBudget(input.budget, advisorBudget));
499
+ const started = this.planAdviceManager.start(advisorInput);
500
+ return { ...started, ...(this.readinessManager ? { preflight } : {}) };
374
501
  }
375
502
 
376
503
  listPlanAdvice(
@@ -426,11 +553,7 @@ export class EnnodiaCore {
426
553
  skills: Skill[];
427
554
  }>;
428
555
  let requestedSkillIds: string[] = [];
429
- const plan = this.planAdviceManager.authorizeExecution(
430
- input.adviceId,
431
- input.expectedPlanDigest,
432
- currentRuntime.inventory,
433
- (authorizedPlan) => {
556
+ const validate = (authorizedPlan: AdvisorExecutionPlan) => {
434
557
  budget = checkBudgetLimits(
435
558
  estimateTaskBatchBudget({
436
559
  tasks: authorizedPlan.slices.map((slice) => ({
@@ -489,8 +612,10 @@ export class EnnodiaCore {
489
612
  }
490
613
  return { slice, harness, skills };
491
614
  });
492
- },
493
- );
615
+ };
616
+ this.planAdviceManager.validateExecution(input.adviceId, input.expectedPlanDigest, currentRuntime.inventory, validate);
617
+ const preflight = await this.prepareHarnesses(currentRuntime.harnesses.map(h => h.discovery), resolved.map(r => ({ harnessId: r.slice.harnessId, model: r.slice.model })), executionCwd, remainingPreflightBudget(input.budget, budget), false);
618
+ const plan = this.planAdviceManager.authorizeExecution(input.adviceId, input.expectedPlanDigest, currentRuntime.inventory, validate);
494
619
 
495
620
  const requestedIds = new Set(requestedSkillIds);
496
621
  const unrequestedSkillsPresent = currentRuntime.projectNativeSkillIds
@@ -527,6 +652,7 @@ export class EnnodiaCore {
527
652
  inventorySnapshotId: currentRuntime.inventory.snapshotId,
528
653
  plan,
529
654
  tasks,
655
+ ...(this.readinessManager ? { preflight } : {}),
530
656
  budget,
531
657
  execution: {
532
658
  cwd: executionCwd,
@@ -537,7 +663,7 @@ export class EnnodiaCore {
537
663
  };
538
664
  }
539
665
 
540
- async resolveRunnableHarness(harnessId?: string): Promise<ResolvedHarness> {
666
+ async resolveRunnableHarness(harnessId?: string, model?: string, cwd?: string, verify = false): Promise<ResolvedHarness> {
541
667
  const harnesses = await this.discoverHarnesses();
542
668
  const preferredIds = harnessId
543
669
  ? [harnessId]
@@ -547,7 +673,9 @@ export class EnnodiaCore {
547
673
  const adapter = this.findHarnessAdapter(id);
548
674
  const discovery = harnesses.find((harness) => harness.id === id);
549
675
 
550
- if (adapter?.buildCommand && discovery?.runnable) {
676
+ if (discovery && harnessId && readinessBlocked(discovery.readiness)) throw new HarnessReadinessError(harnessAttention([discovery]));
677
+ if (adapter?.buildCommand && discovery?.runnable && !readinessBlocked(discovery.readiness)) {
678
+ if (verify) await this.prepareHarnesses(harnesses, [{ harnessId: id, model }], cwd, undefined, false);
551
679
  return { adapter, discovery };
552
680
  }
553
681
  }
@@ -604,6 +732,8 @@ export class EnnodiaCore {
604
732
  );
605
733
 
606
734
  if (selectedHarnessIds.length === 0) {
735
+ const blocked = harnessAttention(harnesses);
736
+ if (blocked.length) throw new HarnessReadinessError(blocked);
607
737
  throw new Error("No runnable harnesses were found.");
608
738
  }
609
739
 
@@ -630,6 +760,7 @@ export class EnnodiaCore {
630
760
  input.cwd,
631
761
  );
632
762
  const unrequestedSkillsPresent = await this.findUnrequestedSkills(input.cwd, skills);
763
+ const preflight = await this.prepareHarnesses(harnesses, selectedHarnessIds.map(harnessId => ({ harnessId, model: input.model })), input.cwd, remainingPreflightBudget(input.budget, budget));
633
764
  const tasks: TaskView[] = [];
634
765
  try {
635
766
  for (const harnessId of selectedHarnessIds) {
@@ -651,7 +782,7 @@ export class EnnodiaCore {
651
782
  throw error;
652
783
  }
653
784
 
654
- return { plan, tasks, budget, unrequestedSkillsPresent };
785
+ return { plan, tasks, budget, unrequestedSkillsPresent, ...(this.readinessManager ? { preflight, actionRequired: plan.actionRequired } : {}) };
655
786
  }
656
787
 
657
788
  async estimateCompositional(
@@ -688,6 +819,7 @@ export class EnnodiaCore {
688
819
  assertBudgetWithinLimits(budget);
689
820
 
690
821
  const unrequestedSkillsPresent = await this.findUnrequestedSkills(input.cwd, requestedSkills);
822
+ const preflight = await this.prepareHarnesses(harnesses, resolvedSlices.map(s => ({ harnessId: s.harnessId, model: s.model })), input.cwd, remainingPreflightBudget(input.budget, budget));
691
823
  const tasks: CompositionalStart["tasks"] = [];
692
824
  try {
693
825
  for (const [index, slice] of resolvedSlices.entries()) {
@@ -715,6 +847,7 @@ export class EnnodiaCore {
715
847
  return {
716
848
  tasks,
717
849
  budget,
850
+ ...(this.readinessManager ? { preflight } : {}),
718
851
  compareNext: {
719
852
  prompt: input.prompt,
720
853
  taskIds: tasks.map((item) => item.task.id),
@@ -837,6 +970,7 @@ export class EnnodiaCore {
837
970
  );
838
971
  assertBudgetWithinLimits(budget);
839
972
 
973
+ const preflight = await this.prepareHarnesses([judgeHarness.discovery, advisorHarness.discovery], [{ harnessId: judgeHarness.adapter.id, model: compareInput.judgeModel }, { harnessId: advisorHarness.adapter.id, model: advisorModel }], compareInput.cwd, remainingPreflightBudget(budgetLimits, budget));
840
974
  const compare = await this.compareManager.start({
841
975
  ...compareInput,
842
976
  judgeHarnessId: judgeHarness.adapter.id,
@@ -844,7 +978,7 @@ export class EnnodiaCore {
844
978
  advisorModel,
845
979
  });
846
980
 
847
- return { ...compare, budget };
981
+ return { ...compare, budget, ...(this.readinessManager ? { preflight } : {}) };
848
982
  }
849
983
 
850
984
  listCompares(options: CompareViewOptions = {}): CompareView[] {
@@ -872,6 +1006,7 @@ export class EnnodiaCore {
872
1006
  }
873
1007
 
874
1008
  async shutdown(options: EnnodiaCoreShutdownOptions = {}): Promise<void> {
1009
+ await this.readinessManager?.shutdown();
875
1010
  await this.runManager.shutdown(options);
876
1011
  await this.compareManager.shutdown(options);
877
1012
  await this.planAdviceManager.shutdown(options);
@@ -963,7 +1098,7 @@ export class EnnodiaCore {
963
1098
  skills: inventorySkills.map(({ skill, supportedHarnessIds }) => ({
964
1099
  id: skill.id,
965
1100
  name: skill.name,
966
- description: skill.description,
1101
+ description: skill.description.slice(0, MAX_ADVISOR_SKILL_DESCRIPTION_CHARS),
967
1102
  harnessIds: supportedHarnessIds,
968
1103
  })),
969
1104
  limits,
@@ -979,6 +1114,8 @@ export class EnnodiaCore {
979
1114
  id: skill.id,
980
1115
  version: skill.version,
981
1116
  hash: skill.hash,
1117
+ // Summary truncation must not hide metadata drift during revalidation.
1118
+ description: skill.description,
982
1119
  native: skill.native,
983
1120
  harnessIds: supportedHarnessIds,
984
1121
  })),
@@ -1114,6 +1251,8 @@ export class EnnodiaCore {
1114
1251
  throw new Error(`Unknown harness: ${harnessId}`);
1115
1252
  }
1116
1253
 
1254
+ if (readinessBlocked(discovery.readiness)) throw new HarnessReadinessError(harnessAttention([discovery]));
1255
+
1117
1256
  if (!discovery.runnable) {
1118
1257
  throw new Error(`Harness is not runnable: ${harnessId}`);
1119
1258
  }
package/src/harnesses.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
2
4
  import { signalOwnedProcess } from "./process";
3
5
  import { type Skill } from "./skills";
6
+ import type { HarnessReadiness } from "./readiness";
4
7
 
5
8
  export type HarnessKind = "cli" | "app";
6
9
 
@@ -84,6 +87,8 @@ export type HarnessAdapter = {
84
87
  /** Whether the adapter supports manager-owned native session continuation. */
85
88
  supportsSessionContinuation?: boolean;
86
89
  buildCommand?: (commandPath: string, input: HarnessRunInput) => CommandSpec;
90
+ /** Extract only the final answer from structured readiness-probe output. */
91
+ readinessAnswer?: (stdout: string) => string | undefined;
87
92
  /** Best-effort usage extraction from a finished task's captured output. */
88
93
  extractUsage?: (stdout: string, stderr: string) => HarnessUsage | undefined;
89
94
  /** Extract a native session ID from captured stdout as it arrives. */
@@ -133,6 +138,7 @@ export type HarnessDiscovery = {
133
138
  capabilities: string[];
134
139
  notes: string[];
135
140
  inputGuidance?: string[];
141
+ readiness?: HarnessReadiness;
136
142
  };
137
143
 
138
144
  export type DiscoverHarnessesOptions = {
@@ -357,7 +363,8 @@ export const harnessAdapters: HarnessAdapter[] = [
357
363
  id: "hermes-agent",
358
364
  name: "Hermes Agent",
359
365
  kind: "cli",
360
- commandCandidates: ["hermes-agent", "hermes"],
366
+ // hermes-agent is the legacy single-query runner, not the chat CLI.
367
+ commandCandidates: ["hermes"],
361
368
  versionArgs: ["--version"],
362
369
  capabilities: ["reasoning", "code", "agents", "non-interactive-cli"],
363
370
  notes: [
@@ -418,6 +425,18 @@ export const harnessAdapters: HarnessAdapter[] = [
418
425
  notes: [
419
426
  "Runs through `cline` with auto-approval explicitly disabled.",
420
427
  ],
428
+ readinessAnswer: (stdout) => {
429
+ // Public CLI JSON contract: docs/cli/cli-reference.mdx in cline/cline.
430
+ for (const line of stdout.trim().split(/\r?\n/).reverse()) {
431
+ try {
432
+ const event = JSON.parse(line);
433
+ if (event.type === "run_result") {
434
+ return event.finishReason !== "aborted" && typeof event.text === "string" ? event.text : undefined;
435
+ }
436
+ } catch { /* Ignore diagnostics outside the JSON result. */ }
437
+ }
438
+ return undefined;
439
+ },
421
440
  buildCommand: (commandPath, input) => {
422
441
  const args = ["--auto-approve", "false", "--json"];
423
442
 
@@ -627,7 +646,13 @@ async function discoverHarness(
627
646
  ): Promise<HarnessDiscovery> {
628
647
  const commandPath = adapter.commandCandidates
629
648
  .map((candidate) => Bun.which(candidate))
630
- .find((match): match is string => Boolean(match));
649
+ .find((match): match is string => Boolean(match))
650
+ // GUI/MCP launches may omit native install roots; keep every existing PATH candidate first.
651
+ ?? (process.platform === "darwin" ? adapter.commandCandidates
652
+ .map((candidate) => Bun.which(candidate, {
653
+ PATH: ["/opt/homebrew/bin", "/usr/local/bin", join(homedir(), ".local", "bin")].join(":"),
654
+ }))
655
+ .find((match): match is string => Boolean(match)) : undefined);
631
656
 
632
657
  const appPath = adapter.appPaths?.find((path) => existsSync(path));
633
658
  let version: string | undefined;
package/src/history.ts CHANGED
@@ -58,6 +58,8 @@ export class FileHistorySink implements HistorySink {
58
58
  this.maxRuns = Math.max(1, options.maxRuns ?? DEFAULT_HISTORY_MAX_RUNS);
59
59
  }
60
60
 
61
+ get storageDirectory(): string { return this.dir; }
62
+
61
63
  async recordRun(snapshot: RunHistorySnapshot): Promise<void> {
62
64
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
63
65
  // Frame each append with a leading newline so a previous truncated record
package/src/index.ts CHANGED
@@ -146,6 +146,7 @@ export {
146
146
  parseJudgeAnalysis,
147
147
  type CompareAnalysis,
148
148
  type AdvisorResult,
149
+ type KeepInMindNote,
149
150
  type CompareCandidate,
150
151
  type CompareCandidateInput,
151
152
  type CompareEvent,
@@ -176,6 +177,8 @@ export {
176
177
  type TaskOutputPreview,
177
178
  } from "./diagnosis";
178
179
  export { ENNODIA_VERSION } from "./version";
180
+ export { inspectInstallation, MIN_BUN_VERSION, type InstallationReport, type InstallationScope, type InstallationCheck, type InstallationOptions } from "./installation";
181
+ export { HarnessReadinessManager, HarnessReadinessError, readinessBlocked, harnessAttention, harnessUpgradeCommand, nativeReadinessFailure, type HarnessReadiness, type HarnessReadinessOptions, type HarnessAttention, type HarnessCheckInput, type PreflightReport, type ReadinessState, type MaintenanceCommandRunner } from "./readiness";
179
182
  export {
180
183
  discoverSkills,
181
184
  discoverSkillsWithWarnings,
@@ -0,0 +1,143 @@
1
+ import { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { ENNODIA_VERSION } from "./version";
7
+
8
+ export const MIN_BUN_VERSION = "1.3.14";
9
+ export type InstallationScope = "source-checkout" | "project-local" | "global" | "cache" | "unknown";
10
+ export type InstallationCheck = { id: string; status: "pass" | "warning" | "fail"; message: string };
11
+ export type InstallationReport = {
12
+ checkedAt: string;
13
+ status: "healthy" | "limited" | "broken";
14
+ version: string;
15
+ packageVersion?: string;
16
+ distribution: "source" | "compiled";
17
+ scope: InstallationScope;
18
+ evidence: string;
19
+ codeRoot: string;
20
+ serverCwd: string;
21
+ launcher?: string;
22
+ runtime: { name: "bun" | "unsupported"; version?: string; executable: string; minimumVersion: string; platform: string; arch: string };
23
+ checks: InstallationCheck[];
24
+ storage: { temporaryDirectory: string; history: { enabled: boolean; directory?: string }; harnessUpdates: { enabled: boolean; directory: string } };
25
+ limitations: string[];
26
+ };
27
+
28
+ export type InstallationOptions = {
29
+ /** Defaults to the loaded module's location, never the caller's workspace. */
30
+ codeRoot?: string;
31
+ cwd?: string;
32
+ home?: string;
33
+ temporaryDirectory?: string;
34
+ env?: Record<string, string | undefined>;
35
+ bunVersion?: string | null;
36
+ history?: { enabled: boolean; directory?: string };
37
+ autoUpgrade?: boolean;
38
+ upgradeStateDirectory?: string;
39
+ };
40
+
41
+ /** Local, read-only evidence. Does not inspect credentials, contact registries,
42
+ * create directories, or call providers. Access checks do not prove disk capacity. */
43
+ export function inspectInstallation(options: InstallationOptions = {}): InstallationReport {
44
+ const env = options.env ?? process.env;
45
+ const home = canonical(options.home ?? homedir());
46
+ const codeRoot = canonical(options.codeRoot ?? fileURLToPath(new URL("..", import.meta.url)));
47
+ const compiled = /(?:^|\/)\$bunfs(?:\/|$)/.test(codeRoot.replaceAll("\\", "/"));
48
+ const serverCwd = canonical(options.cwd ?? process.cwd());
49
+ const bunVersion = options.bunVersion === undefined ? process.versions.bun : options.bunVersion ?? undefined;
50
+ const temporaryDirectory = canonical(options.temporaryDirectory ?? tmpdir());
51
+ const historySetting = options.history ?? { enabled: env.ENNODIA_HISTORY !== "0", directory: env.ENNODIA_HISTORY_DIR ?? join(home, ".ennodia", "history") };
52
+ const history = { ...historySetting, directory: historySetting.directory ? resolve(historySetting.directory) : undefined };
53
+ const updates = { enabled: options.autoUpgrade ?? env.ENNODIA_AUTO_UPDATE !== "false", directory: resolve(options.upgradeStateDirectory ?? join(env.XDG_STATE_HOME ?? join(home, ".local", "state"), "ennodia", "harnesses")) };
54
+ const checks: InstallationCheck[] = [];
55
+ const add = (id: string, status: InstallationCheck["status"], message: string) => checks.push({ id, status, message });
56
+ add("runtime", bunVersion && atLeast(bunVersion, MIN_BUN_VERSION) ? "pass" : "fail", bunVersion ? `Bun ${bunVersion}; requires >=${MIN_BUN_VERSION}.` : `Bun >=${MIN_BUN_VERSION} is required.`);
57
+
58
+ const npm = readMetadata(join(codeRoot, "package.json"));
59
+ const jsr = readMetadata(join(codeRoot, "jsr.json"));
60
+ const metadata = npm?.name === "ennodia" ? npm : jsr?.name === "@cherninlab/ennodia" ? jsr : undefined;
61
+ const packageVersion = typeof metadata?.version === "string" ? metadata.version : undefined;
62
+ add("package-version", packageVersion === ENNODIA_VERSION ? "pass" : packageVersion ? "fail" : "warning", packageVersion ? `Metadata ${packageVersion}; loaded code ${ENNODIA_VERSION}.` : "Package metadata is unavailable; only the loaded code version is known.");
63
+
64
+ // JSR does not ship the npm launcher. Development tooling is not required.
65
+ const declaredModules = Array.isArray(npm?.files) ? npm.files.filter((p): p is string => typeof p === "string" && /^src\/[^/*]+\.ts$/.test(p)) : [];
66
+ const required = [...new Set(["src/index.ts", "src/core.ts", "src/cli.ts", "src/readiness.ts", "src/installation.ts", ...declaredModules, ...(npm?.name === "ennodia" ? ["bin/ennodia"] : [])])];
67
+ const missing = required.filter(p => !readableFile(join(codeRoot, p)));
68
+ add("runtime-files", compiled || !missing.length ? "pass" : "fail", compiled ? "Loaded core code is embedded in the executable; separate source entrypoints are not required." : missing.length ? `Missing or unreadable runtime files: ${missing.join(", ")}. Reinstall this distribution.` : "Core entrypoints and declared runtime modules are readable.");
69
+ const skillFiles = ["pragmatic", "compositional-audit"].map(id => join(codeRoot, "skills", id, "SKILL.md"));
70
+ add("bundled-skills", skillFiles.every(readableFile) ? "pass" : "warning", skillFiles.every(readableFile) ? "Bundled skills are readable." : "Bundled skills are incomplete; skill discovery or installation may be limited.");
71
+ const dependencies = ["@modelcontextprotocol/sdk/server/mcp.js", "zod"];
72
+ if (compiled) add("dependencies", "warning", "Executable uses bundled dependencies; separate dependency-file integrity cannot be checked.");
73
+ else {
74
+ const require = createRequire(join(codeRoot, "src/index.ts"));
75
+ const unresolved = dependencies.filter(id => { try { return !readableFile(require.resolve(id)); } catch { return true; } });
76
+ add("dependencies", unresolved.length ? "warning" : "pass", unresolved.length ? `Dependencies cannot be resolved from the code location: ${unresolved.join(", ")}. Install dependencies for this source distribution.` : "MCP dependencies resolve from the code location.");
77
+ }
78
+
79
+ const { scope, evidence } = installationScope(codeRoot, home, env);
80
+ if (scope === "unknown") add("installation-scope", "warning", "Global versus local scope could not be established from filesystem evidence.");
81
+ else add("installation-scope", "pass", evidence);
82
+ const temporaryAccessible = writableDirectory(temporaryDirectory, false);
83
+ add("temporary-storage", temporaryAccessible ? "pass" : "fail", `Temporary workspace directory ${temporaryDirectory}: ${temporaryAccessible ? "filesystem access available" : "directory missing or filesystem access unavailable"}.`);
84
+ if (history.enabled) {
85
+ const accessible = history.directory && writableDirectory(history.directory) && writableExistingFile(join(history.directory, "runs.jsonl"));
86
+ add("history-storage", accessible ? "pass" : "warning", history.directory ? `History storage ${history.directory}: filesystem access ${accessible ? "available" : "unavailable; receipts may not persist"}.` : "Custom history sink; filesystem access cannot be checked.");
87
+ }
88
+ if (updates.enabled) {
89
+ const accessible = writableDirectory(updates.directory);
90
+ add("maintenance-storage", accessible ? "pass" : "warning", `Harness update receipts and locks ${updates.directory}: filesystem access ${accessible ? "available" : "unavailable; upgrades may fail"}.`);
91
+ }
92
+
93
+ const limitations = checks.filter(c => c.status !== "pass").map(c => c.message);
94
+ if (scope === "cache") limitations.push("This code runs from a package cache. Cache cleanup or a future unpinned launch can change the loaded version; restart after changing the install.");
95
+ if (!updates.enabled) limitations.push("Automatic harness upgrades are disabled.");
96
+ if (!history.enabled) limitations.push("Run history is disabled; receipts will not persist through restart.");
97
+ const launcherPath = env.ENNODIA_LAUNCHER_PATH;
98
+ // Ignore stale inherited launcher metadata (e.g. when a worker starts another installation).
99
+ const launcher = launcherPath && isAbsolute(launcherPath) && canonical(launcherPath) === canonical(join(codeRoot, "bin/ennodia")) ? launcherPath : undefined;
100
+ return {
101
+ checkedAt: new Date().toISOString(), status: checks.some(c => c.status === "fail") ? "broken" : limitations.length ? "limited" : "healthy",
102
+ version: ENNODIA_VERSION, packageVersion, distribution: compiled ? "compiled" : "source", scope, evidence, codeRoot, serverCwd, launcher,
103
+ runtime: { name: bunVersion ? "bun" : "unsupported", version: bunVersion, executable: process.execPath, minimumVersion: MIN_BUN_VERSION, platform: process.platform, arch: process.arch },
104
+ checks, storage: { temporaryDirectory, history, harnessUpdates: updates }, limitations,
105
+ };
106
+ }
107
+
108
+ function installationScope(root: string, home: string, env: Record<string, string | undefined>): { scope: InstallationScope; evidence: string } {
109
+ if (existsSync(join(root, ".git"))) return { scope: "source-checkout", evidence: "Loaded code root contains Git checkout metadata." };
110
+ const npmCache = canonical(env.npm_config_cache ?? join(home, ".npm"));
111
+ const bunInstall = canonical(env.BUN_INSTALL ?? join(home, ".bun"));
112
+ const bunxCache = [canonical(tmpdir()), canonical("/tmp")].some(temp => within(root, temp)) && /(?:^|\/)bunx-\d+-[^/]+\/(?:.*\/)?node_modules\/ennodia$/.test(root);
113
+ if (within(root, join(npmCache, "_npx")) || within(root, join(bunInstall, "install/cache")) || bunxCache) return { scope: "cache", evidence: "Loaded code is inside an npm exec/npx or Bun package cache." };
114
+ if (root === join(bunInstall, "install/global/node_modules/ennodia")) return { scope: "global", evidence: "Loaded code is inside Bun's global installation directory." };
115
+ const globalSuffix = `${sep}lib${sep}node_modules${sep}ennodia`;
116
+ if (root.endsWith(globalSuffix)) {
117
+ const launcher = join(root.slice(0, -globalSuffix.length), "bin", "ennodia");
118
+ if (existsSync(launcher) && canonical(launcher) === canonical(join(root, "bin/ennodia"))) return { scope: "global", evidence: "Global prefix bin/ennodia resolves to this lib/node_modules/ennodia installation." };
119
+ }
120
+ const dependencyParent = dirname(dirname(root));
121
+ if (dirname(root).endsWith(`${sep}node_modules`) && readMetadata(join(dependencyParent, "package.json"))) return { scope: "project-local", evidence: "Loaded code is a node_modules dependency beneath a project package.json." };
122
+ return { scope: "unknown", evidence: "Code location does not establish an installation scope." };
123
+ }
124
+
125
+ function readMetadata(path: string): Record<string, unknown> | undefined {
126
+ try { const value: unknown = JSON.parse(readFileSync(path, "utf8")); return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined; } catch { return undefined; }
127
+ }
128
+ function canonical(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
129
+ function readableFile(path: string): boolean { try { accessSync(path, constants.R_OK); return statSync(path).isFile(); } catch { return false; } }
130
+ function writableExistingFile(path: string): boolean { try { accessSync(path, constants.R_OK | constants.W_OK); return statSync(path).isFile(); } catch (error) { return (error as NodeJS.ErrnoException).code === "ENOENT"; } }
131
+ function writableDirectory(path: string, allowMissing = true): boolean {
132
+ let ancestor = resolve(path);
133
+ if (!allowMissing && !existsSync(ancestor)) return false;
134
+ while (!existsSync(ancestor) && dirname(ancestor) !== ancestor) ancestor = dirname(ancestor);
135
+ try { accessSync(ancestor, constants.R_OK | constants.W_OK | constants.X_OK); return statSync(ancestor).isDirectory(); } catch { return false; }
136
+ }
137
+ function within(path: string, directory: string): boolean { const suffix = relative(directory, path); return suffix !== "" && suffix !== ".." && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix); }
138
+ function atLeast(version: string, minimum: string): boolean {
139
+ if (!/^\d+\.\d+\.\d+(?:[-+].*)?$/.test(version)) return false;
140
+ const parts = version.split(/[.+-]/).slice(0, 3).map(Number), required = minimum.split(".").map(Number);
141
+ for (let i = 0; i < 3; i++) { if (parts[i]! !== required[i]!) return parts[i]! > required[i]!; }
142
+ return !version.split("+")[0]!.includes("-");
143
+ }