@tiangong-ai/cli 0.0.33 → 0.0.35
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/AGENTS.md +9 -2
- package/README.md +84 -2
- package/dist/research/commands.js +58 -1
- package/dist/research/commands.js.map +1 -1
- package/dist/research/orchestration.js +366 -9
- package/dist/research/orchestration.js.map +1 -1
- package/dist/research/workspace/constants.d.ts +1 -0
- package/dist/research/workspace/constants.js +4 -2
- package/dist/research/workspace/constants.js.map +1 -1
- package/dist/research/workspace/context.js +106 -11
- package/dist/research/workspace/context.js.map +1 -1
- package/dist/research/workspace/input-plan.js +27 -1
- package/dist/research/workspace/input-plan.js.map +1 -1
- package/dist/research/workspace/projects.d.ts +6 -3
- package/dist/research/workspace/projects.js +21 -2
- package/dist/research/workspace/projects.js.map +1 -1
- package/dist/research/workspace/publication-workflow.d.ts +146 -0
- package/dist/research/workspace/publication-workflow.js +992 -0
- package/dist/research/workspace/publication-workflow.js.map +1 -0
- package/dist/research/workspace/publication.d.ts +110 -0
- package/dist/research/workspace/publication.js +246 -0
- package/dist/research/workspace/publication.js.map +1 -0
- package/dist/research/workspace/research-policy-wizard.d.ts +40 -0
- package/dist/research/workspace/research-policy-wizard.js +167 -0
- package/dist/research/workspace/research-policy-wizard.js.map +1 -0
- package/dist/research/workspace/research-policy.d.ts +70 -0
- package/dist/research/workspace/research-policy.js +886 -0
- package/dist/research/workspace/research-policy.js.map +1 -0
- package/dist/research/workspace/runtime.d.ts +17 -0
- package/dist/research/workspace/runtime.js +81 -2
- package/dist/research/workspace/runtime.js.map +1 -1
- package/dist/research/workspace/setup-catalog.js +5 -5
- package/dist/research/workspace/setup-invocation.d.ts +11 -0
- package/dist/research/workspace/setup-invocation.js +34 -0
- package/dist/research/workspace/setup-invocation.js.map +1 -0
- package/dist/research/workspace/setup-wizard.d.ts +18 -1
- package/dist/research/workspace/setup-wizard.js +1 -1
- package/dist/research/workspace/setup-wizard.js.map +1 -1
- package/dist/research/workspace/setup.d.ts +52 -6
- package/dist/research/workspace/setup.js +598 -22
- package/dist/research/workspace/setup.js.map +1 -1
- package/dist/research/workspace/types.d.ts +52 -0
- package/package.json +6 -3
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { chmod, link, lstat, open, readFile, realpath, rm } from "node:fs/promises";
|
|
3
|
+
import { chmod, link, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, } from "node:fs/promises";
|
|
4
4
|
import { hostname, homedir, platform } from "node:os";
|
|
5
5
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
@@ -13,8 +13,11 @@ import { appendJournalEvent } from "./journal.js";
|
|
|
13
13
|
import { configuredResearchSecrets, isSensitiveEnvironmentName, sanitizeResearchRecord, sanitizeResearchText, } from "./sanitization.js";
|
|
14
14
|
import { inspectResearchSetupCatalog, RESEARCH_SETUP_CREDENTIALS, RESEARCH_SETUP_INSTALLER, RESEARCH_SETUP_SETTINGS, RESEARCH_SETUP_SKILLS, resolveSetupSkills, setupSkill, setupSource, setupTargetRoot, verifyResearchSetupRuntimeContract, } from "./setup-catalog.js";
|
|
15
15
|
import { acquireFileLock, canonicalJson, ensureDirectory, fileSize, hashRegularTree, isObject, pathExists, REGULAR_TREE_HASH_ALGORITHM, readJsonFile, sha256File, sha256Text, workspacePaths, writeJsonAtomic, writeTextAtomic, } from "./storage.js";
|
|
16
|
-
import { packageVersion } from "./constants.js";
|
|
16
|
+
import { packageRoot, packageVersion, RESEARCH_PACKAGE_NAME } from "./constants.js";
|
|
17
|
+
import { exactResearchCliCommand, pinResearchCliCommand, researchSetupApplyCommand, researchSetupRetryCommand, } from "./setup-invocation.js";
|
|
17
18
|
import { doctorResearchWorkspace, initializeResearchWorkspace, loadWorkspaceConfig, } from "./workspace.js";
|
|
19
|
+
const RECOVERY_SKILL_NAME = "tiangong-auto-research-recovery";
|
|
20
|
+
const RECOVERY_SHIM_MARKER = ".tiangong-recovery-shim.json";
|
|
18
21
|
const BRAVE_PROFILE_SKILLS = {
|
|
19
22
|
none: [],
|
|
20
23
|
[EXTERNAL_SKILL_PROFILE]: ["brave.web-search", "brave.news-search"],
|
|
@@ -329,6 +332,11 @@ export async function applyResearchSetupPlan(planPath, options = {}) {
|
|
|
329
332
|
state = await startSetupStep(root, state, "credentials");
|
|
330
333
|
await configurePlanCredentials(plan, environment);
|
|
331
334
|
state = await completeSetupStep(root, state, "credentials");
|
|
335
|
+
if (plan.selection.skillIds.includes("tiangong.auto-research")) {
|
|
336
|
+
state = await startSetupStep(root, state, "recovery-shim");
|
|
337
|
+
await installResearchSetupRecoveryShims(plan);
|
|
338
|
+
state = await completeSetupStep(root, state, "recovery-shim");
|
|
339
|
+
}
|
|
332
340
|
state = await startSetupStep(root, state, "installation-preflight");
|
|
333
341
|
const selected = plan.selection.skillIds.map(setupSkill);
|
|
334
342
|
const installInspection = await inspectSelectedInstallations(plan, selected, environment);
|
|
@@ -355,7 +363,12 @@ export async function applyResearchSetupPlan(planPath, options = {}) {
|
|
|
355
363
|
].sort();
|
|
356
364
|
const sourceDirectories = new Map();
|
|
357
365
|
for (const sourceId of requiredSourceIds) {
|
|
358
|
-
|
|
366
|
+
try {
|
|
367
|
+
sourceDirectories.set(sourceId, await ensureSetupSourceCheckout(plan, sourceId, runner, installerEnvironment(environment)));
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
throw await annotateSetupSourceCheckoutFailure(error, plan, sourceId);
|
|
371
|
+
}
|
|
359
372
|
}
|
|
360
373
|
state = await completeSetupStep(root, state, "source-checkout");
|
|
361
374
|
state = await startSetupStep(root, state, "skill-install");
|
|
@@ -396,6 +409,11 @@ export async function applyResearchSetupPlan(planPath, options = {}) {
|
|
|
396
409
|
else {
|
|
397
410
|
state = await completeSetupStep(root, state, "skill-install");
|
|
398
411
|
}
|
|
412
|
+
if (plan.selection.skillIds.includes("tiangong.auto-research")) {
|
|
413
|
+
state = await startSetupStep(root, state, "recovery-shim-cleanup");
|
|
414
|
+
await removeResearchSetupRecoveryShims(plan);
|
|
415
|
+
state = await completeSetupStep(root, state, "recovery-shim-cleanup");
|
|
416
|
+
}
|
|
399
417
|
state = await startSetupStep(root, state, "capability-configuration");
|
|
400
418
|
await configureSelectedCapabilities(plan, environment);
|
|
401
419
|
await reconcilePlanCredentialStores(plan);
|
|
@@ -478,12 +496,31 @@ export async function applyResearchSetupPlan(planPath, options = {}) {
|
|
|
478
496
|
}
|
|
479
497
|
}
|
|
480
498
|
export async function inspectResearchSetupStatus(workspace, environment = process.env) {
|
|
481
|
-
const
|
|
499
|
+
const requestedRoot = requireAbsoluteWorkspace(resolve(workspace));
|
|
500
|
+
const requestedPaths = workspacePaths(requestedRoot);
|
|
501
|
+
const plan = await loadAndVerifyResearchSetupPlan(requestedPaths.setupPlan);
|
|
502
|
+
const canonicalRequestedRoot = await realpath(requestedRoot).catch(() => requestedRoot);
|
|
503
|
+
if (canonicalRequestedRoot !== plan.workspace.path) {
|
|
504
|
+
throw setupError({
|
|
505
|
+
code: "RESEARCH_SETUP_WORKSPACE_INVALID",
|
|
506
|
+
step: "workspace",
|
|
507
|
+
reason: "The setup plan is bound to a different canonical workspace path.",
|
|
508
|
+
minimumAction: "Run setup status against the exact workspace recorded in the setup plan.",
|
|
509
|
+
retryCommand: researchSetupApplyCommand({
|
|
510
|
+
version: plan.cli.version,
|
|
511
|
+
planPath: workspacePaths(plan.workspace.path).setupPlan,
|
|
512
|
+
}),
|
|
513
|
+
exitCode: 2,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
const root = plan.workspace.path;
|
|
482
517
|
const paths = workspacePaths(root);
|
|
483
|
-
const
|
|
484
|
-
const state =
|
|
518
|
+
const storedState = await loadSetupState(root, plan.planSha256);
|
|
519
|
+
const state = setupStateForOutput(storedState, plan, root);
|
|
485
520
|
const selected = plan.selection.skillIds.map(setupSkill);
|
|
486
521
|
const installations = await inspectSelectedInstallations(plan, selected, environment);
|
|
522
|
+
const credentialReadiness = await inspectSetupCredentialReadiness(plan);
|
|
523
|
+
const provenance = await inspectSetupProvenance(plan, installations, environment);
|
|
487
524
|
const report = (await pathExists(paths.setupReport))
|
|
488
525
|
? await readJsonFile(paths.setupReport, "Research setup report")
|
|
489
526
|
: null;
|
|
@@ -501,17 +538,476 @@ export async function inspectResearchSetupStatus(workspace, environment = proces
|
|
|
501
538
|
},
|
|
502
539
|
state,
|
|
503
540
|
installations,
|
|
541
|
+
credentialReadiness,
|
|
542
|
+
provenance,
|
|
504
543
|
report,
|
|
505
|
-
next: state
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
544
|
+
next: setupNextAction(plan, state, root),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
export async function resolveVerifiedResearchSetupSkillDirectory(workspace, skillId, environment = process.env) {
|
|
548
|
+
const root = requireAbsoluteWorkspace(resolve(workspace));
|
|
549
|
+
const status = await inspectResearchSetupStatus(root, environment);
|
|
550
|
+
if (status.plan.scope !== "project") {
|
|
551
|
+
throw setupError({
|
|
552
|
+
code: "RESEARCH_SETUP_PROJECT_INSTALL_REQUIRED",
|
|
553
|
+
step: "skill-resolution",
|
|
554
|
+
reason: "This workflow requires a project-scoped Skill installation.",
|
|
555
|
+
minimumAction: "Create and apply a reviewed project-scoped setup plan for this workspace.",
|
|
556
|
+
retryCommand: `tiangong-ai research setup --workspace ${root}`,
|
|
557
|
+
exitCode: 3,
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
if (!status.plan.selectedSkillIds.includes(skillId)) {
|
|
561
|
+
throw setupError({
|
|
562
|
+
code: "RESEARCH_SETUP_SKILL_NOT_SELECTED",
|
|
563
|
+
step: "skill-resolution",
|
|
564
|
+
reason: `The reviewed setup plan did not select ${skillId}.`,
|
|
565
|
+
minimumAction: "Create and apply a replacement setup plan that explicitly selects the required Skill.",
|
|
566
|
+
retryCommand: `tiangong-ai research setup --workspace ${root}`,
|
|
567
|
+
exitCode: 3,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
if (status.state.status !== "ready") {
|
|
571
|
+
throw setupError({
|
|
572
|
+
code: "RESEARCH_SETUP_NOT_READY",
|
|
573
|
+
step: "skill-resolution",
|
|
574
|
+
reason: `Research setup is ${status.state.status}, not ready.`,
|
|
575
|
+
minimumAction: "Complete setup apply and all required doctor checks before using the Skill.",
|
|
576
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
577
|
+
exitCode: 3,
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
const installations = status.installations
|
|
581
|
+
.filter((installation) => installation.skillId === skillId)
|
|
582
|
+
.sort((left, right) => {
|
|
583
|
+
if (left.agent === "codex" && right.agent !== "codex")
|
|
584
|
+
return -1;
|
|
585
|
+
if (right.agent === "codex" && left.agent !== "codex")
|
|
586
|
+
return 1;
|
|
587
|
+
return left.agent.localeCompare(right.agent);
|
|
588
|
+
});
|
|
589
|
+
const verified = installations.find((installation) => installation.status === "installed");
|
|
590
|
+
if (!verified) {
|
|
591
|
+
throw setupError({
|
|
592
|
+
code: "RESEARCH_SETUP_SKILL_INSTALL_INVALID",
|
|
593
|
+
step: "skill-resolution",
|
|
594
|
+
reason: `${skillId} is missing, blocked, or differs from the reviewed tree hash.`,
|
|
595
|
+
minimumAction: "Inspect setup status and re-apply the immutable plan; do not use an unverified Skill tree.",
|
|
596
|
+
retryCommand: `tiangong-ai research setup status --workspace ${root} --json`,
|
|
597
|
+
exitCode: 3,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
return verified.path;
|
|
601
|
+
}
|
|
602
|
+
function setupStateForOutput(state, plan, root) {
|
|
603
|
+
if (!state.lastError)
|
|
604
|
+
return state;
|
|
605
|
+
return {
|
|
606
|
+
...state,
|
|
607
|
+
lastError: {
|
|
608
|
+
...state.lastError,
|
|
609
|
+
retryCommand: researchSetupRetryCommand({
|
|
610
|
+
version: plan.cli.version,
|
|
611
|
+
workspace: root,
|
|
612
|
+
step: state.lastError.step,
|
|
613
|
+
}),
|
|
614
|
+
},
|
|
513
615
|
};
|
|
514
616
|
}
|
|
617
|
+
function setupNextAction(plan, state, root) {
|
|
618
|
+
if (state.status === "ready")
|
|
619
|
+
return null;
|
|
620
|
+
if (state.status === "pending") {
|
|
621
|
+
return {
|
|
622
|
+
action: "apply",
|
|
623
|
+
minimumAction: "Apply the reviewed immutable setup plan.",
|
|
624
|
+
retryCommand: researchSetupApplyCommand({
|
|
625
|
+
version: plan.cli.version,
|
|
626
|
+
planPath: workspacePaths(root).setupPlan,
|
|
627
|
+
}),
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
if (state.status === "blocked" && state.lastError) {
|
|
631
|
+
return {
|
|
632
|
+
action: "retry",
|
|
633
|
+
minimumAction: state.lastError.minimumAction,
|
|
634
|
+
retryCommand: state.lastError.retryCommand,
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (state.status === "applying") {
|
|
638
|
+
return {
|
|
639
|
+
action: "inspect",
|
|
640
|
+
minimumAction: "Inspect the active setup attempt; do not start a competing apply.",
|
|
641
|
+
retryCommand: exactResearchCliCommand(["research", "setup", "status", "--workspace", root, "--json"], plan.cli.version),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
action: "doctor",
|
|
646
|
+
minimumAction: "Run setup doctor and resolve every reported missing readiness item.",
|
|
647
|
+
retryCommand: exactResearchCliCommand(["research", "setup", "doctor", "--workspace", root, "--json"], plan.cli.version),
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
async function inspectSetupCredentialReadiness(plan) {
|
|
651
|
+
const definitions = selectedCredentialDefinitions(plan);
|
|
652
|
+
const brokerIds = definitions
|
|
653
|
+
.filter((definition) => definition.storage === "broker")
|
|
654
|
+
.map((definition) => definition.id);
|
|
655
|
+
const configuredBroker = new Set((await loadCapabilityCredentialMapForIds(plan.workspace.path, brokerIds, {
|
|
656
|
+
ignoreUndeclared: true,
|
|
657
|
+
})).keys());
|
|
658
|
+
const configuredAdapter = await loadAdapterCredentials(plan.workspace.path, definitions, {
|
|
659
|
+
ignoreUndeclared: true,
|
|
660
|
+
});
|
|
661
|
+
const configuredIds = definitions
|
|
662
|
+
.filter((definition) => definition.storage === "broker"
|
|
663
|
+
? configuredBroker.has(definition.id)
|
|
664
|
+
: configuredAdapter.has(definition.id))
|
|
665
|
+
.map((definition) => definition.id)
|
|
666
|
+
.sort();
|
|
667
|
+
const configured = new Set(configuredIds);
|
|
668
|
+
return {
|
|
669
|
+
valuesEmitted: false,
|
|
670
|
+
configuredIds,
|
|
671
|
+
missingRequiredIds: definitions
|
|
672
|
+
.filter((definition) => definition.required && !configured.has(definition.id))
|
|
673
|
+
.map((definition) => definition.id)
|
|
674
|
+
.sort(),
|
|
675
|
+
scopes: [
|
|
676
|
+
...new Set(definitions
|
|
677
|
+
.filter((definition) => configured.has(definition.id))
|
|
678
|
+
.map((definition) => definition.storage)),
|
|
679
|
+
].sort(),
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
async function inspectSetupProvenance(plan, installations, environment) {
|
|
683
|
+
const orchestratorSelected = plan.selection.skillIds.includes("tiangong.auto-research");
|
|
684
|
+
const orchestratorInstallations = installations
|
|
685
|
+
.filter((installation) => installation.skillId === "tiangong.auto-research")
|
|
686
|
+
.map((installation) => ({
|
|
687
|
+
agent: installation.agent,
|
|
688
|
+
path: installation.path,
|
|
689
|
+
status: installation.status,
|
|
690
|
+
observedTreeSha256: installation.observedTreeSha256,
|
|
691
|
+
}));
|
|
692
|
+
return {
|
|
693
|
+
effectiveCli: {
|
|
694
|
+
packageName: RESEARCH_PACKAGE_NAME,
|
|
695
|
+
packageVersion: packageVersion(),
|
|
696
|
+
packageRoot: packageRoot(),
|
|
697
|
+
invocationMode: "exact-npx",
|
|
698
|
+
commandPrefix: exactResearchCliCommand([], plan.cli.version),
|
|
699
|
+
},
|
|
700
|
+
ambientCli: await findAmbientExecutable(environment, "tiangong-ai"),
|
|
701
|
+
ambientSkillConflicts: await inspectAmbientProjectSkillConflicts(plan, environment),
|
|
702
|
+
recoveryShims: await inspectResearchSetupRecoveryShims(plan),
|
|
703
|
+
selectedOrchestrator: orchestratorSelected
|
|
704
|
+
? {
|
|
705
|
+
skillId: "tiangong.auto-research",
|
|
706
|
+
scope: plan.install.scope,
|
|
707
|
+
preferredPath: orchestratorInstallations.find((installation) => installation.status === "installed")
|
|
708
|
+
?.path ?? null,
|
|
709
|
+
installations: orchestratorInstallations,
|
|
710
|
+
}
|
|
711
|
+
: null,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
function recoveryShimPath(plan, agent) {
|
|
715
|
+
return join(setupTargetRoot({
|
|
716
|
+
workspace: plan.workspace.path,
|
|
717
|
+
scope: "project",
|
|
718
|
+
agent,
|
|
719
|
+
}), RECOVERY_SKILL_NAME);
|
|
720
|
+
}
|
|
721
|
+
function recoveryShimMarker(plan, agent) {
|
|
722
|
+
return {
|
|
723
|
+
schemaVersion: 1,
|
|
724
|
+
kind: "tiangong-auto-research-recovery-shim",
|
|
725
|
+
planSha256: plan.planSha256,
|
|
726
|
+
cliVersion: plan.cli.version,
|
|
727
|
+
workspace: plan.workspace.path,
|
|
728
|
+
agent,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function recoveryShimInstructions(marker) {
|
|
732
|
+
const inspectCommand = exactResearchCliCommand(["research", "context", "inspect", "--path", marker.workspace, "--json"], marker.cliVersion);
|
|
733
|
+
const statusCommand = exactResearchCliCommand(["research", "setup", "status", "--workspace", marker.workspace, "--json"], marker.cliVersion);
|
|
734
|
+
return `---
|
|
735
|
+
name: ${RECOVERY_SKILL_NAME}
|
|
736
|
+
description: Recovery-only routing for an explicitly reviewed Tiangong Auto Research setup that is pending, applying, or blocked. Use when a research request occurs under this workspace before the full project orchestrator is installed. Never use for research execution or standalone evidence search.
|
|
737
|
+
---
|
|
738
|
+
|
|
739
|
+
# Tiangong Auto Research recovery-only shim
|
|
740
|
+
|
|
741
|
+
This CLI-generated Skill is bound to setup plan \`${marker.planSha256}\`. It exists only
|
|
742
|
+
until the full external \`tiangong-auto-research\` Skill matches its reviewed tree hash.
|
|
743
|
+
|
|
744
|
+
Never run research or standalone evidence from this shim. Do not read, copy, print, or
|
|
745
|
+
edit credentials, setup state, locks, manifests, or the immutable plan.
|
|
746
|
+
|
|
747
|
+
First run the exact-version read-only preflight:
|
|
748
|
+
|
|
749
|
+
\`\`\`bash
|
|
750
|
+
${inspectCommand}
|
|
751
|
+
\`\`\`
|
|
752
|
+
|
|
753
|
+
If the context is managed, inspect the structured setup state:
|
|
754
|
+
|
|
755
|
+
\`\`\`bash
|
|
756
|
+
${statusCommand}
|
|
757
|
+
\`\`\`
|
|
758
|
+
|
|
759
|
+
For \`pending\` or \`blocked\`, execute only the returned \`setup.next.retryCommand\`.
|
|
760
|
+
For \`applying\`, report the active step and do not start a competing apply. Stop after
|
|
761
|
+
reporting any new blocker. Never fall through to a global Skill, ambient CLI, or
|
|
762
|
+
standalone provider credential.
|
|
763
|
+
`;
|
|
764
|
+
}
|
|
765
|
+
function serializedRecoveryShimMarker(marker) {
|
|
766
|
+
return `${JSON.stringify(marker, null, 2)}\n`;
|
|
767
|
+
}
|
|
768
|
+
async function inspectRecoveryShim(path, workspace, agent, expectedPlanSha256) {
|
|
769
|
+
const info = await lstat(path).catch(() => undefined);
|
|
770
|
+
if (!info)
|
|
771
|
+
return { status: "missing", marker: null };
|
|
772
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
773
|
+
return { status: "blocked", marker: null };
|
|
774
|
+
try {
|
|
775
|
+
const entries = (await readdir(path)).sort();
|
|
776
|
+
if (canonicalJson(entries) !== canonicalJson([RECOVERY_SHIM_MARKER, "SKILL.md"].sort())) {
|
|
777
|
+
return { status: "drifted", marker: null };
|
|
778
|
+
}
|
|
779
|
+
const markerPath = join(path, RECOVERY_SHIM_MARKER);
|
|
780
|
+
const skillPath = join(path, "SKILL.md");
|
|
781
|
+
const [markerInfo, skillInfo, markerText, skillText] = await Promise.all([
|
|
782
|
+
lstat(markerPath),
|
|
783
|
+
lstat(skillPath),
|
|
784
|
+
readFile(markerPath, "utf8"),
|
|
785
|
+
readFile(skillPath, "utf8"),
|
|
786
|
+
]);
|
|
787
|
+
if (!markerInfo.isFile() ||
|
|
788
|
+
markerInfo.isSymbolicLink() ||
|
|
789
|
+
!skillInfo.isFile() ||
|
|
790
|
+
skillInfo.isSymbolicLink()) {
|
|
791
|
+
return { status: "blocked", marker: null };
|
|
792
|
+
}
|
|
793
|
+
const value = JSON.parse(markerText);
|
|
794
|
+
if (!isObject(value) ||
|
|
795
|
+
value.schemaVersion !== 1 ||
|
|
796
|
+
value.kind !== "tiangong-auto-research-recovery-shim" ||
|
|
797
|
+
typeof value.planSha256 !== "string" ||
|
|
798
|
+
!/^[0-9a-f]{64}$/.test(value.planSha256) ||
|
|
799
|
+
typeof value.cliVersion !== "string" ||
|
|
800
|
+
!/^\d+\.\d+\.\d+$/.test(value.cliVersion) ||
|
|
801
|
+
value.workspace !== workspace ||
|
|
802
|
+
value.agent !== agent) {
|
|
803
|
+
return { status: "drifted", marker: null };
|
|
804
|
+
}
|
|
805
|
+
const marker = value;
|
|
806
|
+
if (markerText !== serializedRecoveryShimMarker(marker) ||
|
|
807
|
+
skillText !== recoveryShimInstructions(marker)) {
|
|
808
|
+
return { status: "drifted", marker: null };
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
status: marker.planSha256 === expectedPlanSha256 ? "installed" : "stale",
|
|
812
|
+
marker,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
catch {
|
|
816
|
+
return { status: "blocked", marker: null };
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
async function writeRecoveryShimDirectory(path, marker) {
|
|
820
|
+
const temporary = join(dirname(path), `.${RECOVERY_SKILL_NAME}.${process.pid}.${randomUUID()}.tmp`);
|
|
821
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
822
|
+
try {
|
|
823
|
+
await writeTextAtomic(join(temporary, "SKILL.md"), recoveryShimInstructions(marker), 0o444);
|
|
824
|
+
await writeTextAtomic(join(temporary, RECOVERY_SHIM_MARKER), serializedRecoveryShimMarker(marker), 0o444);
|
|
825
|
+
return temporary;
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
await rm(temporary, { recursive: true, force: true });
|
|
829
|
+
throw error;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async function installResearchSetupRecoveryShims(plan) {
|
|
833
|
+
for (const agent of plan.install.agents) {
|
|
834
|
+
const path = recoveryShimPath(plan, agent);
|
|
835
|
+
const parent = dirname(path);
|
|
836
|
+
await assertNoSymlinkedExistingPath(parent, plan.workspace.path);
|
|
837
|
+
await ensureDirectory(parent);
|
|
838
|
+
await assertNoSymlinkedExistingPath(parent, plan.workspace.path);
|
|
839
|
+
const inspection = await inspectRecoveryShim(path, plan.workspace.path, agent, plan.planSha256);
|
|
840
|
+
if (inspection.status === "installed")
|
|
841
|
+
continue;
|
|
842
|
+
if (inspection.status === "drifted" || inspection.status === "blocked") {
|
|
843
|
+
throw setupError({
|
|
844
|
+
code: "RESEARCH_SETUP_RECOVERY_SHIM_UNSAFE",
|
|
845
|
+
step: "recovery-shim",
|
|
846
|
+
reason: `The recovery Skill destination is not an exact CLI-owned shim for ${agent}.`,
|
|
847
|
+
minimumAction: "Review the reported project Skill directory. Setup will not overwrite or delete ambiguous bytes.",
|
|
848
|
+
retryCommand: exactResearchCliCommand(["research", "setup", "status", "--workspace", plan.workspace.path, "--json"], plan.cli.version),
|
|
849
|
+
exitCode: 3,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
const temporary = await writeRecoveryShimDirectory(path, recoveryShimMarker(plan, agent));
|
|
853
|
+
try {
|
|
854
|
+
if (inspection.status === "missing") {
|
|
855
|
+
await rename(temporary, path);
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
const backup = `${path}.${process.pid}.${randomUUID()}.previous`;
|
|
859
|
+
await rename(path, backup);
|
|
860
|
+
try {
|
|
861
|
+
await rename(temporary, path);
|
|
862
|
+
await rm(backup, { recursive: true, force: true });
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
if (!(await pathExists(path)))
|
|
866
|
+
await rename(backup, path).catch(() => undefined);
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
catch (error) {
|
|
872
|
+
await rm(temporary, { recursive: true, force: true });
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
async function removeResearchSetupRecoveryShims(plan) {
|
|
878
|
+
for (const agent of plan.install.agents) {
|
|
879
|
+
const path = recoveryShimPath(plan, agent);
|
|
880
|
+
const inspection = await inspectRecoveryShim(path, plan.workspace.path, agent, plan.planSha256);
|
|
881
|
+
if (inspection.status === "missing")
|
|
882
|
+
continue;
|
|
883
|
+
if (inspection.status !== "installed") {
|
|
884
|
+
throw setupError({
|
|
885
|
+
code: "RESEARCH_SETUP_RECOVERY_SHIM_UNSAFE",
|
|
886
|
+
step: "recovery-shim-cleanup",
|
|
887
|
+
reason: `The recovery Skill changed before verified cleanup for ${agent}.`,
|
|
888
|
+
minimumAction: "Review the recovery Skill directory. Setup removes only its exact plan-bound generated bytes.",
|
|
889
|
+
retryCommand: exactResearchCliCommand(["research", "setup", "status", "--workspace", plan.workspace.path, "--json"], plan.cli.version),
|
|
890
|
+
exitCode: 3,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
await rm(path, { recursive: true, force: false });
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
async function inspectResearchSetupRecoveryShims(plan) {
|
|
897
|
+
if (!plan.selection.skillIds.includes("tiangong.auto-research"))
|
|
898
|
+
return [];
|
|
899
|
+
const results = [];
|
|
900
|
+
for (const agent of plan.install.agents) {
|
|
901
|
+
const path = recoveryShimPath(plan, agent);
|
|
902
|
+
const inspection = await inspectRecoveryShim(path, plan.workspace.path, agent, plan.planSha256);
|
|
903
|
+
if (inspection.status === "missing")
|
|
904
|
+
continue;
|
|
905
|
+
results.push({
|
|
906
|
+
agent,
|
|
907
|
+
path,
|
|
908
|
+
status: inspection.status === "stale" ? "drifted" : inspection.status,
|
|
909
|
+
planSha256: inspection.marker?.planSha256 ?? null,
|
|
910
|
+
cliVersion: inspection.marker?.cliVersion ?? null,
|
|
911
|
+
recoveryOnly: true,
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
return results;
|
|
915
|
+
}
|
|
916
|
+
async function inspectAmbientProjectSkillConflicts(plan, environment) {
|
|
917
|
+
if (plan.install.scope !== "project")
|
|
918
|
+
return [];
|
|
919
|
+
const conflicts = [];
|
|
920
|
+
for (const agent of plan.install.agents) {
|
|
921
|
+
const globalRoot = setupTargetRoot({
|
|
922
|
+
workspace: plan.workspace.path,
|
|
923
|
+
scope: "global",
|
|
924
|
+
agent,
|
|
925
|
+
environment,
|
|
926
|
+
});
|
|
927
|
+
for (const skillId of plan.selection.skillIds) {
|
|
928
|
+
const skill = setupSkill(skillId);
|
|
929
|
+
const path = join(globalRoot, skill.skillName);
|
|
930
|
+
const info = await lstat(path).catch(() => undefined);
|
|
931
|
+
if (!info)
|
|
932
|
+
continue;
|
|
933
|
+
let status = "blocked";
|
|
934
|
+
let observedTreeSha256 = null;
|
|
935
|
+
if (info.isDirectory() && !info.isSymbolicLink()) {
|
|
936
|
+
try {
|
|
937
|
+
observedTreeSha256 = await hashRegularTree(path);
|
|
938
|
+
status = observedTreeSha256 === skill.expectedTreeSha256 ? "matching" : "drifted";
|
|
939
|
+
}
|
|
940
|
+
catch {
|
|
941
|
+
status = "blocked";
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
conflicts.push({
|
|
945
|
+
skillId: skill.id,
|
|
946
|
+
skillName: skill.skillName,
|
|
947
|
+
agent,
|
|
948
|
+
path,
|
|
949
|
+
status,
|
|
950
|
+
observedTreeSha256,
|
|
951
|
+
expectedTreeSha256: skill.expectedTreeSha256,
|
|
952
|
+
unmanagedPathCliFallback: await containsUnmanagedPathCliFallback(path),
|
|
953
|
+
ignoredByProjectScope: true,
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return conflicts;
|
|
958
|
+
}
|
|
959
|
+
async function containsUnmanagedPathCliFallback(root) {
|
|
960
|
+
const state = { inspectedFiles: 0 };
|
|
961
|
+
const inspectDirectory = async (directory, depth) => {
|
|
962
|
+
if (depth > 4 || state.inspectedFiles >= 100)
|
|
963
|
+
return false;
|
|
964
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
965
|
+
for (const entry of entries) {
|
|
966
|
+
if (entry.isSymbolicLink())
|
|
967
|
+
continue;
|
|
968
|
+
const path = join(directory, entry.name);
|
|
969
|
+
if (entry.isDirectory()) {
|
|
970
|
+
if (await inspectDirectory(path, depth + 1))
|
|
971
|
+
return true;
|
|
972
|
+
continue;
|
|
973
|
+
}
|
|
974
|
+
if (!entry.isFile() || !/\.(?:c?js|mjs|py|sh)$/.test(entry.name))
|
|
975
|
+
continue;
|
|
976
|
+
state.inspectedFiles += 1;
|
|
977
|
+
const info = await lstat(path).catch(() => undefined);
|
|
978
|
+
if (!info?.isFile() || info.isSymbolicLink() || info.size > 256 * 1024)
|
|
979
|
+
continue;
|
|
980
|
+
const content = await readFile(path, "utf8").catch(() => "");
|
|
981
|
+
if (content.includes("TIANGONG_AI_CLI:-tiangong-ai"))
|
|
982
|
+
return true;
|
|
983
|
+
}
|
|
984
|
+
return false;
|
|
985
|
+
};
|
|
986
|
+
return inspectDirectory(root, 0);
|
|
987
|
+
}
|
|
988
|
+
async function findAmbientExecutable(environment, executable) {
|
|
989
|
+
const pathValue = environment.PATH;
|
|
990
|
+
if (!pathValue)
|
|
991
|
+
return null;
|
|
992
|
+
const suffixes = process.platform === "win32" ? [".cmd", ".exe", ""] : [""];
|
|
993
|
+
for (const directory of pathValue.split(process.platform === "win32" ? ";" : ":")) {
|
|
994
|
+
if (!directory)
|
|
995
|
+
continue;
|
|
996
|
+
for (const suffix of suffixes) {
|
|
997
|
+
const candidate = join(directory, `${executable}${suffix}`);
|
|
998
|
+
const info = await lstat(candidate).catch(() => undefined);
|
|
999
|
+
if (!info || (!info.isFile() && !info.isSymbolicLink()))
|
|
1000
|
+
continue;
|
|
1001
|
+
const resolved = await realpath(candidate).catch(() => undefined);
|
|
1002
|
+
if (!resolved)
|
|
1003
|
+
continue;
|
|
1004
|
+
const resolvedInfo = await lstat(resolved).catch(() => undefined);
|
|
1005
|
+
if (resolvedInfo?.isFile() && !resolvedInfo.isSymbolicLink())
|
|
1006
|
+
return { path: resolved, ignoredByExactInvocation: true };
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
515
1011
|
export async function setResearchSetupCredentialFromEnvironment(input) {
|
|
516
1012
|
const root = requireAbsoluteWorkspace(input.workspace);
|
|
517
1013
|
const { credential } = await selectedSetupCredential(root, input.credentialId);
|
|
@@ -794,6 +1290,26 @@ export async function doctorResearchSetup(workspace, options = {}) {
|
|
|
794
1290
|
: "Restore the pinned Skill bytes; setup will not overwrite a drifted or symlinked directory.",
|
|
795
1291
|
});
|
|
796
1292
|
}
|
|
1293
|
+
const provenance = await inspectSetupProvenance(plan, installations, environment);
|
|
1294
|
+
for (const conflict of provenance.ambientSkillConflicts) {
|
|
1295
|
+
const projectInstallation = installations.find((installation) => installation.agent === conflict.agent && installation.skillId === conflict.skillId);
|
|
1296
|
+
const projectInstalled = projectInstallation?.status === "installed";
|
|
1297
|
+
checks.push({
|
|
1298
|
+
id: `skill-scope.${conflict.agent}.${conflict.skillId}`,
|
|
1299
|
+
category: "skill-installation",
|
|
1300
|
+
scope: "research-core",
|
|
1301
|
+
componentIds: [conflict.skillId],
|
|
1302
|
+
status: projectInstalled ? "warn" : "fail",
|
|
1303
|
+
detail: projectInstalled
|
|
1304
|
+
? `A global same-name Skill exists at ${conflict.path}, but the verified project copy is authoritative and the global copy is ignored.`
|
|
1305
|
+
: `SKILL_SCOPE_FALLBACK_UNSAFE: the project copy is not verified while a global same-name Skill exists at ${conflict.path}${conflict.unmanagedPathCliFallback ? " and contains an unmanaged PATH CLI fallback" : ""}.`,
|
|
1306
|
+
minimumAction: projectInstalled
|
|
1307
|
+
? "Remove or update the ignored global copy during separate owner-approved maintenance if it is no longer needed."
|
|
1308
|
+
: "Resume the exact setup plan until the project Skill matches its reviewed tree; do not use the global fallback.",
|
|
1309
|
+
blocking: !projectInstalled,
|
|
1310
|
+
requiredFor: projectInstalled ? [] : ["setup", "research-core"],
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
797
1313
|
for (const setting of requiredSettingsForSkills(selected)) {
|
|
798
1314
|
const configured = plan.settings[setting.id];
|
|
799
1315
|
checks.push({
|
|
@@ -1761,7 +2277,7 @@ async function ensureSetupSourceCheckout(plan, sourceId, runner, environment) {
|
|
|
1761
2277
|
const source = plan.sources.find((candidate) => candidate.id === sourceId);
|
|
1762
2278
|
if (!source)
|
|
1763
2279
|
throw planCatalogDrift(`missing source ${sourceId}`);
|
|
1764
|
-
const checkout =
|
|
2280
|
+
const checkout = setupSourceCheckoutPath(plan, sourceId);
|
|
1765
2281
|
await assertNoSymlinkedExistingPath(dirname(checkout), plan.workspace.path);
|
|
1766
2282
|
let createdCheckout = false;
|
|
1767
2283
|
if (!(await pathExists(checkout))) {
|
|
@@ -1874,6 +2390,42 @@ async function ensureSetupSourceCheckout(plan, sourceId, runner, environment) {
|
|
|
1874
2390
|
}
|
|
1875
2391
|
return checkout;
|
|
1876
2392
|
}
|
|
2393
|
+
function setupSourceCheckoutPath(plan, sourceId) {
|
|
2394
|
+
const source = plan.sources.find((candidate) => candidate.id === sourceId);
|
|
2395
|
+
if (!source)
|
|
2396
|
+
throw planCatalogDrift(`missing source ${sourceId}`);
|
|
2397
|
+
return join(workspacePaths(plan.workspace.path).setupSources, `${source.id}-${source.immutableRef.slice(0, 12)}`);
|
|
2398
|
+
}
|
|
2399
|
+
async function annotateSetupSourceCheckoutFailure(error, plan, sourceId) {
|
|
2400
|
+
if (!(error instanceof CliError) || error.code !== "RESEARCH_SETUP_COMMAND_FAILED")
|
|
2401
|
+
return error;
|
|
2402
|
+
const source = plan.sources.find((candidate) => candidate.id === sourceId);
|
|
2403
|
+
if (!source)
|
|
2404
|
+
return error;
|
|
2405
|
+
const details = isObject(error.details) ? error.details : {};
|
|
2406
|
+
const checkout = setupSourceCheckoutPath(plan, sourceId);
|
|
2407
|
+
return setupError({
|
|
2408
|
+
code: error.code,
|
|
2409
|
+
step: "source-checkout",
|
|
2410
|
+
reason: typeof details.reason === "string" ? details.reason : sanitizeResearchText(error.message),
|
|
2411
|
+
minimumAction: typeof details.minimumAction === "string"
|
|
2412
|
+
? details.minimumAction
|
|
2413
|
+
: "Resolve the source transport failure, then retry only the recorded source-checkout step.",
|
|
2414
|
+
retryCommand: researchSetupRetryCommand({
|
|
2415
|
+
version: plan.cli.version,
|
|
2416
|
+
workspace: plan.workspace.path,
|
|
2417
|
+
step: "source-checkout",
|
|
2418
|
+
}),
|
|
2419
|
+
exitCode: error.exitCode,
|
|
2420
|
+
diagnostics: {
|
|
2421
|
+
sourceId: source.id,
|
|
2422
|
+
repository: source.repository,
|
|
2423
|
+
immutableRef: source.immutableRef,
|
|
2424
|
+
cacheState: (await pathExists(checkout)) ? "partial" : "absent",
|
|
2425
|
+
safeToRetry: true,
|
|
2426
|
+
},
|
|
2427
|
+
});
|
|
2428
|
+
}
|
|
1877
2429
|
async function configureDeterministicSourceCheckout(checkout, runner, cwd, environment) {
|
|
1878
2430
|
for (const [key, value] of [
|
|
1879
2431
|
["core.autocrlf", "false"],
|
|
@@ -2654,7 +3206,14 @@ async function loadSetupState(root, planSha256) {
|
|
|
2654
3206
|
typeof value.attempts !== "number" ||
|
|
2655
3207
|
!Number.isInteger(value.attempts) ||
|
|
2656
3208
|
typeof value.updatedAt !== "string" ||
|
|
2657
|
-
!(value.lastError === null ||
|
|
3209
|
+
!(value.lastError === null ||
|
|
3210
|
+
(isObject(value.lastError) &&
|
|
3211
|
+
typeof value.lastError.code === "string" &&
|
|
3212
|
+
typeof value.lastError.step === "string" &&
|
|
3213
|
+
typeof value.lastError.reason === "string" &&
|
|
3214
|
+
typeof value.lastError.minimumAction === "string" &&
|
|
3215
|
+
typeof value.lastError.retryCommand === "string" &&
|
|
3216
|
+
(value.lastError.diagnostics === undefined || isObject(value.lastError.diagnostics))))) {
|
|
2658
3217
|
throw setupError({
|
|
2659
3218
|
code: "RESEARCH_SETUP_STATE_INVALID",
|
|
2660
3219
|
step: "state",
|
|
@@ -2940,6 +3499,15 @@ function setupMutations(root, targets, selected) {
|
|
|
2940
3499
|
reason: "Initialize or verify the auditable research workspace control plane.",
|
|
2941
3500
|
},
|
|
2942
3501
|
];
|
|
3502
|
+
if (selected.some((skill) => skill.id === "tiangong.auto-research")) {
|
|
3503
|
+
for (const target of targets) {
|
|
3504
|
+
mutations.push({
|
|
3505
|
+
step: "recovery-shim",
|
|
3506
|
+
target: join(setupTargetRoot({ workspace: root, scope: "project", agent: target.agent }), RECOVERY_SKILL_NAME),
|
|
3507
|
+
reason: "Create a plan-bound recovery-only routing Skill until the full external orchestrator is verified.",
|
|
3508
|
+
});
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
2943
3511
|
for (const target of targets) {
|
|
2944
3512
|
for (const skill of selected) {
|
|
2945
3513
|
mutations.push({
|
|
@@ -3516,16 +4084,20 @@ function syntheticPdfText() {
|
|
|
3516
4084
|
function setupFailure(error, fallbackStep, root) {
|
|
3517
4085
|
if (error instanceof CliError && isObject(error.details)) {
|
|
3518
4086
|
const details = sanitizeResearchRecord(error.details);
|
|
4087
|
+
const step = typeof details.step === "string" ? details.step : fallbackStep;
|
|
3519
4088
|
return {
|
|
3520
4089
|
code: error.code,
|
|
3521
|
-
step
|
|
4090
|
+
step,
|
|
3522
4091
|
reason: typeof details.reason === "string" ? details.reason : sanitizeResearchText(error.message),
|
|
3523
4092
|
minimumAction: typeof details.minimumAction === "string"
|
|
3524
4093
|
? details.minimumAction
|
|
3525
4094
|
: "Resolve the reported setup error and retry the exact recorded step.",
|
|
3526
|
-
retryCommand:
|
|
3527
|
-
|
|
3528
|
-
:
|
|
4095
|
+
retryCommand: researchSetupRetryCommand({
|
|
4096
|
+
version: packageVersion(),
|
|
4097
|
+
workspace: root,
|
|
4098
|
+
step,
|
|
4099
|
+
}),
|
|
4100
|
+
...(isObject(details.diagnostics) ? { diagnostics: details.diagnostics } : {}),
|
|
3529
4101
|
};
|
|
3530
4102
|
}
|
|
3531
4103
|
return {
|
|
@@ -3533,7 +4105,11 @@ function setupFailure(error, fallbackStep, root) {
|
|
|
3533
4105
|
step: fallbackStep,
|
|
3534
4106
|
reason: sanitizeResearchText(error instanceof Error ? error.message : String(error)),
|
|
3535
4107
|
minimumAction: "Inspect the sanitized setup status, correct the failure, and retry the exact recorded step.",
|
|
3536
|
-
retryCommand:
|
|
4108
|
+
retryCommand: researchSetupRetryCommand({
|
|
4109
|
+
version: packageVersion(),
|
|
4110
|
+
workspace: root,
|
|
4111
|
+
step: fallbackStep,
|
|
4112
|
+
}),
|
|
3537
4113
|
};
|
|
3538
4114
|
}
|
|
3539
4115
|
function setupError(input) {
|
|
@@ -3541,7 +4117,7 @@ function setupError(input) {
|
|
|
3541
4117
|
step: input.step,
|
|
3542
4118
|
reason: input.reason,
|
|
3543
4119
|
minimumAction: input.minimumAction,
|
|
3544
|
-
retryCommand: input.retryCommand,
|
|
4120
|
+
retryCommand: pinResearchCliCommand(input.retryCommand),
|
|
3545
4121
|
...(input.diagnostics === undefined ? {} : { diagnostics: input.diagnostics }),
|
|
3546
4122
|
});
|
|
3547
4123
|
return new CliError(sanitizeResearchText(input.reason), {
|