@tiangong-ai/cli 0.0.31 → 0.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +140 -98
  3. package/dist/research/orchestration.js +519 -21
  4. package/dist/research/orchestration.js.map +1 -1
  5. package/dist/research/workspace/acquisition.d.ts +71 -0
  6. package/dist/research/workspace/acquisition.js +593 -0
  7. package/dist/research/workspace/acquisition.js.map +1 -0
  8. package/dist/research/workspace/artifacts.d.ts +43 -0
  9. package/dist/research/workspace/artifacts.js +544 -0
  10. package/dist/research/workspace/artifacts.js.map +1 -0
  11. package/dist/research/workspace/broker.d.ts +5 -0
  12. package/dist/research/workspace/broker.js +318 -43
  13. package/dist/research/workspace/broker.js.map +1 -1
  14. package/dist/research/workspace/companion-readiness.d.ts +11 -0
  15. package/dist/research/workspace/companion-readiness.js +75 -0
  16. package/dist/research/workspace/companion-readiness.js.map +1 -0
  17. package/dist/research/workspace/discovery-planning.d.ts +25 -0
  18. package/dist/research/workspace/discovery-planning.js +106 -0
  19. package/dist/research/workspace/discovery-planning.js.map +1 -0
  20. package/dist/research/workspace/discovery-status.d.ts +46 -0
  21. package/dist/research/workspace/discovery-status.js +183 -0
  22. package/dist/research/workspace/discovery-status.js.map +1 -0
  23. package/dist/research/workspace/discovery.d.ts +25 -0
  24. package/dist/research/workspace/discovery.js +268 -0
  25. package/dist/research/workspace/discovery.js.map +1 -0
  26. package/dist/research/workspace/downloads.d.ts +76 -0
  27. package/dist/research/workspace/downloads.js +274 -0
  28. package/dist/research/workspace/downloads.js.map +1 -0
  29. package/dist/research/workspace/evidence-ledger.d.ts +52 -0
  30. package/dist/research/workspace/evidence-ledger.js +487 -0
  31. package/dist/research/workspace/evidence-ledger.js.map +1 -0
  32. package/dist/research/workspace/evidence.d.ts +1 -0
  33. package/dist/research/workspace/evidence.js +2 -0
  34. package/dist/research/workspace/evidence.js.map +1 -1
  35. package/dist/research/workspace/input-plan.js +4 -0
  36. package/dist/research/workspace/input-plan.js.map +1 -1
  37. package/dist/research/workspace/native-activity.d.ts +65 -0
  38. package/dist/research/workspace/native-activity.js +153 -0
  39. package/dist/research/workspace/native-activity.js.map +1 -0
  40. package/dist/research/workspace/preflight.d.ts +18 -0
  41. package/dist/research/workspace/preflight.js +47 -20
  42. package/dist/research/workspace/preflight.js.map +1 -1
  43. package/dist/research/workspace/projects.d.ts +3 -1
  44. package/dist/research/workspace/projects.js +357 -5
  45. package/dist/research/workspace/projects.js.map +1 -1
  46. package/dist/research/workspace/runtime.d.ts +171 -3
  47. package/dist/research/workspace/runtime.js +1582 -77
  48. package/dist/research/workspace/runtime.js.map +1 -1
  49. package/dist/research/workspace/sanitization.js +28 -6
  50. package/dist/research/workspace/sanitization.js.map +1 -1
  51. package/dist/research/workspace/schemas.d.ts +3 -0
  52. package/dist/research/workspace/schemas.js +206 -33
  53. package/dist/research/workspace/schemas.js.map +1 -1
  54. package/dist/research/workspace/setup-catalog.js +2 -2
  55. package/dist/research/workspace/setup-wizard.js +45 -17
  56. package/dist/research/workspace/setup-wizard.js.map +1 -1
  57. package/dist/research/workspace/setup.d.ts +27 -5
  58. package/dist/research/workspace/setup.js +400 -85
  59. package/dist/research/workspace/setup.js.map +1 -1
  60. package/dist/research/workspace/types.d.ts +38 -3
  61. package/dist/research/workspace/workspace.d.ts +2 -0
  62. package/dist/research/workspace/workspace.js +128 -26
  63. package/dist/research/workspace/workspace.js.map +1 -1
  64. package/package.json +1 -1
