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/CONTRIBUTING.md +6 -0
- package/README.md +78 -28
- package/bin/ennodia +14 -3
- package/docs/getting-started.md +15 -3
- package/docs/guides/understand-results.md +45 -0
- package/docs/install.md +9 -3
- package/docs/questions.md +56 -0
- package/docs/reference/mcp-tools.md +92 -3
- package/docs/reference/supported-harnesses.md +84 -6
- package/docs/releases/0.5.0.md +43 -0
- package/docs/roadmap.md +5 -1
- package/package.json +7 -3
- package/skills/compositional-audit/SKILL.md +9 -0
- package/skills/pragmatic/SKILL.md +11 -0
- package/src/advisor.ts +3 -1
- package/src/budget.ts +5 -4
- package/src/compare.ts +83 -8
- package/src/core.ts +160 -21
- package/src/harnesses.ts +27 -2
- package/src/history.ts +2 -0
- package/src/index.ts +3 -0
- package/src/installation.ts +143 -0
- package/src/plan-advice.ts +20 -9
- package/src/planner.ts +4 -1
- package/src/readiness.ts +298 -0
- package/src/runs.ts +40 -1
- package/src/server.ts +106 -7
- package/src/setup-app.ts +338 -0
- package/src/tasks.ts +15 -0
- package/src/version.ts +1 -1
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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
+
}
|