@@ -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, rm } from "node:fs/promises";
3
+ import { chmod, link, lstat, open, readFile, realpath, 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";
@@ -30,10 +30,10 @@ const BRAVE_PROFILE_SKILLS = {
30
30
  const ADAPTER_ENV_KEY = "TIANGONG_RESEARCH_ADAPTER_CREDENTIALS_JSON";
31
31
  const MAX_COMMAND_OUTPUT_BYTES = 1024 * 1024;
32
32
  export async function createResearchSetupPlan(input) {
33
- const root = requireAbsoluteWorkspace(input.workspace);
34
- await assertWorkspaceDirectory(root);
33
+ const root = await resolveResearchSetupWorkspacePath(input.workspace);
35
34
  const scope = input.scope ?? "project";
36
35
  const agents = normalizeAgents(input.agents ?? ["codex"]);
36
+ const agentRoutes = normalizeAgentRoutes(input.agentRoutes);
37
37
  if (!input.confirmNetworkDownloads &&
38
38
  input.skillIds.length + BRAVE_PROFILE_SKILLS[input.evidenceProfile].length) {
39
39
  throw setupError({
@@ -88,6 +88,18 @@ export async function createResearchSetupPlan(input) {
88
88
  const selected = resolveSetupSkills([
89
89
  ...new Set([...BRAVE_PROFILE_SKILLS[input.evidenceProfile], ...input.skillIds]),
90
90
  ]);
91
+ const producerInstallTarget = agentRoutes.producerAgent === "codex" ? "codex" : "claude-code";
92
+ if (selected.some((skill) => skill.role === "orchestrator") &&
93
+ !agents.includes(producerInstallTarget)) {
94
+ throw setupError({
95
+ code: "RESEARCH_SETUP_SELECTION_INVALID",
96
+ step: "selection",
97
+ reason: `The native ${agentRoutes.producerAgent} producer requires the orchestrator Skill in its project Skill root.`,
98
+ minimumAction: `Include ${producerInstallTarget} in --agents, or choose the other native producer.`,
99
+ retryCommand: "tiangong-ai research setup plan --help",
100
+ exitCode: 2,
101
+ });
102
+ }
91
103
  if (selected.some((skill) => skill.role === "evidence-capability") && !agents.includes("codex")) {
92
104
  throw setupError({
93
105
  code: "RESEARCH_SETUP_SELECTION_INVALID",
@@ -102,7 +114,6 @@ export async function createResearchSetupPlan(input) {
102
114
  validateLicenseAcceptances(selected, input.acceptedLicenseIds);
103
115
  const settings = normalizedSettings(selected, input.settings ?? {});
104
116
  const credentialSources = normalizedCredentialSources(selected, input.credentialEnvironment ?? {});
105
- const agentRoutes = normalizeAgentRoutes(input.agentRoutes);
106
117
  const targets = plannedInstallTargets(root, scope, agents, input.environment ?? process.env, input.targetRoots);
107
118
  const selectedSources = [...new Set(selected.map((skill) => skill.sourceId))]
108
119
  .map(setupSource)
@@ -433,11 +444,11 @@ export async function applyResearchSetupPlan(planPath, options = {}) {
433
444
  });
434
445
  state = await updateSetupState(root, {
435
446
  ...state,
436
- status: report.readiness === "READY"
437
- ? "ready"
438
- : report.readiness === "PARTIALLY_READY"
447
+ status: report.researchReadiness === "BLOCKED"
448
+ ? "blocked"
449
+ : report.overallReadiness === "PARTIALLY_READY"
439
450
  ? "partially-ready"
440
- : "blocked",
451
+ : "ready",
441
452
  currentStep: null,
442
453
  completedSteps: [...new Set([...state.completedSteps, "doctor"])],
443
454
  });
@@ -691,41 +702,85 @@ export async function doctorResearchSetup(workspace, options = {}) {
691
702
  return `${command} is executable (${sanitizeResearchText(result.stdout.trim()).slice(0, 120)}).`;
692
703
  });
693
704
  }
694
- await setupDoctorCheck(checks, "platform-sandbox", "runtime", async () => {
695
- if (platform() === "darwin") {
696
- const info = await lstat("/usr/bin/sandbox-exec").catch(() => undefined);
697
- if (!info?.isFile())
698
- throw new Error("/usr/bin/sandbox-exec is unavailable");
699
- return "macOS sandbox-exec is available.";
705
+ if (platform() === "win32") {
706
+ const production = plan.workspace.mode === "production-research";
707
+ checks.push({
708
+ id: "platform-sandbox",
709
+ category: "runtime",
710
+ scope: "research-core",
711
+ status: production ? "fail" : "warn",
712
+ detail: production
713
+ ? "Production research execution is unsupported on Windows because no approved capsule sandbox is available."
714
+ : "Windows can inspect and smoke-test setup state, but native research execution requires an approved macOS or Linux capsule sandbox.",
715
+ minimumAction: production
716
+ ? "Run production research on macOS with sandbox-exec or Linux with Bubblewrap."
717
+ : "Use macOS or Linux before switching this workspace to production-research mode.",
718
+ blocking: production,
719
+ requiredFor: production ? ["setup", "research-core"] : [],
720
+ });
721
+ }
722
+ else {
723
+ await setupDoctorCheck(checks, "platform-sandbox", "runtime", async () => {
724
+ if (platform() === "darwin") {
725
+ const info = await lstat("/usr/bin/sandbox-exec").catch(() => undefined);
726
+ if (!info?.isFile())
727
+ throw new Error("/usr/bin/sandbox-exec is unavailable");
728
+ return "macOS sandbox-exec is available.";
729
+ }
730
+ if (platform() === "linux") {
731
+ const result = await runner({
732
+ command: "bwrap",
733
+ args: ["--version"],
734
+ cwd: root,
735
+ environment: installerEnvironment(environment),
736
+ timeoutMs: 15_000,
737
+ });
738
+ if (result.exitCode !== 0)
739
+ throw new Error("Bubblewrap is unavailable");
740
+ return "Linux Bubblewrap is available.";
741
+ }
742
+ throw new Error("Research execution is unsupported on this platform");
743
+ });
744
+ }
745
+ checks.push({
746
+ id: "agent.native-producer",
747
+ category: "agent",
748
+ scope: "research-core",
749
+ status: "pass",
750
+ detail: `Producer work is bound to the current interactive ${plan.agentRoutes.producerAgent} host; setup will not launch it as a child process.`,
751
+ minimumAction: null,
752
+ blocking: true,
753
+ requiredFor: ["setup", "research-core"],
754
+ });
755
+ const reviewerCommand = plan.agentRoutes.reviewerAgent;
756
+ await setupDoctorCheck(checks, `agent.${reviewerCommand}.reviewer`, "agent", async () => {
757
+ const result = await runner({
758
+ command: reviewerCommand,
759
+ args: ["--version"],
760
+ cwd: root,
761
+ environment: agentDoctorEnvironment(environment),
762
+ timeoutMs: 30_000,
763
+ });
764
+ if (result.exitCode !== 0)
765
+ throw new Error(`${reviewerCommand} is not executable`);
766
+ return `${reviewerCommand} reviewer CLI is executable (${sanitizeResearchText(result.stdout.trim()).slice(0, 160)}).`;
767
+ });
768
+ await setupDoctorCheck(checks, "agent-route-config", "agent", async () => {
769
+ const config = await loadWorkspaceConfig(root);
770
+ if (config.producer.executionMode !== "native-host" ||
771
+ config.reviewer.executionMode !== "headless-cli" ||
772
+ config.producer.agent === config.reviewer.agent) {
773
+ throw new Error("Workspace must bind producer=native-host and reviewer=headless-cli on different agent families.");
700
774
  }
701
- if (platform() === "linux") {
702
- const result = await runner({
703
- command: "bwrap",
704
- args: ["--version"],
705
- cwd: root,
706
- environment: installerEnvironment(environment),
707
- timeoutMs: 15_000,
708
- });
709
- if (result.exitCode !== 0)
710
- throw new Error("Bubblewrap is unavailable");
711
- return "Linux Bubblewrap is available.";
775
+ if (config.mode === "production-research" &&
776
+ (!config.producer.model ||
777
+ !config.reviewer.model ||
778
+ !config.producer.pricing ||
779
+ !config.reviewer.pricing)) {
780
+ throw new Error("Production agent models and reviewed pricing are incomplete.");
712
781
  }
713
- throw new Error("Research execution is unsupported on this platform");
782
+ return `${config.producer.agent}(native-host) -> ${config.reviewer.agent}(headless-cli).`;
714
783
  });
715
- for (const command of ["codex", "claude"]) {
716
- await setupDoctorCheck(checks, `agent.${command}`, "agent", async () => {
717
- const result = await runner({
718
- command,
719
- args: ["--version"],
720
- cwd: root,
721
- environment: agentDoctorEnvironment(environment),
722
- timeoutMs: 30_000,
723
- });
724
- if (result.exitCode !== 0)
725
- throw new Error(`${command} is not executable`);
726
- return `${command} is executable (${sanitizeResearchText(result.stdout.trim()).slice(0, 160)}).`;
727
- });
728
- }
729
784
  const selected = plan.selection.skillIds.map(setupSkill);
730
785
  const installations = await inspectSelectedInstallations(plan, selected, environment);
731
786
  for (const installation of installations) {
@@ -802,10 +857,15 @@ export async function doctorResearchSetup(workspace, options = {}) {
802
857
  }
803
858
  await appendDependencyChecks(checks, selected, runner, root, environment);
804
859
  let capabilityDoctor = null;
860
+ const blockingBeforeLive = checks
861
+ .map((check) => normalizeSetupDoctorCheck(check, selected))
862
+ .filter((check) => check.blocking && check.status === "fail")
863
+ .map((check) => check.id);
864
+ const runRequiredLive = options.live === true && blockingBeforeLive.length === 0;
805
865
  if (selected.some((skill) => skill.role === "evidence-capability")) {
806
866
  try {
807
867
  capabilityDoctor = await doctorExternalCapabilities(root, {
808
- live: options.live === true,
868
+ live: runRequiredLive,
809
869
  fetcher,
810
870
  sleeper,
811
871
  });
@@ -829,7 +889,20 @@ export async function doctorResearchSetup(workspace, options = {}) {
829
889
  });
830
890
  }
831
891
  }
832
- if (options.live) {
892
+ if (options.live === true && !runRequiredLive) {
893
+ checks.push({
894
+ id: "live.required-capabilities.skipped",
895
+ category: "live-check",
896
+ scope: "evidence",
897
+ status: "fail",
898
+ detail: "Required live capability probes were not started because a static blocking prerequisite failed.",
899
+ minimumAction: "Resolve the static blocking prerequisites, then rerun live doctor checks.",
900
+ blocking: true,
901
+ requiredFor: ["setup", "research-core"],
902
+ skippedBecause: blockingBeforeLive.join(", "),
903
+ });
904
+ }
905
+ if (runRequiredLive) {
833
906
  await appendCompanionLiveChecks(checks, {
834
907
  plan,
835
908
  selected,
@@ -839,13 +912,45 @@ export async function doctorResearchSetup(workspace, options = {}) {
839
912
  allowSyntheticUnstructureUpload: options.allowSyntheticUnstructureUpload === true,
840
913
  });
841
914
  }
915
+ else if (options.live === true &&
916
+ selected.some((skill) => ["input-preprocessor", "acquisition-adapter", "post-closure-authoring"].includes(skill.role))) {
917
+ checks.push({
918
+ id: "live.optional-components.skipped",
919
+ category: "live-check",
920
+ status: "warn",
921
+ detail: "Optional component diagnostics were skipped after a static blocking prerequisite failed.",
922
+ minimumAction: "Resolve the blocking prerequisites before retrying optional diagnostics.",
923
+ blocking: false,
924
+ requiredFor: [],
925
+ skippedBecause: blockingBeforeLive.join(", "),
926
+ });
927
+ }
842
928
  let workspaceDoctor = null;
929
+ const blockingPrerequisiteFailures = checks
930
+ .map((check) => normalizeSetupDoctorCheck(check, selected))
931
+ .filter((check) => check.blocking && check.status === "fail")
932
+ .map((check) => check.id);
933
+ const runAgentSmoke = options.agentSmoke === true && blockingPrerequisiteFailures.length === 0;
934
+ if (options.agentSmoke === true && !runAgentSmoke) {
935
+ checks.push({
936
+ id: "agent-reviewer-smoke.skipped",
937
+ category: "agent",
938
+ scope: "review",
939
+ status: "fail",
940
+ detail: "The paid reviewer smoke was not started because a zero/low-cost blocking prerequisite failed.",
941
+ minimumAction: "Resolve the listed blocking prerequisites, then rerun the explicitly confirmed reviewer smoke.",
942
+ blocking: true,
943
+ requiredFor: ["setup", "research-core"],
944
+ skippedBecause: blockingPrerequisiteFailures.join(", "),
945
+ });
946
+ }
843
947
  try {
844
948
  workspaceDoctor = await doctorResearchWorkspace(root, {
845
- agentSmoke: options.agentSmoke === true,
846
- capabilitySmoke: options.live === true,
949
+ agentSmoke: runAgentSmoke,
950
+ capabilitySmoke: runRequiredLive,
847
951
  environment,
848
952
  capabilityFetcher: fetcher,
953
+ ...(capabilityDoctor === null ? {} : { capabilityDoctorResult: capabilityDoctor }),
849
954
  ...(options.executor === undefined ? {} : { executor: options.executor }),
850
955
  });
851
956
  const requiredRuntimeChecks = options.agentSmoke === true || options.live === true;
@@ -888,9 +993,16 @@ export async function doctorResearchSetup(workspace, options = {}) {
888
993
  : `Run tiangong-ai research setup doctor --live --workspace ${root} --json after reviewing quota impact.`,
889
994
  });
890
995
  }
891
- const readiness = checks.some((check) => check.status === "fail")
996
+ const scopedChecks = checks.map((check) => normalizeSetupDoctorCheck(check, selected));
997
+ const researchReadiness = scopedChecks.some((check) => check.blocking && check.status === "fail")
892
998
  ? "BLOCKED"
893
- : checks.some((check) => check.status === "warn")
999
+ : "READY";
1000
+ const preprocessingReadiness = setupDomainReadiness(scopedChecks, "preprocessing");
1001
+ const acquisitionReadiness = setupDomainReadiness(scopedChecks, "acquisition");
1002
+ const authoringReadiness = setupDomainReadiness(scopedChecks, "authoring");
1003
+ const overallReadiness = researchReadiness === "BLOCKED"
1004
+ ? "BLOCKED"
1005
+ : scopedChecks.some((check) => check.status !== "pass")
894
1006
  ? "PARTIALLY_READY"
895
1007
  : "READY";
896
1008
  const setupSecrets = [
@@ -908,14 +1020,19 @@ export async function doctorResearchSetup(workspace, options = {}) {
908
1020
  planSha256: plan.planSha256,
909
1021
  checkedAt: new Date().toISOString(),
910
1022
  mode: options.live ? "live" : "static",
911
- readiness,
912
- checks,
1023
+ readiness: researchReadiness,
1024
+ researchReadiness,
1025
+ preprocessingReadiness,
1026
+ acquisitionReadiness,
1027
+ authoringReadiness,
1028
+ overallReadiness,
1029
+ checks: scopedChecks,
913
1030
  capabilityDoctor,
914
1031
  workspaceDoctor,
915
1032
  summary: {
916
- pass: checks.filter((check) => check.status === "pass").length,
917
- warn: checks.filter((check) => check.status === "warn").length,
918
- fail: checks.filter((check) => check.status === "fail").length,
1033
+ pass: scopedChecks.filter((check) => check.status === "pass").length,
1034
+ warn: scopedChecks.filter((check) => check.status === "warn").length,
1035
+ fail: scopedChecks.filter((check) => check.status === "fail").length,
919
1036
  },
920
1037
  }, setupSecrets);
921
1038
  await writeJsonAtomic(paths.setupReport, report);
@@ -1206,10 +1323,20 @@ function assertPlanMatchesCatalog(plan) {
1206
1323
  }
1207
1324
  }
1208
1325
  function validAgentRoutes(value) {
1209
- const allowed = new Set(["producerModel", "reviewerModel", "producerPricing", "reviewerPricing"]);
1326
+ const allowed = new Set([
1327
+ "producerAgent",
1328
+ "reviewerAgent",
1329
+ "producerModel",
1330
+ "reviewerModel",
1331
+ "producerPricing",
1332
+ "reviewerPricing",
1333
+ ]);
1210
1334
  if (Object.keys(value).some((key) => !allowed.has(key)))
1211
1335
  return false;
1212
- if (!(value.producerModel === null || typeof value.producerModel === "string") ||
1336
+ if ((value.producerAgent !== "codex" && value.producerAgent !== "claude") ||
1337
+ (value.reviewerAgent !== "codex" && value.reviewerAgent !== "claude") ||
1338
+ value.producerAgent === value.reviewerAgent ||
1339
+ !(value.producerModel === null || typeof value.producerModel === "string") ||
1213
1340
  !(value.reviewerModel === null || typeof value.reviewerModel === "string")) {
1214
1341
  return false;
1215
1342
  }
@@ -1318,13 +1445,39 @@ function normalizedCredentialSources(selected, supplied) {
1318
1445
  .sort((left, right) => left.id.localeCompare(right.id));
1319
1446
  }
1320
1447
  function normalizeAgentRoutes(value) {
1448
+ const producerAgent = normalizeAgentKind(value?.producerAgent ?? "codex", "producer");
1449
+ const reviewerAgent = normalizeAgentKind(value?.reviewerAgent ?? (producerAgent === "codex" ? "claude" : "codex"), "reviewer");
1450
+ if (producerAgent === reviewerAgent) {
1451
+ throw setupError({
1452
+ code: "RESEARCH_SETUP_AGENT_ROUTE_INVALID",
1453
+ step: "agent-route",
1454
+ reason: "The native producer and independent reviewer must use different agent families.",
1455
+ minimumAction: "Choose Codex + Claude Code in either producer/reviewer order.",
1456
+ retryCommand: "tiangong-ai research setup plan --help",
1457
+ exitCode: 2,
1458
+ });
1459
+ }
1321
1460
  return {
1461
+ producerAgent,
1462
+ reviewerAgent,
1322
1463
  producerModel: normalizeNullableIdentifier(value?.producerModel),
1323
1464
  reviewerModel: normalizeNullableIdentifier(value?.reviewerModel),
1324
1465
  producerPricing: normalizePricing(value?.producerPricing),
1325
1466
  reviewerPricing: normalizePricing(value?.reviewerPricing),
1326
1467
  };
1327
1468
  }
1469
+ function normalizeAgentKind(value, label) {
1470
+ if (value === "codex" || value === "claude")
1471
+ return value;
1472
+ throw setupError({
1473
+ code: "RESEARCH_SETUP_AGENT_ROUTE_INVALID",
1474
+ step: "agent-route",
1475
+ reason: `${label} agent must be codex or claude.`,
1476
+ minimumAction: "Choose Codex or Claude Code and keep the reviewer on the other family.",
1477
+ retryCommand: "tiangong-ai research setup plan --help",
1478
+ exitCode: 2,
1479
+ });
1480
+ }
1328
1481
  function normalizePricing(value) {
1329
1482
  if (value === undefined || value === null)
1330
1483
  return null;
@@ -1474,33 +1627,29 @@ async function ensureSetupWorkspace(plan) {
1474
1627
  await initializeResearchWorkspace(plan.workspace.path, plan.workspace.name, plan.workspace.mode);
1475
1628
  }
1476
1629
  async function configureAgentRoutes(plan) {
1477
- if (!plan.agentRoutes.producerModel &&
1478
- !plan.agentRoutes.reviewerModel &&
1479
- !plan.agentRoutes.producerPricing &&
1480
- !plan.agentRoutes.reviewerPricing) {
1481
- return;
1482
- }
1483
1630
  const paths = workspacePaths(plan.workspace.path);
1484
1631
  const config = await loadWorkspaceConfig(plan.workspace.path);
1485
1632
  const updated = {
1486
1633
  ...config,
1487
- producer: {
1488
- ...config.producer,
1489
- ...(plan.agentRoutes.producerModel === null ? {} : { model: plan.agentRoutes.producerModel }),
1490
- ...(plan.agentRoutes.producerPricing === null
1491
- ? {}
1492
- : { pricing: plan.agentRoutes.producerPricing }),
1493
- },
1494
- reviewer: {
1495
- ...config.reviewer,
1496
- ...(plan.agentRoutes.reviewerModel === null ? {} : { model: plan.agentRoutes.reviewerModel }),
1497
- ...(plan.agentRoutes.reviewerPricing === null
1498
- ? {}
1499
- : { pricing: plan.agentRoutes.reviewerPricing }),
1500
- },
1634
+ producer: setupAgentRoute(config.producer, plan.agentRoutes.producerAgent, "native-host", plan.agentRoutes.producerModel, plan.agentRoutes.producerPricing),
1635
+ reviewer: setupAgentRoute(config.reviewer, plan.agentRoutes.reviewerAgent, "headless-cli", plan.agentRoutes.reviewerModel, plan.agentRoutes.reviewerPricing),
1501
1636
  };
1502
1637
  await writeJsonAtomic(paths.config, updated);
1503
1638
  }
1639
+ function setupAgentRoute(current, agent, executionMode, plannedModel, plannedPricing) {
1640
+ const sameAgent = current.agent === agent;
1641
+ return {
1642
+ agent,
1643
+ executionMode,
1644
+ binary: agent === "codex" ? "codex" : "claude",
1645
+ model: plannedModel ?? (sameAgent ? current.model : null),
1646
+ effort: sameAgent ? (current.effort ?? "low") : "low",
1647
+ ...(agent === "codex" ? { verbosity: sameAgent ? (current.verbosity ?? "low") : "low" } : {}),
1648
+ ...((plannedPricing ?? (sameAgent ? current.pricing : undefined)) === undefined
1649
+ ? {}
1650
+ : { pricing: plannedPricing ?? current.pricing }),
1651
+ };
1652
+ }
1504
1653
  async function inspectSelectedInstallations(plan, selected, _environment) {
1505
1654
  const results = [];
1506
1655
  for (const agent of plan.install.agents) {
@@ -2161,6 +2310,12 @@ async function runAcademicPaperCompanion(input) {
2161
2310
  }
2162
2311
  const result = results[0];
2163
2312
  if (execution.exitCode !== 0 || result.success !== true) {
2313
+ const sourcesTried = Array.isArray(result.sources_tried)
2314
+ ? result.sources_tried
2315
+ .filter((source) => typeof source === "string")
2316
+ .map((source) => sanitizeResearchText(source).slice(0, 100))
2317
+ : [];
2318
+ const adapterError = sanitizeResearchRecord(isObject(result.error) ? result.error : {});
2164
2319
  if (result.success === false &&
2165
2320
  isObject(result.browser_handoff) &&
2166
2321
  result.file === null &&
@@ -2170,7 +2325,7 @@ async function runAcademicPaperCompanion(input) {
2170
2325
  skillId: input.skill.id,
2171
2326
  skillTreeSha256: input.skill.expectedTreeSha256,
2172
2327
  querySha256: sha256Text(doi ?? title),
2173
- sourcesTried: Array.isArray(result.sources_tried) ? result.sources_tried : [],
2328
+ sourcesTried,
2174
2329
  artifactCommitted: false,
2175
2330
  });
2176
2331
  return {
@@ -2181,20 +2336,32 @@ async function runAcademicPaperCompanion(input) {
2181
2336
  skillId: input.skill.id,
2182
2337
  role: input.skill.role,
2183
2338
  artifactCommitted: false,
2184
- sourcesTried: Array.isArray(result.sources_tried) ? result.sources_tried : [],
2185
- error: sanitizeResearchRecord(isObject(result.error) ? result.error : {}),
2339
+ sourcesTried,
2340
+ error: adapterError,
2186
2341
  provenance: companionProvenance(input.plan, input.skill),
2187
2342
  next: "Automatic legal OA sources were exhausted. Follow the installed academic-paper-download browser-handoff reference explicitly; no browser is launched or selected automatically.",
2188
2343
  };
2189
2344
  }
2345
+ const adapterCode = typeof adapterError.code === "string"
2346
+ ? sanitizeResearchText(adapterError.code).trim().slice(0, 100)
2347
+ : "unknown-adapter-error";
2348
+ const adapterMessage = typeof adapterError.message === "string"
2349
+ ? sanitizeResearchText(adapterError.message).trim().slice(0, 500)
2350
+ : "";
2190
2351
  throw setupError({
2191
2352
  code: "RESEARCH_SETUP_COMPANION_COMMAND_FAILED",
2192
2353
  step: "companion-paper-download",
2193
- reason: `The pinned paper adapter exited with status ${execution.exitCode}.`,
2194
- minimumAction: sanitizeResearchText(execution.stderr).trim().slice(0, 500) ||
2354
+ reason: `The pinned paper adapter failed (${adapterCode}; exit status ${execution.exitCode}).`,
2355
+ minimumAction: adapterMessage ||
2356
+ sanitizeResearchText(execution.stderr).trim().slice(0, 500) ||
2195
2357
  "Inspect the structured adapter error and verify its pinned Python dependencies.",
2196
2358
  retryCommand: `tiangong-ai research setup doctor --workspace ${input.root} --json`,
2197
2359
  exitCode: 3,
2360
+ diagnostics: {
2361
+ adapterError,
2362
+ sourcesTried,
2363
+ artifactCommitted: false,
2364
+ },
2198
2365
  });
2199
2366
  }
2200
2367
  const artifactPath = requireContainedArtifactPath(result.file, outputDirectory, "file");
@@ -2565,6 +2732,84 @@ async function archiveSetupGeneration(root) {
2565
2732
  }
2566
2733
  return prior.planSha256;
2567
2734
  }
2735
+ function normalizeSetupDoctorCheck(check, selected) {
2736
+ const componentIds = check.componentIds ?? setupCheckComponentIds(check, selected);
2737
+ const scope = check.scope ?? setupCheckScope(check, selected, componentIds);
2738
+ const blocking = check.blocking ?? ["research-core", "evidence", "review"].includes(scope);
2739
+ return {
2740
+ ...check,
2741
+ scope,
2742
+ componentIds,
2743
+ requiredFor: check.requiredFor ??
2744
+ (blocking
2745
+ ? ["setup", "research-core"]
2746
+ : componentIds.map((componentId) => `component:${componentId}`)),
2747
+ blocking,
2748
+ componentGate: check.componentGate ?? check.id !== "live.semantic-scholar",
2749
+ };
2750
+ }
2751
+ function setupCheckComponentIds(check, selected) {
2752
+ if (check.id === "live.semantic-scholar")
2753
+ return ["tiangong.academic-paper-download"];
2754
+ if (check.id === "live.tiangong-unstructure") {
2755
+ return ["tiangong.document-granular-decompose"];
2756
+ }
2757
+ if (check.id.startsWith("skill.")) {
2758
+ return selected.filter((skill) => check.id.endsWith(`.${skill.id}`)).map((skill) => skill.id);
2759
+ }
2760
+ if (check.id.startsWith("setting.")) {
2761
+ const id = check.id.slice("setting.".length);
2762
+ const setting = RESEARCH_SETUP_SETTINGS.find((candidate) => candidate.id === id);
2763
+ return selected
2764
+ .filter((skill) => setting?.requiredBy.includes(skill.id))
2765
+ .map((skill) => skill.id);
2766
+ }
2767
+ if (check.id.startsWith("credential.")) {
2768
+ const id = check.id.slice("credential.".length);
2769
+ const credential = RESEARCH_SETUP_CREDENTIALS.find((candidate) => candidate.id === id);
2770
+ return selected
2771
+ .filter((skill) => credential?.requiredBy.includes(skill.id))
2772
+ .map((skill) => skill.id);
2773
+ }
2774
+ if (check.id.startsWith("dependency.")) {
2775
+ const id = check.id.slice("dependency.".length);
2776
+ return selected
2777
+ .filter((skill) => skill.dependencies.some((item) => item.id === id))
2778
+ .map((skill) => skill.id);
2779
+ }
2780
+ return [];
2781
+ }
2782
+ function setupCheckScope(check, selected, componentIds) {
2783
+ if (check.id === "live.semantic-scholar")
2784
+ return "acquisition";
2785
+ if (check.id === "live.tiangong-unstructure")
2786
+ return "preprocessing";
2787
+ if (check.category === "evidence-capability" || check.id.includes("capability")) {
2788
+ return "evidence";
2789
+ }
2790
+ if (check.category === "agent" || check.id.includes("attestation"))
2791
+ return "review";
2792
+ const roles = componentIds
2793
+ .map((id) => selected.find((skill) => skill.id === id)?.role)
2794
+ .filter((role) => Boolean(role));
2795
+ if (roles.includes("evidence-capability"))
2796
+ return "evidence";
2797
+ if (roles.includes("input-preprocessor"))
2798
+ return "preprocessing";
2799
+ if (roles.includes("acquisition-adapter"))
2800
+ return "acquisition";
2801
+ if (roles.includes("post-closure-authoring"))
2802
+ return "authoring";
2803
+ return "research-core";
2804
+ }
2805
+ function setupDomainReadiness(checks, scope) {
2806
+ const matching = checks.filter((check) => check.scope === scope);
2807
+ if (matching.length === 0)
2808
+ return "NOT_REQUIRED";
2809
+ if (matching.some((check) => check.blocking && check.status === "fail"))
2810
+ return "BLOCKED";
2811
+ return matching.some((check) => check.status !== "pass") ? "DEGRADED" : "READY";
2812
+ }
2568
2813
  function requireAbsoluteWorkspace(value) {
2569
2814
  if (!value || !isAbsolute(value) || /[\0\r\n]/.test(value)) {
2570
2815
  throw setupError({
@@ -2578,9 +2823,25 @@ function requireAbsoluteWorkspace(value) {
2578
2823
  }
2579
2824
  return resolve(value);
2580
2825
  }
2581
- async function assertWorkspaceDirectory(root) {
2826
+ export async function resolveResearchSetupWorkspacePath(value, options = {}) {
2827
+ const root = requireAbsoluteWorkspace(value);
2582
2828
  const info = await lstat(root).catch(() => undefined);
2583
- if (!info?.isDirectory() || info.isSymbolicLink()) {
2829
+ if (info) {
2830
+ if (!info.isDirectory() || info.isSymbolicLink()) {
2831
+ throw setupError({
2832
+ code: "RESEARCH_SETUP_WORKSPACE_INVALID",
2833
+ step: "workspace",
2834
+ reason: "Setup workspace must exist as a regular non-symlink directory.",
2835
+ minimumAction: `Create the directory explicitly, then retry with --workspace ${root}.`,
2836
+ retryCommand: "tiangong-ai research setup --help",
2837
+ exitCode: 2,
2838
+ });
2839
+ }
2840
+ const canonicalRoot = await realpath(root);
2841
+ await assertNoSymlinkedExistingPath(canonicalRoot);
2842
+ return canonicalRoot;
2843
+ }
2844
+ if (!options.allowMissingLeaf) {
2584
2845
  throw setupError({
2585
2846
  code: "RESEARCH_SETUP_WORKSPACE_INVALID",
2586
2847
  step: "workspace",
@@ -2590,7 +2851,24 @@ async function assertWorkspaceDirectory(root) {
2590
2851
  exitCode: 2,
2591
2852
  });
2592
2853
  }
2593
- await assertNoSymlinkedExistingPath(root);
2854
+ const requestedParent = dirname(root);
2855
+ const canonicalParent = await realpath(requestedParent).catch(() => undefined);
2856
+ const parentInfo = canonicalParent
2857
+ ? await lstat(canonicalParent).catch(() => undefined)
2858
+ : undefined;
2859
+ if (!canonicalParent || !parentInfo?.isDirectory() || parentInfo.isSymbolicLink()) {
2860
+ throw setupError({
2861
+ code: "RESEARCH_SETUP_WORKSPACE_INVALID",
2862
+ step: "workspace",
2863
+ reason: "The parent of a new setup workspace must exist as a regular directory.",
2864
+ minimumAction: `Create the parent directory explicitly, then retry with --workspace ${root}.`,
2865
+ retryCommand: "tiangong-ai research setup --help",
2866
+ exitCode: 2,
2867
+ });
2868
+ }
2869
+ const canonicalRoot = join(canonicalParent, basename(root));
2870
+ await assertNoSymlinkedExistingPath(canonicalRoot);
2871
+ return canonicalRoot;
2594
2872
  }
2595
2873
  function normalizedWorkspaceName(value) {
2596
2874
  const normalized = value.trim();
@@ -2995,15 +3273,36 @@ async function appendDependencyChecks(checks, selected, runner, root, environmen
2995
3273
  async function appendCompanionLiveChecks(checks, input) {
2996
3274
  if (input.selected.some((skill) => skill.id === "tiangong.academic-paper-download")) {
2997
3275
  await appendSemanticScholarLiveCheck(checks, input);
3276
+ const semanticScholar = checks.findLast((check) => check.id === "live.semantic-scholar");
3277
+ checks.push({
3278
+ id: "companion.tiangong.academic-paper-download",
3279
+ category: "live-check",
3280
+ scope: "acquisition",
3281
+ componentIds: ["tiangong.academic-paper-download"],
3282
+ status: semanticScholar?.status === "pass" ? "pass" : "warn",
3283
+ detail: semanticScholar?.status === "pass"
3284
+ ? "OA resolver diagnostics: Unpaywall=unknown, Semantic Scholar=ready, arXiv=unknown; the deterministic resolver order is unchanged."
3285
+ : "OA resolver diagnostics: Unpaywall=unknown, Semantic Scholar=degraded, arXiv=unknown. The adapter remains available and actual acquisition will stop or request explicit browser handoff only after its ordered OA sources are exhausted.",
3286
+ minimumAction: semanticScholar?.status === "pass"
3287
+ ? null
3288
+ : "Retry the resolver diagnostic later or configure its optional key; unrelated research remains authorized and no standalone evidence fallback is permitted.",
3289
+ blocking: false,
3290
+ componentGate: false,
3291
+ requiredFor: ["component:tiangong.academic-paper-download"],
3292
+ });
2998
3293
  }
2999
3294
  if (input.selected.some((skill) => skill.id === "tiangong.document-granular-decompose")) {
3000
3295
  if (!input.allowSyntheticUnstructureUpload) {
3001
3296
  checks.push({
3002
3297
  id: "live.tiangong-unstructure",
3003
3298
  category: "live-check",
3299
+ scope: "preprocessing",
3300
+ componentIds: ["tiangong.document-granular-decompose"],
3004
3301
  status: "warn",
3005
3302
  detail: "Synthetic document upload was not explicitly authorized, so no document was sent.",
3006
3303
  minimumAction: "Rerun setup doctor with the separate synthetic-upload confirmation after reviewing service cost and data policy.",
3304
+ blocking: false,
3305
+ componentGate: true,
3007
3306
  });
3008
3307
  }
3009
3308
  else {
@@ -3068,9 +3367,13 @@ async function appendSemanticScholarLiveCheck(checks, input) {
3068
3367
  checks.push({
3069
3368
  id: "live.semantic-scholar",
3070
3369
  category: "live-check",
3370
+ scope: "acquisition",
3371
+ componentIds: ["tiangong.academic-paper-download"],
3071
3372
  status: "fail",
3072
3373
  detail: "Semantic Scholar returned a redirect; credential-bearing redirects are not followed.",
3073
- minimumAction: "Verify the fixed Semantic Scholar endpoint and network policy, then rerun setup doctor; do not fall back to a standalone source wrapper.",
3374
+ minimumAction: "Verify the fixed Semantic Scholar endpoint and network policy before relying on that resolver; do not fall back to a standalone evidence wrapper.",
3375
+ blocking: false,
3376
+ componentGate: false,
3074
3377
  diagnostics: {
3075
3378
  code: "PROVIDER_REDIRECT_REJECTED",
3076
3379
  executionMode: "setup-doctor",
@@ -3090,13 +3393,17 @@ async function appendSemanticScholarLiveCheck(checks, input) {
3090
3393
  checks.push({
3091
3394
  id: "live.semantic-scholar",
3092
3395
  category: "live-check",
3396
+ scope: "acquisition",
3397
+ componentIds: ["tiangong.academic-paper-download"],
3093
3398
  status: "fail",
3094
3399
  detail: sanitizeResearchText(`Semantic Scholar live check returned HTTP ${response.status}${detail ? `: ${detail}` : ""}.`, apiKey ? [apiKey] : []),
3095
3400
  minimumAction: rateLimited
3096
- ? `Wait for the provider quota window${retryAfterSeconds === null ? "" : ` (Retry-After ${retryAfterSeconds}s)`}, then rerun setup doctor. The production workflow remains blocked and must not downgrade to standalone search.`
3401
+ ? `Wait for the provider quota window${retryAfterSeconds === null ? "" : ` (Retry-After ${retryAfterSeconds}s)`}, then rerun this optional resolver diagnostic. Unrelated research remains available and must not downgrade to standalone search.`
3097
3402
  : authenticationFailure
3098
3403
  ? "Replace or remove the optional Semantic Scholar API key, verify provider entitlement, and rerun setup doctor; do not expose the key in output."
3099
- : "Verify Semantic Scholar availability and the fixed API contract, then rerun setup doctor; do not downgrade the research workflow.",
3404
+ : "Verify Semantic Scholar availability and the fixed API contract before relying on that resolver; do not downgrade the research workflow.",
3405
+ blocking: false,
3406
+ componentGate: false,
3100
3407
  diagnostics: {
3101
3408
  code: authenticationFailure
3102
3409
  ? "PROVIDER_AUTHENTICATION_FAILED"
@@ -3116,20 +3423,28 @@ async function appendSemanticScholarLiveCheck(checks, input) {
3116
3423
  checks.push({
3117
3424
  id: "live.semantic-scholar",
3118
3425
  category: "live-check",
3426
+ scope: "acquisition",
3427
+ componentIds: ["tiangong.academic-paper-download"],
3119
3428
  status: "pass",
3120
3429
  detail: apiKey
3121
3430
  ? "Semantic Scholar accepted the configured optional API key."
3122
3431
  : "Semantic Scholar public API is reachable without an optional API key.",
3123
3432
  minimumAction: null,
3433
+ blocking: false,
3434
+ componentGate: false,
3124
3435
  });
3125
3436
  }
3126
3437
  catch (error) {
3127
3438
  checks.push({
3128
3439
  id: "live.semantic-scholar",
3129
3440
  category: "live-check",
3441
+ scope: "acquisition",
3442
+ componentIds: ["tiangong.academic-paper-download"],
3130
3443
  status: "fail",
3131
3444
  detail: sanitizeResearchText(error instanceof Error ? error.message : String(error), apiKey ? [apiKey] : []),
3132
- minimumAction: "Restore provider connectivity, then rerun setup doctor; the production workflow must remain blocked instead of using a standalone fallback.",
3445
+ minimumAction: "Restore provider connectivity before relying on Semantic Scholar; unrelated research remains available and no standalone fallback is authorized.",
3446
+ blocking: false,
3447
+ componentGate: false,
3133
3448
  diagnostics: {
3134
3449
  code: "PROVIDER_TRANSPORT_FAILED",
3135
3450
  executionMode: "setup-doctor",